// 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) } }