Skip to content

fix: allow null id in JSONRPCError per JSON-RPC 2.0 spec#2056

Open
maxisbey wants to merge 7 commits intomainfrom
fix/jsonrpc-error-null-id
Open

fix: allow null id in JSONRPCError per JSON-RPC 2.0 spec#2056
maxisbey wants to merge 7 commits intomainfrom
fix/jsonrpc-error-null-id

Conversation

@maxisbey
Copy link
Contributor

Problem

The JSON-RPC 2.0 specification states:

If there was an error in detecting the id in the Request object (e.g. Parse error/Invalid Request), it MUST be Null.

The SDK's JSONRPCError.id was typed as RequestId (int | str), rejecting null. This forced a non-compliant id="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.id changed from RequestId to RequestId | None
  • Added @model_serializer to ensure id: null is preserved in JSON output even when serializing with exclude_none=True (which is used across 20+ call sites)
  • JSONRPCResponse.id unchanged — successful responses always have a non-null id

Server transports (streamable_http.py, streamable_http_manager.py):

  • Replaced id="server-error" sentinel with id=None
  • Added null guard in the server-side message router to prevent str(None) producing "None"

Session layer (shared/session.py):

  • Added null-id guard in _handle_response before response routing — surfaces null-id errors as MCPError via the message handler instead of silently discarding them

Partially addresses #1821

This fixes the id field problem described in #1821 (Python used id="server-error" where the spec requires null). The error code divergence tracked in that issue is still pending spec clarification.

AI Disclaimer

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
@maxisbey maxisbey force-pushed the fix/jsonrpc-error-null-id branch from 030a6c3 to 88bb121 Compare February 13, 2026 18:32
@maxisbey maxisbey requested a review from Kludex February 13, 2026 18:35
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.
@maxisbey maxisbey requested review from Kludex and removed request for Kludex February 13, 2026 23:52
Comment on lines +978 to +982
# 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we coercing to str tho?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was already happening before. There's a ticket for this here: http://github.com/modelcontextprotocol/python-sdk/issues/1795

Comment on lines 470 to 473
# 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You shouldn't need to do this after. This information should be inferred by the type checker, why is it not?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
    return

above, but that required the pragma.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

found a better way that pyright is apparently happy with

Comment on lines 470 to 472
# 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).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please drop verbose comments as well.

Suggested change
# 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).

Comment on lines 81 to 89
@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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 369 to 383
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need to test Pydantic. Pydantic already tests Pydantic.

Copy link
Contributor Author

@maxisbey maxisbey Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tests the custom _serialize method, not normal vanilla model_dump though?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@maxisbey maxisbey requested review from Kludex February 16, 2026 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants