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:
165
internal/e2e/e2e_test.go
Normal file
165
internal/e2e/e2e_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user