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
+168
View File
@@ -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, &params)
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, &params)
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,
+13
View File
@@ -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