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:
drjones
2026-07-07 09:43:08 +00:00
parent 8d3ffef920
commit 72020b1901
25 changed files with 2450 additions and 0 deletions

165
internal/e2e/e2e_test.go Normal file
View File

@@ -0,0 +1,165 @@
// Package e2e exercises the whole Phase 1 data plane end-to-end: an MCP client
// connected (over in-process pipes) to the built-in demo MCP server, aggregated
// by the Router, and reached through the Gateway's HTTP MCP endpoint.
package e2e
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/demomcp"
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/gateway"
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/mcp"
"gitea.thetempleofdoom.com/drjones/mcp-gateway-nexus/internal/router"
)
// newDemoClient wires an mcp.Client to a demo server over two pipes, mirroring
// how a real subprocess stdio transport behaves (server closes its output on
// exit so the client's read loop can unblock).
func newDemoClient(t *testing.T) *mcp.Client {
t.Helper()
toServerR, toServerW := io.Pipe() // client -> server
toClientR, toClientW := io.Pipe() // server -> client
go func() {
_ = demomcp.Run(toServerR, toClientW)
_ = toClientW.Close()
}()
transport := mcp.NewStreamTransport(toClientR, toServerW)
return mcp.NewClient(transport, mcp.Implementation{Name: "test", Version: "0"})
}
func setup(t *testing.T) (*httptest.Server, *router.Router) {
t.Helper()
r := router.New(mcp.Implementation{Name: "mcp-nexus", Version: "test"}, nil)
r.Add("demo", newDemoClient(t))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
r.ConnectAll(ctx)
statuses := r.Statuses()
if len(statuses) != 1 || !statuses[0].Ready {
t.Fatalf("upstream not ready: %+v", statuses)
}
gw := gateway.New(gateway.Options{Name: "MCP Nexus", Version: "test", MCPPath: "/mcp", Router: r})
srv := httptest.NewServer(gw.Handler())
t.Cleanup(func() { srv.Close(); r.Close() })
return srv, r
}
// rpc posts a JSON-RPC request to the gateway MCP endpoint and returns the
// decoded response message.
func rpc(t *testing.T, url, method string, params any) *mcp.Message {
t.Helper()
req, err := mcp.NewRequest(json.RawMessage(`1`), method, params)
if err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(req)
resp, err := http.Post(url+"/mcp", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var msg mcp.Message
if err := json.Unmarshal(b, &msg); err != nil {
t.Fatalf("decode response: %v (body=%s)", err, b)
}
return &msg
}
func TestGatewayInitialize(t *testing.T) {
srv, _ := setup(t)
msg := rpc(t, srv.URL, mcp.MethodInitialize, mcp.InitializeParams{
ProtocolVersion: mcp.ProtocolVersion,
ClientInfo: mcp.Implementation{Name: "agent", Version: "1"},
})
if msg.Error != nil {
t.Fatalf("initialize error: %v", msg.Error)
}
var res mcp.InitializeResult
if err := msg.UnmarshalResult(&res); err != nil {
t.Fatal(err)
}
if res.ServerInfo.Name != "MCP Nexus" {
t.Fatalf("serverInfo.name = %q, want MCP Nexus", res.ServerInfo.Name)
}
if res.Capabilities.Tools == nil {
t.Fatal("expected tools capability")
}
}
func TestGatewayToolsListAggregatesAndNamespaces(t *testing.T) {
srv, _ := setup(t)
msg := rpc(t, srv.URL, mcp.MethodToolsList, mcp.ListToolsParams{})
if msg.Error != nil {
t.Fatalf("tools/list error: %v", msg.Error)
}
var res mcp.ListToolsResult
if err := msg.UnmarshalResult(&res); err != nil {
t.Fatal(err)
}
want := map[string]bool{"demo.echo": false, "demo.add": false, "demo.now": false}
for _, tool := range res.Tools {
if _, ok := want[tool.Name]; ok {
want[tool.Name] = true
}
}
for name, found := range want {
if !found {
t.Errorf("tool %q not found in aggregated list; got %+v", name, res.Tools)
}
}
}
func TestGatewayToolCallRoutesToUpstream(t *testing.T) {
srv, _ := setup(t)
// demo.echo
msg := rpc(t, srv.URL, mcp.MethodToolsCall, mcp.CallToolParams{
Name: "demo.echo",
Arguments: json.RawMessage(`{"message":"hello nexus"}`),
})
if msg.Error != nil {
t.Fatalf("tools/call echo error: %v", msg.Error)
}
var res mcp.CallToolResult
if err := msg.UnmarshalResult(&res); err != nil {
t.Fatal(err)
}
if len(res.Content) != 1 || res.Content[0].Text != "hello nexus" {
t.Fatalf("echo result = %+v, want text 'hello nexus'", res.Content)
}
// demo.add
msg = rpc(t, srv.URL, mcp.MethodToolsCall, mcp.CallToolParams{
Name: "demo.add",
Arguments: json.RawMessage(`{"a":2,"b":40}`),
})
if msg.Error != nil {
t.Fatalf("tools/call add error: %v", msg.Error)
}
_ = msg.UnmarshalResult(&res)
if res.Content[0].Text != "42" {
t.Fatalf("add result = %q, want 42", res.Content[0].Text)
}
}
func TestGatewayUnknownToolReturnsError(t *testing.T) {
srv, _ := setup(t)
msg := rpc(t, srv.URL, mcp.MethodToolsCall, mcp.CallToolParams{Name: "nope.missing"})
if msg.Error == nil {
t.Fatal("expected error for unknown tool")
}
if msg.Error.Code != mcp.CodeMethodNotFound {
t.Fatalf("error code = %d, want %d", msg.Error.Code, mcp.CodeMethodNotFound)
}
}