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 }