Skip to content

Echo the request JSON-RPC id in session-not-found 404s - #6031

Merged
JAORMX merged 2 commits into
mainfrom
echo-jsonrpc-id-in-session-404
Jul 27, 2026
Merged

Echo the request JSON-RPC id in session-not-found 404s#6031
JAORMX merged 2 commits into
mainfrom
echo-jsonrpc-id-in-session-404

Conversation

@JAORMX

@JAORMX JAORMX commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #5945. 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 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. sawInitialize was already hoisted out of that block for the same reason; the id now is too.

It is set only when parseRPCRequest reports a single request — and that predicate is already method != "" && 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

⚠️ An earlier revision of this description contained an error. It listed
httpsse/http_proxy.go:555 as a bodiless GET and concluded the transparent proxy was
"the only site discarding an id it had". That is wrong: line 555 is inside handlePostRequest
(line 537, which rejects non-POST at 539), and io.ReadAll(r.Body) runs at ~line 569 —
after the session lookup. So it is a POST whose id is available but not yet read.
The corrected table is below. Thanks to the follow-up audit for catching it.

Site Request shape id available? Verdict
transparent_proxy.go guard POST with JSON-RPC body yes, discarded fixed here
streamable_proxy.go:1096 POST yes already echoes req.ID.Raw()
streamable_proxy.go:433 standalone SSE GET no body nil correct
streamable_proxy.go:484 DELETE no body nil correct
httpsse/http_proxy.go:555 POST (handlePostRequest) yes, but unread at that point same asymmetry survives — see below

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 when nil is legitimate instead of implying it is a free choice.

Type of change

  • Bug fix

Test plan

  • Unit tests (task test) — pkg/transport/session and pkg/transport/proxy/transparent green with -race
  • Linting — golangci-lint run ./test/e2e/... 0 issues; go vet ./... and go vet ./test/e2e/... clean
  • Manual testing (described below)
  • E2E suite — cannot be run in our environment (no container runtime; thv --help itself exits 1, so CheckTHVBinaryAvailable fails in BeforeEach). CI is the verification.

TestRoundTrip404EchoesRequestID is 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 = nil inside the singleRequest block (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: TestRoundTripReturns404ForUnknownSession sends {"method":"tools/list"} with no id, so it renders a null id before and after the fix.

Second commit: the e2e assertion that encoded the old behaviour

3c6aa253 fixes test/e2e/hostile_input_proxy_test.go, which had a hostileCase.idNotEchoed field 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 -32600 rejections, bodiless DELETE, the raw client's own envelope parsing).

API Compatibility

  • This PR does not break the v1beta1 API.

session.NotFoundResponse gains a requestID parameter. 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-Id presence 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:

  1. Go Vulnerability CheckGO-2026-5841 in klauspost/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.
  2. 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 on main and on unrelated branches.

Closes #5945

Generated with Claude Code

@github-actions github-actions Bot added the size/S Small PR: 100-299 lines changed label Jul 27, 2026
rdimitrov
rdimitrov previously approved these changes Jul 27, 2026
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.17%. Comparing base (dc26afe) to head (0f8a149).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@JAORMX
JAORMX force-pushed the echo-jsonrpc-id-in-session-404 branch from d3a7ee8 to 1bd0d48 Compare July 27, 2026 16:22
@JAORMX
JAORMX changed the base branch from fix-main-reserved-meta-test-build to main July 27, 2026 16:22
@JAORMX
JAORMX dismissed rdimitrov’s stale review July 27, 2026 16:22

The base branch was changed.

@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Jul 27, 2026
jhrozek
jhrozek previously approved these changes Jul 27, 2026
@JAORMX
JAORMX force-pushed the echo-jsonrpc-id-in-session-404 branch from 1bd0d48 to abd7af2 Compare July 27, 2026 17:07
@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Jul 27, 2026
@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Jul 27, 2026
@JAORMX
JAORMX force-pushed the echo-jsonrpc-id-in-session-404 branch from 16fd074 to 3c6aa25 Compare July 27, 2026 17:54
@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Jul 27, 2026
@JAORMX

JAORMX commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

@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 (abd7af2c) is untouched. New head 3c6aa253 adds one commit on top, changing only test/e2e/hostile_input_proxy_test.go (+12/−15).

Why it was needed. CI's E2E Tests Core (proxy) shard failed on abd7af2c, and it was a genuine regression in test expectations, not a flake. That suite had already found this asymmetry and deliberately pinned it:

// 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 idNotEchoed field no longer has a true case, so it and its branch are deleted and all five hostile cases now assert the echoed id.

My miss, worth naming: I audited the five production NotFound call sites but never grepped the e2e suite for assertions encoding the old behaviour. A follow-up grep across test/ for idNotEchoed, NotFoundBody, NotFoundResponse, WriteNotFound, CodeSessionNotFound, -32001, "id":null and resp.ID found no others. Three near-misses were checked and are correctly unaffected — the batch rejection in stdio_proxy_over_streamable_http_mcp_server_test.go:129 still has no single id (-32600), and the nil-id assertions in mcp_raw_client_test.go are about the raw client's own envelope parsing, not the proxy.

Verification, stated honestly: the e2e suite cannot run in our environment (no container runtime — thv --help itself exits 1, so CheckTHVBinaryAvailable fails in BeforeEach). So the new assertion is not confirmed by a live run. It rests on tracing the code path — all five cases are single JSON-RPC POSTs with an explicit id, so parseRPCRequest returns singleRequest=true and requestID is set before the session guard at transparent_proxy.go:629 — plus the unit coverage in backend_routing_test.go. CI will be the real check.

Two things currently blocking this PR that are not about the code:

  1. Go Vulnerability Check now fails repo-wide — GO-2026-5841, an OOB read in github.com/klauspost/compress/s2, fixed in v1.18.7; we are on v1.18.6 (indirect, via prometheus/client_golangzstd). The same check passed on this PR at 17:08 and failed at 17:52 on a test-only delta, so the advisory landed in the DB between runs. It will fail on main and every open PR until the dep moves.
  2. E2E Tests Core (core) fails on group_rm_test.go:124, a pre-existing flake now tracked as Flaky: group rm "should delete group with workloads" times out waiting for readiness #6040 — unrelated to this change.

@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Jul 27, 2026
JAORMX added a commit that referenced this pull request Jul 27, 2026
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.
JAORMX added 2 commits July 27, 2026 18:04
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.
@JAORMX
JAORMX force-pushed the echo-jsonrpc-id-in-session-404 branch from 3c6aa25 to 0f8a149 Compare July 27, 2026 18:04
@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Jul 27, 2026
@JAORMX
JAORMX merged commit cb1f9d1 into main Jul 27, 2026
48 checks passed
@JAORMX
JAORMX deleted the echo-jsonrpc-id-in-session-404 branch July 27, 2026 18:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/S Small PR: 100-299 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transparent proxy: session-not-found 404 does not echo the request JSON-RPC id

4 participants