Skip to content

Commit d98117c

Browse files
Gautham Prabhuclaude
andcommitted
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 <noreply@anthropic.com>
1 parent cf110e3 commit d98117c

7 files changed

Lines changed: 176 additions & 7 deletions

File tree

docs/migration.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,29 @@ await ctx.log(level="info", data="hello")
531531

532532
Positional calls (`await ctx.info("hello")`) are unaffected.
533533

534+
### Invalid JSON-RPC envelopes return `-32600` with a correlated request id
535+
536+
**What changed:** A message that is valid JSON but not a valid JSON-RPC
537+
request object (wrong `jsonrpc` version, missing `jsonrpc`, non-string
538+
`method`, etc.) is now answered with an **Invalid Request (`-32600`)** error
539+
that echoes the original request `id` when it can be detected.
540+
541+
In v1 this case was handled inconsistently: streamable HTTP replied with
542+
`-32602` (Invalid params) and `id: null`, while stdio sent no response at
543+
all (the validation error was dropped). Neither path let a client correlate
544+
the failure back to the request that caused it.
545+
546+
**Why it changed:** Per JSON-RPC 2.0, an unparseable-as-a-request envelope is
547+
an Invalid Request, and the error response should carry the original `id` so
548+
clients can match it to the offending request.
549+
550+
**How to migrate:** If you string-matched on the `-32602` code (or relied on
551+
`id: null`) to detect malformed requests over streamable HTTP, switch to
552+
`-32600` and read the echoed `id`. Lines over stdio that previously produced
553+
no response now produce a `-32600` error when an `id` is present; a line with
554+
no detectable `id` (parse error, malformed notification, or an `id` of an
555+
invalid type) still produces no response.
556+
534557
### Replace `RootModel` by union types with `TypeAdapter` validation
535558

536559
The following union types are no longer `RootModel` subclasses:

src/mcp/server/stdio.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,35 @@ 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
2930
from mcp.shared.message import SessionMessage
31+
from mcp.types.jsonrpc import extract_request_id
32+
33+
34+
def _invalid_request_error(line: str) -> types.JSONRPCError | None:
35+
"""Build an Invalid Request error for an id-bearing line that failed envelope validation.
36+
37+
Per JSON-RPC 2.0, a request that is valid JSON but not a valid request
38+
object gets a -32600 error response echoing the original request id, so
39+
the client can correlate the failure. Returns None when the line is not
40+
valid JSON (parse error, no response expected by existing consumers) or
41+
when no id can be detected (a malformed notification gets no response).
42+
"""
43+
try:
44+
raw = pydantic_core.from_json(line)
45+
except ValueError:
46+
return None
47+
request_id = extract_request_id(raw)
48+
if request_id is None:
49+
return None
50+
return types.JSONRPCError(
51+
jsonrpc="2.0",
52+
id=request_id,
53+
error=types.ErrorData(code=types.INVALID_REQUEST, message="Invalid Request"),
54+
)
3055

3156

3257
@asynccontextmanager
@@ -53,6 +78,8 @@ async def stdin_reader():
5378
try:
5479
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
5580
except Exception as exc:
81+
if (error := _invalid_request_error(line)) is not None:
82+
await write_stream.send(SessionMessage(error))
5683
await read_stream_writer.send(exc)
5784
continue
5885

src/mcp/server/streamable_http.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
from mcp.types import (
3333
DEFAULT_NEGOTIATED_VERSION,
3434
INTERNAL_ERROR,
35-
INVALID_PARAMS,
3635
INVALID_REQUEST,
3736
PARSE_ERROR,
3837
ErrorData,
@@ -43,6 +42,7 @@
4342
RequestId,
4443
jsonrpc_message_adapter,
4544
)
45+
from mcp.types.jsonrpc import extract_request_id
4646

4747
logger = logging.getLogger(__name__)
4848

