Implements the walking skeleton from the roadmap: agents connect to one HTTP
endpoint and Nexus aggregates upstream MCP servers behind it with namespaced
tools. Written in Go (single static binary), no external services required.
What works end-to-end (verified live + hermetic e2e tests):
- MCP protocol layer: JSON-RPC 2.0 + initialize/tools.list/tools.call
(internal/mcp), with stdio (subprocess) and Streamable HTTP client
transports, and a reusable stream transport for in-process wiring.
- Router + Dynamic Tool Registry: connects/initializes upstreams in parallel,
loads tools, namespaces them as {namespace}.{tool}, dispatches tools/call to
the owning upstream; refreshes on list_changed. A failed upstream stays
not-ready without taking down the gateway (data plane stays up).
- Gateway: single MCP endpoint (POST /mcp) that is an MCP server to agents,
plus /healthz and a minimal /metrics exposition.
- CLI (cmd/nexus): `serve`, `connect` (stdio<->HTTP bridge for local agents),
`demo-mcp` (built-in zero-dep demo server: echo/add/now), `version`.
- Config: declarative YAML with env expansion + validation.
- Store: persistence interfaces + in-memory impl (SQLite lands later).
Foundations for later phases: domain types (ARCHITECTURE §7), two-plane
split, event-driven refresh seam.
Tooling: Makefile (build/test/vet/fmt with version ldflags), config.example
.yaml. GOPATH moved to /go so it doesn't collide with the module root at /root;
.gitignore whitelist extended to track Go sources while ignoring build output.
Tests: unit (registry/namespacing) + full e2e (client -> demo server over
pipes -> router -> HTTP gateway: initialize, aggregated tools/list, tool-call
routing, unknown-tool error). go vet + gofmt clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
130 lines
3.7 KiB
Go
130 lines
3.7 KiB
Go
// 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)
|
|
}
|