test(integration): mocktool JSON-RPC + federated /mcp coverage (SF4)

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.
This commit is contained in:
2026-08-21 19:21:45 +01:00
parent d5a5c3e064
commit 12d55e66ef
3 changed files with 247 additions and 0 deletions
+66
View File
@@ -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 "<serverName>_" 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