From 12d55e66ef89a860245f18dca80fbb7f12536665 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 21 Aug 2026 19:21:45 +0100 Subject: [PATCH] test(integration): mocktool JSON-RPC + federated /mcp coverage (SF4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mocktool's 'tool' mode gains two federated-/mcp JSON-RPC endpoints: '/' (lenient — every bare, session-less call succeeds, matching 8 of 11 real servers the live probe found) and '/strict' (session-required — rejects a bare tools/list/tools/call with a JSON-RPC error until a prior 'initialize' call's Mcp-Session-Id header accompanies it, matching readitall/fetch's real behavior). A new 'toolstrict' MCPServers entry in dynamic.yml.tmpl points at '/strict'. TestFederatedMCP drives POST /mcp against real Traefik+Yaegi: a federated tools/list must include both tool_echo (bare path) and toolstrict_echo (reachable only via the plugin's own bare-first-with-fallback handshake retry, mcp_federation.go's mcpBackendCall), and a tools/call to toolstrict_echo must route correctly with the prefix stripped. Closes the review gap ('only goroutine in the package, zero real-interpreter coverage') the go-test-only suite left: the handshake fallback, and the plugin's goroutine fan-out in mcpFederatedToolsList, now both run for real under Yaegi, not just against go test's in-process mocks. Verified: make integration — ok github.com/lukaszraczylo/ traefik-llmgateway/integration 40.113s, exit 0, no FAIL/panic/skip in the full run. --- integration/integration_test.go | 66 +++++++++++ integration/mock/main.go | 168 +++++++++++++++++++++++++++ integration/traefik/dynamic.yml.tmpl | 13 +++ 3 files changed, 247 insertions(+) diff --git a/integration/integration_test.go b/integration/integration_test.go index 33f0005..678f491 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -657,6 +657,72 @@ func TestPassthroughAndMCPStream(t *testing.T) { } } +// TestFederatedMCP covers integration test 8 (SF4, review round 2, v0.21): +// POST /mcp aggregates both the lenient "tool" server (a bare tools/list +// answers outright) and the strict "toolstrict" server (rejects a bare +// call with a JSON-RPC error, only answers after the plugin's own +// bare-first-with-fallback handshake retry — mcp_federation.go's +// mcpBackendCall) into one federated tools/list, then routes a tools/call +// to the correct one by its "_" prefix — all under real +// Traefik+Yaegi, not just go test's own mocks (closes the "only goroutine +// in the package, zero real-interpreter coverage" review gap). +func TestFederatedMCP(t *testing.T) { + listReq := map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"} + resp, body := doJSON(t, http.MethodPost, traefik1URL+"/mcp", aliceKey, listReq) + if resp.StatusCode != http.StatusOK { + t.Fatalf("tools/list: status = %d, body=%#v", resp.StatusCode, body) + } + if body["error"] != nil { + t.Fatalf("tools/list: unexpected JSON-RPC error: %#v", body["error"]) + } + result, ok := body["result"].(map[string]any) + if !ok { + t.Fatalf("tools/list: result = %#v, want an object", body["result"]) + } + tools, ok := result["tools"].([]any) + if !ok { + t.Fatalf("tools/list: tools = %#v, want an array", result["tools"]) + } + + var names []string + for _, tool := range tools { + toolMap, ok := tool.(map[string]any) + if !ok { + continue + } + if name, ok := toolMap["name"].(string); ok { + names = append(names, name) + } + } + if !slices.Contains(names, "tool_echo") { + t.Errorf("tools/list names = %v, want tool_echo (the lenient server's bare tools/list)", names) + } + if !slices.Contains(names, "toolstrict_echo") { + t.Errorf("tools/list names = %v, want toolstrict_echo (the strict server, reachable ONLY via the plugin's own handshake-fallback retry)", names) + } + + callReq := map[string]any{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": map[string]any{"name": "toolstrict_echo", "arguments": map[string]any{"q": "hello"}}, + } + callResp, callBody := doJSON(t, http.MethodPost, traefik1URL+"/mcp", aliceKey, callReq) + if callResp.StatusCode != http.StatusOK { + t.Fatalf("tools/call: status = %d, body=%#v", callResp.StatusCode, callBody) + } + if callBody["error"] != nil { + t.Fatalf("tools/call: unexpected JSON-RPC error: %#v", callBody["error"]) + } + callResult, ok := callBody["result"].(map[string]any) + if !ok { + t.Fatalf("tools/call: result = %#v, want an object", callBody["result"]) + } + if got, want := callResult["echoed"], "echo"; got != want { + t.Errorf("tools/call: echoed = %v, want %q (the server-name prefix must be stripped before forwarding to toolstrict)", got, want) + } +} + // TestRealUpstreamSmoke covers integration test 7 (optional): a live round // trip through a second middleware config carrying a keyless openai-type // "uni" provider pointed at the operator's real gateway. Gated on diff --git a/integration/mock/main.go b/integration/mock/main.go index 1c1a59b..4ab5b8b 100644 --- a/integration/mock/main.go +++ b/integration/mock/main.go @@ -27,6 +27,7 @@ import ( "os" "strconv" "strings" + "sync" "sync/atomic" "time" ) @@ -93,6 +94,22 @@ func newServerMux(mode string) (*http.ServeMux, error) { mux.HandleFunc("/v1beta/models/", handleGeminiGenerate) case "tool": mux.HandleFunc("/sse", handleToolSSE) + // "/" is this mode's own base URL (Config.MCPServers["tool"].URL, + // no path — an empty-path request's wire target normalizes to + // "/") — the LENIENT federated-/mcp JSON-RPC endpoint (SF4, + // review round 2, v0.21): every bare, session-less call succeeds + // outright, modeling the 8-of-11 majority the live production + // probe found (mcp_federation.go's own mcpBackendCall doc + // comment). "/strict" is the STRICT counterpart + // (Config.MCPServers["toolstrict"].URL, + // dynamic.yml.tmpl) — every method but "initialize" is rejected + // with a JSON-RPC error until a real Mcp-Session-Id header from a + // prior initialize accompanies it, modeling readitall/fetch's + // real, live-probed behavior — so the integration suite exercises + // BOTH the bare path and the plugin's handshake-fallback retry + // under real Traefik+Yaegi, not just go test's own mocks. + mux.HandleFunc("/", handleToolJSONRPC) + mux.HandleFunc("/strict", handleToolJSONRPCStrict) default: return nil, fmt.Errorf("unknown -mode %q (want openai, anthropic, gemini, or tool)", mode) } @@ -443,6 +460,157 @@ func handleToolSSE(w http.ResponseWriter, _ *http.Request) { } } +// --- tool mode: federated /mcp JSON-RPC endpoints (SF4, review round 2, v0.21) --- + +// mcpSessionHeader mirrors the plugin's own constant of the same name +// (mcp_federation.go) — duplicated here rather than imported, since +// integration/ is its own Go module (integration/go.mod) and this mock +// process is a wire-protocol peer of the plugin, not a code dependent on +// its internals. +const mcpSessionHeader = "Mcp-Session-Id" + +// jsonrpcMsg is the minimal JSON-RPC 2.0 envelope this mock's tool-mode +// handlers read and write — matching the plugin's own wire format +// (mcp_federation.go's jsonrpcRequest/jsonrpcResponse) field for field, +// without sharing Go types across the two separate modules. +type jsonrpcMsg struct { + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *jsonrpcMsgErr `json:"error,omitempty"` + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + ID json.RawMessage `json:"id,omitempty"` +} + +type jsonrpcMsgErr struct { + Message string `json:"message"` + Code int `json:"code"` +} + +// toolMockTools is the fixed "tools/list" result both the lenient ("/") +// and strict ("/strict") JSON-RPC endpoints answer with, once reachable. +var toolMockTools = []map[string]any{ + {"name": "echo", "description": "echoes its call arguments back"}, +} + +// writeJSONRPC writes msg as a JSON-RPC response body at HTTP 200 — +// JSON-RPC errors are payload-level, matching the plugin's own +// writeJSONRPCEnvelope convention (mcp_federation.go) this mock mirrors +// so the integration suite exercises the real wire shape end to end. +func writeJSONRPC(w http.ResponseWriter, msg jsonrpcMsg) { + msg.JSONRPC = "2.0" + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(msg) +} + +// mcpToolCallParams is the "tools/call" params shape both endpoints below +// decode — name and (optional) arguments, matching the plugin's own +// mcpToolCallParams (mcp_federation.go). +type mcpToolCallParams struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments,omitempty"` +} + +// handleToolJSONRPC answers the LENIENT federated-/mcp endpoint +// (Config.MCPServers["tool"].URL, dynamic.yml.tmpl): every bare, +// session-less call succeeds outright — the majority-case behavior the +// live production probe found (8 of 11 real servers), and the plugin's +// own "bare-first" fast path (mcp_federation.go's mcpBackendCall) never +// needs to retry against this server at all. +func handleToolJSONRPC(w http.ResponseWriter, r *http.Request) { + var req jsonrpcMsg + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONRPC(w, jsonrpcMsg{Error: &jsonrpcMsgErr{Code: -32700, Message: "parse error"}}) + return + } + switch req.Method { + case "initialize": + result, _ := json.Marshal(map[string]any{"protocolVersion": "2025-11-25", "capabilities": map[string]any{}}) + writeJSONRPC(w, jsonrpcMsg{ID: req.ID, Result: result}) + case "tools/list": + result, _ := json.Marshal(map[string]any{"tools": toolMockTools}) + writeJSONRPC(w, jsonrpcMsg{ID: req.ID, Result: result}) + case "tools/call": + var params mcpToolCallParams + _ = json.Unmarshal(req.Params, ¶ms) + result, _ := json.Marshal(map[string]any{"echoed": params.Name}) + writeJSONRPC(w, jsonrpcMsg{ID: req.ID, Result: result}) + default: + writeJSONRPC(w, jsonrpcMsg{ID: req.ID, Error: &jsonrpcMsgErr{Code: -32601, Message: "method not found"}}) + } +} + +// toolStrictSessions tracks the session ids "/strict" has issued via its +// own "initialize" — guarded by toolStrictSessionsMu, since the compose +// stack's traefik1 can serve concurrent requests against this one mock +// process. toolStrictSessionSeq mints a new id per initialize call. +var ( + toolStrictSessionsMu sync.Mutex + toolStrictSessions = map[string]bool{} + toolStrictSessionSeq atomic.Int64 +) + +// handleToolJSONRPCStrict answers the STRICT federated-/mcp endpoint +// (Config.MCPServers["toolstrict"].URL, dynamic.yml.tmpl): "initialize" +// always succeeds and mints a fresh Mcp-Session-Id (returned as a +// response header); every OTHER method is rejected with a JSON-RPC error +// unless the request carries a session id this handler itself issued — +// modeling readitall/fetch's real, live-probed behavior (a JSON-RPC +// error on a bare call, not an HTTP status) so the integration suite +// exercises the plugin's bare-first-with-fallback handshake retry +// (mcp_federation.go's mcpBackendCall) under real Traefik+Yaegi. DELETE +// ends a session — the plugin's own best-effort cleanup call. +func handleToolJSONRPCStrict(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + toolStrictSessionsMu.Lock() + delete(toolStrictSessions, r.Header.Get(mcpSessionHeader)) + toolStrictSessionsMu.Unlock() + w.WriteHeader(http.StatusNoContent) + return + } + + var req jsonrpcMsg + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONRPC(w, jsonrpcMsg{Error: &jsonrpcMsgErr{Code: -32700, Message: "parse error"}}) + return + } + + if req.Method == "initialize" { + sessionID := fmt.Sprintf("strict-session-%d", toolStrictSessionSeq.Add(1)) + toolStrictSessionsMu.Lock() + toolStrictSessions[sessionID] = true + toolStrictSessionsMu.Unlock() + w.Header().Set(mcpSessionHeader, sessionID) + result, _ := json.Marshal(map[string]any{"protocolVersion": "2025-11-25", "capabilities": map[string]any{}}) + writeJSONRPC(w, jsonrpcMsg{ID: req.ID, Result: result}) + return + } + + toolStrictSessionsMu.Lock() + valid := toolStrictSessions[r.Header.Get(mcpSessionHeader)] + toolStrictSessionsMu.Unlock() + if !valid { + writeJSONRPC(w, jsonrpcMsg{ID: req.ID, Error: &jsonrpcMsgErr{ + Code: -32000, + Message: "method " + req.Method + " is invalid during session-less access; call initialize first", + }}) + return + } + + switch req.Method { + case "tools/list": + result, _ := json.Marshal(map[string]any{"tools": toolMockTools}) + writeJSONRPC(w, jsonrpcMsg{ID: req.ID, Result: result}) + case "tools/call": + var params mcpToolCallParams + _ = json.Unmarshal(req.Params, ¶ms) + result, _ := json.Marshal(map[string]any{"echoed": params.Name}) + writeJSONRPC(w, jsonrpcMsg{ID: req.ID, Result: result}) + default: + writeJSONRPC(w, jsonrpcMsg{ID: req.ID, Error: &jsonrpcMsgErr{Code: -32601, Message: "method not found"}}) + } +} + // --- probe mode ---------------------------------------------------------- // runProbe dials target over a plain net.Conn (never through an http.Client, diff --git a/integration/traefik/dynamic.yml.tmpl b/integration/traefik/dynamic.yml.tmpl index 5d2f881..303ef33 100644 --- a/integration/traefik/dynamic.yml.tmpl +++ b/integration/traefik/dynamic.yml.tmpl @@ -120,6 +120,19 @@ http: mcpServers: tool: url: "http://mocktool:8080" + # toolstrict backs the federated-/mcp handshake-fallback + # integration coverage (SF4, review round 2, v0.21): mocktool's + # own "/strict" route (integration/mock/main.go's + # handleToolJSONRPCStrict) rejects a bare, session-less + # tools/list or tools/call with a JSON-RPC error and only + # honors one carrying a session id its own "initialize" + # issued — the same shape readitall/fetch answer live, and the + # exact trigger the plugin's bare-first-with-fallback retry + # (mcp_federation.go's mcpBackendCall) exists for. Proves that + # retry works under real Traefik+Yaegi, not just go test's own + # mocks. + toolstrict: + url: "http://mocktool:8080/strict" # v0.2 features (spec §1, §2, §4, §5), all opt-in and off by # default in v0.1: enabled here so the integration suite # exercises every one under real Traefik+Yaegi, not just go