Skip to content

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

Open
maxisbey wants to merge 3 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 3 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?

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?

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.

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.

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