feat: Phase 0/1 — runnable MCP gateway (aggregation + namespaced routing)
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>
This commit is contained in:
129
internal/router/registry.go
Normal file
129
internal/router/registry.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// 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)
|
||||
}
|
||||
66
internal/router/registry_test.go
Normal file
66
internal/router/registry_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/mcp"
|
||||
)
|
||||
|
||||
func TestQualifyAndSplit(t *testing.T) {
|
||||
q := Qualify("demo", "echo")
|
||||
if q != "demo.echo" {
|
||||
t.Fatalf("Qualify = %q, want demo.echo", q)
|
||||
}
|
||||
ns, name, ok := SplitQualified("demo.echo")
|
||||
if !ok || ns != "demo" || name != "echo" {
|
||||
t.Fatalf("SplitQualified = (%q,%q,%v)", ns, name, ok)
|
||||
}
|
||||
// tool name containing a dot: split on first separator only
|
||||
ns, name, ok = SplitQualified("fs.read.file")
|
||||
if !ok || ns != "fs" || name != "read.file" {
|
||||
t.Fatalf("SplitQualified dotted = (%q,%q,%v)", ns, name, ok)
|
||||
}
|
||||
if _, _, ok := SplitQualified("nodot"); ok {
|
||||
t.Fatal("SplitQualified with no separator should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryReplaceLookupDefinitions(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Replace("demo", []mcp.ToolDefinition{
|
||||
{Name: "echo", Description: "echo"},
|
||||
{Name: "add", Description: "add"},
|
||||
})
|
||||
r.Replace("fs", []mcp.ToolDefinition{{Name: "read"}})
|
||||
|
||||
if r.Len() != 3 {
|
||||
t.Fatalf("Len = %d, want 3", r.Len())
|
||||
}
|
||||
ns, name, ok := r.Lookup("demo.echo")
|
||||
if !ok || ns != "demo" || name != "echo" {
|
||||
t.Fatalf("Lookup demo.echo = (%q,%q,%v)", ns, name, ok)
|
||||
}
|
||||
|
||||
defs := r.Definitions()
|
||||
if len(defs) != 3 {
|
||||
t.Fatalf("Definitions len = %d, want 3", len(defs))
|
||||
}
|
||||
// definitions must expose the namespaced id and be sorted
|
||||
if defs[0].Name != "demo.add" {
|
||||
t.Fatalf("first def = %q, want demo.add", defs[0].Name)
|
||||
}
|
||||
|
||||
// replacing a namespace must not leave stale tools
|
||||
r.Replace("demo", []mcp.ToolDefinition{{Name: "echo"}})
|
||||
if _, _, ok := r.Lookup("demo.add"); ok {
|
||||
t.Fatal("demo.add should be gone after replace")
|
||||
}
|
||||
if r.Len() != 2 {
|
||||
t.Fatalf("Len after replace = %d, want 2", r.Len())
|
||||
}
|
||||
|
||||
r.Remove("fs")
|
||||
if _, _, ok := r.Lookup("fs.read"); ok {
|
||||
t.Fatal("fs.read should be gone after Remove")
|
||||
}
|
||||
}
|
||||
189
internal/router/router.go
Normal file
189
internal/router/router.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/mcp"
|
||||
)
|
||||
|
||||
// Upstream is a single aggregated MCP server behind the Gateway.
|
||||
type Upstream struct {
|
||||
Namespace string
|
||||
Client *mcp.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
ready bool
|
||||
server mcp.Implementation
|
||||
lastErr error
|
||||
}
|
||||
|
||||
func (u *Upstream) setReady(server mcp.Implementation) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
u.ready = true
|
||||
u.server = server
|
||||
u.lastErr = nil
|
||||
}
|
||||
|
||||
func (u *Upstream) setErr(err error) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
u.ready = false
|
||||
u.lastErr = err
|
||||
}
|
||||
|
||||
// Status is a snapshot of an upstream's connection state.
|
||||
type Status struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Ready bool `json:"ready"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Router aggregates upstreams and routes tool calls, backed by the Registry.
|
||||
type Router struct {
|
||||
info mcp.Implementation
|
||||
log *slog.Logger
|
||||
registry *Registry
|
||||
|
||||
mu sync.RWMutex
|
||||
ups map[string]*Upstream // by namespace
|
||||
}
|
||||
|
||||
// New creates a Router. info identifies Nexus to upstreams.
|
||||
func New(info mcp.Implementation, log *slog.Logger) *Router {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Router{
|
||||
info: info,
|
||||
log: log,
|
||||
registry: NewRegistry(),
|
||||
ups: make(map[string]*Upstream),
|
||||
}
|
||||
}
|
||||
|
||||
// Registry exposes the tool catalog.
|
||||
func (r *Router) Registry() *Registry { return r.registry }
|
||||
|
||||
// Add registers an upstream client under a namespace. It does not connect.
|
||||
func (r *Router) Add(namespace string, client *mcp.Client) *Upstream {
|
||||
u := &Upstream{Namespace: namespace, Client: client}
|
||||
r.mu.Lock()
|
||||
r.ups[namespace] = u
|
||||
r.mu.Unlock()
|
||||
return u
|
||||
}
|
||||
|
||||
// ConnectAll initializes every upstream and loads its tools, in parallel.
|
||||
// Individual failures are logged and leave that upstream not-ready rather than
|
||||
// failing the whole gateway (the data plane must stay up — ARCHITECTURE §5).
|
||||
func (r *Router) ConnectAll(ctx context.Context) {
|
||||
r.mu.RLock()
|
||||
ups := make([]*Upstream, 0, len(r.ups))
|
||||
for _, u := range r.ups {
|
||||
ups = append(ups, u)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, u := range ups {
|
||||
wg.Add(1)
|
||||
go func(u *Upstream) {
|
||||
defer wg.Done()
|
||||
if err := r.connect(ctx, u); err != nil {
|
||||
u.setErr(err)
|
||||
r.log.Warn("upstream connect failed", "namespace", u.Namespace, "error", err)
|
||||
}
|
||||
}(u)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (r *Router) connect(ctx context.Context, u *Upstream) error {
|
||||
if err := u.Client.Initialize(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
u.setReady(u.Client.ServerInfo())
|
||||
// Refresh tools list on upstream-initiated change notifications.
|
||||
u.Client.SetNotificationHandler(func(m *mcp.Message) {
|
||||
if m.Method == mcp.MethodToolListChged {
|
||||
r.log.Info("upstream tools changed", "namespace", u.Namespace)
|
||||
// Best-effort refresh in the background.
|
||||
go func() {
|
||||
if err := r.RefreshTools(context.Background(), u); err != nil {
|
||||
r.log.Warn("tool refresh failed", "namespace", u.Namespace, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
})
|
||||
return r.RefreshTools(ctx, u)
|
||||
}
|
||||
|
||||
// RefreshTools reloads and re-indexes a single upstream's tools.
|
||||
func (r *Router) RefreshTools(ctx context.Context, u *Upstream) error {
|
||||
defs, err := u.Client.ListTools(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.registry.Replace(u.Namespace, defs)
|
||||
r.log.Info("indexed upstream tools", "namespace", u.Namespace, "count", len(defs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTools returns the aggregated, namespaced tool definitions for agents.
|
||||
func (r *Router) ListTools() []mcp.ToolDefinition {
|
||||
return r.registry.Definitions()
|
||||
}
|
||||
|
||||
// CallTool routes a namespaced tool call to its owning upstream and returns the
|
||||
// raw MCP result (passed through unmodified) or a JSON-RPC error.
|
||||
func (r *Router) CallTool(ctx context.Context, qualified string, args json.RawMessage) (json.RawMessage, *mcp.RPCError, error) {
|
||||
namespace, name, ok := r.registry.Lookup(qualified)
|
||||
if !ok {
|
||||
return nil, &mcp.RPCError{Code: mcp.CodeMethodNotFound, Message: fmt.Sprintf("unknown tool %q", qualified)}, nil
|
||||
}
|
||||
r.mu.RLock()
|
||||
u := r.ups[namespace]
|
||||
r.mu.RUnlock()
|
||||
if u == nil {
|
||||
return nil, &mcp.RPCError{Code: mcp.CodeInternalError, Message: fmt.Sprintf("no upstream for namespace %q", namespace)}, nil
|
||||
}
|
||||
u.mu.RLock()
|
||||
ready := u.ready
|
||||
u.mu.RUnlock()
|
||||
if !ready {
|
||||
return nil, &mcp.RPCError{Code: mcp.CodeInternalError, Message: fmt.Sprintf("upstream %q is not ready", namespace)}, nil
|
||||
}
|
||||
return u.Client.CallTool(ctx, name, args)
|
||||
}
|
||||
|
||||
// Statuses returns a snapshot of every upstream's connection state.
|
||||
func (r *Router) Statuses() []Status {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]Status, 0, len(r.ups))
|
||||
for _, u := range r.ups {
|
||||
u.mu.RLock()
|
||||
s := Status{Namespace: u.Namespace, Ready: u.ready, Server: u.server.Name}
|
||||
if u.lastErr != nil {
|
||||
s.Error = u.lastErr.Error()
|
||||
}
|
||||
u.mu.RUnlock()
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Close shuts down every upstream client.
|
||||
func (r *Router) Close() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, u := range r.ups {
|
||||
_ = u.Client.Close()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user