Add MCP server for network discovery tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2026-02-04 11:30:23 -08:00
parent 93e5ff90c1
commit aee1487f22
5 changed files with 416 additions and 3 deletions

View File

@@ -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 *)"

10
go.mod
View File

@@ -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

22
go.sum
View File

@@ -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=

View File

@@ -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 {

369
mcp.go Normal file
View File

@@ -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
}