Skip to content

Commit 11ab96b

Browse files
Parker FawcettParker Fawcett
authored andcommitted
Answer envelope-invalid requests with errors correlated to the original 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 #2848
1 parent 57394b0 commit 11ab96b

11 files changed

Lines changed: 226 additions & 22 deletions

src/mcp/server/_streamable_http_modern.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
from mcp.server.runner import modern_error_data, serve_one
5555
from mcp.server.streamable_http import check_accept_headers
5656
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
57-
from mcp.shared.dispatcher import CallOptions
57+
from mcp.shared.dispatcher import CallOptions, request_id_in
5858
from mcp.shared.exceptions import NoBackChannelError
5959
from mcp.shared.inbound import (
6060
ERROR_CODE_HTTP_STATUS,
@@ -413,7 +413,10 @@ async def handle_modern_request(
413413
except ValidationError:
414414
# A batch, a posted response (clients MUST NOT send those: streamable-http
415415
# §Sending Messages item 4), or a request whose envelope is malformed.
416-
await _write(_INVALID_BODY, scope, receive, send)
416+
# Echo the original request id so envelope-invalid failures correlate.
417+
request_id = request_id_in(decoded)
418+
rej = JSONRPCError(jsonrpc="2.0", id=request_id, error=_INVALID_BODY.error)
419+
await _write(rej, scope, receive, send)
417420
return
418421

419422
if req.method == "subscriptions/listen" and not has_sse:

src/mcp/server/stdio.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ async def run_server():
2626

2727
from mcp.os.win32.utilities import rebind_std_handle_to_fd
2828
from mcp.shared._context_streams import create_context_streams
29+
from mcp.shared.jsonrpc_dispatcher import UnparseableMessageError
2930
from mcp.shared.message import SessionMessage
3031

3132
if sys.platform != "win32": # pragma: no branch
@@ -188,7 +189,7 @@ async def stdin_reader():
188189
try:
189190
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
190191
except Exception as exc:
191-
await read_stream_writer.send(exc)
192+
await read_stream_writer.send(UnparseableMessageError(line, cause=exc))
192193
continue
193194

194195
session_message = SessionMessage(message)

src/mcp/server/streamable_http.py

Lines changed: 7 additions & 3 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,
@@ -43,6 +42,7 @@
4342
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
4443
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
4544
from mcp.shared._stream_protocols import ReadStream, WriteStream
45+
from mcp.shared.dispatcher import request_id_in
4646
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
4747
from mcp.shared.message import CloseSSEStreamCallback, ServerMessageMetadata, SessionMessage
4848

@@ -369,6 +369,7 @@ def _create_error_response(
369369
status_code: HTTPStatus,
370370
error_code: int = INVALID_REQUEST,
371371
headers: dict[str, str] | None = None,
372+
request_id: RequestId | None = None,
372373
) -> Response:
373374
"""Create an error response with a simple string message."""
374375
response_headers = {"Content-Type": CONTENT_TYPE_JSON}
@@ -381,7 +382,7 @@ def _create_error_response(
381382
# Return a properly formatted JSON error response
382383
error_response = JSONRPCError(
383384
jsonrpc="2.0",
384-
id=None,
385+
id=request_id,
385386
error=ErrorData(code=error_code, message=error_message),
386387
)
387388

@@ -546,10 +547,13 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
546547
try:
547548
message = jsonrpc_message_adapter.validate_python(raw_message, by_name=False)
548549
except ValidationError as e:
550+
# Echo the original request id so envelope-invalid failures correlate (#2848).
551+
request_id = request_id_in(raw_message)
549552
response = self._create_error_response(
550553
f"Validation error: {str(e)}",
551554
HTTPStatus.BAD_REQUEST,
552-
INVALID_PARAMS,
555+
INVALID_REQUEST,
556+
request_id=request_id,
553557
)
554558
await response(scope, receive, send)
555559
return

src/mcp/shared/dispatcher.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"ProgressFnT",
4141
"as_request_id",
4242
"coerce_request_id",
43+
"request_id_in",
4344
"run_notify_intercept",
4445
]
4546

@@ -53,6 +54,16 @@ def as_request_id(value: object) -> RequestId | None:
5354
return None
5455

5556

57+
def request_id_in(message: Any) -> RequestId | None:
58+
"""A decoded JSON-RPC message's top-level request id, or None when the message is
59+
not an object or carries no scalar string/int id."""
60+
try:
61+
rid: Any = message.get("id")
62+
except AttributeError:
63+
return None
64+
return as_request_id(rid)
65+
66+
5667
def coerce_request_id(request_id: RequestId) -> RequestId:
5768
"""Coerce a stringified int request id back to int so a peer-echoed id still correlates (matches the TS SDK).
5869

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
import contextvars
11+
import json
1112
import logging
1213
from collections.abc import Awaitable, Callable, Mapping
1314
from dataclasses import dataclass, field
@@ -22,6 +23,7 @@
2223
CONNECTION_CLOSED,
2324
INTERNAL_ERROR,
2425
INVALID_PARAMS,
26+
INVALID_REQUEST,
2527
REQUEST_TIMEOUT,
2628
ErrorData,
2729
JSONRPCError,
@@ -49,6 +51,7 @@
4951
ProgressFnT,
5052
as_request_id,
5153
coerce_request_id,
54+
request_id_in,
5255
run_notify_intercept,
5356
)
5457
from mcp.shared.exceptions import MCPError, NoBackChannelError
@@ -62,6 +65,7 @@
6265

6366
__all__ = [
6467
"JSONRPCDispatcher",
68+
"UnparseableMessageError",
6569
"cancelled_request_id_from_params",
6670
"handler_exception_to_error_data",
6771
"progress_token_from_params",
@@ -85,6 +89,33 @@
8589
answered - the handler's eventual result or error is dropped, not written."""
8690

8791

92+
class UnparseableMessageError(Exception):
93+
"""A transport failed to decode an inbound frame as a JSON-RPC message.
94+
95+
Transports send this instead of a bare parse exception so the receiving
96+
side can still correlate an error response with the frame's JSON-RPC
97+
request id, when one is recoverable from the raw payload.
98+
"""
99+
100+
def __init__(self, payload: str | bytes | None = None, *, cause: BaseException | None = None) -> None:
101+
super().__init__(
102+
f"failed to decode inbound frame: {cause!r}" if cause is not None else "failed to decode inbound frame"
103+
)
104+
self.payload = payload
105+
self.__cause__ = cause
106+
107+
@property
108+
def request_id(self) -> RequestId | None:
109+
"""Best-effort recovery of the frame's top-level request id, else None."""
110+
if self.payload is None:
111+
return None
112+
try:
113+
decoded = json.loads(self.payload)
114+
except (ValueError, RecursionError):
115+
return None
116+
return request_id_in(decoded)
117+
118+
88119
def handler_exception_to_error_data(exc: BaseException) -> ErrorData | None:
89120
"""Map a handler-raised exception to its wire `ErrorData`.
90121
@@ -537,6 +568,17 @@ async def _dispatch(
537568
"""
538569
if isinstance(item, Exception):
539570
if self.on_stream_exception is None:
571+
# Answer an envelope-invalid frame whose request id is still
572+
# recoverable; bare exceptions stay dropped as before.
573+
request_id = item.request_id if isinstance(item, UnparseableMessageError) else None
574+
if request_id is not None:
575+
self._spawn(
576+
self._write_error,
577+
request_id,
578+
ErrorData(code=INVALID_REQUEST, message="Invalid Request"),
579+
sender_ctx=sender_ctx,
580+
)
581+
return
540582
logger.debug("transport yielded exception: %r", item)
541583
return
542584
try:

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/interaction/transports/test_hosting_http_modern.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,10 @@ async def test_modern_notification_post_is_acknowledged_202_and_a_posted_respons
181181
assert (acknowledged.status_code, acknowledged.content) == (202, b"")
182182
assert "mcp-session-id" not in acknowledged.headers
183183
assert refused.status_code == 400
184+
# The posted response's own id is echoed so the client can correlate the refusal.
184185
assert JSONRPCError.model_validate(refused.json()) == JSONRPCError(
185186
jsonrpc="2.0",
186-
id=None,
187+
id=1,
187188
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request or notification object"),
188189
)
189190

tests/server/test_stdio.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from mcp.server.mcpserver import MCPServer
2626
from mcp.server.stdio import stdio_server
27+
from mcp.shared.jsonrpc_dispatcher import UnparseableMessageError
2728
from mcp.shared.message import SessionMessage
2829

2930

@@ -75,6 +76,39 @@ async def test_stdio_server_round_trips_messages_over_injected_streams() -> None
7576
assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={})
7677

7778

79+
@pytest.mark.anyio
80+
@pytest.mark.parametrize(
81+
("line", "expected_id"),
82+
[
83+
pytest.param('{"jsonrpc": "1.0", "id": 3, "method": "ping", "params": {}}', 3, id="wrong-jsonrpc-version"),
84+
pytest.param('{"id": 4, "method": "ping", "params": {}}', 4, id="missing-jsonrpc-field"),
85+
pytest.param('{"jsonrpc": "2.0", "id": 8, "method": 12345, "params": {}}', 8, id="non-string-method"),
86+
],
87+
)
88+
async def test_stdio_server_envelope_invalid_line_surfaces_with_recoverable_request_id(
89+
line: str, expected_id: int
90+
) -> None:
91+
"""A line that is valid JSON but not a valid JSON-RPC envelope surfaces as an
92+
UnparseableMessageError carrying the raw payload, so the session can still
93+
correlate an error response with the request's original id."""
94+
stdin = io.StringIO(line + "\n")
95+
stdout = io.StringIO()
96+
97+
with anyio.fail_after(5):
98+
async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as (
99+
read_stream,
100+
write_stream,
101+
):
102+
async with read_stream:
103+
received = await read_stream.receive()
104+
105+
assert isinstance(received, UnparseableMessageError)
106+
assert received.request_id == expected_id
107+
108+
# Closing write_stream ends stdout_writer so the server context can join.
109+
await write_stream.aclose()
110+
111+
78112
@pytest.mark.anyio
79113
async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> None:
80114
"""Non-UTF-8 stdin bytes surface as an in-stream exception without killing the stream."""

tests/server/test_streamable_http_modern.py

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -171,30 +171,35 @@ async def test_handle_modern_request_rejects_a_notification_post_at_an_unserved_
171171

172172

173173
@pytest.mark.parametrize(
174-
"body",
174+
("body", "expected_id"),
175175
[
176-
pytest.param({"jsonrpc": "2.0", "id": 1, "result": {}}, id="posted-response"),
177-
pytest.param({"jsonrpc": "2.0", "id": 1, "error": {"code": -1, "message": "x"}}, id="posted-error"),
178-
pytest.param([{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}], id="batch"),
179-
pytest.param({"jsonrpc": "2.0", "id": None, "method": "tools/list"}, id="null-id-request"),
180-
pytest.param({"jsonrpc": "2.0", "id": [1], "method": "tools/list"}, id="non-scalar-id-request"),
181-
pytest.param({"jsonrpc": "2.0", "method": 7}, id="non-string-method-notification"),
182-
pytest.param({"jsonrpc": "1.0", "method": "notifications/cancelled"}, id="wrong-jsonrpc-version"),
183-
pytest.param("just a string", id="scalar"),
176+
pytest.param({"jsonrpc": "2.0", "id": 1, "result": {}}, 1, id="posted-response"),
177+
pytest.param({"jsonrpc": "2.0", "id": 1, "error": {"code": -1, "message": "x"}}, 1, id="posted-error"),
178+
pytest.param(
179+
[{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}], None, id="batch"
180+
),
181+
pytest.param({"jsonrpc": "2.0", "id": None, "method": "tools/list"}, None, id="null-id-request"),
182+
pytest.param({"jsonrpc": "2.0", "id": [1], "method": "tools/list"}, None, id="non-scalar-id-request"),
183+
pytest.param({"jsonrpc": "2.0", "method": 7}, None, id="non-string-method-notification"),
184+
pytest.param({"jsonrpc": "1.0", "method": "notifications/cancelled"}, None, id="wrong-jsonrpc-version"),
185+
pytest.param("just a string", None, id="scalar"),
184186
],
185187
)
186-
async def test_handle_modern_request_rejects_a_body_that_is_neither_request_nor_notification(body: Any) -> None:
188+
async def test_handle_modern_request_rejects_a_body_that_is_neither_request_nor_notification(
189+
body: Any, expected_id: int | None
190+
) -> None:
187191
"""Spec-mandated (streamable-http §Sending Messages item 4): the body MUST be a single request
188192
or notification and clients MUST NOT post responses. SDK-defined: anything else -- a posted
189193
response, a batch, a request whose `id` is malformed, a scalar -- is `INVALID_REQUEST` at
190-
HTTP 400 with `id: null`, distinct from `PARSE_ERROR` (malformed JSON). A malformed-`id`
191-
request in particular must not be mistaken for a notification and silently 202'd."""
194+
HTTP 400 with the original id echoed when one is recoverable (else `id: null`), distinct
195+
from `PARSE_ERROR` (malformed JSON). A malformed-`id` request in particular must not be
196+
mistaken for a notification and silently 202'd."""
192197
async with _asgi_client(Server("test")) as http:
193198
response = await http.post("/mcp", json=body)
194199
assert response.status_code == 400
195200
assert response.json() == {
196201
"jsonrpc": "2.0",
197-
"id": None,
202+
"id": expected_id,
198203
"error": {"code": INVALID_REQUEST, "message": "Body must be a single JSON-RPC request or notification object"},
199204
}
200205

@@ -216,6 +221,27 @@ async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -
216221
}
217222

