From 5536de64124c59d2f5accec41dc43cbe288b5599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9B=9B=E9=87=8E?= <528040597@qq.com> Date: Tue, 22 Sep 2026 10:29:43 +0800 Subject: [PATCH] fix(server): answer a non-Request body with INVALID_REQUEST, not INVALID_PARAMS A POST body that parsed as JSON but was not a JSON-RPC message was answered with -32602 (INVALID_PARAMS). There are no params to be invalid in that case, so JSON-RPC 2.0 reserves -32600 (INVALID_REQUEST) for it; -32602 misreports the failure to the client. This is the first of the two shapes in #3557. The second shape (an unknown method answered with INVALID_PARAMS) no longer reproduces on main: request parsing moved from shared/session.py's receive loop to shared/jsonrpc_dispatcher.py, and server/runner.py answers an unknown method with METHOD_NOT_FOUND. A batch body (a JSON array) keeps INVALID_PARAMS, pinned by the hosting conformance test for unsupported batch bodies; #3557's case is the non-Request object body. Fixes #3557 --- src/mcp/server/streamable_http.py | 8 ++++++- tests/shared/test_streamable_http.py | 33 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 416dd9e2b4..1dfe1817fc 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -587,10 +587,16 @@ 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: + # A body that parsed as JSON but is not a JSON-RPC message has no + # params to be invalid, so JSON-RPC 2.0 reserves -32600 + # (INVALID_REQUEST) for it; -32602 would misreport it as bad params. + # A batch (a JSON array) keeps INVALID_PARAMS, as pinned by the + # hosting conformance test for unsupported batch bodies. + error_code = INVALID_PARAMS if isinstance(raw_message, list) else INVALID_REQUEST response = self._create_error_response( f"Validation error: {str(e)}", HTTPStatus.BAD_REQUEST, - INVALID_PARAMS, + error_code, ) await response(scope, receive, send) return diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 655d9941dc..cdca71abd4 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -512,6 +512,39 @@ async def test_json_parsing(basic_app: Starlette) -> None: assert "Validation error" in response.text +@pytest.mark.anyio +async def test_non_request_body_is_invalid_request(basic_app: Starlette) -> None: + """A body that is not a JSON-RPC Request is INVALID_REQUEST, not INVALID_PARAMS. + + JSON-RPC 2.0 reserves -32602 for invalid params and -32600 for a message + that is not a valid Request; a body with no params to be invalid belongs + to the latter (issue #3557). A batch (a JSON array) keeps INVALID_PARAMS, + as pinned by the hosting conformance test for unsupported batch bodies. + """ + 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={"foo": "bar"}, + ) + assert response.status_code == 400 + assert response.json()["error"]["code"] == INVALID_REQUEST + + batched = await client.post( + "/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=[{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}], + ) + assert batched.status_code == 400 + assert batched.json()["error"]["code"] == INVALID_PARAMS + + @pytest.mark.anyio async def test_method_not_allowed(basic_app: Starlette) -> None: """Unsupported HTTP methods are rejected with 405."""