Echo the request JSON-RPC id in session-not-found 404s - #6031
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6031 +/- ##
==========================================
- Coverage 72.17% 72.17% -0.01%
==========================================
Files 721 721
Lines 75069 75071 +2
==========================================
- Hits 54182 54180 -2
- Misses 17025 17029 +4
Partials 3862 3862 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
d3a7ee8 to
1bd0d48
Compare
1bd0d48 to
abd7af2
Compare
16fd074 to
3c6aa25
Compare
|
@jhrozek — your approval was dismissed by a push, and I want to be clear about what changed so you can decide how much of a re-read it deserves. The logic you approved ( Why it was needed. CI's // idNotEchoed marks a rejection path that -- unlike ClassificationErrorResponse
// -- doesn't echo the request id at all: session.NotFoundResponse hardcodes a
// nil id ... A real minor asymmetry in the current code, left flagged rather
// than fixed (out of scope for a test-only step).That is #5945 verbatim. Since this PR removes the asymmetry, the My miss, worth naming: I audited the five production Verification, stated honestly: the e2e suite cannot run in our environment (no container runtime — Two things currently blocking this PR that are not about the code:
|
GO-2026-5841 (GHSA-259r-337f-4rfw), an out-of-bounds read in github.com/klauspost/compress/s2, is fixed in v1.18.7. We were on v1.18.6, so govulncheck fails on main and on every open PR. The advisory is newly published rather than newly introduced: the same Go Vulnerability Check passed on PR #6031 at 17:08 and failed at 17:52 on a test-only delta. govulncheck queries the advisory database at run time, so nothing in the tree changed -- the database did. Bumped rather than added to the workflow's IGNORED_VULNS. That list is reserved for advisories with no available fix (GO-2026-5932, openpgp deprecated-by-design) or an unavoidable toolchain lag (GO-2026-5037/38/39, pending a setup-go manifest refresh), each with a written justification. This one has a fixed version, so excluding it would misuse that mechanism. The dependency stays indirect: it reaches us via prometheus/client_golang -> klauspost/compress/zstd. Only zstd is used, not the affected s2 package, so exposure was low -- but the fix is one line, so reachability does not need arguing.
The transparent proxy's unknown-session guard returned a -32001 404 whose body hardcoded a null id, while the classification-error path on the very same RoundTrip already echoed the incoming id. A client correlating responses by id got null for this one error class. The id was parsed a few lines above the guard but scoped to the classification block, so the guard could not reach it. Hoist it. Only set it when parseRPCRequest reports a single request, which by construction means the id is present and non-null -- notifications, batches and bodiless GET/DELETE keep the null id JSON-RPC requires. The three sibling WriteNotFound call sites were audited and are correct as-is: standalone-SSE GET, DELETE, and the httpsse GET all carry no body and therefore no id. The streamable POST path already echoed it. So this was the only site discarding an id it had. NotFoundBody's doc comment listed the transparent proxy as an id-unavailable case, which this makes untrue, so it is updated to say when nil is legitimate. Closes #5945
The hostile-input proxy spec carried a hostileCase.idNotEchoed flag whose only true case was the foreign-session 404, and whose doc comment spelled out the very asymmetry the previous commit removes: session.NotFoundResponse hardcoding a nil id while ClassificationErrorResponse on the same RoundTrip echoed it. With the id now threaded through to the guard, the spec's Expect(resp.ID).To(BeNil()) failed -- correctly. The flag has no remaining true case: all five rejections are single JSON-RPC requests posted to /mcp, so RoundTrip parses an id for every one of them. Drop the field and the conditional so each case asserts the echoed id uniformly, and record on the foreign-session case why its 404 now carries one.
3c6aa25 to
0f8a149
Compare
Summary
Closes #5945. The transparent proxy's unknown-session guard returned a
-32001404 whose body hardcoded a null id, while the classification-error path on the very sameRoundTripalready echoed the incoming id. A client correlating responses by id gotnullfor this one error class.The cause is scoping, not intent: the id is parsed a few lines above the guard, but inside the classification block, so the guard could not reach it.
sawInitializewas already hoisted out of that block for the same reason; the id now is too.It is set only when
parseRPCRequestreports a single request — and that predicate is alreadymethod != "" && len(id) > 0 && id != "null", so by construction a set id is present and non-null. Notifications, batches, and bodiless GET/DELETE keep the null id JSON-RPC requires.Audit of the sibling call sites — corrected
transparent_proxy.goguardstreamable_proxy.go:1096req.ID.Raw()streamable_proxy.go:433nilcorrectstreamable_proxy.go:484nilcorrecthttpsse/http_proxy.go:555handlePostRequest)So the accurate claim is narrower than the original: this fixes the asymmetry in the transparent proxy, and the identical asymmetry still exists in the legacy HTTP+SSE transport. A client POSTing
{"jsonrpc":"2.0","id":7,…}to/messages?session_id=<unknown>still receives"id":null.That is deliberately not fixed here. Fixing it means reading and parsing the body before the session lookup, which changes error precedence — a malformed body under an unknown session would become 400 instead of 404. That is a design call about which error wins, not a scoping oversight, and it belongs in its own PR. Filed as a follow-up.
Consequently
NotFoundBody's doc comment — which listed "transparent proxy" as an id-unavailable case — became untrue, so it now states whennilis legitimate instead of implying it is a free choice.Type of change
Test plan
task test) —pkg/transport/sessionandpkg/transport/proxy/transparentgreen with-racegolangci-lint run ./test/e2e/...0 issues;go vet ./...andgo vet ./test/e2e/...cleanthv --helpitself exits 1, soCheckTHVBinaryAvailablefails inBeforeEach). CI is the verification.TestRoundTrip404EchoesRequestIDis the end-to-end pin, table-driven over numeric id, string id, notification, and explicit-null id. It also decodes the body to assert the envelope stays a valid JSON-RPC object.Mutation-verified. Setting
requestID = nilinside thesingleRequestblock (semantically pre-fix, build intact) fails exactly the two id-bearing subtests and correctly leaves the two null-id subtests passing.Why the existing test could not have caught the original bug:
TestRoundTripReturns404ForUnknownSessionsends{"method":"tools/list"}with noid, so it renders a null id before and after the fix.Second commit: the e2e assertion that encoded the old behaviour
3c6aa253fixestest/e2e/hostile_input_proxy_test.go, which had ahostileCase.idNotEchoedfield whose doc comment pinned the old asymmetry explicitly ("a real minor asymmetry in the current code, left flagged rather than fixed"). With the asymmetry gone that field has no true case, so it and its branch are deleted and all five hostile cases assert the echoed id. Verified by tracing that all five are single JSON-RPC POSTs with explicit ids, so none can now produce a nil id.A repo-wide audit for other assertions encoding the old behaviour found none — every other null-id assertion is a genuinely id-less path (batch
-32600rejections, bodiless DELETE, the raw client's own envelope parsing).API Compatibility
v1beta1API.session.NotFoundResponsegains arequestIDparameter. Internal Go helper, single production caller; no operator API surface touched.Does this introduce a user-facing change?
Yes, narrowly. A session-not-found 404 from the transparent proxy now echoes the request's JSON-RPC id instead of always sending
null. The legacy HTTP+SSE transport is unchanged (see the follow-up above). Requests carrying no id are unaffected.Special notes for reviewers
The 404 behaviour is deliberately untouched. As #5945 notes, the guard keying on
Mcp-Session-Idpresence rather than the client-forgeable revision is a fail-safe by design — this changes only the id in the error body.Two currently-red checks are not this PR's doing:
Go Vulnerability Check—GO-2026-5841inklauspost/compress, red repo-wide since ~17:52 (this same check passed here at 17:08). Fixed by Bump klauspost/compress to v1.18.7 #6041.E2E Tests Core (core)—group_rm_test.go:124, a pre-existing flake tracked as Flaky: group rm "should delete group with workloads" times out waiting for readiness #6040, reproduced onmainand on unrelated branches.Closes #5945
Generated with Claude Code