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:
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user