Extract JSON-RPC wrapping into a Dispatcher component#2320
Draft
Extract JSON-RPC wrapping into a Dispatcher component#2320
Conversation
BaseSession previously did two jobs: MCP protocol semantics (progress
tokens, cancellation, in-flight tracking, result validation) and JSON-RPC
wire encoding (wrap/unwrap envelopes, ID correlation, the receive loop).
This split those into a composition: BaseSession owns the MCP semantics,
a Dispatcher owns the wire protocol.
The default JSONRPCDispatcher is the old _receive_loop + send_request +
send_notification + _send_response logic extracted verbatim. BaseSession
constructs one from the supplied streams, so every existing transport
(stdio, SHTTP, WebSocket, in-memory) works unchanged.
The Dispatcher Protocol deals in {"method": str, "params": dict} — the
same dict request.model_dump() already produces. A custom dispatcher
(gRPC stub, message broker, anything) implements five methods and passes
itself as ClientSession(dispatcher=...). All of initialize(),
list_tools(), call_tool() work unchanged on top; no parallel Client
hierarchy, no fat Protocol.
This addresses the composition-not-inheritance point raised in review of
the previous pluggable-transport PRs. Closes the SessionMessage/JSON-RPC
coupling that was the remaining blocker.
Github-Issue: #1690
9 tasks
…t for send_message narrow The E2E dispatcher test now triggers a server→client sampling request, so the client's response flows through spy.send_response. All five Dispatcher methods are now exercised in one round-trip. ServerSession.send_message: replace the if-not-isinstance-raise guard with an assert. Same type-narrowing for pyright; the assert line runs in every test; no coverage pragma needed.
…arrowing InMemoryTransport handles the _lowlevel_server unwrap internally, so the test no longer reaches for it. The union-attr on CallToolResult.content is narrowed with a proper assert isinstance(content, TextContent).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Splits
BaseSessioninto two layers by composition: aDispatcherowns the wire protocol (JSON-RPC wrap/unwrap, ID correlation, the receive loop),BaseSessionkeeps the MCP semantics (progress tokens, cancellation, in-flight tracking, result validation). The defaultJSONRPCDispatcheris the old_receive_loop+send_request+_send_responselogic extracted verbatim.Motivation and Context
#1591 and #2117 both tried to add a pluggable-transport abstraction so non-JSON-RPC wire protocols (gRPC with typed RPCs, message brokers, etc.) can plug into the SDK. Both abstracted at the session layer — adding Protocol classes above
BaseSession— which forces custom implementations to rebuild the entire MCP method surface (19 methods) and can't be used from theClientclass.The actual coupling is inside
BaseSession: it constructsJSONRPCRequest/JSONRPCNotification/JSONRPCResponseobjects and pushes them throughSessionMessage. That's what this PR extracts. A custom dispatcher implements five methods dealing in{"method": str, "params": dict}dicts — the same thingrequest.model_dump()already produces — and passes itself asClientSession(dispatcher=...). All ofinitialize(),list_tools(),call_tool()work unchanged; no parallelClienthierarchy, no fat Protocol.The streams constructor path is unchanged —
BaseSession(read_stream, write_stream)internally constructs aJSONRPCDispatcher— so every existing transport works without modification.The
DispatcherProtocol is marked experimental. Custom transports that carry JSON-RPC should continue implementing theTransportProtocol frommcp.client._transport(yielding stream pairs), which is the stable path.Note
The spec currently says custom transports MUST preserve the JSON-RPC message format (2025-11-25 §Custom Transports). The Transport WG's March 4 position is this needs a "minor cosmetic update to loosen the JSON-RPC requirement" before non-JSON-RPC dispatchers are spec-compliant. This PR provides the SDK plumbing; the spec gate is still open.
How Has This Been Tested?
All 1142 existing tests pass unchanged. New E2E test (
tests/shared/test_dispatcher.py) shows a customSpyDispatcherwrappingJSONRPCDispatcher, passed toClientSession(dispatcher=spy), round-trippinginitialize+call_toolagainst a realMCPServerand recording every wire-level call.Breaking Changes
None. The streams constructor on
BaseSession/ClientSession/ServerSessionstill works.ServerSession._receive_loopwas renamed to_run(private, only overridden internally).Types of changes
Checklist
Additional context
Related: #1690, #1591 (closed), #2117, modelcontextprotocol/modelcontextprotocol#1319 (SEP-1319 extracted
*RequestParams), modelcontextprotocol/modelcontextprotocol#1352 (SEP-1352 closed in favour of this path). See also the Transport WG track brief.AI Disclaimer