mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
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.
700 lines
27 KiB
Go
700 lines
27 KiB
Go
// Command mock is the integration suite's fake upstream. It runs in two
|
|
// distinct modes, selected by flags:
|
|
//
|
|
// - Server mode (-mode openai|anthropic|gemini|tool): serves the minimal
|
|
// subset of one upstream's wire API the corresponding plugin adapter
|
|
// calls, using canned fixtures shaped like the plugin's own unit-test
|
|
// fakes (see provider_openai_test.go, provider_anthropic_test.go,
|
|
// provider_gemini_test.go). "tool" mode serves a bare SSE endpoint,
|
|
// standing in for an MCP target the gateway's target proxy forwards to
|
|
// verbatim.
|
|
// - Probe mode (-probe): a raw-TCP client that dials a running gateway
|
|
// directly (inside the compose network, never through the Docker
|
|
// Desktop host port-proxy — that layer buffers and hides true
|
|
// inter-chunk timing), issues one streaming chat completion by hand,
|
|
// and reports via its exit code whether the response arrived as
|
|
// genuinely incremental SSE chunks rather than one buffered write.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// streamChunkGap is the pause between successive SSE writes in every mode's
|
|
// streaming handler. The probe (see runProbe) requires at least 3 gaps of
|
|
// 100ms or more between arrivals; 150ms per gap leaves comfortable margin
|
|
// against scheduler jitter while keeping the suite fast.
|
|
const streamChunkGap = 150 * time.Millisecond
|
|
|
|
func main() {
|
|
mode := flag.String("mode", "", "server mode: openai, anthropic, gemini, or tool")
|
|
addr := flag.String("addr", ":8080", "listen address for server mode")
|
|
probe := flag.Bool("probe", false, "run as a raw-TCP streaming probe instead of a server")
|
|
target := flag.String("target", "traefik1:80", "probe mode: host:port of the gateway to dial")
|
|
apiKey := flag.String("apikey", "sk-int-alice", "probe mode: gateway API key to present")
|
|
model := flag.String("model", "openai/gpt-mock", "probe mode: model id to request")
|
|
flag.Parse()
|
|
|
|
if *probe {
|
|
os.Exit(runProbe(*target, *apiKey, *model))
|
|
}
|
|
|
|
mux, err := newServerMux(*mode)
|
|
if err != nil {
|
|
log.Fatalf("mock: %v", err)
|
|
}
|
|
log.Printf("mock: mode=%s listening on %s", *mode, *addr)
|
|
if err := http.ListenAndServe(*addr, mux); err != nil { //nolint:gosec // integration-only fixture server, no need for timeouts
|
|
log.Fatalf("mock: listen: %v", err)
|
|
}
|
|
}
|
|
|
|
// newServerMux returns the handler set for one server mode.
|
|
func newServerMux(mode string) (*http.ServeMux, error) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
|
|
switch mode {
|
|
case "openai":
|
|
mux.HandleFunc("/v1/models", handleOpenAIModels)
|
|
mux.HandleFunc("/v1/chat/completions", handleOpenAIChat)
|
|
// flaky/v1/chat/completions backs the retry integration test (task
|
|
// 5): the plugin's "flaky" provider points its baseUrl at this
|
|
// mode's own address plus "/flaky", so a.baseURL+"/v1/chat/
|
|
// completions" (provider_openai.go) resolves to exactly this path.
|
|
// flaky/reset is reachable the same way, through the "flaky"
|
|
// provider's native passthrough route (adapter.base()+"/"+rest,
|
|
// routes_passthrough.go) — the test calls it directly, never this
|
|
// mock process's own network address.
|
|
mux.HandleFunc("/flaky/v1/chat/completions", handleFlakyOpenAIChat)
|
|
mux.HandleFunc("/flaky/reset", handleFlakyReset)
|
|
mux.HandleFunc("/v1/images/generations", handleOpenAIImages)
|
|
mux.HandleFunc("/v1/audio/speech", handleOpenAIAudioSpeech)
|
|
mux.HandleFunc("/v1/audio/transcriptions", handleOpenAIAudioTranscriptions)
|
|
case "anthropic":
|
|
mux.HandleFunc("/v1/models", handleAnthropicModels)
|
|
mux.HandleFunc("/v1/messages", handleAnthropicMessages)
|
|
case "gemini":
|
|
mux.HandleFunc("/v1beta/models", handleGeminiModels)
|
|
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)
|
|
}
|
|
return mux, nil
|
|
}
|
|
|
|
// decodeJSONBody reads and parses r's body as a generic JSON object. It
|
|
// returns an empty, non-nil map on any read/parse error — every handler
|
|
// below only reads a couple of optional fields out of it, so a malformed
|
|
// body degrades to default fixture values rather than a 400 the tests
|
|
// don't care about.
|
|
func decodeJSONBody(r *http.Request) map[string]any {
|
|
var body map[string]any
|
|
defer func() { _ = r.Body.Close() }()
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
return map[string]any{}
|
|
}
|
|
return body
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// sseFlusher wraps the response writer for one SSE handler, setting the
|
|
// standard streaming headers up front and flushing after every write so a
|
|
// downstream proxy (the gateway, Traefik) observes each event as it is
|
|
// produced rather than buffered until the handler returns.
|
|
type sseFlusher struct {
|
|
w http.ResponseWriter
|
|
f http.Flusher
|
|
}
|
|
|
|
func newSSEFlusher(w http.ResponseWriter) sseFlusher {
|
|
h := w.Header()
|
|
h.Set("Content-Type", "text/event-stream")
|
|
h.Set("Cache-Control", "no-cache")
|
|
h.Set("X-Accel-Buffering", "no")
|
|
w.WriteHeader(http.StatusOK)
|
|
f, _ := w.(http.Flusher)
|
|
return sseFlusher{w: w, f: f}
|
|
}
|
|
|
|
func (s sseFlusher) write(event, data string) {
|
|
if event != "" {
|
|
_, _ = fmt.Fprintf(s.w, "event: %s\n", event)
|
|
}
|
|
_, _ = fmt.Fprintf(s.w, "data: %s\n\n", data)
|
|
if s.f != nil {
|
|
s.f.Flush()
|
|
}
|
|
}
|
|
|
|
// --- openai mode -----------------------------------------------------------
|
|
|
|
func handleOpenAIModels(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, map[string]any{
|
|
"object": "list",
|
|
"data": []map[string]any{
|
|
{"id": "gpt-mock", "object": "model", "owned_by": "mock-openai"},
|
|
},
|
|
})
|
|
}
|
|
|
|
// openaiStreamDeltas are the content fragments the streaming handler emits
|
|
// as separate SSE frames, one streamChunkGap apart, mirroring the shape of
|
|
// provider_openai_test.go's streamFrames fixture.
|
|
var openaiStreamDeltas = []string{"Hel", "lo", " wor", "ld", "!"}
|
|
|
|
func handleOpenAIChat(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Mock-Received-Auth", r.Header.Get("Authorization"))
|
|
body := decodeJSONBody(r)
|
|
model, _ := body["model"].(string)
|
|
if model == "" {
|
|
model = "gpt-mock"
|
|
}
|
|
stream, _ := body["stream"].(bool)
|
|
|
|
if !stream {
|
|
writeJSON(w, map[string]any{
|
|
"id": "chatcmpl-mock",
|
|
"object": "chat.completion",
|
|
"model": model,
|
|
"choices": []map[string]any{
|
|
{
|
|
"index": 0,
|
|
"message": map[string]any{"role": "assistant", "content": "mock openai response"},
|
|
"finish_reason": "stop",
|
|
},
|
|
},
|
|
"usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 5},
|
|
})
|
|
return
|
|
}
|
|
|
|
sw := newSSEFlusher(w)
|
|
for i, delta := range openaiStreamDeltas {
|
|
deltaObj := map[string]any{"content": delta}
|
|
if i == 0 {
|
|
deltaObj["role"] = "assistant"
|
|
}
|
|
frame := map[string]any{
|
|
"id": "chatcmpl-mock",
|
|
"choices": []map[string]any{{"index": 0, "delta": deltaObj}},
|
|
}
|
|
b, _ := json.Marshal(frame)
|
|
sw.write("", string(b))
|
|
time.Sleep(streamChunkGap)
|
|
}
|
|
usage, _ := json.Marshal(map[string]any{
|
|
"id": "chatcmpl-mock",
|
|
"choices": []map[string]any{},
|
|
"usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 5},
|
|
})
|
|
sw.write("", string(usage))
|
|
time.Sleep(streamChunkGap)
|
|
sw.write("", "[DONE]")
|
|
}
|
|
|
|
// flakyHits counts every request handleFlakyOpenAIChat has served since
|
|
// the last reset (handleFlakyReset, below), or since process start if
|
|
// never reset. It is package-level state deliberately, not reset per
|
|
// request: the retry integration test relies on exactly the FIRST
|
|
// request counted this way failing, and every one after succeeding.
|
|
// TestRetryFlakyRecovers calls handleFlakyReset (via the gateway's own
|
|
// "flaky" passthrough provider) at its own start, so a re-run against an
|
|
// already-up stack (`make integration-keep`, no mockopenai restart)
|
|
// reproduces the same forced-503-then-success scenario every time — the
|
|
// same idempotence flushRedis gives the rate-limit counters via
|
|
// TestCrossReplicaLimits.
|
|
var flakyHits int32
|
|
|
|
// handleFlakyReset resets flakyHits to 0, letting the retry integration
|
|
// test reproduce its forced-503-then-success scenario on demand rather
|
|
// than only on this process's true first request. POST only — a GET (or
|
|
// any other method) must never mutate state; it answers 405 instead.
|
|
func handleFlakyReset(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
atomic.StoreInt32(&flakyHits, 0)
|
|
writeJSON(w, map[string]any{"reset": true})
|
|
}
|
|
|
|
// handleFlakyOpenAIChat answers the first request since the last reset
|
|
// (handleFlakyReset, or process start if never reset) with HTTP 503 (a
|
|
// transient failure retryPolicy.isTransient classifies as retryable,
|
|
// retry.go) and every request after that with a normal chat completion
|
|
// response carrying an extra top-level
|
|
// "mock_attempt" field set to this handler's own call count — the
|
|
// unified route's openai-type adapter forwards a JSON response body
|
|
// verbatim (forwardJSON, provider_openai.go), so this field survives
|
|
// straight through to the client and lets the retry integration test
|
|
// prove the mock was hit exactly twice without needing to inspect
|
|
// response headers forwardJSON never copies.
|
|
func handleFlakyOpenAIChat(w http.ResponseWriter, r *http.Request) {
|
|
n := atomic.AddInt32(&flakyHits, 1)
|
|
if n == 1 {
|
|
http.Error(w, `{"error":{"message":"mock flaky failure","type":"server_error"}}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
body := decodeJSONBody(r)
|
|
model, _ := body["model"].(string)
|
|
if model == "" {
|
|
model = "flaky-mock"
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"id": "chatcmpl-flaky",
|
|
"object": "chat.completion",
|
|
"model": model,
|
|
"mock_attempt": n,
|
|
"choices": []map[string]any{
|
|
{
|
|
"index": 0,
|
|
"message": map[string]any{"role": "assistant", "content": "mock flaky response"},
|
|
"finish_reason": "stop",
|
|
},
|
|
},
|
|
"usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 5},
|
|
})
|
|
}
|
|
|
|
// handleOpenAIImages answers POST /v1/images/generations with a canned
|
|
// OpenAI images.generations response — the images integration test's
|
|
// openai-type case is a native forward (routes_media.go), so whatever
|
|
// this returns reaches the client unmodified.
|
|
func handleOpenAIImages(w http.ResponseWriter, r *http.Request) {
|
|
_ = decodeJSONBody(r) // request shape isn't asserted on; draining it is enough
|
|
writeJSON(w, map[string]any{
|
|
"created": 1755600000,
|
|
"data": []map[string]any{
|
|
{"b64_json": "bW9jay1vcGVuYWktaW1hZ2UtYnl0ZXM=", "revised_prompt": "mock revised prompt"},
|
|
},
|
|
})
|
|
}
|
|
|
|
// mockAudioBytes is the fixed binary payload handleOpenAIAudioSpeech
|
|
// returns — deliberately not valid MP3 data, since nothing in the
|
|
// gateway's audioSpeech path (a pure byte-for-byte stream,
|
|
// provider_openai.go) ever parses it; only its content-type and byte
|
|
// equality on round trip matter to the audio integration test.
|
|
var mockAudioBytes = []byte("MOCK-AUDIO-BYTES-0123456789-DISTINCTIVE-PAYLOAD")
|
|
|
|
// handleOpenAIAudioSpeech answers POST /v1/audio/speech with mockAudioBytes
|
|
// under Content-Type audio/mpeg, mirroring a real TTS endpoint's binary
|
|
// response.
|
|
func handleOpenAIAudioSpeech(w http.ResponseWriter, r *http.Request) {
|
|
_ = decodeJSONBody(r)
|
|
w.Header().Set("Content-Type", "audio/mpeg")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(mockAudioBytes)
|
|
}
|
|
|
|
// handleOpenAIAudioTranscriptions answers POST /v1/audio/transcriptions by
|
|
// parsing the multipart body Traefik forwarded and echoing back the "model"
|
|
// form field it finds — this is the review wave's highest-value Yaegi
|
|
// coverage gap closed: the audio-transcriptions integration test sends a
|
|
// provider-prefixed model ("openai/gpt-mock") and asserts this handler
|
|
// receives the bare, rewritten form ("gpt-mock"), proving
|
|
// rewriteMultipartModel (routes_media.go) actually rebuilds the multipart
|
|
// body under real Traefik+Yaegi, not just in the compiled unit tests. A
|
|
// second, already-bare request exercises the byte-identical replay path
|
|
// instead, so this same field still reads back "gpt-mock" with no rebuild.
|
|
func handleOpenAIAudioTranscriptions(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil { //nolint:gosec // G120: maxMemory is set (1MiB) and the whole handler is behind readCapped's 10MiB gateway-side cap (routes_media.go) before this mock ever sees the body; test-only fixture server, not production code
|
|
http.Error(w, `{"error":{"message":"invalid multipart body","type":"invalid_request_error"}}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"text": "mock transcription",
|
|
"model_received": r.FormValue("model"),
|
|
})
|
|
}
|
|
|
|
// --- anthropic mode ----------------------------------------------------------
|
|
|
|
func handleAnthropicModels(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, map[string]any{
|
|
"data": []map[string]any{{"id": "claude-mock"}},
|
|
})
|
|
}
|
|
|
|
func handleAnthropicMessages(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Mock-Received-Auth", r.Header.Get("x-api-key"))
|
|
body := decodeJSONBody(r)
|
|
model, _ := body["model"].(string)
|
|
if model == "" {
|
|
model = "claude-mock"
|
|
}
|
|
stream, _ := body["stream"].(bool)
|
|
|
|
if !stream {
|
|
writeJSON(w, map[string]any{
|
|
"id": "msg_mock",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": []map[string]any{
|
|
{"type": "text", "text": "mock anthropic response"},
|
|
},
|
|
"model": model,
|
|
"stop_reason": "end_turn",
|
|
"usage": map[string]any{"input_tokens": 10, "output_tokens": 4},
|
|
})
|
|
return
|
|
}
|
|
|
|
sw := newSSEFlusher(w)
|
|
frames := []struct{ event, data string }{
|
|
{"message_start", `{"type":"message_start","message":{"id":"msg_mock","usage":{"input_tokens":10,"output_tokens":1}}}`},
|
|
{"content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`},
|
|
{"content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"mock "}}`},
|
|
{"content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"anthropic "}}`},
|
|
{"content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"response"}}`},
|
|
{"content_block_stop", `{"type":"content_block_stop","index":0}`},
|
|
{"message_delta", `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":4}}`},
|
|
{"message_stop", `{"type":"message_stop"}`},
|
|
}
|
|
for _, f := range frames {
|
|
sw.write(f.event, f.data)
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// --- gemini mode -------------------------------------------------------------
|
|
|
|
func handleGeminiModels(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, map[string]any{
|
|
"models": []map[string]any{{"name": "models/gemini-mock"}},
|
|
})
|
|
}
|
|
|
|
func handleGeminiGenerate(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Mock-Received-Auth", r.Header.Get("x-goog-api-key"))
|
|
rest := strings.TrimPrefix(r.URL.Path, "/v1beta/models/")
|
|
_, action, _ := strings.Cut(rest, ":")
|
|
|
|
switch action {
|
|
case "generateContent":
|
|
writeJSON(w, map[string]any{
|
|
"candidates": []map[string]any{
|
|
{
|
|
"content": map[string]any{"role": "model", "parts": []map[string]any{{"text": "mock gemini response"}}},
|
|
"finishReason": "STOP",
|
|
},
|
|
},
|
|
"usageMetadata": map[string]any{"promptTokenCount": 10, "candidatesTokenCount": 4},
|
|
"responseId": "resp_mock",
|
|
})
|
|
case "streamGenerateContent":
|
|
sw := newSSEFlusher(w)
|
|
frames := []string{
|
|
`{"candidates":[{"content":{"role":"model","parts":[{"text":"mock "}]}}],"responseId":"resp_mock"}`,
|
|
`{"candidates":[{"content":{"role":"model","parts":[{"text":"gemini "}]}}],"responseId":"resp_mock"}`,
|
|
`{"candidates":[{"content":{"role":"model","parts":[{"text":"response"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":4}}`,
|
|
}
|
|
for _, f := range frames {
|
|
sw.write("", f) // Gemini's SSE stream carries no "event:" field.
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
case "predict":
|
|
// Backs the images integration test's gemini case: the gateway's
|
|
// geminiAdapter.imagesGeneration (provider_gemini.go) POSTs to
|
|
// {base}/v1beta/models/{model}:predict and translates this
|
|
// Imagen-shaped response into OpenAI's images.generations shape
|
|
// (translate_gemini_images.go) before it ever reaches the client.
|
|
writeJSON(w, map[string]any{
|
|
"predictions": []map[string]any{
|
|
{"bytesBase64Encoded": "bW9jay1nZW1pbmktaW1hZ2UtYnl0ZXM="},
|
|
},
|
|
})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
// --- tool mode (MCP target proxy test) ---------------------------------------
|
|
|
|
func handleToolSSE(w http.ResponseWriter, _ *http.Request) {
|
|
sw := newSSEFlusher(w)
|
|
for i, msg := range []string{"hello", "from", "mocktool"} {
|
|
b, _ := json.Marshal(map[string]any{"seq": i, "msg": msg})
|
|
sw.write("", string(b))
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// --- 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,
|
|
// and never through the Docker Desktop host port-proxy — the caller must
|
|
// pass an in-compose-network address such as "traefik1:80"), hand-writes one
|
|
// streaming chat completion request, and times each raw read that carries
|
|
// SSE "data:" bytes. It returns 0 iff at least 3 distinct inter-arrival gaps
|
|
// of 100ms or more were observed, proving the response arrived as
|
|
// incremental chunks rather than one buffered write; 1 otherwise.
|
|
func runProbe(target, apiKey, model string) int {
|
|
conn, err := net.DialTimeout("tcp", target, 10*time.Second)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "probe: dial %s: %v\n", target, err)
|
|
return 1
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
body := fmt.Sprintf(`{"model":%q,"stream":true,"messages":[{"role":"user","content":"hi"}]}`, model)
|
|
host, _, err := net.SplitHostPort(target)
|
|
if err != nil || host == "" {
|
|
host = target
|
|
}
|
|
req := "POST /v1/chat/completions HTTP/1.1\r\n" +
|
|
"Host: " + host + "\r\n" +
|
|
"Authorization: Bearer " + apiKey + "\r\n" +
|
|
"Content-Type: application/json\r\n" +
|
|
"Content-Length: " + strconv.Itoa(len(body)) + "\r\n" +
|
|
"Connection: close\r\n" +
|
|
"\r\n" + body
|
|
|
|
if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
|
|
fmt.Fprintf(os.Stderr, "probe: set deadline: %v\n", err)
|
|
return 1
|
|
}
|
|
if _, err := conn.Write([]byte(req)); err != nil {
|
|
fmt.Fprintf(os.Stderr, "probe: write request: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
var arrivals []time.Time
|
|
var headerBuf []byte
|
|
buf := make([]byte, 4096)
|
|
headerDone := false
|
|
for {
|
|
n, readErr := conn.Read(buf)
|
|
if n > 0 {
|
|
chunk := buf[:n]
|
|
if !headerDone {
|
|
// The "\r\n\r\n" header/body terminator is not guaranteed to
|
|
// land inside a single Read: accumulate every read into a
|
|
// rolling buffer and search that, not just the latest
|
|
// chunk, so a terminator split across two reads is still
|
|
// found instead of silently discarding both reads' bytes.
|
|
headerBuf = append(headerBuf, chunk...)
|
|
idx := bytes.Index(headerBuf, []byte("\r\n\r\n"))
|
|
if idx < 0 {
|
|
if readErr != nil {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
headerDone = true
|
|
chunk = headerBuf[idx+4:]
|
|
headerBuf = nil
|
|
}
|
|
if len(chunk) > 0 && bytes.Contains(chunk, []byte("data:")) {
|
|
arrivals = append(arrivals, time.Now())
|
|
}
|
|
}
|
|
if readErr != nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
gaps := 0
|
|
for i := 1; i < len(arrivals); i++ {
|
|
if d := arrivals[i].Sub(arrivals[i-1]); d >= 100*time.Millisecond {
|
|
gaps++
|
|
}
|
|
}
|
|
fmt.Printf("probe: target=%s model=%s arrivals=%d gaps>=100ms=%d\n", target, model, len(arrivals), gaps)
|
|
if gaps >= 3 {
|
|
return 0
|
|
}
|
|
return 1
|
|
}
|