fix(mcp): backend timeouts, response validity, session handshake fallback

Review round 2 fix batch for the federated /mcp endpoint, driven by a
live probe against all 11 production MCP servers behind this gateway
through the byte-pipe proxy (session-less tools/list): 8 answer bare,
readitall and fetch reject it and need a session handshake, playwright
is a legacy HTTP+SSE-transport server (400 Missing sessionId) that no
handshake fixes.

MF1: every backend call now runs under its own context.WithTimeout
derived from r.Context() — toolsListBackendTimeout (20s) per fan-out
call, toolsCallBackendTimeout (120s) for tools/call, named constants.

MF2: doBackendJSONRPC rejects any non-2xx upstream status as an error
(previously unchecked — a non-JSON-RPC error body only failed by
accident of not parsing). parseBackendJSONRPC additionally rejects a
validly-JSON body that carries neither "result" nor "error" — the
exact silent-failure shape that would otherwise decode into an
all-zero jsonrpcResponse and hand the client a bare
{"jsonrpc":"2.0","id":1}. mcpFederatedToolsCall carries a
defensive final guard against the same shape before relaying.

MF3: mcpBackendCall is now bare-first-with-fallback — attempt the
bare call; on a JSON-RPC error OR HTTP 4xx (never a network failure,
timeout, or 5xx), retry once via initialize -> capture Mcp-Session-Id
-> retry with the header -> best-effort DELETE close. A legacy
HTTP+SSE-transport server (URL path ending "/sse") is excluded from
federation entirely before ever reaching this — isLegacySSETransportURL,
folded into allowedMCPServerNames — since no handshake fixes a
different transport; it stays reachable via the per-server proxy. When
every attempted server fails, tools/list returns a JSON-RPC internal
error ("no MCP server reachable") instead of a silently-empty list.

SF6: defaultMCPProtocolVersion -> "2025-11-25"; a caller's own
protocolVersion is echoed back only from a small explicit allowlist
(knownMCPProtocolVersions), falling back to the default otherwise —
never echoes an arbitrary caller string as this gateway's own claimed
capability.

SF8: GET/DELETE/etc. on exactly /mcp is now 405 with Allow: POST,
checked before auth (llmgateway.go).

Folded minors: countTargetRequests batches per-target counter writes
for tools/list's whole fan-out into ONE storeIncrMulti call after
wg.Wait() (was one call per server); every "actually contacted" doc
comment now says "attempted" (attribution is attempt-, not
success-, accounting); lastSSEDataLine documents its no-cross-message-
id-correlation limitation; routes_unified.go's stale buildLimitScopes/
withTotalScope comments (which claimed handleAdminAPI calls
buildLimitScopes directly — it never has) are corrected; jsonrpcRequest
documents why its "jsonrpc" field is decoded but never validated.

