Answer envelope-invalid requests with errors correlated to the original id - #3365
Conversation
…al id A request that is valid JSON but not a valid JSON-RPC envelope was answered (or dropped) with an error carrying no request id, so clients could not correlate the failure: - streamable HTTP: the 400 validation-error body now echoes the original top-level id and uses INVALID_REQUEST (-32600), matching the JSON-RPC 2.0 meaning of an invalid Request object. - the 2026-07-28 single-exchange entry: same id echo on its malformed-envelope rejection. - stdio: a line that fails to decode now surfaces as an UnparseableMessageError carrying the raw payload, and the session's dispatcher answers it with INVALID_REQUEST naming the recovered id instead of dropping it silently. Fixes modelcontextprotocol#2848
|
Thanks for the contribution. This repository only keeps pull requests open when they're linked to an issue that a maintainer has assigned to the author — CONTRIBUTING.md explains why and how we work. This PR has been closed for now because you aren't currently assigned to #2848. If a maintainer would like this change as a PR from you, they'll assign you to #2848 and this PR will reopen automatically — there's nothing more you need to do. (If you opened the issue, this PR already shows up on its timeline.) There's no need to open a new PR — this one will be reopened. While it's closed, please push any updates as new commits rather than force-pushing, since GitHub can't reopen a PR whose branch has been rewritten. Maintainers: reopening this PR, removing the |
Answer envelope-invalid requests with errors correlated to the original id
Fixes #2848
What
Three server-side parse-failure paths dropped or de-correlated the original JSON-RPC request id:
id: null,-32602-32600id: null-32600with recovered id via the session dispatcherSSE is unchanged (its plain-text 400 is a separate redesign, noted on the issue).
How
request_id_in()inmcp/shared/dispatcher.pynarrows a decoded payload's top-levelid(scalar string/int, bool rejected — same rules as the existingas_request_id).UnparseableMessageErrorinmcp/shared/jsonrpc_dispatcher.py: transports wrap undecodable frames in it, carrying the raw payload and the original exception as__cause__. Itsrequest_idproperty does best-effort recovery (json.loads+request_id_in;(ValueError, RecursionError)safe).JSONRPCDispatcher._dispatch, when noon_stream_exceptionobserver is installed, now answers anUnparseableMessageErrorwhose id is recoverable withINVALID_REQUESTthrough the normal_write_errorpath. Bare exceptions and unrecoverable frames keep the existing drop behavior, so client sessions (which install observers) are unaffected.Behavior change flagged for review
The 2025-era streamable HTTP entry's malformed/batched-body error code moves
-32602→-32600. Rationale: JSON-RPC 2.0 defines -32600 as "the JSON sent is not a valid Request object", which is precisely this case; the modern entry already uses -32600 here; and #2848 explicitly accepts either code. Existing tests only asserted status 400 + message substring, so the surface change is limited to the code value and the echoed id.Tests
tests/shared/test_jsonrpc_dispatcher.py: correlated-32600emission + loop-health after; id-recovery matrix (int/str/null/list/object-id, unparseable JSON, scalar, no payload); bare-exception drop behavior preserved.tests/server/test_stdio.py: all three repro inputs from Invalid JSON-RPC envelope errors are not correlated with the original request id #2848 over real injected stdio streams.tests/shared/test_streamable_http.py: id echo on all three inputs.tests/server/test_streamable_http_modern.py: parametrized rejection table updated for echoed ids on posted-response/error bodies; focused test for the three issue inputs.Full gate green locally:
./scripts/test(5761 passed, 100% branch coverage, strict-no-cover clean),ruff format/check,pyright.(AI-assisted implementation, prepared with a coding agent and reviewed by me.)