From aee1487f2269de892392adcf20e70f54e8dac114 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 4 Feb 2026 11:30:23 -0800 Subject: [PATCH] Add MCP server for network discovery tools Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 10 +- go.mod | 10 +- go.sum | 22 ++- http.go | 8 + mcp.go | 369 ++++++++++++++++++++++++++++++++++++ 5 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 mcp.go diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 30ed05e..31d0a78 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -98,7 +98,15 @@ "Bash(while read id)", "Bash(do cat /home/flamingcow/tendrils/status)", "Bash(for id in node_01kgay2wynew8tjh1bpr9sd11e node_01kgay2wynew2t7qmfvtkrq2nb node_01kgay2wykevgbkxfvavxgpq30 node_01kgay2wynew5b80p45h8mdtpj node_01kgay2wynevtsrw26tprvqysw node_01kgay2wynevy9gp10a6rr4y2d)", - "Bash(do)" + "Bash(do)", + "WebFetch(domain:deepwiki.com)", + "Bash(claude mcp add --help:*)", + "mcp__tendrils__get_topology", + "mcp__tendrils__list_nodes", + "mcp__tendrils__list_errors", + "mcp__tendrils__list_links", + "mcp__tendrils__search_nodes", + "mcp__tendrils__get_node" ], "ask": [ "Bash(rm *)" diff --git a/go.mod b/go.mod index cebe72e..f23f42e 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/gopatchy/multicast v0.0.0-20260130233915-4278628690a3 github.com/gopatchy/sacn v0.0.0-20260130234631-9c2787a20064 github.com/gosnmp/gosnmp v1.43.2 + github.com/mark3labs/mcp-go v0.43.2 github.com/miekg/dns v1.1.72 go.jetify.com/typeid v1.3.0 golang.org/x/net v0.49.0 @@ -16,8 +17,15 @@ require ( ) require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/gofrs/uuid/v5 v5.4.0 // indirect - github.com/kr/text v0.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect diff --git a/go.sum b/go.sum index 6f9075e..ffaa399 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,11 @@ -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= @@ -9,6 +14,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopatchy/artnet v0.0.0-20260204180605-8f14a4f373c2 h1:PtXW+4SwVWI/JJ7XZcCbvLycF7T3C8plJirPo9kIC4Y= github.com/gopatchy/artnet v0.0.0-20260204180605-8f14a4f373c2/go.mod h1:V/D32mh1xfK/llCKbrqI2jxw4xL4hf6Ge2yLiIrp9/4= github.com/gopatchy/multicast v0.0.0-20260130233915-4278628690a3 h1:JVyjjl5wWP0NaC6b4QL7uvQ7I3G0a4bCDNFVtUoiYvU= @@ -17,18 +24,31 @@ github.com/gopatchy/sacn v0.0.0-20260130234631-9c2787a20064 h1:gyNOXY+87MjFlk1IU github.com/gopatchy/sacn v0.0.0-20260130234631-9c2787a20064/go.mod h1:bhLO4+JE+C1961n8l70X/8zbLZJWK7PXboPYIQ9Tw7I= github.com/gosnmp/gosnmp v1.43.2 h1:F9loz6uMCNtIQj0RNO5wz/mZ+FZt2WyNKJYOvw+Zosw= github.com/gosnmp/gosnmp v1.43.2/go.mod h1:smHIwoaqr1M+HTAEd7+mKkPs8lp3Lf/U+htPUql1Q3c= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.jetify.com/typeid v1.3.0 h1:fuWV7oxO4mSsgpxwhaVpFXgt0IfjogR29p+XAjDCVKY= go.jetify.com/typeid v1.3.0/go.mod h1:CtVGyt2+TSp4Rq5+ARLvGsJqdNypKBAC6INQ9TLPlmk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/http.go b/http.go index 44cb7a1..1f268a2 100644 --- a/http.go +++ b/http.go @@ -50,6 +50,7 @@ func (t *Tendrils) startHTTPServer() { mux.HandleFunc("/tendrils/api/status", t.handleAPIStatus) mux.HandleFunc("/tendrils/api/errors/clear", t.handleClearError) mux.HandleFunc("/tendrils/api/nodes/remove", t.handleRemoveNode) + mux.Handle("/tendrils/mcp/", t.newMCPServer()) mux.Handle("/", noCacheHandler(http.FileServer(http.Dir("static")))) log.Printf("[https] listening on :443") @@ -58,6 +59,13 @@ func (t *Tendrils) startHTTPServer() { log.Printf("[ERROR] https server failed: %v", err) } }() + + log.Printf("[http] listening on :80") + go func() { + if err := http.ListenAndServe(":80", mux); err != nil { + log.Printf("[ERROR] http server failed: %v", err) + } + }() } func ensureCert() error { diff --git a/mcp.go b/mcp.go new file mode 100644 index 0000000..7d13f7e --- /dev/null +++ b/mcp.go @@ -0,0 +1,369 @@ +package tendrils + +import ( + "context" + "fmt" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func (t *Tendrils) newMCPServer() *server.SSEServer { + s := server.NewMCPServer("tendrils", "1.0.0", + server.WithToolCapabilities(false)) + + s.AddTool( + mcp.NewTool("list_nodes", + mcp.WithDescription("List all discovered network devices (nodes). Returns node names, types, interfaces, IPs, and MACs.")), + t.mcpListNodes) + + s.AddTool( + mcp.NewTool("get_node", + mcp.WithDescription("Get detailed information about a specific node by name, IP address, or MAC address."), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Node name, IP address, or MAC address to search for"))), + t.mcpGetNode) + + s.AddTool( + mcp.NewTool("list_links", + mcp.WithDescription("List all discovered network links between devices. Shows which ports connect to which devices.")), + t.mcpListLinks) + + s.AddTool( + mcp.NewTool("list_errors", + mcp.WithDescription("List current network errors including unreachable nodes, port errors, and high utilization warnings.")), + t.mcpListErrors) + + s.AddTool( + mcp.NewTool("search_nodes", + mcp.WithDescription("Search for nodes by partial name, IP, or MAC address match."), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Search query (partial match on name, IP, or MAC)"))), + t.mcpSearchNodes) + + s.AddTool( + mcp.NewTool("get_topology", + mcp.WithDescription("Get a summary of the network topology including node counts by type and connectivity overview.")), + t.mcpGetTopology) + + return server.NewSSEServer(s, + server.WithStaticBasePath("/tendrils/mcp"), + server.WithKeepAlive(true)) +} + +func (t *Tendrils) mcpListNodes(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + t.nodes.mu.RLock() + defer t.nodes.mu.RUnlock() + + nodes := t.getNodesLocked() + if len(nodes) == 0 { + return mcp.NewToolResultText("No nodes discovered yet."), nil + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Discovered %d nodes:\n\n", len(nodes))) + + for _, node := range nodes { + name := node.DisplayName() + if name == "" { + name = "(unnamed)" + } + nodeType := string(node.Type) + if nodeType == "" { + nodeType = "unknown" + } + + sb.WriteString(fmt.Sprintf("• %s [%s]\n", name, nodeType)) + + for _, iface := range node.Interfaces { + sb.WriteString(fmt.Sprintf(" - %s", iface.MAC)) + if len(iface.IPs) > 0 { + sb.WriteString(fmt.Sprintf(" (%s)", strings.Join(iface.IPs.Slice(), ", "))) + } + if iface.Name != "" { + sb.WriteString(fmt.Sprintf(" port:%s", iface.Name)) + } + sb.WriteString("\n") + } + + if node.Unreachable { + sb.WriteString(" ⚠ UNREACHABLE\n") + } + } + + return mcp.NewToolResultText(sb.String()), nil +} + +func (t *Tendrils) mcpGetNode(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query, err := req.RequireString("query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + t.nodes.mu.RLock() + defer t.nodes.mu.RUnlock() + + queryLower := strings.ToLower(query) + nodes := t.getNodesLocked() + links := t.getLinksLocked() + + for _, node := range nodes { + if matchesNode(node, queryLower) { + return mcp.NewToolResultText(formatNodeDetails(node, links)), nil + } + } + + return mcp.NewToolResultText(fmt.Sprintf("no node found matching '%s'", query)), nil +} + +func matchesNode(node *Node, query string) bool { + for name := range node.Names { + if strings.ToLower(name) == query { + return true + } + } + for _, iface := range node.Interfaces { + if strings.ToLower(string(iface.MAC)) == query { + return true + } + for ip := range iface.IPs { + if strings.ToLower(ip) == query { + return true + } + } + } + return false +} + +func formatNodeDetails(node *Node, links []*Link) string { + var sb strings.Builder + + name := node.DisplayName() + if name == "" { + name = "(unnamed)" + } + sb.WriteString(fmt.Sprintf("Node: %s\n", name)) + sb.WriteString(fmt.Sprintf("ID: %s\n", node.ID)) + if node.Type != "" { + sb.WriteString(fmt.Sprintf("Type: %s\n", node.Type)) + } + if node.Unreachable { + sb.WriteString("Status: UNREACHABLE\n") + } + + sb.WriteString("\nInterfaces:\n") + for _, iface := range node.Interfaces { + sb.WriteString(fmt.Sprintf(" • MAC: %s\n", iface.MAC)) + if iface.Name != "" { + sb.WriteString(fmt.Sprintf(" Port: %s\n", iface.Name)) + } + if len(iface.IPs) > 0 { + sb.WriteString(fmt.Sprintf(" IPs: %s\n", strings.Join(iface.IPs.Slice(), ", "))) + } + if iface.Stats != nil { + if iface.Stats.Speed > 0 { + speed := iface.Stats.Speed + if speed >= 1000000000 { + sb.WriteString(fmt.Sprintf(" Speed: %dG\n", speed/1000000000)) + } else if speed >= 1000000 { + sb.WriteString(fmt.Sprintf(" Speed: %dM\n", speed/1000000)) + } + } + if iface.Stats.InErrors > 0 || iface.Stats.OutErrors > 0 { + sb.WriteString(fmt.Sprintf(" Errors: in=%d out=%d\n", iface.Stats.InErrors, iface.Stats.OutErrors)) + } + } + } + + if node.MulticastGroups != nil && len(node.MulticastGroups) > 0 { + groups := node.MulticastGroups.Groups() + var groupStrs []string + for _, g := range groups { + groupStrs = append(groupStrs, g.String()) + } + sb.WriteString(fmt.Sprintf("\nMulticast Groups: %s\n", strings.Join(groupStrs, ", "))) + } + if node.ArtNetInputs != nil && len(node.ArtNetInputs) > 0 { + universes := node.ArtNetInputs.Universes() + var univStrs []string + for _, u := range universes { + univStrs = append(univStrs, u.String()) + } + sb.WriteString(fmt.Sprintf("Art-Net Inputs: %s\n", strings.Join(univStrs, ", "))) + } + if node.ArtNetOutputs != nil && len(node.ArtNetOutputs) > 0 { + universes := node.ArtNetOutputs.Universes() + var univStrs []string + for _, u := range universes { + univStrs = append(univStrs, u.String()) + } + sb.WriteString(fmt.Sprintf("Art-Net Outputs: %s\n", strings.Join(univStrs, ", "))) + } + + var connectionCount int + for _, link := range links { + if link.NodeA.ID == node.ID || link.NodeB.ID == node.ID { + connectionCount++ + } + } + if connectionCount > 0 { + sb.WriteString(fmt.Sprintf("\nConnections: %d links\n", connectionCount)) + } + + return sb.String() +} + +func (t *Tendrils) mcpListLinks(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + t.nodes.mu.RLock() + defer t.nodes.mu.RUnlock() + + links := t.getLinksLocked() + if len(links) == 0 { + return mcp.NewToolResultText("No links discovered yet."), nil + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Discovered %d links:\n\n", len(links))) + + for _, link := range links { + sb.WriteString(fmt.Sprintf("• %s\n", link.String())) + } + + return mcp.NewToolResultText(sb.String()), nil +} + +func (t *Tendrils) mcpListErrors(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + errors := t.errors.GetErrors() + if len(errors) == 0 { + return mcp.NewToolResultText("No errors currently reported."), nil + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Current errors (%d):\n\n", len(errors))) + + for _, e := range errors { + sb.WriteString(fmt.Sprintf("• [%s] %s", e.Type, e.NodeName)) + if e.Port != "" { + sb.WriteString(fmt.Sprintf(" port %s", e.Port)) + } + if e.InErrors > 0 || e.OutErrors > 0 { + sb.WriteString(fmt.Sprintf(" (in=%d out=%d)", e.InErrors, e.OutErrors)) + } + if e.Utilization > 0 { + sb.WriteString(fmt.Sprintf(" (%.0f%% utilization)", e.Utilization)) + } + sb.WriteString("\n") + } + + return mcp.NewToolResultText(sb.String()), nil +} + +func (t *Tendrils) mcpSearchNodes(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query, err := req.RequireString("query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + t.nodes.mu.RLock() + defer t.nodes.mu.RUnlock() + + queryLower := strings.ToLower(query) + nodes := t.getNodesLocked() + var matches []*Node + + for _, node := range nodes { + if nodeContains(node, queryLower) { + matches = append(matches, node) + } + } + + if len(matches) == 0 { + return mcp.NewToolResultText(fmt.Sprintf("no nodes found matching '%s'", query)), nil + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d nodes matching '%s':\n\n", len(matches), query)) + + for _, node := range matches { + name := node.DisplayName() + if name == "" { + name = "(unnamed)" + } + nodeType := string(node.Type) + if nodeType == "" { + nodeType = "unknown" + } + + sb.WriteString(fmt.Sprintf("• %s [%s]\n", name, nodeType)) + for _, iface := range node.Interfaces { + sb.WriteString(fmt.Sprintf(" - %s", iface.MAC)) + if len(iface.IPs) > 0 { + sb.WriteString(fmt.Sprintf(" (%s)", strings.Join(iface.IPs.Slice(), ", "))) + } + sb.WriteString("\n") + } + } + + return mcp.NewToolResultText(sb.String()), nil +} + +func nodeContains(node *Node, query string) bool { + for name := range node.Names { + if strings.Contains(strings.ToLower(name), query) { + return true + } + } + for _, iface := range node.Interfaces { + if strings.Contains(strings.ToLower(string(iface.MAC)), query) { + return true + } + for ip := range iface.IPs { + if strings.Contains(ip, query) { + return true + } + } + } + return false +} + +func (t *Tendrils) mcpGetTopology(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + t.nodes.mu.RLock() + defer t.nodes.mu.RUnlock() + + nodes := t.getNodesLocked() + links := t.getLinksLocked() + errors := t.errors.GetErrors() + + typeCounts := map[string]int{} + var unreachable int + for _, node := range nodes { + nodeType := string(node.Type) + if nodeType == "" { + nodeType = "unknown" + } + typeCounts[nodeType]++ + if node.Unreachable { + unreachable++ + } + } + + var sb strings.Builder + sb.WriteString("Network Topology Summary\n") + sb.WriteString("========================\n\n") + sb.WriteString(fmt.Sprintf("Total nodes: %d\n", len(nodes))) + sb.WriteString(fmt.Sprintf("Total links: %d\n", len(links))) + sb.WriteString(fmt.Sprintf("Active errors: %d\n", len(errors))) + if unreachable > 0 { + sb.WriteString(fmt.Sprintf("Unreachable nodes: %d\n", unreachable)) + } + + sb.WriteString("\nNodes by type:\n") + for nodeType, count := range typeCounts { + sb.WriteString(fmt.Sprintf(" • %s: %d\n", nodeType, count)) + } + + return mcp.NewToolResultText(sb.String()), nil +}