Add MCP server for network discovery tools
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -98,7 +98,15 @@
|
|||||||
"Bash(while read id)",
|
"Bash(while read id)",
|
||||||
"Bash(do cat /home/flamingcow/tendrils/status)",
|
"Bash(do cat /home/flamingcow/tendrils/status)",
|
||||||
"Bash(for id in node_01kgay2wynew8tjh1bpr9sd11e node_01kgay2wynew2t7qmfvtkrq2nb node_01kgay2wykevgbkxfvavxgpq30 node_01kgay2wynew5b80p45h8mdtpj node_01kgay2wynevtsrw26tprvqysw node_01kgay2wynevy9gp10a6rr4y2d)",
|
"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": [
|
"ask": [
|
||||||
"Bash(rm *)"
|
"Bash(rm *)"
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -9,6 +9,7 @@ require (
|
|||||||
github.com/gopatchy/multicast v0.0.0-20260130233915-4278628690a3
|
github.com/gopatchy/multicast v0.0.0-20260130233915-4278628690a3
|
||||||
github.com/gopatchy/sacn v0.0.0-20260130234631-9c2787a20064
|
github.com/gopatchy/sacn v0.0.0-20260130234631-9c2787a20064
|
||||||
github.com/gosnmp/gosnmp v1.43.2
|
github.com/gosnmp/gosnmp v1.43.2
|
||||||
|
github.com/mark3labs/mcp-go v0.43.2
|
||||||
github.com/miekg/dns v1.1.72
|
github.com/miekg/dns v1.1.72
|
||||||
go.jetify.com/typeid v1.3.0
|
go.jetify.com/typeid v1.3.0
|
||||||
golang.org/x/net v0.49.0
|
golang.org/x/net v0.49.0
|
||||||
@@ -16,8 +17,15 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/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/mod v0.32.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
|||||||
22
go.sum
22
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw=
|
||||||
github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
|
github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
|
||||||
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
|
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/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 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
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 h1:PtXW+4SwVWI/JJ7XZcCbvLycF7T3C8plJirPo9kIC4Y=
|
||||||
github.com/gopatchy/artnet v0.0.0-20260204180605-8f14a4f373c2/go.mod h1:V/D32mh1xfK/llCKbrqI2jxw4xL4hf6Ge2yLiIrp9/4=
|
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=
|
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/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 h1:F9loz6uMCNtIQj0RNO5wz/mZ+FZt2WyNKJYOvw+Zosw=
|
||||||
github.com/gosnmp/gosnmp v1.43.2/go.mod h1:smHIwoaqr1M+HTAEd7+mKkPs8lp3Lf/U+htPUql1Q3c=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:fuWV7oxO4mSsgpxwhaVpFXgt0IfjogR29p+XAjDCVKY=
|
||||||
go.jetify.com/typeid v1.3.0/go.mod h1:CtVGyt2+TSp4Rq5+ARLvGsJqdNypKBAC6INQ9TLPlmk=
|
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=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
|||||||
8
http.go
8
http.go
@@ -50,6 +50,7 @@ func (t *Tendrils) startHTTPServer() {
|
|||||||
mux.HandleFunc("/tendrils/api/status", t.handleAPIStatus)
|
mux.HandleFunc("/tendrils/api/status", t.handleAPIStatus)
|
||||||
mux.HandleFunc("/tendrils/api/errors/clear", t.handleClearError)
|
mux.HandleFunc("/tendrils/api/errors/clear", t.handleClearError)
|
||||||
mux.HandleFunc("/tendrils/api/nodes/remove", t.handleRemoveNode)
|
mux.HandleFunc("/tendrils/api/nodes/remove", t.handleRemoveNode)
|
||||||
|
mux.Handle("/tendrils/mcp/", t.newMCPServer())
|
||||||
mux.Handle("/", noCacheHandler(http.FileServer(http.Dir("static"))))
|
mux.Handle("/", noCacheHandler(http.FileServer(http.Dir("static"))))
|
||||||
|
|
||||||
log.Printf("[https] listening on :443")
|
log.Printf("[https] listening on :443")
|
||||||
@@ -58,6 +59,13 @@ func (t *Tendrils) startHTTPServer() {
|
|||||||
log.Printf("[ERROR] https server failed: %v", err)
|
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 {
|
func ensureCert() error {
|
||||||
|
|||||||
369
mcp.go
Normal file
369
mcp.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user