FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

fix: correlate invalid JSON-RPC envelope errors with the original req… · modelcontextprotocol/python-sdk@9ab7b71 · GitHub

Commit 9ab7b71

Browse files
fix: correlate invalid JSON-RPC envelope errors with the original request id
When an incoming message is valid JSON but fails JSON-RPC envelope validation, the error response previously could not be correlated with the originating request: the stdio server transport dropped the message with no response at all, and the Streamable HTTP transport replied with a null id. Extract the request id best-effort from the raw parsed payload and preserve it in the error response on both transports, falling back to a null id (per the JSON-RPC 2.0 spec) when no valid id can be extracted. The stdio transport now also answers unparseable lines with a Parse error (-32700, null id), and both transports report envelope-invalid messages as Invalid Request (-32600) instead of Invalid params (-32602), matching the JSON-RPC 2.0 error code semantics.
1 parent 220d362 commit 9ab7b71

6 files changed

Lines changed: 153 additions & 9 deletions

File tree

‎src/mcp/server/stdio.py‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,36 @@ async def run_server():
2424
import anyio
2525
import anyio.lowlevel
2626
import mcp_types as types
27+
import pydantic_core
2728

2829
from mcp.shared._context_streams import create_context_streams
29-
from mcp.shared.message import SessionMessage
30+
from mcp.shared.message import SessionMessage, extract_raw_request_id
31+
32+
33+
def _error_response_for_invalid_line(line: str) -> SessionMessage:
34+
"""Build the JSON-RPC error response for a stdin line that failed message validation.
35+
36+
Correlates the error with the originating request where possible: for lines that
37+
are valid JSON but an invalid JSON-RPC envelope, the request id is extracted
38+
best-effort from the raw payload (Invalid Request, -32600); for lines that are
39+
not valid JSON, a null id is used (Parse error, -32700), per the JSON-RPC 2.0
40+
specification.
41+
42+
Args:
43+
line: The raw stdin line that failed to validate as a JSON-RPC message.
44+
45+
Returns:
46+
A `SessionMessage` wrapping the `JSONRPCError` to write back to the client.
47+
"""
48+
try:
49+
raw_message = pydantic_core.from_json(line)
50+
except ValueError:
51+
request_id = None
52+
error = types.ErrorData(code=types.PARSE_ERROR, message="Parse error")
53+
else:
54+
request_id = extract_raw_request_id(raw_message)
55+
error = types.ErrorData(code=types.INVALID_REQUEST, message="Invalid Request")
56+
return SessionMessage(types.JSONRPCError(jsonrpc="2.0", id=request_id, error=error))
3057

3158

3259
@asynccontextmanager
@@ -53,6 +80,13 @@ async def stdin_reader():
5380
try:
5481
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
5582
except Exception as exc:
83+
try:
84+
await write_stream.send(_error_response_for_invalid_line(line))
85+
except anyio.ClosedResourceError:
86+
# The server side already closed the write stream; the
87+
# error response cannot be delivered, but the exception
88+
# below still surfaces the bad line in-stream.
89+
await anyio.lowlevel.checkpoint()
5690
await read_stream_writer.send(exc)
5791
continue
5892