@@ -288,6 +288,7 @@ def _create_error_response(
288288
status_code: HTTPStatus,
289289
error_code: int = INVALID_REQUEST,
290290
headers: dict[str, str] | None = None,
291+
request_id: RequestId | None = None,
291292
) -> Response:
292293
"""Create an error response with a simple string message."""
293294
response_headers = {"Content-Type": CONTENT_TYPE_JSON}
@@ -300,7 +301,7 @@ def _create_error_response(
300301
# Return a properly formatted JSON error response
301302
error_response = JSONRPCError(
302303
jsonrpc="2.0",
303-
id=None,
304+
id=request_id,
304305
error=ErrorData(code=error_code, message=error_message),
305306
)
306307

@@ -468,10 +469,13 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
468469
try:
469470
message = jsonrpc_message_adapter.validate_python(raw_message, by_name=False)
470471
except ValidationError as e:
472+
# Per JSON-RPC 2.0, an invalid envelope is an Invalid Request
473+
# error, echoing the original request id when it is detectable.
471474
response = self._create_error_response(
472475
f"Validation error: {str(e)}",
473476
HTTPStatus.BAD_REQUEST,
474-
INVALID_PARAMS,
477+
INVALID_REQUEST,
478+
request_id=extract_request_id(raw_message),
475479
)
476480
await response(scope, receive, send)
477481
return

src/mcp/types/jsonrpc.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,17 @@ class JSONRPCError(BaseModel):
8282

8383
JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError
8484
jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage)
85+
86+
87+
def extract_request_id(raw: Any) -> RequestId | None:
88+
"""Best-effort extraction of the request id from an invalid JSON-RPC envelope.
89+
90+
Per JSON-RPC 2.0, an Invalid Request error response must echo the original
91+
request id when it can be detected, and null otherwise. The bool guard
92+
matters: `bool` is an `int` subclass but not a valid id type.
93+
"""
94+
match raw:
95+
case {"id": str() | int() as request_id} if not isinstance(request_id, bool):
96+
return request_id
97+
case _:
98+
return None

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: 63 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+
ErrorData,
17+
JSONRPCError,
18+
JSONRPCMessage,
19+
JSONRPCRequest,
20+
JSONRPCResponse,
21+
jsonrpc_message_adapter,
22+
)
1523

1624

1725
@pytest.mark.anyio
@@ -66,6 +74,60 @@ async def test_stdio_server_round_trips_messages_over_injected_streams() -> None
6674
assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={})
6775

6876