Tests: ~30 new/updated cases covering per-backend timeout derivation
(via the shorter-of-two-context-deadlines technique, no real 20s/120s
waits), non-2xx rejection, the empty-envelope guard end-to-end,
handshake-trigger classification (table-driven: JSON-RPC error and
HTTP 4xx trigger, HTTP 500 and network failure do not), the full
handshake sequence (bare -> initialize -> retry -> close, asserted
call-by-call), handshake-gives-up-too, all-servers-failed, legacy-SSE
exclusion (never contacted, in both tools/list and tools/call),
protocol-version allowlist (all 4 versions + one rejected), 405/Allow,
and best-effort session-close failure never surfacing to the caller.
This commit is contained in:
2026-08-21 19:13:36 +01:00
parent d565c1d4ea
commit 8afb903227
5 changed files with 1277 additions and 132 deletions
+47 -21
View File
@@ -1011,14 +1011,16 @@ func (l *limiter) account(scopes []limitScope, u usage, costMicros int64) {
l.storeIncrMulti(entries) // no error return by contract (doc comment above); ok is intentionally discarded
}
// countTargetRequest increments one MCP-server's or A2A agent's per-target
// request counters (Feature B, v0.21) at min, hour, day, AND month — the
// only scope kind this package tracks a month window of REQUESTS for.
// kind is targetScopeKind's own output ("mcp" or scopeKindAgent —
// mcp_a2a.go), id is the configured target name; both come embedded in
// windowKey's own key string, so a target scope can never collide with a
// user/group/total scope of the same id even by coincidence — kind is
// part of the key, not just a struct field.
// countTargetRequests increments MULTIPLE target scopes' per-target request
// counters (Feature B, v0.21) — each at min, hour, day, AND month, the only
// scope kind this package tracks a month window of REQUESTS for — in ONE
// storeIncrMulti call. kind is targetScopeKind's own output ("mcp" or
// scopeKindAgent — mcp_a2a.go), id is the configured target name; both come
// embedded in windowKey's own key string, so a target scope can never
// collide with a user/group/total scope of the same id even by coincidence
// — kind is part of the key, not just a struct field. A scope's own
// `limits` field is ignored entirely (never read); callers pass plain
// {kind, id} pairs.
//
// This is deliberately NOT folded into checkAndCount: a target scope
// carries no limits by design (this round adds no config surface for
@@ -1033,20 +1035,44 @@ func (l *limiter) account(scopes []limitScope, u usage, costMicros int64) {
// rejected as scope creep touching user/group accounting nothing else in
// this round asked to change.
//
// Callers: handleTargetProxy (mcp_a2a.go, once per proxied request, after
// its own user/group/total admission check passes) and the federated /mcp
// endpoint (mcp_federation.go, once per actual backend server contacted —
// tools/list may contact several in one incoming client request, tools/call
// exactly one), so a call attributed to a specific target is counted the
// same way regardless of which route reached it.
func (l *limiter) countTargetRequest(kind, id string) {
// Batching (review round 2, 2026-08-21): the federated /mcp endpoint's own
// tools/list fan-out can attempt several backend servers for ONE incoming
// client request — the earlier per-server countTargetRequest call inside
// each fan-out goroutine paid one round trip per server; this collects
// every scope from the whole fan-out into a single call after the fan-out's
// own wg.Wait(), the same "one request in, one round trip out" discipline
// checkAndCount/account already apply per scope-slice.
//
// Callers: handleTargetProxy (mcp_a2a.go, a 1-element slice, once per
// proxied request, after its own user/group/total admission check passes)
// and the federated /mcp endpoint (mcp_federation.go: a 1-element slice for
// tools/call's single resolved target, or one element per server ATTEMPTED
// — not necessarily reached or succeeded — for tools/list's fan-out; see
// mcpFederatedToolsList's own doc comment for why "attempted" is the
// correct word here).
func (l *limiter) countTargetRequests(scopes []limitScope) {
if len(scopes) == 0 {
return
}
now := l.now()
l.storeIncrMulti([]counterIncr{
newCounterIncr(kind, id, metricReq, windowMin, now, 1, minWindowTTL),
newCounterIncr(kind, id, metricReq, windowHour, now, 1, hourWindowTTL),
newCounterIncr(kind, id, metricReq, windowDay, now, 1, dayWindowTTL),
newCounterIncr(kind, id, metricReq, windowMonth, now, 1, monthWindowTTL),
})
entries := make([]counterIncr, 0, len(scopes)*4)
for _, sc := range scopes {
entries = append(entries,
newCounterIncr(sc.kind, sc.id, metricReq, windowMin, now, 1, minWindowTTL),
newCounterIncr(sc.kind, sc.id, metricReq, windowHour, now, 1, hourWindowTTL),
newCounterIncr(sc.kind, sc.id, metricReq, windowDay, now, 1, dayWindowTTL),
newCounterIncr(sc.kind, sc.id, metricReq, windowMonth, now, 1, monthWindowTTL),
)
}
l.storeIncrMulti(entries)
}
// countTargetRequest is countTargetRequests for exactly one target scope —
// handleTargetProxy's own single-target shape (mcp_a2a.go), where a batch
// of one buys nothing but keeps the call site a plain two-argument call
// instead of a one-element slice literal.
func (l *limiter) countTargetRequest(kind, id string) {
l.countTargetRequests([]limitScope{{kind: kind, id: id}})
}
// targetCounterKeysPerScope is the number of windowKey strings