Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,29 @@ await ctx.log(level="info", data="hello")

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

### Invalid JSON-RPC envelopes return `-32600` with a correlated request id

**What changed:** A message that is valid JSON but not a valid JSON-RPC
request object (wrong `jsonrpc` version, missing `jsonrpc`, non-string
`method`, etc.) is now answered with an **Invalid Request (`-32600`)** error
that echoes the original request `id` when it can be detected.

In v1 this case was handled inconsistently: streamable HTTP replied with
`-32602` (Invalid params) and `id: null`, while stdio sent no response at
all (the validation error was dropped). Neither path let a client correlate
the failure back to the request that caused it.

**Why it changed:** Per JSON-RPC 2.0, an unparseable-as-a-request envelope is
an Invalid Request, and the error response should carry the original `id` so
clients can match it to the offending request.

**How to migrate:** If you string-matched on the `-32602` code (or relied on
`id: null`) to detect malformed requests over streamable HTTP, switch to
`-32600` and read the echoed `id`. Lines over stdio that previously produced
no response now produce a `-32600` error when an `id` is present; a line with
no detectable `id` (parse error, malformed notification, or an `id` of an
invalid type) still produces no response.

### Replace `RootModel` by union types with `TypeAdapter` validation

The following union types are no longer `RootModel` subclasses:
Expand Down
27 changes: 27 additions & 0 deletions src/mcp/server/stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,35 @@ async def run_server():

import anyio
import anyio.lowlevel
import pydantic_core

from mcp import types
from mcp.shared._context_streams import create_context_streams
from mcp.shared.message import SessionMessage
from mcp.types.jsonrpc import extract_request_id


def _invalid_request_error(line: str) -> types.JSONRPCError | None:
"""Build an Invalid Request error for an id-bearing line that failed envelope validation.

Per JSON-RPC 2.0, a request that is valid JSON but not a valid request
object gets a -32600 error response echoing the original request id, so
the client can correlate the failure. Returns None when the line is not
valid JSON (parse error, no response expected by existing consumers) or
when no id can be detected (a malformed notification gets no response).
"""
try:
raw = pydantic_core.from_json(line)
except ValueError:
return None
request_id = extract_request_id(raw)
if request_id is None:
return None
return types.JSONRPCError(
jsonrpc="2.0",
id=request_id,
error=types.ErrorData(code=types.INVALID_REQUEST, message="Invalid Request"),
)


@asynccontextmanager
Expand All @@ -53,6 +78,8 @@ async def stdin_reader():
try:
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
except Exception as exc:
if (error := _invalid_request_error(line)) is not None:
await write_stream.send(SessionMessage(error))
await read_stream_writer.send(exc)
continue

Expand Down
10 changes: 7 additions & 3 deletions src/mcp/server/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
from mcp.types import (
DEFAULT_NEGOTIATED_VERSION,
INTERNAL_ERROR,
INVALID_PARAMS,
INVALID_REQUEST,
PARSE_ERROR,
ErrorData,
Expand All @@ -43,6 +42,7 @@
RequestId,
jsonrpc_message_adapter,
)
from mcp.types.jsonrpc import extract_request_id

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -288,6 +288,7 @@ def _create_error_response(
status_code: HTTPStatus,
error_code: int = INVALID_REQUEST,
headers: dict[str, str] | None = None,
request_id: RequestId | None = None,
) -> Response:
"""Create an error response with a simple string message."""
response_headers = {"Content-Type": CONTENT_TYPE_JSON}
Expand All @@ -300,7 +301,7 @@ def _create_error_response(
# Return a properly formatted JSON error response
error_response = JSONRPCError(
jsonrpc="2.0",
id=None,
id=request_id,
error=ErrorData(code=error_code, message=error_message),
)

Expand Down Expand Up @@ -468,10 +469,13 @@ 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:
# Per JSON-RPC 2.0, an invalid envelope is an Invalid Request
# error, echoing the original request id when it is detectable.
response = self._create_error_response(
f"Validation error: {str(e)}",
HTTPStatus.BAD_REQUEST,
INVALID_PARAMS,
INVALID_REQUEST,
request_id=extract_request_id(raw_message),
)
await response(scope, receive, send)
return
Expand Down
14 changes: 14 additions & 0 deletions src/mcp/types/jsonrpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,17 @@ class JSONRPCError(BaseModel):

JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError
jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage)


