Skip to content

Commit 384b532

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 cf110e3 commit 384b532

6 files changed

Lines changed: 159 additions & 11 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,37 @@ async def run_server():
2323

2424
import anyio
2525
import anyio.lowlevel
26+
import pydantic_core
2627

2728
from mcp import types
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
@@ -27,12 +27,11 @@
2727
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
2828
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
2929
from mcp.shared._stream_protocols import ReadStream, WriteStream
30-
from mcp.shared.message import ServerMessageMetadata, SessionMessage
30+
from mcp.shared.message import ServerMessageMetadata, SessionMessage, extract_raw_request_id
3131
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS, is_version_at_least
3232
from mcp.types import (
3333
DEFAULT_NEGOTIATED_VERSION,
3434
INTERNAL_ERROR,
35-
INVALID_PARAMS,
3635
INVALID_REQUEST,
3736
PARSE_ERROR,
3837
ErrorData,
@@ -288,8 +287,14 @@ def _create_error_response(
288287
status_code: HTTPStatus,
289288
error_code: int = INVALID_REQUEST,
290289
headers: dict[str, str] | None = None,
290+
request_id: RequestId | None = None,
291291
) -> Response:
292-
"""Create an error response with a simple string message."""
292+
"""Create an error response with a simple string message.
293+
294+
``request_id`` correlates the error with the originating request when it
295+
could be extracted from the (possibly invalid) request body; it defaults
296+
to ``None`` (a null id) per the JSON-RPC 2.0 specification.
297+
"""
293298
response_headers = {"Content-Type": CONTENT_TYPE_JSON}
294299
if headers:
295300
response_headers.update(headers)
@@ -300,7 +305,7 @@ def _create_error_response(
300305
# Return a properly formatted JSON error response
301306
error_response = JSONRPCError(
302307
jsonrpc="2.0",
303-
id=None,
308+
id=request_id,
304309
error=ErrorData(code=error_code, message=error_message),
305310
)
306311

@@ -468,10 +473,14 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
468473
try:
469474
message = jsonrpc_message_adapter.validate_python(raw_message, by_name=False)
470475
except ValidationError as e:
476+
# Correlate the error with the originating request: even though the
477+
# envelope is invalid, the id is often still extractable from the raw
478+
# payload (falls back to a null id per the JSON-RPC 2.0 spec).
471479
response = self._create_error_response(
472480
f"Validation error: {str(e)}",
473481
HTTPStatus.BAD_REQUEST,
474-
INVALID_PARAMS,
482+
INVALID_REQUEST,
483+
request_id=extract_raw_request_id(raw_message),
475484
)
476485
await response(scope, receive, send)
477486
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 & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from mcp.server import Server, ServerRequestContext
1616
from mcp.server.transport_security import TransportSecuritySettings
1717
from mcp.types import (
18-
INVALID_PARAMS,
18+
INVALID_REQUEST,
1919
PARSE_ERROR,
2020
CallToolRequestParams,
2121
CallToolResult,
@@ -129,7 +129,7 @@ async def test_non_json_content_type_is_rejected() -> None:
129129
@requirement("hosting:http:parse-error-400")
130130
@requirement("hosting:http:batch")
131131
async def test_malformed_and_batched_bodies_return_400() -> None:
132-
"""A non-JSON body returns 400 Parse error; a JSON array of requests returns 400 Invalid params."""
132+
"""A non-JSON body returns 400 Parse error; a JSON array of requests returns 400 Invalid Request."""
133133
async with mounted_app(_server()) as (http, _):
134134
session_id = await initialize_via_http(http)
135135
not_json = await http.post(
@@ -149,7 +149,7 @@ async def test_malformed_and_batched_bodies_return_400() -> None:
149149
assert not_json.status_code == 400
150150
assert JSONRPCError.model_validate_json(not_json.text).error.code == PARSE_ERROR
151151
assert batched.status_code == 400
152-
assert JSONRPCError.model_validate_json(batched.text).error.code == INVALID_PARAMS
152+
assert JSONRPCError.model_validate_json(batched.text).error.code == INVALID_REQUEST
153153

154154

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

tests/server/test_stdio.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,15 @@
1111
from mcp.server.mcpserver import MCPServer
1212
from mcp.server.stdio import stdio_server
1313
from mcp.shared.message import SessionMessage
14-
from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, jsonrpc_message_adapter
14+
from mcp.types import (
15+
INVALID_REQUEST,
16+
PARSE_ERROR,
17+
JSONRPCError,
18+
JSONRPCMessage,
19+
JSONRPCRequest,
20+
JSONRPCResponse,
21+
jsonrpc_message_adapter,
22+
)
1523

1624

1725
@pytest.mark.anyio
@@ -96,6 +104,46 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> Non
96104
assert second.message == valid
97105

98106

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

tests/shared/test_streamable_http.py

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

501501

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

0 commit comments

Comments
 (0)