fix(mcp): stop weighting tools/list budget, widen fan-out, split caps

Addresses critical finding 3 and important findings 3-4 from security
review round 2, 2026-08-22.

Critical finding 3 — the tools/list-per-backend weight charged against
the caller's own requests-per-minute/day budget (security audit finding
1b) was not default-preserving: both live groups have an empty MCP
allow-list (all 11 servers), so it silently cut every configured
requests-per-minute budget by ~11x with no config change, and
examples/kubernetes.yaml's shipped requests-per-minute: 10 would 429 the
very first tools/list call ever made. Reverted: checkAndCountWeighted
removed from limits.go, handleMCPFederated back to a plain checkAndCount.
The real amplification stays fully visible to an operator via
countTargetRequests' existing per-server 'mcp' target-scope counters —
a separate scope from the user/group budget — rather than silently
consuming tenant quota. The fan-out semaphore (below) is the actual
protection against the amplification finding 1 described.

Important finding 3 — the fan-out semaphore (4 concurrent backends)
tripled worst-case latency (907ms measured at 300ms/server, was ~300ms)
and pushed the hung-backend worst case to ceil(11/4)x20s=60s, past the
~30s timeout many MCP clients use. Raised to 8, and every backend in one
tools/list call now shares ONE overall toolsListBackendTimeout deadline
(fanoutCtx) instead of each getting a fresh one from its own staggered
start — restoring this function's original 'bounds the whole call
regardless of server count' invariant even with bounded concurrency.

Important finding 4 — the 4MiB response cap (sized for the tools/list
fan-out's memory-amplification risk) silently applied to tools/call too,
where exactly one backend is ever contacted and a legitimate result (an
image, an extracted document) can be much larger. tools/call now uses
mcpBackendCallResponseMaxBytes (10MiB, the pre-finding-1c budget);
tools/list keeps the 4MiB cap. A response that hits either cap is
detected via a cap+1 read (never silently truncated into a confusing
parse failure) and reported to the client as 'response too large'.
This commit is contained in:
2026-08-22 15:26:38 +01:00
parent 3536c32667
commit 452be5151c
4 changed files with 389 additions and 201 deletions
+33 -22
View File
@@ -1998,34 +1998,39 @@ func TestAdminTargets_AccessListsMatchEnforcement_AndCountersReflectTraffic(t *t
// --- default-preserving: the live cluster's real shape (security audit, 2026-08-22) ---
// TestDefaultPreserving_LiveClusterShape_LoadsAndServesIdentically models
// the live production shape this round's five fixes must not break
// (project brief): 5 "friend" users plus 4 "home" users (steve-cw and
// kevinsandom are named live users; the rest are representative — real
// names this test has no visibility into, which is exactly why finding
// 5's fix stops short of restricting the name charset, see buildEntry's
// own doc comment, auth.go) and 11 MCP servers. A2A agents are out of
// scope here: none of this round's five findings touch mcp_a2a.go or any
// A2A-specific code path, so there is no mechanism by which they could
// regress agent handling.
// the live production shape this round's fixes must not break (project
// brief): 5 "friend" users plus 4 "home" users (steve-cw and kevinsandom
// are named live users; the rest are representative — real names this
// test has no visibility into, which is exactly why finding 5's fix stops
// short of restricting the name charset, see buildEntry's own doc
// comment, auth.go) and 11 MCP servers. A2A agents are out of scope here:
// none of this round's fixes touch mcp_a2a.go or any A2A-specific code
// path, so there is no mechanism by which they could regress agent
// handling.
//
// It proves construction succeeds unchanged (finding 5's new empty/
// duplicate-name checks accept every one of these real names), a normal
// federated tools/list call from an ordinary user still succeeds under a
// realistic per-minute limit (finding 1b's per-backend weighting does not
// spuriously throttle ordinary usage — 11 servers is comfortably under a
// 60/min budget), and the admin dashboard's usage poll still returns
// exactly one row per user and per group in a single store round trip
// (finding 4's chunking is a no-op at 10 users, well under
// adminUsageChunkScopes).
// duplicate-name checks accept every one of these real names), a
// friends-group user's configured requests-per-minute budget still buys
// EXACTLY that many tools/list calls against all 11 servers — not
// budget/11 — regressing security review round 2's critical finding 3
// (weighting tools/list by backend count was reverted specifically
// because it was not default-preserving at this exact shape: both live
// groups have an empty MCP allow-list, i.e. all 11 servers, so a naive
// per-backend weight would have silently cut every configured
// requests-per-minute budget by ~11x here), and the admin dashboard's
// usage poll still returns exactly one row per user and per group in a
// single store round trip (finding 4's chunking is a no-op at 10 users,
// well under adminUsageChunkScopes).
func TestDefaultPreserving_LiveClusterShape_LoadsAndServesIdentically(t *testing.T) {
friendNames := []string{"steve-cw", "kevinsandom", "friend3", "friend4", "friend5"}
homeNames := []string{"home1", "home2", "home3", "home4"}
const friendsRPM = 60
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Admin = &AdminConfig{Enabled: true}
cfg.Groups = map[string]*GroupConfig{
"friends": {Limits: &LimitsConfig{RequestsPerMinute: 60}},
"friends": {Limits: &LimitsConfig{RequestsPerMinute: friendsRPM}},
"home": {Limits: &LimitsConfig{RequestsPerMinute: 120}},
}
@@ -2049,10 +2054,16 @@ func TestDefaultPreserving_LiveClusterShape_LoadsAndServesIdentically(t *testing
t.Fatalf("New: want the realistic live-cluster config to load unchanged, got error: %v", err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, newFederatedRequest(t, "sk-steve-cw", jsonrpcRequest{JSONRPC: jsonrpcVersion, Method: "tools/list", ID: json.RawMessage("1")}))
if rec.Code != http.StatusOK {
t.Fatalf("tools/list status = %d, want 200, body=%s", rec.Code, rec.Body.String())
// Drives friendsRPM calls, not just one (security review round 2:
// a single-call assertion cannot distinguish "budget buys N calls"
// from "budget buys N/11 calls" — exactly the blind spot that let the
// weighted-charge regression ship undetected).
for i := 1; i <= friendsRPM; i++ {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, newFederatedRequest(t, "sk-steve-cw", jsonrpcRequest{JSONRPC: jsonrpcVersion, Method: "tools/list", ID: json.RawMessage(fmt.Sprintf("%d", i))}))
if rec.Code != http.StatusOK {
t.Fatalf("tools/list call %d/%d status = %d, want 200 (budget must buy all %d calls against 11 servers), body=%s", i, friendsRPM, rec.Code, friendsRPM, rec.Body.String())
}
}
usageReq := httptest.NewRequest("GET", adminUsagePath, nil)