def extract_request_id(raw: Any) -> RequestId | None:
"""Best-effort extraction of the request id from an invalid JSON-RPC envelope.

Per JSON-RPC 2.0, an Invalid Request error response must echo the original
request id when it can be detected, and null otherwise. The bool guard
matters: `bool` is an `int` subclass but not a valid id type.
"""
match raw:
case {"id": str() | int() as request_id} if not isinstance(request_id, bool):
return request_id
case _:
return None
6 changes: 3 additions & 3 deletions tests/interaction/transports/test_hosting_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from mcp.server import Server, ServerRequestContext
from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import (
INVALID_PARAMS,
INVALID_REQUEST,
PARSE_ERROR,
CallToolRequestParams,
CallToolResult,
Expand Down Expand Up @@ -129,7 +129,7 @@ async def test_non_json_content_type_is_rejected() -> None:
@requirement("hosting:http:parse-error-400")
@requirement("hosting:http:batch")
async def test_malformed_and_batched_bodies_return_400() -> None:
"""A non-JSON body returns 400 Parse error; a JSON array of requests returns 400 Invalid params."""
"""A non-JSON body returns 400 Parse error; a JSON array of requests returns 400 Invalid Request."""
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
not_json = await http.post(
Expand All @@ -149,7 +149,7 @@ async def test_malformed_and_batched_bodies_return_400() -> None:
assert not_json.status_code == 400
assert JSONRPCError.model_validate_json(not_json.text).error.code == PARSE_ERROR
assert batched.status_code == 400
assert JSONRPCError.model_validate_json(batched.text).error.code == INVALID_PARAMS
assert JSONRPCError.model_validate_json(batched.text).error.code == INVALID_REQUEST


@requirement("hosting:http:protocol-version-400")
Expand Down
64 changes: 63 additions & 1 deletion tests/server/test_stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@
from mcp.server.mcpserver import MCPServer
from mcp.server.stdio import stdio_server
from mcp.shared.message import SessionMessage
from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, jsonrpc_message_adapter
from mcp.types import (
INVALID_REQUEST,
ErrorData,
JSONRPCError,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
jsonrpc_message_adapter,
)


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


@pytest.mark.anyio
async def test_stdio_server_replies_invalid_request_for_invalid_envelope() -> None:
"""An id-bearing line that fails envelope validation gets a correlated -32600 response.

Lines that are valid JSON but invalid JSON-RPC envelopes get an Invalid Request
error response echoing the original request id. Lines without a detectable id
(parse errors, malformed notifications, ids of an invalid type) get no response;
every invalid line still surfaces as an in-stream exception and later valid
messages keep flowing. Regression test for issue #2848.
"""
stdin = io.StringIO()
stdout = io.StringIO()

invalid_lines = [
'{"jsonrpc": "1.0", "id": 3, "method": "ping", "params": {}}', # wrong jsonrpc version
'{"id": 4, "method": "ping", "params": {}}', # missing jsonrpc field
'{"jsonrpc": "2.0", "id": 8, "method": 12345, "params": {}}', # method is not a string
'{"jsonrpc": "2.0", "id": "abc", "method": 12345}', # string id is echoed as-is
"this is not json", # parse error: no response
'{"jsonrpc": "1.0", "method": "ping"}', # no id (malformed notification): no response
'{"jsonrpc": "2.0", "id": true, "method": 12345}', # bool is not a valid id type: no response
'{"jsonrpc": "2.0", "id": 1.5, "method": 12345}', # fractional id is not a valid id type: no response
]
valid = JSONRPCRequest(jsonrpc="2.0", id=99, method="ping")
for line in invalid_lines:
stdin.write(line + "\n")
stdin.write(valid.model_dump_json(by_alias=True, exclude_none=True) + "\n")
stdin.seek(0)

with anyio.fail_after(5):
async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as (
read_stream,
write_stream,
):
async with read_stream:
for _ in invalid_lines:
received = await read_stream.receive()
assert isinstance(received, Exception)
final = await read_stream.receive()
assert isinstance(final, SessionMessage)
assert final.message == valid
await write_stream.aclose()

stdout.seek(0)
responses = [jsonrpc_message_adapter.validate_json(line.strip()) for line in stdout.readlines()]
invalid_request = ErrorData(code=INVALID_REQUEST, message="Invalid Request")
assert responses == [
JSONRPCError(jsonrpc="2.0", id=3, error=invalid_request),
JSONRPCError(jsonrpc="2.0", id=4, error=invalid_request),
JSONRPCError(jsonrpc="2.0", id=8, error=invalid_request),
JSONRPCError(jsonrpc="2.0", id="abc", error=invalid_request),
]


@pytest.mark.anyio
async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> None:
"""Non-UTF-8 stdin bytes surface as an in-stream exception without killing the stream.
Expand Down
39 changes: 39 additions & 0 deletions tests/shared/test_streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
from mcp.shared.session import RequestResponder
from mcp.types import (
INVALID_REQUEST,
CallToolRequestParams,
CallToolResult,
InitializeResult,
Expand Down Expand Up @@ -499,6 +500,44 @@ async def test_json_parsing(basic_app: Starlette) -> None:
assert "Validation error" in response.text


@pytest.mark.anyio
@pytest.mark.parametrize(
("body", "expected_id"),
[
pytest.param({"jsonrpc": "1.0", "id": 3, "method": "ping", "params": {}}, 3, id="wrong-jsonrpc-version"),
pytest.param({"id": 4, "method": "ping", "params": {}}, 4, id="missing-jsonrpc-field"),
pytest.param({"jsonrpc": "2.0", "id": 8, "method": 12345, "params": {}}, 8, id="method-not-a-string"),
pytest.param({"jsonrpc": "2.0", "id": "abc", "method": 12345}, "abc", id="string-id"),
pytest.param({"foo": "bar"}, None, id="no-id-detectable"),
pytest.param({"jsonrpc": "2.0", "id": True, "method": 12345}, None, id="bool-is-not-a-valid-id"),
pytest.param({"jsonrpc": "2.0", "id": 1.5, "method": 12345}, None, id="fractional-id-is-not-valid"),
],
)
async def test_invalid_envelope_error_echoes_request_id(
basic_app: Starlette, body: dict[str, Any], expected_id: int | str | None
) -> None:
"""An invalid JSON-RPC envelope gets a -32600 error echoing the original request id.

Valid JSON that fails envelope validation is an Invalid Request per JSON-RPC 2.0;
the error response must carry the original request id when it is detectable (and
null otherwise) so the client can correlate the failure. Regression test for
issue #2848.
"""
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=body,
)
assert response.status_code == 400
error_response = response.json()
assert error_response["id"] == expected_id
assert error_response["error"]["code"] == INVALID_REQUEST


@pytest.mark.anyio
async def test_method_not_allowed(basic_app: Starlette) -> None:
"""Unsupported HTTP methods are rejected with 405."""
Expand Down
Loading