Add stateless routing to streamable MCP proxy#5839
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5839 +/- ##
==========================================
+ Coverage 71.28% 71.36% +0.08%
==========================================
Files 693 693
Lines 70572 70615 +43
==========================================
+ Hits 50307 50395 +88
+ Misses 16633 16568 -65
- Partials 3632 3652 +20 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
7233c3e to
6da2110
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Panel review — stateless routing for the streamable MCP proxy
Reviewed by a multi-lens panel (security & confidentiality · MCP spec conformance · Go idiom, duplication & reuse) against 6da2110, cross-checked with #5830 / #5755 and the draft Streamable-HTTP binding. This is the security-critical routing slice, so the confidentiality invariant got the hardest look.
Verdict: the confidentiality fix holds — no blockers. Tracing resolveSessionForRequest top-to-bottom against the worktree: the Modern branch mints a fresh crypto-random routing token and returns before any Mcp-Session-Id read (first at :805) and before any sessionManager access (:817/:837). So a Modern request can never route by a client-supplied session id or collide on a shared compositeKey, and the RNG-failure path is fail-closed. TestModernConcurrentRequestsAreNotMixed exercises exactly the regression (two id=1 Modern POSTs, one with a foreign Mcp-Session-Id, under a barrier backend) — a genuine guard. Findings below are all hardening / minor.
🟡 Minor
1. The batch path doesn't apply Modern classification — "Legacy-only by construction" is a convention, not enforced — streamable_proxy.go:735-750
A client can send a JSON-array body carrying Modern _meta + a foreign Mcp-Session-Id; resolveSessionForBatch never calls ClassifyRevision, so with a header that resolves in sessionManager.Get it routes batch items onto compositeKey(victimSessID, idKey). Not exploitable today and not a regression — it requires knowing the victim's server-generated random session-id UUID (the same bearer credential the Legacy single-request path already trusts), and sessionless batches each get a fresh unique token so they can't collide. But it means the "ignore Mcp-Session-Id for Modern" invariant isn't yet uniform. Hardening (matches the deferred #5830 plan): classify on the batch path and reject a batch carrying a Modern signal (Modern has no batching).
2. writeClassificationError is the third JSON-RPC-error-to-ResponseWriter writer with the same shape — streamable_proxy.go:848-894
Not reusing session.WriteNotFound directly is correct — that helper is hard-wired to CodeSessionNotFound (-32001) / HTTP 404 / a fixed message, while this path needs variable code+message+data and HTTP 400. But it now shares the marshal-envelope-with-hand-crafted-fallback shape with session.NotFoundBody/WriteNotFound. Consider a generic session.WriteJSONRPCError(w, status, code, message, data, requestID) that both call. (writeHTTPError in utils.go:23 is plain-text http.Error — not the right target.) Non-blocking.
3. Magic -32602 literal duplicated across the package boundary — streamable_proxy.go:853 & :878
Both hard-code mcp.jsonRPCCodeInvalidParams (revision.go:65, unexported); the inline comment acknowledges it. The :853 default is currently unreachable (every ClassifyRevision error implements mcp.CodedError, so errors.As always overwrites it), so drift risk is low. If you want it kept in sync, export mcp.CodeInvalidParams and reference it at :853 (the :878 fallback must stay a literal — it's the marshal-failure escape hatch). This ties into the #5834 finding that MissingModernMetadataError uses -32602 at all — worth resolving there first.
4. Non-object _meta with no matching header silently downgrades to Legacy — revision.go:242 (ExtractMeta) + streamable_proxy.go:783
ExtractMeta returns nil when _meta is present but not a JSON object, so hasModernSignal can then only see Modern via the header — the reserved io.modelcontextprotocol/* keys inside the non-object _meta become invisible, and the request is accepted on the stateful path. This can't happen for a conformant Modern client (object _meta with reserved keys, or the header); only a malformed request hits it, where defaulting to Legacy is defensible. Optionally note in the ExtractMeta doc that a non-object _meta erases the reserved-key signal.
⚪ Nits
- Notification/client-response path uses the raw
Mcp-Session-Idfor TTL refresh (:917-928, called before classification). Confirmed no cross-delivery — no waiter/compositeKeyis registered for these — so it's safe; residual is only that a client knowing a session id could nudge someone else's session TTL (keep-alive, not confidentiality). Fold into the same deferred Modern-shape enforcement. - Pre-existing: an invalid
MCP-Protocol-Versionheader alone (no_metasignal) still isn't 400'd —isSupportedMCPVersion(utils.go:65) is a no-op returningtrue. Unchanged by this PR and out of scope; flagging for completeness. //nolint:gosec // G104at:883matches the established convention (session.WriteNotFounduses the identical suppression) — correct, no change.
✅ Strengths
- The invariant is enforced by control flow (Modern returns before the session read), not by remembering to null out a variable — the right structural fix, and the
:763-772doc comment captures exactly why. uuid.NewRandom()(v4, crypto-random) for routing tokens, with fail-closed RNG handling at every mint site (batch:738, Modern:796, Legacy sessionless:828).writeClassificationErrormarshals the body before writing status/headers (mirrorssession.WriteNotFound), guaranteeing a valid single response; no double-write — the sole caller returns immediately on error (verified). No response-splitting: the id comes fromreq.ID.Raw()and errorData()only echoes client-supplied version strings / the static supported-version list — no secret leakage.- Classification runs at the correct decode point (only after
req.ID.IsValid()), so the echoed id is always valid; HTTP 400 + JSON-RPC body is a spec-conformant mapping. _metaextraction dedup is clean and complete —paramsMap["_meta"]is now accessed in exactly one shared helper (revision.go:257), with bothparser.goandExtractMetarouting through it.
🤖 AI-assisted panel review via Claude Code (security · MCP-spec · Go/reuse lenses). Line numbers against 6da2110. Note this PR is stacked on #5834 — the -32602 item is best resolved there.
JAORMX
left a comment
There was a problem hiding this comment.
Inline notes, including a confirmation on the load-bearing confidentiality line (full rationale + strengths are in the panel summary review above). 🤖 AI-assisted panel review via Claude Code.
a809c24 to
e0b3a33
Compare
6da2110 to
e07a547
Compare
e07a547 to
22fe115
Compare
🔴 Regression:
|
| Build | vs base | tools-call-sampling |
|---|---|---|
| #5834 (this PR's base, incl. the go-sdk migration) | — | ✅ pass |
#5839 6da2110 (earlier head) |
✅ pass | |
#5839 e07a547 (base + routing commit) |
run 1 | 🔴 60.0s timeout |
#5839 22fe115 (= e07a547 + the comment-only codespell fix) |
run 2 | 🔴 60.0s timeout |
Two CI runs of runtime-identical code both fail with a clean 60-second timeout (server→client sampling/createMessage round-trip never completes), while the base passes — so it's deterministic, not a flake.
Why it matters: conformance exercises the Legacy (2025-11-25) path, so this appears to contradict this PR's stated "Legacy behavior is unchanged" invariant. Notably tools-call-elicitation — same server→client-request-then-client-response shape — still passes, so it looks specific to the sampling round-trip / client-response routing rather than a blanket break.
I isolated it to the commit but haven't pinned the exact line; the likely area is how the reordered classification path in resolveSessionForRequest / the client-response handling interacts with a server-initiated request's stream. Worth confirming before merge.
🤖 AI-assisted review via Claude Code — CI regression analysis (isolated across builds #5834 / 6da2110 / e07a547 / 22fe115).
|
Correction / lower confidence on the above. I dug into the actual diff and want to walk back the "deterministic regression" framing a bit before anyone chases it too hard:
So there's no obvious code mechanism here for a Legacy Recommend a couple of re-runs of 🤖 AI-assisted review via Claude Code. |
|
Update — the re-run settles it: this is real, not a flake. After #5834 merged and this PR retargeted to
3/3 with the routing commit fail; 2/2 without it pass, including against a clean The puzzle remains that the Legacy 🤖 AI-assisted review via Claude Code — this supersedes my two earlier notes on this failure. |
22fe115 to
874c588
Compare
|
Final correction — this is a flake, not a deterministic regression. My earlier "confirmed real" note was wrong; sorry for the churn. Definitive evidence: the same commit
Same code, both outcomes → intermittent race, not a hard break. That also reconciles with the static read that the Legacy Caveats, honestly: it flaked at a high rate across these builds (~4 fails : 1 pass observed), and the 60s-vs-22ms split points at a timing/race in the sampling round-trip or the conformance harness. I can't fully rule out that this PR's added per-request work slightly widens that window — but it should not be treated as a merge-blocking deterministic bug. A re-run will go green, and the sampling scenario looks worth deflaking on its own (separate from this PR). Apologies again for the back-and-forth — this is the definitive read and supersedes my two prior notes on this failure. 🤖 AI-assisted review via Claude Code. |
JAORMX
left a comment
There was a problem hiding this comment.
LGTM on the merits. ✅ The stateless-routing change is well-structured: the confidentiality invariant is enforced by control flow (the Modern branch returns before any Mcp-Session-Id read / sessionManager access), routing tokens are crypto-random with fail-closed RNG handling, and TestModernConcurrentRequestsAreNotMixed is a real guard for the cross-delivery case. The review findings are addressed (the -32602 literal now references mcp.CodeInvalidParams, codespell fixed) and the batch-path hardening is reasonably deferred to #5830.
On the red CI: as documented in the thread, E2E Tests / MCP Conformance (tools-call-sampling) and E2E Test Lifecycle (v1.33.7) are flakes — the same commit 874c588 produced both a passing (~22 ms) and a failing (60 s timeout) conformance run, so it's an intermittent race, not a Legacy regression. Approving the code.
Two non-blocking asks before merge:
- Get a green conformance re-run (merge is gated on it anyway).
- Consider deflaking
tools-call-samplingseparately — it failed ~4:1 across these builds, and I couldn't fully rule out that the added per-request work slightly widens the round-trip race window; worth a glance even though it's not a hard regression here.
🤖 AI-assisted review via Claude Code (security · MCP-spec · Go/reuse panel). Approval covers the code; CI gating still applies.
The MCP 2026-07-28 (Modern) revision is stateless: requests carry protocol metadata per-request in _meta and there is no Mcp-Session-Id. The streamable proxy multiplexes every request onto one stdio pipe and demuxes responses by compositeKey(sessID, idKey), so a Modern request that carried a client-supplied session id would fall through to the session-lookup path and could collide with a concurrent request on the same key, cross-delivering one client's response to another. Classify each single POST via mcp.ClassifyRevision. Modern requests get a fresh per-request routing token, ignore any Mcp-Session-Id header, and never touch the session manager; classification errors are rejected with an HTTP 400 JSON-RPC error. Legacy behavior is unchanged. Add mcp.ExtractMeta to pull _meta from raw params, deduped with the parser's existing extraction via a shared helper. Message-shape enforcement (batches, client-sent responses, GET/DELETE) is deferred; those paths remain Legacy-only by construction. Part of #5830. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
874c588 to
36d013a
Compare
Debug logs from PR #5839's failing run showed --debug does NOT mask the sampling flake: the run failed with --debug on, and the trace localized the stall to a missing server->client sampling/createMessage delivery (the client never sends its sampling-response POST). So restore --debug (it is our best signal, not a mask) and add the one artifact still missing: the Node backend container's own logs, which reveal whether the backend emitted the sampling request (-> proxy relay race) or not (-> backend-side). SSE bodies are not in the proxy log, so this is the only way to split those two legs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Heads-up: the red
|
Summary
The MCP 2026-07-28 (Modern) revision is stateless — requests carry protocol metadata per-request in
_metaand there is noMcp-Session-Id. The streamable proxy multiplexes every concurrent request onto a single stdio pipe and demuxes backend responses bycompositeKey(sessID, idKey). A Modern request that carried a client-supplied session id would otherwise fall through to the session-lookup path and could collide with a concurrent request on the same key, cross-delivering one client's response to another — a confidentiality bug.This wires per-request revision classification into the streamable proxy's routing:
mcp.ClassifyRevision.Mcp-Session-Id, and never touch the session manager.mcp.ExtractMetato pull_metafrom raw params, deduped with the parser's existing extraction via a shared helper.Message-shape enforcement (batches, client-sent responses, GET/DELETE → 405) is deferred — those paths remain Legacy-only by construction. Tracked as a follow-up.
Part of #5830. Design: RFC THV-0081 + arch doc (#5808).
Type of change
Test plan
New tests:
TestModernConcurrentRequestsAreNotMixed— the security case: two concurrent Modern POSTs sharing JSON-RPC id1, one carrying a foreignMcp-Session-Id, must not cross-deliver responses.TestModernRequestIgnoresClientSessionID— a Modern request with a bogus session id is served statelessly (200, no session header, session manager untouched) rather than 404'd.TestModernClassificationErrorsReturn400— header/body mismatch →-32020, unsupported version →-32022, both HTTP 400.TestExtractMeta— tolerant_metaextraction across malformed/absent/wrong-typed inputs.Special notes for reviewers
Mcp-Session-Idread orsessionManageraccess. Reviewed by an independent security pass which confirmed it holds and that routing tokens are crypto-random (no collision).task lint-fixhas not been run locally; relying on CI lint.Generated with Claude Code