◐ Shell
clean mode source ↗

fix: allow null id in JSONRPCError per JSON-RPC 2.0 spec by maxisbey · Pull Request #2056 · modelcontextprotocol/python-sdk

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

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.

Kludex

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 deleted the fix/jsonrpc-error-null-id branch

February 17, 2026 10:30

maxisbey added a commit that referenced this pull request

Jun 15, 2026
Collapse 12 dense bullets to 6: keep BaseSession removal, the new
concurrency model, pre-enter/post-close error contracts, the raising-
callback code change, the removed metadata kwargs, and the
ClientRequestContext rename. Drop ids-from-1, the courtesy-cancel
exemption taxonomy, the (incorrect) resumption-hints-from-callbacks
bullet, the shutdown-CONNECTION_CLOSED detail, and the stray-response
handling - these are feature/internals trivia, not migration steps.

Also correct the protocol:error:null-id divergence note in the
requirements manifest: v1.x had a non-nullable JSONRPCError.id, so a
null-id error response failed transport validation and the
ValidationError reached message_handler as an exception. The note
previously described the v2-prerelease BaseSession's MCPError path
(PR #2056) as v1 behavior.