218223

224+
@pytest.mark.parametrize(
225+
("body", "expected_id"),
226+
[
227+
pytest.param({"jsonrpc": "1.0", "id": 3, "method": "ping", "params": {}}, 3, id="wrong-jsonrpc-version"),
228+
pytest.param({"id": 4, "method": "ping", "params": {}}, 4, id="missing-jsonrpc-field"),
229+
pytest.param({"jsonrpc": "2.0", "id": 8, "method": 12345, "params": {}}, 8, id="non-string-method"),
230+
],
231+
)
232+
async def test_handle_modern_request_envelope_invalid_request_echoes_the_original_id(
233+
body: dict[str, Any], expected_id: int
234+
) -> None:
235+
"""An envelope-invalid but id-bearing request is answered -32600 with the
236+
original request id, so the client can correlate the failure."""
237+
async with _asgi_client(Server("test")) as http:
238+
response = await http.post("/mcp", json=body)
239+
assert response.status_code == 400
240+
error = response.json()
241+
assert error["id"] == expected_id
242+
assert error["error"]["code"] == INVALID_REQUEST
243+
244+
219245
async def test_handle_modern_request_returns_transport_security_error_response() -> None:
220246
"""The transport-security middleware's error response is sent verbatim and short-circuits."""
221247
settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["good.example"])

0 commit comments

Comments
 (0)