77+
@pytest.mark.anyio
78+
async def test_stdio_server_replies_invalid_request_for_invalid_envelope() -> None:
79+
"""An id-bearing line that fails envelope validation gets a correlated -32600 response.
80+
81+
Lines that are valid JSON but invalid JSON-RPC envelopes get an Invalid Request
82+
error response echoing the original request id. Lines without a detectable id
83+
(parse errors, malformed notifications, ids of an invalid type) get no response;
84+
every invalid line still surfaces as an in-stream exception and later valid
85+
messages keep flowing. Regression test for issue #2848.
86+
"""
87+
stdin = io.StringIO()
88+
stdout = io.StringIO()
89+
90+
invalid_lines = [
91+
'{"jsonrpc": "1.0", "id": 3, "method": "ping", "params": {}}', # wrong jsonrpc version
92+
'{"id": 4, "method": "ping", "params": {}}', # missing jsonrpc field
93+
'{"jsonrpc": "2.0", "id": 8, "method": 12345, "params": {}}', # method is not a string
94+
'{"jsonrpc": "2.0", "id": "abc", "method": 12345}', # string id is echoed as-is
95+
"this is not json", # parse error: no response
96+
'{"jsonrpc": "1.0", "method": "ping"}', # no id (malformed notification): no response
97+
'{"jsonrpc": "2.0", "id": true, "method": 12345}', # bool is not a valid id type: no response
98+
'{"jsonrpc": "2.0", "id": 1.5, "method": 12345}', # fractional id is not a valid id type: no response
99+
]
100+
valid = JSONRPCRequest(jsonrpc="2.0", id=99, method="ping")
101+
for line in invalid_lines:
102+
stdin.write(line + "\n")
103+
stdin.write(valid.model_dump_json(by_alias=True, exclude_none=True) + "\n")
104+
stdin.seek(0)
105+
106+
with anyio.fail_after(5):
107+
async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as (
108+
read_stream,
109+
write_stream,
110+
):
111+
async with read_stream:
112+
for _ in invalid_lines:
113+
received = await read_stream.receive()
114+
assert isinstance(received, Exception)
115+
final = await read_stream.receive()
116+
assert isinstance(final, SessionMessage)
117+
assert final.message == valid
118+
await write_stream.aclose()
119+
120+
stdout.seek(0)
121+
responses = [jsonrpc_message_adapter.validate_json(line.strip()) for line in stdout.readlines()]
122+
invalid_request = ErrorData(code=INVALID_REQUEST, message="Invalid Request")
123+
assert responses == [
124+
JSONRPCError(jsonrpc="2.0", id=3, error=invalid_request),
125+
JSONRPCError(jsonrpc="2.0", id=4, error=invalid_request),
126+
JSONRPCError(jsonrpc="2.0", id=8, error=invalid_request),
127+
JSONRPCError(jsonrpc="2.0", id="abc", error=invalid_request),
128+
]
129+
130+
69131
@pytest.mark.anyio
70132
async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> None:
71133
"""Non-UTF-8 stdin bytes surface as an in-stream exception without killing the stream.

tests/shared/test_streamable_http.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
4747
from mcp.shared.session import RequestResponder
4848
from mcp.types import (
49+
INVALID_REQUEST,
4950
CallToolRequestParams,
5051
CallToolResult,
5152
InitializeResult,
@@ -499,6 +500,44 @@ async def test_json_parsing(basic_app: Starlette) -> None:
499500
assert "Validation error" in response.text
500501

501502

503+
@pytest.mark.anyio
504+
@pytest.mark.parametrize(
505+
("body", "expected_id"),
506+
[
507+
pytest.param({"jsonrpc": "1.0", "id": 3, "method": "ping", "params": {}}, 3, id="wrong-jsonrpc-version"),
508+
pytest.param({"id": 4, "method": "ping", "params": {}}, 4, id="missing-jsonrpc-field"),
509+
pytest.param({"jsonrpc": "2.0", "id": 8, "method": 12345, "params": {}}, 8, id="method-not-a-string"),
510+
pytest.param({"jsonrpc": "2.0", "id": "abc", "method": 12345}, "abc", id="string-id"),
511+
pytest.param({"foo": "bar"}, None, id="no-id-detectable"),
512+
pytest.param({"jsonrpc": "2.0", "id": True, "method": 12345}, None, id="bool-is-not-a-valid-id"),
513+
pytest.param({"jsonrpc": "2.0", "id": 1.5, "method": 12345}, None, id="fractional-id-is-not-valid"),
514+
],
515+
)
516+
async def test_invalid_envelope_error_echoes_request_id(
517+
basic_app: Starlette, body: dict[str, Any], expected_id: int | str | None
518+
) -> None:
519+
"""An invalid JSON-RPC envelope gets a -32600 error echoing the original request id.
520+
521+
Valid JSON that fails envelope validation is an Invalid Request per JSON-RPC 2.0;
522+
the error response must carry the original request id when it is detectable (and
523+
null otherwise) so the client can correlate the failure. Regression test for
524+
issue #2848.
525+
"""
526+
async with make_client(basic_app) as client:
527+
response = await client.post(
528+
"/mcp",
529+
headers={
530+
"Accept": "application/json, text/event-stream",
531+
"Content-Type": "application/json",
532+
},
533+
json=body,
534+
)
535+
assert response.status_code == 400
536+
error_response = response.json()
537+
assert error_response["id"] == expected_id
538+
assert error_response["error"]["code"] == INVALID_REQUEST
539+
540+
502541
@pytest.mark.anyio
503542
async def test_method_not_allowed(basic_app: Starlette) -> None:
504543
"""Unsupported HTTP methods are rejected with 405."""

0 commit comments

Comments
 (0)