Skip to content

Extract JSON-RPC wrapping into a Dispatcher component#2320

Draft
maxisbey wants to merge 3 commits intomainfrom
extract-dispatcher-from-base-session
Draft

Extract JSON-RPC wrapping into a Dispatcher component#2320
maxisbey wants to merge 3 commits intomainfrom
extract-dispatcher-from-base-session

Conversation

@maxisbey
Copy link
Contributor

Splits BaseSession into two layers by composition: a Dispatcher owns the wire protocol (JSON-RPC wrap/unwrap, ID correlation, the receive loop), BaseSession keeps the MCP semantics (progress tokens, cancellation, in-flight tracking, result validation). The default JSONRPCDispatcher is the old _receive_loop + send_request + _send_response logic 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 the Client class.

The actual coupling is inside BaseSession: it constructs JSONRPCRequest/JSONRPCNotification/JSONRPCResponse objects and pushes them through SessionMessage. That's what this PR extracts. A custom dispatcher implements five methods dealing in {"method": str, "params": dict} dicts — the same thing request.model_dump() already produces — and passes itself as ClientSession(dispatcher=...). All of initialize(), list_tools(), call_tool() work unchanged; no parallel Client hierarchy, no fat Protocol.

class Dispatcher(Protocol):
    def set_handlers(self, on_request, on_notification, on_error) -> None: ...
    async def run(self) -> None: ...
    async def send_request(self, request_id, request: dict, metadata, timeout) -> dict: ...
    async def send_notification(self, notification: dict, related_request_id) -> None: ...
    async def send_response(self, request_id, response: dict | ErrorData) -> None: ...

The streams constructor path is unchanged — BaseSession(read_stream, write_stream) internally constructs a JSONRPCDispatcher — so every existing transport works without modification.

The Dispatcher Protocol is marked experimental. Custom transports that carry JSON-RPC should continue implementing the Transport Protocol from mcp.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 custom SpyDispatcher wrapping JSONRPCDispatcher, passed to ClientSession(dispatcher=spy), round-tripping initialize + call_tool against a real MCPServer and recording every wire-level call.

Breaking Changes

None. The streams constructor on BaseSession/ClientSession/ServerSession still works. ServerSession._receive_loop was renamed to _run (private, only overridden internally).

Types of changes

  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

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

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
…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).
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.

1 participant