From e3f1cfd992330a14c9b9f91dd727d29a81fbb12b Mon Sep 17 00:00:00 2001 From: Gautham Prabhu Date: Sat, 13 Jun 2026 08:54:28 +0530 Subject: [PATCH] Correlate invalid-envelope errors with the original request id Per JSON-RPC 2.0, a message that is valid JSON but not a valid request object must be answered with an Invalid Request (-32600) error that echoes the original request id when it is detectable, so clients can correlate the failure. Previously: - Streamable HTTP replied 400 with id null and code -32602 (Invalid params), breaking client-side request/response correlation. - stdio sent no response at all; the validation exception was forwarded into the read stream and silently dropped by the dispatcher. Both transports now extract the id from the raw payload (via the new mcp.types.jsonrpc.extract_request_id helper) and reply with a correlated -32600 error. On stdio, lines without a detectable id (parse errors, malformed notifications, ids of an invalid type) keep the previous behavior of forwarding the exception without a response. Fixes #2848 Co-Authored-By: Claude Fable 5 --- docs/migration.md | 23 +++++++ src/mcp/server/stdio.py | 27 ++++++++ src/mcp/server/streamable_http.py | 10 ++- src/mcp/types/jsonrpc.py | 14 ++++ .../transports/test_hosting_http.py | 6 +- tests/server/test_stdio.py | 64 ++++++++++++++++++- tests/shared/test_streamable_http.py | 39 +++++++++++ 7 files changed, 176 insertions(+), 7 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 850e052550..5f751e8e73 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -531,6 +531,29 @@ await ctx.log(level="info", data="hello") Positional calls (`await ctx.info("hello")`) are unaffected. +### Invalid JSON-RPC envelopes return `-32600` with a correlated request id + +**What changed:** A message that is valid JSON but not a valid JSON-RPC +request object (wrong `jsonrpc` version, missing `jsonrpc`, non-string +`method`, etc.) is now answered with an **Invalid Request (`-32600`)** error +that echoes the original request `id` when it can be detected. + +In v1 this case was handled inconsistently: streamable HTTP replied with +`-32602` (Invalid params) and `id: null`, while stdio sent no response at +all (the validation error was dropped). Neither path let a client correlate +the failure back to the request that caused it. + +**Why it changed:** Per JSON-RPC 2.0, an unparseable-as-a-request envelope is +an Invalid Request, and the error response should carry the original `id` so +clients can match it to the offending request. + +**How to migrate:** If you string-matched on the `-32602` code (or relied on +`id: null`) to detect malformed requests over streamable HTTP, switch to +`-32600` and read the echoed `id`. Lines over stdio that previously produced +no response now produce a `-32600` error when an `id` is present; a line with +no detectable `id` (parse error, malformed notification, or an `id` of an +invalid type) still produces no response. + ### Replace `RootModel` by union types with `TypeAdapter` validation The following union types are no longer `RootModel` subclasses: diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 5c1459dff6..2c54b54e96 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -23,10 +23,35 @@ async def run_server(): import anyio import anyio.lowlevel +import pydantic_core from mcp import types from mcp.shared._context_streams import create_context_streams from mcp.shared.message import SessionMessage +from mcp.types.jsonrpc import extract_request_id + + +def _invalid_request_error(line: str) -> types.JSONRPCError | None: + """Build an Invalid Request error for an id-bearing line that failed envelope validation. + + Per JSON-RPC 2.0, a request that is valid JSON but not a valid request + object gets a -32600 error response echoing the original request id, so + the client can correlate the failure. Returns None when the line is not + valid JSON (parse error, no response expected by existing consumers) or + when no id can be detected (a malformed notification gets no response). + """ + try: + raw = pydantic_core.from_json(line) + except ValueError: + return None + request_id = extract_request_id(raw) + if request_id is None: + return None + return types.JSONRPCError( + jsonrpc="2.0", + id=request_id, + error=types.ErrorData(code=types.INVALID_REQUEST, message="Invalid Request"), + ) @asynccontextmanager @@ -53,6 +78,8 @@ async def stdin_reader(): try: message = types.jsonrpc_message_adapter.validate_json(line, by_name=False) except Exception as exc: + if (error := _invalid_request_error(line)) is not None: + await write_stream.send(SessionMessage(error)) await read_stream_writer.send(exc) continue diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 220d46f9a3..3e7a1524be 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -32,7 +32,6 @@ from mcp.types import ( DEFAULT_NEGOTIATED_VERSION, INTERNAL_ERROR, - INVALID_PARAMS, INVALID_REQUEST, PARSE_ERROR, ErrorData, @@ -43,6 +42,7 @@ RequestId, jsonrpc_message_adapter, ) +from mcp.types.jsonrpc import extract_request_id logger = logging.getLogger(__name__) @@ -288,6 +288,7 @@ def _create_error_response( status_code: HTTPStatus, error_code: int = INVALID_REQUEST, headers: dict[str, str] | None = None, + request_id: RequestId | None = None, ) -> Response: """Create an error response with a simple string message.""" response_headers = {"Content-Type": CONTENT_TYPE_JSON} @@ -300,7 +301,7 @@ def _create_error_response( # Return a properly formatted JSON error response error_response = JSONRPCError( jsonrpc="2.0", - id=None, + id=request_id, error=ErrorData(code=error_code, message=error_message), ) @@ -468,10 +469,13 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re try: message = jsonrpc_message_adapter.validate_python(raw_message, by_name=False) except ValidationError as e: + # Per JSON-RPC 2.0, an invalid envelope is an Invalid Request + # error, echoing the original request id when it is detectable. response = self._create_error_response( f"Validation error: {str(e)}", HTTPStatus.BAD_REQUEST, - INVALID_PARAMS, + INVALID_REQUEST, + request_id=extract_request_id(raw_message), ) await response(scope, receive, send) return diff --git a/src/mcp/types/jsonrpc.py b/src/mcp/types/jsonrpc.py index 14743c33b0..a254d04bfb 100644 --- a/src/mcp/types/jsonrpc.py +++ b/src/mcp/types/jsonrpc.py @@ -82,3 +82,17 @@ class JSONRPCError(BaseModel): JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage) + + +def extract_request_id(raw: Any) -> RequestId | None: + """Best-effort extraction of the request id from an invalid JSON-RPC envelope. + + Per JSON-RPC 2.0, an Invalid Request error response must echo the original + request id when it can be detected, and null otherwise. The bool guard + matters: `bool` is an `int` subclass but not a valid id type. + """ + match raw: + case {"id": str() | int() as request_id} if not isinstance(request_id, bool): + return request_id + case _: + return None diff --git a/tests/interaction/transports/test_hosting_http.py b/tests/interaction/transports/test_hosting_http.py index 85e64ded42..c2cc9dae8e 100644 --- a/tests/interaction/transports/test_hosting_http.py +++ b/tests/interaction/transports/test_hosting_http.py @@ -15,7 +15,7 @@ from mcp.server import Server, ServerRequestContext from mcp.server.transport_security import TransportSecuritySettings from mcp.types import ( - INVALID_PARAMS, + INVALID_REQUEST, PARSE_ERROR, CallToolRequestParams, CallToolResult, @@ -129,7 +129,7 @@ async def test_non_json_content_type_is_rejected() -> None: @requirement("hosting:http:parse-error-400") @requirement("hosting:http:batch") async def test_malformed_and_batched_bodies_return_400() -> None: - """A non-JSON body returns 400 Parse error; a JSON array of requests returns 400 Invalid params.""" + """A non-JSON body returns 400 Parse error; a JSON array of requests returns 400 Invalid Request.""" async with mounted_app(_server()) as (http, _): session_id = await initialize_via_http(http) not_json = await http.post( @@ -149,7 +149,7 @@ async def test_malformed_and_batched_bodies_return_400() -> None: assert not_json.status_code == 400 assert JSONRPCError.model_validate_json(not_json.text).error.code == PARSE_ERROR assert batched.status_code == 400 - assert JSONRPCError.model_validate_json(batched.text).error.code == INVALID_PARAMS + assert JSONRPCError.model_validate_json(batched.text).error.code == INVALID_REQUEST @requirement("hosting:http:protocol-version-400") diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 054a157b3b..5660c070a4 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -11,7 +11,15 @@ from mcp.server.mcpserver import MCPServer from mcp.server.stdio import stdio_server from mcp.shared.message import SessionMessage -from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, jsonrpc_message_adapter +from mcp.types import ( + INVALID_REQUEST, + ErrorData, + JSONRPCError, + JSONRPCMessage, + JSONRPCRequest, + JSONRPCResponse, + jsonrpc_message_adapter, +) @pytest.mark.anyio @@ -66,6 +74,60 @@ async def test_stdio_server_round_trips_messages_over_injected_streams() -> None assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={}) +@pytest.mark.anyio +async def test_stdio_server_replies_invalid_request_for_invalid_envelope() -> None: + """An id-bearing line that fails envelope validation gets a correlated -32600 response. + + Lines that are valid JSON but invalid JSON-RPC envelopes get an Invalid Request + error response echoing the original request id. Lines without a detectable id + (parse errors, malformed notifications, ids of an invalid type) get no response; + every invalid line still surfaces as an in-stream exception and later valid + messages keep flowing. Regression test for issue #2848. + """ + stdin = io.StringIO() + stdout = io.StringIO() + + invalid_lines = [ + '{"jsonrpc": "1.0", "id": 3, "method": "ping", "params": {}}', # wrong jsonrpc version + '{"id": 4, "method": "ping", "params": {}}', # missing jsonrpc field + '{"jsonrpc": "2.0", "id": 8, "method": 12345, "params": {}}', # method is not a string + '{"jsonrpc": "2.0", "id": "abc", "method": 12345}', # string id is echoed as-is + "this is not json", # parse error: no response + '{"jsonrpc": "1.0", "method": "ping"}', # no id (malformed notification): no response + '{"jsonrpc": "2.0", "id": true, "method": 12345}', # bool is not a valid id type: no response + '{"jsonrpc": "2.0", "id": 1.5, "method": 12345}', # fractional id is not a valid id type: no response + ] + valid = JSONRPCRequest(jsonrpc="2.0", id=99, method="ping") + for line in invalid_lines: + stdin.write(line + "\n") + stdin.write(valid.model_dump_json(by_alias=True, exclude_none=True) + "\n") + stdin.seek(0) + + with anyio.fail_after(5): + async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as ( + read_stream, + write_stream, + ): + async with read_stream: + for _ in invalid_lines: + received = await read_stream.receive() + assert isinstance(received, Exception) + final = await read_stream.receive() + assert isinstance(final, SessionMessage) + assert final.message == valid + await write_stream.aclose() + + stdout.seek(0) + responses = [jsonrpc_message_adapter.validate_json(line.strip()) for line in stdout.readlines()] + invalid_request = ErrorData(code=INVALID_REQUEST, message="Invalid Request") + assert responses == [ + JSONRPCError(jsonrpc="2.0", id=3, error=invalid_request), + JSONRPCError(jsonrpc="2.0", id=4, error=invalid_request), + JSONRPCError(jsonrpc="2.0", id=8, error=invalid_request), + JSONRPCError(jsonrpc="2.0", id="abc", error=invalid_request), + ] + + @pytest.mark.anyio async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> None: """Non-UTF-8 stdin bytes surface as an in-stream exception without killing the stream. diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 7db7e68fb2..652a242cea 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -46,6 +46,7 @@ from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder from mcp.types import ( + INVALID_REQUEST, CallToolRequestParams, CallToolResult, InitializeResult, @@ -499,6 +500,44 @@ async def test_json_parsing(basic_app: Starlette) -> None: assert "Validation error" in response.text +@pytest.mark.anyio +@pytest.mark.parametrize( + ("body", "expected_id"), + [ + pytest.param({"jsonrpc": "1.0", "id": 3, "method": "ping", "params": {}}, 3, id="wrong-jsonrpc-version"), + pytest.param({"id": 4, "method": "ping", "params": {}}, 4, id="missing-jsonrpc-field"), + pytest.param({"jsonrpc": "2.0", "id": 8, "method": 12345, "params": {}}, 8, id="method-not-a-string"), + pytest.param({"jsonrpc": "2.0", "id": "abc", "method": 12345}, "abc", id="string-id"), + pytest.param({"foo": "bar"}, None, id="no-id-detectable"), + pytest.param({"jsonrpc": "2.0", "id": True, "method": 12345}, None, id="bool-is-not-a-valid-id"), + pytest.param({"jsonrpc": "2.0", "id": 1.5, "method": 12345}, None, id="fractional-id-is-not-valid"), + ], +) +async def test_invalid_envelope_error_echoes_request_id( + basic_app: Starlette, body: dict[str, Any], expected_id: int | str | None +) -> None: + """An invalid JSON-RPC envelope gets a -32600 error echoing the original request id. + + Valid JSON that fails envelope validation is an Invalid Request per JSON-RPC 2.0; + the error response must carry the original request id when it is detectable (and + null otherwise) so the client can correlate the failure. Regression test for + issue #2848. + """ + async with make_client(basic_app) as client: + response = await client.post( + "/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=body, + ) + assert response.status_code == 400 + error_response = response.json() + assert error_response["id"] == expected_id + assert error_response["error"]["code"] == INVALID_REQUEST + + @pytest.mark.anyio async def test_method_not_allowed(basic_app: Starlette) -> None: """Unsupported HTTP methods are rejected with 405."""