From 88bb1215898072b30aadbce6ec25478f03073749 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:29:45 +0000 Subject: [PATCH 1/7] fix: allow null id in JSONRPCError per JSON-RPC 2.0 spec The JSON-RPC 2.0 specification requires error responses to use id: null when the request id could not be determined (e.g., parse errors, invalid requests). The SDK rejected null ids, forcing a non-compliant id="server-error" sentinel workaround. Changes: - JSONRPCError.id now accepts None (JSONRPCResponse.id unchanged) - Add model_serializer to preserve id: null under exclude_none=True - Replace id="server-error" sentinel with id=None in server transports - Add null-id guard in session layer to surface errors via message handler - Guard server-side message router against str(None) misrouting Github-Issue: #1821 --- src/mcp/server/streamable_http.py | 11 +- src/mcp/server/streamable_http_manager.py | 2 +- src/mcp/shared/session.py | 13 ++ src/mcp/types/jsonrpc.py | 14 ++- tests/server/test_streamable_http_manager.py | 2 +- tests/shared/test_session.py | 120 +++++++++++++++++++ tests/test_types.py | 20 ++++ 7 files changed, 173 insertions(+), 9 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 54ac7374a..3b895aeb8 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -298,7 +298,7 @@ def _create_error_response( # Return a properly formatted JSON error response error_response = JSONRPCError( jsonrpc="2.0", - id="server-error", # We don't have a request ID for general errors + id=None, error=ErrorData(code=error_code, message=error_message), ) @@ -977,10 +977,11 @@ async def message_router(): target_request_id = None # Check if this is a response if isinstance(message, JSONRPCResponse | JSONRPCError): - response_id = str(message.id) - # If this response is for an existing request stream, - # send it there - target_request_id = response_id + # Null-id errors (e.g., parse errors) go to the + # GET stream since they can't be correlated to a + # specific request. + if message.id is not None: + target_request_id = str(message.id) # Extract related_request_id from meta if it exists elif ( # pragma: no cover session_message.metadata is not None diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index 8eb29c4d4..288bba6d1 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -244,7 +244,7 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE # See: https://github.com/modelcontextprotocol/python-sdk/issues/1821 error_response = JSONRPCError( jsonrpc="2.0", - id="server-error", + id=None, error=ErrorData(code=INVALID_REQUEST, message="Session not found"), ) response = Response( diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 453e36274..ab059b530 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -458,6 +458,19 @@ async def _handle_response(self, message: SessionMessage) -> None: if not isinstance(message.message, JSONRPCResponse | JSONRPCError): return # pragma: no cover + # Handle null-id errors (e.g., parse errors, invalid requests) before + # routing. Per JSON-RPC 2.0, id is null when the request id could not + # be determined — these cannot be correlated to any pending request. + if isinstance(message.message, JSONRPCError) and message.message.id is None: + error = message.message.error + logging.warning(f"Received error with null ID: {error.message}") + await self._handle_incoming(MCPError(error.code, error.message, error.data)) + return + + # After the null-id guard above, id is guaranteed to be non-None. + # JSONRPCResponse.id is always RequestId, and JSONRPCError.id is only + # None for parse errors (handled above). + assert message.message.id is not None # Normalize response ID to handle type mismatches (e.g., "0" vs 0) response_id = self._normalize_request_id(message.message.id) diff --git a/src/mcp/types/jsonrpc.py b/src/mcp/types/jsonrpc.py index 0cfdc993a..aaea1fd53 100644 --- a/src/mcp/types/jsonrpc.py +++ b/src/mcp/types/jsonrpc.py @@ -4,7 +4,7 @@ from typing import Annotated, Any, Literal -from pydantic import BaseModel, Field, TypeAdapter +from pydantic import BaseModel, Field, SerializationInfo, SerializerFunctionWrapHandler, TypeAdapter, model_serializer RequestId = Annotated[int, Field(strict=True)] | str """The ID of a JSON-RPC request.""" @@ -75,9 +75,19 @@ class JSONRPCError(BaseModel): """A response to a request that indicates an error occurred.""" jsonrpc: Literal["2.0"] - id: RequestId + id: RequestId | None error: ErrorData + @model_serializer(mode="wrap") + def _serialize(self, handler: SerializerFunctionWrapHandler, _: SerializationInfo) -> dict[str, Any]: + result = handler(self) + # JSON-RPC 2.0 requires id to always be present in error responses, + # even when null (e.g. parse errors). Ensure exclude_none=True + # cannot strip it. + if "id" not in result and self.id is None: + result["id"] = None + return result + JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage) diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 475eaa167..e9a8720f1 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -312,7 +312,7 @@ async def mock_receive(): # Verify JSON-RPC error format error_data = json.loads(response_body) assert error_data["jsonrpc"] == "2.0" - assert error_data["id"] == "server-error" + assert error_data["id"] is None assert error_data["error"]["code"] == INVALID_REQUEST assert error_data["error"]["message"] == "Session not found" diff --git a/tests/shared/test_session.py b/tests/shared/test_session.py index 2c220f737..13548d5de 100644 --- a/tests/shared/test_session.py +++ b/tests/shared/test_session.py @@ -7,14 +7,18 @@ from mcp.shared.exceptions import MCPError from mcp.shared.memory import create_client_server_memory_streams from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder from mcp.types import ( CancelledNotification, CancelledNotificationParams, + ClientResult, EmptyResult, ErrorData, JSONRPCError, JSONRPCRequest, JSONRPCResponse, + ServerNotification, + ServerRequest, ) @@ -297,3 +301,119 @@ async def mock_server(): await ev_closed.wait() with anyio.fail_after(1): # pragma: no branch await ev_response.wait() + + +@pytest.mark.anyio +async def test_null_id_error_surfaced_via_message_handler(): + """Test that a JSONRPCError with id=None is surfaced to the message handler. + + Per JSON-RPC 2.0, error responses use id=null when the request id could not + be determined (e.g., parse errors). These cannot be correlated to any pending + request, so they are forwarded to the message handler as MCPError. + """ + ev_error_received = anyio.Event() + error_holder: list[Exception] = [] + + async def capture_errors( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): + error_holder.append(message) + ev_error_received.set() + + sent_error = ErrorData(code=-32700, message="Parse error") + + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + _server_read, server_write = server_streams + + async def mock_server(): + """Send a null-id error (simulating a parse error).""" + error_response = JSONRPCError(jsonrpc="2.0", id=None, error=sent_error) + await server_write.send(SessionMessage(message=error_response)) + + async with ( + anyio.create_task_group() as tg, + ClientSession( + read_stream=client_read, + write_stream=client_write, + message_handler=capture_errors, + ) as _client_session, + ): + tg.start_soon(mock_server) + + with anyio.fail_after(2): # pragma: no branch + await ev_error_received.wait() + + assert len(error_holder) == 1 + assert isinstance(error_holder[0], MCPError) + assert error_holder[0].error == sent_error + + +@pytest.mark.anyio +async def test_null_id_error_does_not_affect_pending_request(): + """Test that a null-id error doesn't interfere with an in-flight request. + + When a null-id error arrives while a request is pending, the error should + go to the message handler and the pending request should still complete + normally with its own response. + """ + ev_error_received = anyio.Event() + ev_response_received = anyio.Event() + error_holder: list[Exception] = [] + result_holder: list[EmptyResult] = [] + + async def capture_errors( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): + error_holder.append(message) + ev_error_received.set() + + sent_error = ErrorData(code=-32700, message="Parse error") + + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + server_read, server_write = server_streams + + async def mock_server(): + """Read a request, inject a null-id error, then respond normally.""" + message = await server_read.receive() + assert isinstance(message, SessionMessage) + assert isinstance(message.message, JSONRPCRequest) + request_id = message.message.id + + # First, send a null-id error (should go to message handler) + await server_write.send(SessionMessage(message=JSONRPCError(jsonrpc="2.0", id=None, error=sent_error))) + + # Then, respond normally to the pending request + await server_write.send(SessionMessage(message=JSONRPCResponse(jsonrpc="2.0", id=request_id, result={}))) + + async def make_request(client_session: ClientSession): + result = await client_session.send_ping() + result_holder.append(result) + ev_response_received.set() + + async with ( + anyio.create_task_group() as tg, + ClientSession( + read_stream=client_read, + write_stream=client_write, + message_handler=capture_errors, + ) as client_session, + ): + tg.start_soon(mock_server) + tg.start_soon(make_request, client_session) + + with anyio.fail_after(2): # pragma: no branch + await ev_error_received.wait() + await ev_response_received.wait() + + # Null-id error reached the message handler + assert len(error_holder) == 1 + assert isinstance(error_holder[0], MCPError) + assert error_holder[0].error == sent_error + + # Pending request completed successfully + assert len(result_holder) == 1 + assert isinstance(result_holder[0], EmptyResult) diff --git a/tests/test_types.py b/tests/test_types.py index f424efdbf..0704231b2 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,3 +1,4 @@ +import json from typing import Any import pytest @@ -8,9 +9,11 @@ CreateMessageRequestParams, CreateMessageResult, CreateMessageResultWithTools, + ErrorData, Implementation, InitializeRequest, InitializeRequestParams, + JSONRPCError, JSONRPCRequest, ListToolsResult, SamplingCapability, @@ -360,3 +363,20 @@ def test_list_tools_result_preserves_json_schema_2020_12_fields(): assert tool.input_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" assert "$defs" in tool.input_schema assert tool.input_schema["additionalProperties"] is False + + +def test_jsonrpc_error_null_id_serialization_preserves_id(): + """Test that id: null is preserved in JSON output even with exclude_none=True. + + JSON-RPC 2.0 requires the id field to be present with value null for + parse errors, not absent entirely. + """ + error = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=-32700, message="Parse error")) + serialized = error.model_dump(by_alias=True, exclude_none=True) + assert "id" in serialized + assert serialized["id"] is None + + json_str = error.model_dump_json(by_alias=True, exclude_none=True) + parsed = json.loads(json_str) + assert "id" in parsed + assert parsed["id"] is None From 6500c7ce78e32140053cf6eb4ef104bc1b53eba8 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:35:05 +0000 Subject: [PATCH 2/7] fix: use PARSE_ERROR constant instead of raw -32700 in tests --- tests/shared/test_session.py | 5 +++-- tests/test_types.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/shared/test_session.py b/tests/shared/test_session.py index 13548d5de..7a8135f83 100644 --- a/tests/shared/test_session.py +++ b/tests/shared/test_session.py @@ -9,6 +9,7 @@ from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder from mcp.types import ( + PARSE_ERROR, CancelledNotification, CancelledNotificationParams, ClientResult, @@ -321,7 +322,7 @@ async def capture_errors( error_holder.append(message) ev_error_received.set() - sent_error = ErrorData(code=-32700, message="Parse error") + sent_error = ErrorData(code=PARSE_ERROR, message="Parse error") async with create_client_server_memory_streams() as (client_streams, server_streams): client_read, client_write = client_streams @@ -370,7 +371,7 @@ async def capture_errors( error_holder.append(message) ev_error_received.set() - sent_error = ErrorData(code=-32700, message="Parse error") + sent_error = ErrorData(code=PARSE_ERROR, message="Parse error") async with create_client_server_memory_streams() as (client_streams, server_streams): client_read, client_write = client_streams diff --git a/tests/test_types.py b/tests/test_types.py index 0704231b2..51507a9e5 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -5,6 +5,7 @@ from mcp.types import ( LATEST_PROTOCOL_VERSION, + PARSE_ERROR, ClientCapabilities, CreateMessageRequestParams, CreateMessageResult, @@ -371,7 +372,7 @@ def test_jsonrpc_error_null_id_serialization_preserves_id(): JSON-RPC 2.0 requires the id field to be present with value null for parse errors, not absent entirely. """ - error = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=-32700, message="Parse error")) + error = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error")) serialized = error.model_dump(by_alias=True, exclude_none=True) assert "id" in serialized assert serialized["id"] is None From bf35f272f483ba3685f0cc6ccf30faa5e07f58bc Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:45:42 +0000 Subject: [PATCH 3/7] fix: eliminate uncovered branches in null-id handling Merge the nested if conditions in the server message router into a single condition so the false branch is naturally exercised by non-response messages. Remove isinstance guards in test callbacks since we control the input. --- src/mcp/server/streamable_http.py | 12 +++++------- tests/shared/test_session.py | 18 ++++++++---------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 3b895aeb8..5254a5066 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -975,13 +975,11 @@ async def message_router(): # Determine which request stream(s) should receive this message message = session_message.message target_request_id = None - # Check if this is a response - if isinstance(message, JSONRPCResponse | JSONRPCError): - # Null-id errors (e.g., parse errors) go to the - # GET stream since they can't be correlated to a - # specific request. - if message.id is not None: - target_request_id = str(message.id) + # Check if this is a response with a known request id. + # Null-id errors (e.g., parse errors) fall through to + # the GET stream since they can't be correlated. + if isinstance(message, JSONRPCResponse | JSONRPCError) and message.id is not None: + target_request_id = str(message.id) # Extract related_request_id from meta if it exists elif ( # pragma: no cover session_message.metadata is not None diff --git a/tests/shared/test_session.py b/tests/shared/test_session.py index 7a8135f83..d7c6cc3b5 100644 --- a/tests/shared/test_session.py +++ b/tests/shared/test_session.py @@ -313,14 +313,14 @@ async def test_null_id_error_surfaced_via_message_handler(): request, so they are forwarded to the message handler as MCPError. """ ev_error_received = anyio.Event() - error_holder: list[Exception] = [] + error_holder: list[MCPError] = [] async def capture_errors( message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, ) -> None: - if isinstance(message, Exception): - error_holder.append(message) - ev_error_received.set() + assert isinstance(message, MCPError) + error_holder.append(message) + ev_error_received.set() sent_error = ErrorData(code=PARSE_ERROR, message="Parse error") @@ -347,7 +347,6 @@ async def mock_server(): await ev_error_received.wait() assert len(error_holder) == 1 - assert isinstance(error_holder[0], MCPError) assert error_holder[0].error == sent_error @@ -361,15 +360,15 @@ async def test_null_id_error_does_not_affect_pending_request(): """ ev_error_received = anyio.Event() ev_response_received = anyio.Event() - error_holder: list[Exception] = [] + error_holder: list[MCPError] = [] result_holder: list[EmptyResult] = [] async def capture_errors( message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, ) -> None: - if isinstance(message, Exception): - error_holder.append(message) - ev_error_received.set() + assert isinstance(message, MCPError) + error_holder.append(message) + ev_error_received.set() sent_error = ErrorData(code=PARSE_ERROR, message="Parse error") @@ -412,7 +411,6 @@ async def make_request(client_session: ClientSession): # Null-id error reached the message handler assert len(error_holder) == 1 - assert isinstance(error_holder[0], MCPError) assert error_holder[0].error == sent_error # Pending request completed successfully From c583fb67dd511897c8fa9032a0a7e54d4e8a609d Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:01:23 +0000 Subject: [PATCH 4/7] refactor: replace assert with natural type narrowing for null-id check Restructure the null-id guard so that `id is None` is checked first, allowing Pyright to naturally narrow the type to JSONRPCError (since JSONRPCResponse.id is always RequestId). This eliminates the need for the assert that was previously required to work around Pyright's inability to narrow through negated compound conditions. --- src/mcp/shared/session.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index ab059b530..65d59405c 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -458,19 +458,12 @@ async def _handle_response(self, message: SessionMessage) -> None: if not isinstance(message.message, JSONRPCResponse | JSONRPCError): return # pragma: no cover - # Handle null-id errors (e.g., parse errors, invalid requests) before - # routing. Per JSON-RPC 2.0, id is null when the request id could not - # be determined — these cannot be correlated to any pending request. - if isinstance(message.message, JSONRPCError) and message.message.id is None: + if message.message.id is None: + # Narrows to JSONRPCError since JSONRPCResponse.id is always RequestId error = message.message.error logging.warning(f"Received error with null ID: {error.message}") await self._handle_incoming(MCPError(error.code, error.message, error.data)) return - - # After the null-id guard above, id is guaranteed to be non-None. - # JSONRPCResponse.id is always RequestId, and JSONRPCError.id is only - # None for parse errors (handled above). - assert message.message.id is not None # Normalize response ID to handle type mismatches (e.g., "0" vs 0) response_id = self._normalize_request_id(message.message.id) From 43ee2d994701a56fc0f89fdd69ed603e0755abb6 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:31:13 +0000 Subject: [PATCH 5/7] refactor: use exclude_unset for JSONRPC wire serialization Switch all JSONRPC wire-level serialization from exclude_none=True to exclude_unset=True. This correctly preserves the null-vs-absent distinction required by JSON-RPC 2.0 (e.g., id must be null in parse error responses, not absent entirely). exclude_unset only omits fields not passed to the constructor, so explicitly set id=None is preserved while optional params/data fields that were never set are still omitted. This eliminates the need for the model_serializer hack on JSONRPCError that was re-inserting id after exclude_none stripped it. Inner MCP model serialization (capabilities, tools, resources, etc.) retains exclude_none=True since those types have many optional fields where None genuinely means 'omit'. --- src/mcp/client/sse.py | 2 +- src/mcp/client/stdio.py | 2 +- src/mcp/client/streamable_http.py | 2 +- src/mcp/client/websocket.py | 2 +- src/mcp/server/lowlevel/server.py | 2 +- src/mcp/server/sse.py | 2 +- src/mcp/server/stdio.py | 2 +- src/mcp/server/streamable_http.py | 6 +++--- src/mcp/server/streamable_http_manager.py | 2 +- src/mcp/server/websocket.py | 2 +- src/mcp/shared/session.py | 2 +- src/mcp/types/jsonrpc.py | 12 +----------- tests/test_types.py | 6 +++--- 13 files changed, 17 insertions(+), 27 deletions(-) diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index 8f8e4dadc..7c309ecb5 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -138,7 +138,7 @@ async def post_writer(endpoint_url: str): json=session_message.message.model_dump( by_alias=True, mode="json", - exclude_none=True, + exclude_unset=True, ), ) response.raise_for_status() diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index 605c5ea24..5b8209eeb 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio.py @@ -167,7 +167,7 @@ async def stdin_writer(): try: async with write_stream_reader: async for session_message in write_stream_reader: - json = session_message.message.model_dump_json(by_alias=True, exclude_none=True) + json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True) await process.stdin.send( (json + "\n").encode( encoding=server.encoding, diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 9d45bec6e..bd77b3e2f 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -259,7 +259,7 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: async with ctx.client.stream( "POST", self.url, - json=message.model_dump(by_alias=True, mode="json", exclude_none=True), + json=message.model_dump(by_alias=True, mode="json", exclude_unset=True), headers=headers, ) as response: if response.status_code == 202: diff --git a/src/mcp/client/websocket.py b/src/mcp/client/websocket.py index cf4b86e99..bda199f36 100644 --- a/src/mcp/client/websocket.py +++ b/src/mcp/client/websocket.py @@ -65,7 +65,7 @@ async def ws_writer(): async with write_stream_reader: async for session_message in write_stream_reader: # Convert to a dict, then to JSON - msg_dict = session_message.message.model_dump(by_alias=True, mode="json", exclude_none=True) + msg_dict = session_message.message.model_dump(by_alias=True, mode="json", exclude_unset=True) await ws.send(json.dumps(msg_dict)) async with anyio.create_task_group() as tg: diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 04404a3fc..9ca5ac4fc 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -490,7 +490,7 @@ async def _handle_request( except Exception as err: if raise_exceptions: # pragma: no cover raise err - response = types.ErrorData(code=0, message=str(err), data=None) + response = types.ErrorData(code=0, message=str(err)) await message.respond(response) else: # pragma: no cover diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index 5be6b78ca..674294c5c 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -170,7 +170,7 @@ async def sse_writer(): await sse_stream_writer.send( { "event": "message", - "data": session_message.message.model_dump_json(by_alias=True, exclude_none=True), + "data": session_message.message.model_dump_json(by_alias=True, exclude_unset=True), } ) diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 7f3aa2ac2..864d387bd 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -71,7 +71,7 @@ async def stdout_writer(): try: async with write_stream_reader: async for session_message in write_stream_reader: - json = session_message.message.model_dump_json(by_alias=True, exclude_none=True) + json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True) await stdout.write(json + "\n") await stdout.flush() except anyio.ClosedResourceError: # pragma: no cover diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 5254a5066..a8202e385 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -303,7 +303,7 @@ def _create_error_response( ) return Response( - error_response.model_dump_json(by_alias=True, exclude_none=True), + error_response.model_dump_json(by_alias=True, exclude_unset=True), status_code=status_code, headers=response_headers, ) @@ -323,7 +323,7 @@ def _create_json_response( response_headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id return Response( - response_message.model_dump_json(by_alias=True, exclude_none=True) if response_message else None, + response_message.model_dump_json(by_alias=True, exclude_unset=True) if response_message else None, status_code=status_code, headers=response_headers, ) @@ -336,7 +336,7 @@ def _create_event_data(self, event_message: EventMessage) -> dict[str, str]: """Create event data dictionary from an EventMessage.""" event_data = { "event": "message", - "data": event_message.message.model_dump_json(by_alias=True, exclude_none=True), + "data": event_message.message.model_dump_json(by_alias=True, exclude_unset=True), } # If an event ID was provided, include it diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index 288bba6d1..9ffabf109 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -248,7 +248,7 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE error=ErrorData(code=INVALID_REQUEST, message="Session not found"), ) response = Response( - content=error_response.model_dump_json(by_alias=True, exclude_none=True), + content=error_response.model_dump_json(by_alias=True, exclude_unset=True), status_code=HTTPStatus.NOT_FOUND, media_type="application/json", ) diff --git a/src/mcp/server/websocket.py b/src/mcp/server/websocket.py index a4c844811..7b00f7905 100644 --- a/src/mcp/server/websocket.py +++ b/src/mcp/server/websocket.py @@ -47,7 +47,7 @@ async def ws_writer(): try: async with write_stream_reader: async for session_message in write_stream_reader: - obj = session_message.message.model_dump_json(by_alias=True, exclude_none=True) + obj = session_message.message.model_dump_json(by_alias=True, exclude_unset=True) await websocket.send_text(obj) except anyio.ClosedResourceError: await websocket.close() diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 65d59405c..5ee8f3baa 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -142,7 +142,7 @@ async def cancel(self) -> None: # Send an error response to indicate cancellation await self._session._send_response( # type: ignore[reportPrivateUsage] request_id=self.request_id, - response=ErrorData(code=0, message="Request cancelled", data=None), + response=ErrorData(code=0, message="Request cancelled"), ) @property diff --git a/src/mcp/types/jsonrpc.py b/src/mcp/types/jsonrpc.py index aaea1fd53..84304a37c 100644 --- a/src/mcp/types/jsonrpc.py +++ b/src/mcp/types/jsonrpc.py @@ -4,7 +4,7 @@ from typing import Annotated, Any, Literal -from pydantic import BaseModel, Field, SerializationInfo, SerializerFunctionWrapHandler, TypeAdapter, model_serializer +from pydantic import BaseModel, Field, TypeAdapter RequestId = Annotated[int, Field(strict=True)] | str """The ID of a JSON-RPC request.""" @@ -78,16 +78,6 @@ class JSONRPCError(BaseModel): id: RequestId | None error: ErrorData - @model_serializer(mode="wrap") - def _serialize(self, handler: SerializerFunctionWrapHandler, _: SerializationInfo) -> dict[str, Any]: - result = handler(self) - # JSON-RPC 2.0 requires id to always be present in error responses, - # even when null (e.g. parse errors). Ensure exclude_none=True - # cannot strip it. - if "id" not in result and self.id is None: - result["id"] = None - return result - JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage) diff --git a/tests/test_types.py b/tests/test_types.py index 51507a9e5..1aa47c077 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -367,17 +367,17 @@ def test_list_tools_result_preserves_json_schema_2020_12_fields(): def test_jsonrpc_error_null_id_serialization_preserves_id(): - """Test that id: null is preserved in JSON output even with exclude_none=True. + """Test that id: null is preserved in JSON output with exclude_unset=True. JSON-RPC 2.0 requires the id field to be present with value null for parse errors, not absent entirely. """ error = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error")) - serialized = error.model_dump(by_alias=True, exclude_none=True) + serialized = error.model_dump(by_alias=True, exclude_unset=True) assert "id" in serialized assert serialized["id"] is None - json_str = error.model_dump_json(by_alias=True, exclude_none=True) + json_str = error.model_dump_json(by_alias=True, exclude_unset=True) parsed = json.loads(json_str) assert "id" in parsed assert parsed["id"] is None From 75c84bfa050fa072d72aa7c66b8694452f996e21 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:44:17 +0000 Subject: [PATCH 6/7] fix: prevent MCPError from marking data as set when None MCPError.__init__ always passed data=data to ErrorData(), even when data was None (the default). This marked data as 'set' in Pydantic's model_fields_set, causing exclude_unset=True to emit 'data': null on the wire. Fix by only passing data when non-None. --- src/mcp/shared/exceptions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/mcp/shared/exceptions.py b/src/mcp/shared/exceptions.py index 7a2b2ded4..6c3a7745c 100644 --- a/src/mcp/shared/exceptions.py +++ b/src/mcp/shared/exceptions.py @@ -12,7 +12,10 @@ class MCPError(Exception): def __init__(self, code: int, message: str, data: Any = None): super().__init__(code, message, data) - self.error = ErrorData(code=code, message=message, data=data) + if data is not None: + self.error = ErrorData(code=code, message=message, data=data) + else: + self.error = ErrorData(code=code, message=message) @property def code(self) -> int: From dbfba9d53a213c96722bbf401b60ec1ab45a7bf7 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:49:42 +0000 Subject: [PATCH 7/7] test: remove vanilla Pydantic serialization test The test was originally validating the custom model_serializer on JSONRPCError. With the model_serializer removed in favor of exclude_unset, this test only exercises standard Pydantic behavior. The null-id functionality is covered by E2E tests in test_session.py. --- tests/test_types.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/tests/test_types.py b/tests/test_types.py index 1aa47c077..f424efdbf 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,20 +1,16 @@ -import json from typing import Any import pytest from mcp.types import ( LATEST_PROTOCOL_VERSION, - PARSE_ERROR, ClientCapabilities, CreateMessageRequestParams, CreateMessageResult, CreateMessageResultWithTools, - ErrorData, Implementation, InitializeRequest, InitializeRequestParams, - JSONRPCError, JSONRPCRequest, ListToolsResult, SamplingCapability, @@ -364,20 +360,3 @@ def test_list_tools_result_preserves_json_schema_2020_12_fields(): assert tool.input_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" assert "$defs" in tool.input_schema assert tool.input_schema["additionalProperties"] is False - - -def test_jsonrpc_error_null_id_serialization_preserves_id(): - """Test that id: null is preserved in JSON output with exclude_unset=True. - - JSON-RPC 2.0 requires the id field to be present with value null for - parse errors, not absent entirely. - """ - error = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error")) - serialized = error.model_dump(by_alias=True, exclude_unset=True) - assert "id" in serialized - assert serialized["id"] is None - - json_str = error.model_dump_json(by_alias=True, exclude_unset=True) - parsed = json.loads(json_str) - assert "id" in parsed - assert parsed["id"] is None