// Package router implements the Dynamic Tool Registry and the MCP Router: // it aggregates every upstream MCP server, namespaces their tools to avoid // collisions, and dispatches tools/call to the owning upstream (see the // Gateway and Dynamic Tool Registry module docs). package router import ( "sort" "strings" "sync" "gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/domain" "gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/mcp" ) // NamespaceSep separates a namespace from a tool name in a qualified id. const NamespaceSep = "." // Qualify builds the agent-facing tool id "{namespace}.{name}". func Qualify(namespace, name string) string { return namespace + NamespaceSep + name } // SplitQualified splits a qualified id into namespace and upstream tool name on // the first separator. ok is false if there is no separator. func SplitQualified(qualified string) (namespace, name string, ok bool) { idx := strings.Index(qualified, NamespaceSep) if idx <= 0 || idx == len(qualified)-1 { return "", "", false } return qualified[:idx], qualified[idx+1:], true } type indexed struct { namespace string name string // upstream (un-namespaced) tool name def mcp.ToolDefinition } // Registry is the live, namespaced catalog of every tool exposed by every // healthy upstream. It is safe for concurrent use. type Registry struct { mu sync.RWMutex tools map[string]indexed // qualified id -> entry } // NewRegistry creates an empty registry. func NewRegistry() *Registry { return &Registry{tools: make(map[string]indexed)} } // Replace atomically swaps the full set of tools for a namespace. func (r *Registry) Replace(namespace string, defs []mcp.ToolDefinition) { r.mu.Lock() defer r.mu.Unlock() // drop existing tools for this namespace for q, e := range r.tools { if e.namespace == namespace { delete(r.tools, q) } } for _, d := range defs { q := Qualify(namespace, d.Name) r.tools[q] = indexed{namespace: namespace, name: d.Name, def: d} } } // Remove drops all tools belonging to a namespace (e.g. upstream went away). func (r *Registry) Remove(namespace string) { r.mu.Lock() defer r.mu.Unlock() for q, e := range r.tools { if e.namespace == namespace { delete(r.tools, q) } } } // Lookup resolves a qualified id to its owning namespace and upstream tool name. func (r *Registry) Lookup(qualified string) (namespace, name string, ok bool) { r.mu.RLock() defer r.mu.RUnlock() e, found := r.tools[qualified] if !found { return "", "", false } return e.namespace, e.name, true } // Definitions returns the namespaced tool definitions to advertise to agents // via tools/list. Names are the qualified ids. func (r *Registry) Definitions() []mcp.ToolDefinition { r.mu.RLock() defer r.mu.RUnlock() out := make([]mcp.ToolDefinition, 0, len(r.tools)) for q, e := range r.tools { d := e.def d.Name = q // present the namespaced id to agents out = append(out, d) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // List returns the catalog as domain.Tool records (for APIs/dashboard). func (r *Registry) List() []domain.Tool { r.mu.RLock() defer r.mu.RUnlock() out := make([]domain.Tool, 0, len(r.tools)) for q, e := range r.tools { out = append(out, domain.Tool{ Namespace: e.namespace, Name: e.name, QualifiedID: q, Title: e.def.Title, Description: e.def.Description, InputSchema: e.def.InputSchema, }) } sort.Slice(out, func(i, j int) bool { return out[i].QualifiedID < out[j].QualifiedID }) return out } // Len returns the number of registered tools. func (r *Registry) Len() int { r.mu.RLock() defer r.mu.RUnlock() return len(r.tools) }