fix: allow null id in JSONRPCError per JSON-RPC 2.0 spec#2056
fix: allow null id in JSONRPCError per JSON-RPC 2.0 spec#2056
Conversation
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
030a6c3 to
88bb121
Compare
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.
| # 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) |
There was a problem hiding this comment.
It was already happening before. There's a ticket for this here: http://github.com/modelcontextprotocol/python-sdk/issues/1795
src/mcp/shared/session.py
Outdated
| # 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 |
There was a problem hiding this comment.
You shouldn't need to do this after. This information should be inferred by the type checker, why is it not?
There was a problem hiding this comment.
if isinstance(message.message, JSONRPCError) and message.message.id is None only proves that either both are true, or one or more of the two isn't true. If the condition is false it doesn't necessarily mean that message.message.id is or isn't None, just that both isinstance(message.message, JSONRPCError) and message.message.id is None are not both true.
Alternative would be doing something like:
if message.message.id is None:
if isinstance(...): # pragma: no branch
do stuff
returnabove, but that required the pragma.
There was a problem hiding this comment.
found a better way that pyright is apparently happy with
src/mcp/shared/session.py
Outdated
| # 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). |
There was a problem hiding this comment.
Please drop verbose comments as well.
| # 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). |
src/mcp/types/jsonrpc.py
Outdated
| @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 |
There was a problem hiding this comment.
Is exclude_none applied recursively? I think the point is that we shouldn't be using it at the JSONRPC level.
I think we can avoid using model_serializer and all that.
There was a problem hiding this comment.
exclude_none is applied recursively to pydantic objects, but not to dicts if there's a dict in a field.
Another option would be using exclude_unset on the jsonrpc layer, and exclude_none on the mcp layer
There was a problem hiding this comment.
Reworked to using exclude_unset on JSONRPC related objects, and keeping exclude_none on MCP stuff. Had to change a few places which set things like error=None and remove them so they no longer show up on the wire.
tests/test_types.py
Outdated
| 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=PARSE_ERROR, 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 |
There was a problem hiding this comment.
There's no need to test Pydantic. Pydantic already tests Pydantic.
There was a problem hiding this comment.
This tests the custom _serialize method, not normal vanilla model_dump though?
There was a problem hiding this comment.
removed the _serialized, so removed this unit test
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.
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'.
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.
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.
Problem
The JSON-RPC 2.0 specification states:
The SDK's
JSONRPCError.idwas typed asRequestId(int | str), rejectingnull. This forced a non-compliantid="server-error"sentinel workaround in the server transports. The TypeScript SDK and MCP schema both allow null ids — the Python SDK was the sole outlier.Changes
Type layer (
src/mcp/types/jsonrpc.py):JSONRPCError.idchanged fromRequestIdtoRequestId | None@model_serializerto ensureid: nullis preserved in JSON output even when serializing withexclude_none=True(which is used across 20+ call sites)JSONRPCResponse.idunchanged — successful responses always have a non-null idServer transports (
streamable_http.py,streamable_http_manager.py):id="server-error"sentinel withid=Nonestr(None)producing"None"Session layer (
shared/session.py):_handle_responsebefore response routing — surfaces null-id errors asMCPErrorvia the message handler instead of silently discarding themPartially addresses #1821
This fixes the
idfield problem described in #1821 (Python usedid="server-error"where the spec requiresnull). The error code divergence tracked in that issue is still pending spec clarification.AI Disclaimer