‎src/mcp/server/streamable_http.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from mcp_types import (
2323
DEFAULT_NEGOTIATED_VERSION,
2424
INTERNAL_ERROR,
25-
INVALID_PARAMS,
2625
INVALID_REQUEST,
2726
PARSE_ERROR,
2827
ErrorData,
@@ -44,7 +43,7 @@
4443
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
4544
from mcp.shared._stream_protocols import ReadStream, WriteStream
4645
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
47-
from mcp.shared.message import ServerMessageMetadata, SessionMessage
46+
from mcp.shared.message import ServerMessageMetadata, SessionMessage, extract_raw_request_id
4847

4948
logger = logging.getLogger(__name__)
5049

@@ -331,8 +330,14 @@ def _create_error_response(
331330
status_code: HTTPStatus,
332331
error_code: int = INVALID_REQUEST,
333332
headers: dict[str, str] | None = None,
333+
request_id: RequestId | None = None,
334334
) -> Response:
335-
"""Create an error response with a simple string message."""
335+
"""Create an error response with a simple string message.
336+
337+
``request_id`` correlates the error with the originating request when it
338+
could be extracted from the (possibly invalid) request body; it defaults
339+
to ``None`` (a null id) per the JSON-RPC 2.0 specification.
340+
"""
336341
response_headers = {"Content-Type": CONTENT_TYPE_JSON}
337342
if headers:
338343
response_headers.update(headers)
@@ -343,7 +348,7 @@ def _create_error_response(
343348
# Return a properly formatted JSON error response
344349
error_response = JSONRPCError(
345350
jsonrpc="2.0",
346-
id=None,
351+
id=request_id,
347352
error=ErrorData(code=error_code, message=error_message),
348353
)
349354

@@ -494,10 +499,14 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
494499
try:
495500
message = jsonrpc_message_adapter.validate_python(raw_message, by_name=False)
496501
except ValidationError as e:
502+
# Correlate the error with the originating request: even though the
503+
# envelope is invalid, the id is often still extractable from the raw
504+
# payload (falls back to a null id per the JSON-RPC 2.0 spec).
497505
response = self._create_error_response(
498506
f"Validation error: {str(e)}",
499507
HTTPStatus.BAD_REQUEST,
500-
INVALID_PARAMS,
508+
INVALID_REQUEST,
509+
request_id=extract_raw_request_id(raw_message),
501510
)
502511
await response(scope, receive, send)
503512
return

‎src/mcp/shared/message.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,37 @@
66

77
from collections.abc import Awaitable, Callable
88
from dataclasses import dataclass
9-
from typing import Any
9+
from typing import Any, cast
1010

1111
from mcp_types import JSONRPCMessage, RequestId
1212

1313
ResumptionToken = str
1414

1515
ResumptionTokenUpdateCallback = Callable[[ResumptionToken], Awaitable[None]]
1616

17+
18+
def extract_raw_request_id(raw_message: Any) -> RequestId | None:
19+
"""Best-effort extraction of a JSON-RPC request id from an unvalidated payload.
20+
21+
Used to correlate error responses with the originating request when an incoming
22+
message fails JSON-RPC envelope validation: even though the envelope is invalid,
23+
the ``id`` member is often still present in the raw parsed JSON.
24+
25+
Args:
26+
raw_message: The parsed JSON payload, before any envelope validation.
27+
28+
Returns:
29+
The request id when it is a valid JSON-RPC id type (a string, or an integer
30+
that is not a bool — ``bool`` subclasses ``int`` but is not a valid id),
31+
otherwise ``None``.
32+
"""
33+
if isinstance(raw_message, dict):
34+
raw_id = cast("dict[Any, Any]", raw_message).get("id")
35+
if isinstance(raw_id, str) or (isinstance(raw_id, int) and not isinstance(raw_id, bool)):
36+
return raw_id
37+
return None
38+
39+
1740
# Callback type for closing SSE streams without terminating
1841
CloseSSEStreamCallback = Callable[[], Awaitable[None]]
1942

‎tests/interaction/transports/test_hosting_http.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
CLIENT_CAPABILITIES_META_KEY,
1616
CLIENT_INFO_META_KEY,
1717
INVALID_PARAMS,
18+
INVALID_REQUEST,
1819
PARSE_ERROR,
1920
PROTOCOL_VERSION_META_KEY,
2021
UNSUPPORTED_PROTOCOL_VERSION,
@@ -134,7 +135,7 @@ async def test_non_json_content_type_is_rejected() -> None:
134135
@requirement("hosting:http:parse-error-400")
135136
@requirement("hosting:http:batch")
136137
async def test_malformed_and_batched_bodies_return_400() -> None:
137-
"""A non-JSON body returns 400 Parse error; a JSON array of requests returns 400 Invalid params."""
138+
"""A non-JSON body returns 400 Parse error; a JSON array of requests returns 400 Invalid Request."""
138139
async with mounted_app(_server()) as (http, _):
139140
session_id = await initialize_via_http(http)
140141
not_json = await http.post(
@@ -154,7 +155,7 @@ async def test_malformed_and_batched_bodies_return_400() -> None:
154155
assert not_json.status_code == 400
155156
assert JSONRPCError.model_validate_json(not_json.text).error.code == PARSE_ERROR
156157
assert batched.status_code == 400
157-
assert JSONRPCError.model_validate_json(batched.text).error.code == INVALID_PARAMS
158+
assert JSONRPCError.model_validate_json(batched.text).error.code == INVALID_REQUEST
158159

159160

160161
@requirement("hosting:http:protocol-version-400")

‎tests/server/test_stdio.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
from mcp_types import (
1111
CLIENT_CAPABILITIES_META_KEY,
1212
CLIENT_INFO_META_KEY,
13+
INVALID_REQUEST,
14+
PARSE_ERROR,
1315
PROTOCOL_VERSION_META_KEY,
16+
JSONRPCError,
1417
JSONRPCMessage,
1518
JSONRPCRequest,
1619
JSONRPCResponse,
@@ -105,6 +108,46 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> Non
105108
assert second.message == valid
106109

107110

111+
@pytest.mark.anyio
112+
async def test_stdio_server_replies_to_invalid_messages_with_correlated_errors() -> None:
113+
"""Invalid stdin lines are answered with a JSON-RPC error carrying the original id.
114+
115+
Lines that are valid JSON but invalid JSON-RPC envelopes get an Invalid Request
116+
error with the id extracted best-effort from the raw payload; lines that are not
117+
valid JSON get a Parse error with a null id, per the JSON-RPC 2.0 specification.
118+
The exception is still surfaced in-stream for each bad line.
119+
"""
120+
invalid_lines = [
121+
'{"jsonrpc": "1.0", "id": 3, "method": "ping", "params": {}}',
122+
'{"id": 4, "method": "ping", "params": {}}',
123+
'{"jsonrpc": "2.0", "id": 8, "method": 12345, "params": {}}',
124+
"this is not valid json",
125+
]
126+
stdin = io.StringIO("".join(line + "\n" for line in invalid_lines))
127+
stdout = io.StringIO()
128+
129+
with anyio.fail_after(5):
130+
async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as (
131+
read_stream,
132+
write_stream,
133+
):
134+
async with read_stream:
135+
for _ in invalid_lines:
136+
received = await read_stream.receive()
137+
assert isinstance(received, Exception)
138+
await write_stream.aclose()
139+
140+
stdout.seek(0)
141+
error_responses = [JSONRPCError.model_validate_json(line.strip()) for line in stdout.readlines()]
142+
assert [error_response.id for error_response in error_responses] == [3, 4, 8, None]
143+
assert [error_response.error.code for error_response in error_responses] == [
144+
INVALID_REQUEST,
145+
INVALID_REQUEST,
146+
INVALID_REQUEST,
147+
PARSE_ERROR,
148+
]
149+
150+
108151
class _GatedStdin(io.RawIOBase):
109152
"""Raw stdin double: serves its frames, then blocks until released before EOF.
110153

‎tests/shared/test_streamable_http.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,40 @@ async def test_json_parsing(basic_app: Starlette) -> None:
510510
assert "Validation error" in response.text
511511

512512

513+
@pytest.mark.anyio
514+
@pytest.mark.parametrize(
515+
("body", "expected_id"),
516+
[
517+
pytest.param({"jsonrpc": "1.0", "id": 3, "method": "ping", "params": {}}, 3, id="wrong-jsonrpc-version"),
518+
pytest.param({"id": 4, "method": "ping", "params": {}}, 4, id="missing-jsonrpc-field"),
519+
pytest.param({"jsonrpc": "2.0", "id": 8, "method": 12345, "params": {}}, 8, id="method-not-a-string"),
520+
pytest.param({"jsonrpc": "2.0", "id": 2.5, "method": 12345, "params": {}}, None, id="id-not-a-valid-type"),
521+
],
522+
)
523+
async def test_validation_error_preserves_request_id(
524+
basic_app: Starlette, body: dict[str, Any], expected_id: int | None
525+
) -> None:
526+
"""An envelope-invalid message is answered with an error carrying the original request id.
527+
528+
The id is extracted best-effort from the raw payload so the client can correlate the
529+
error response with its request; when no valid id can be extracted, the error falls
530+
back to a null id per the JSON-RPC 2.0 specification.
531+
"""
532+
async with make_client(basic_app) as client:
533+
response = await client.post(
534+
"/mcp",
535+
headers={
536+
"Accept": "application/json, text/event-stream",
537+
"Content-Type": "application/json",
538+
},
539+
json=body,
540+
)
541+
assert response.status_code == 400
542+
error = types.JSONRPCError.model_validate_json(response.text)
543+
assert error.id == expected_id
544+
assert error.error.code == types.INVALID_REQUEST
545+
546+
513547
@pytest.mark.anyio
514548
async def test_method_not_allowed(basic_app: Starlette) -> None:
515549
"""Unsupported HTTP methods are rejected with 405."""

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL