feat: add Dispatcher Protocol and DirectDispatcher#2452
feat: add Dispatcher Protocol and DirectDispatcher#2452
Conversation
Introduces the Dispatcher abstraction that decouples MCP request/response handling from JSON-RPC framing. A Dispatcher exposes call/notify for outbound messages and run(on_call, on_notify) for inbound dispatch, with no knowledge of MCP types or wire encoding. - shared/dispatcher.py: Dispatcher, DispatchContext, RequestSender Protocols; CallOptions, OnCall/OnNotify, ProgressFnT, DispatchMiddleware - shared/transport_context.py: TransportContext base dataclass - shared/direct_dispatcher.py: in-memory Dispatcher impl that wires two peers with no transport; serves as a fast test substrate and second-impl proof - shared/exceptions.py: NoBackChannelError(MCPError) for transports without a server-to-client request channel - types: REQUEST_CANCELLED SDK error code The JSON-RPC implementation and ServerRunner that consume this Protocol land in follow-up PRs.
- tests: replace unreachable 'return {}' with 'raise NotImplementedError'
(already in coverage exclude_also) and collapse send_request+return into
one statement
- dispatcher: RequestSender docstring no longer claims Dispatcher satisfies it
(Dispatcher exposes call(), not send_request())
…er with Outbound The design doc's `send_request = call` alias only makes the concrete class satisfy RequestSender, not the abstract Dispatcher Protocol — so any consumer typed against `Dispatcher[TT]` (Connection, ServerRunner) couldn't pass it to something expecting a RequestSender without a cast or hand-written bridge. RequestSender was also half a contract: every implementor (Dispatcher, DispatchContext, Connection, Context) has `notify` too, and PeerMixin needs both for its typed sugar (elicit/sample are requests, log is a notification). Outbound(Protocol) declares both methods; Dispatcher and DispatchContext extend it. PeerMixin will wrap an Outbound. One verb everywhere, no aliases, no extra Protocols. - Dispatcher.call -> send_request - OnCall -> OnRequest, on_call -> on_request - RequestSender -> Outbound (now also declares notify) - Dispatcher(Outbound, Protocol[TT]), DispatchContext(Outbound, Protocol[TT])
| server.close() | ||
|
|
||
|
|
||
| @pytest.mark.anyio |
There was a problem hiding this comment.
mark this once at the top of the file
The dispatcher-layer raw channel is now `send_raw_request(method, params) -> dict`. This frees the `send_request` name for the typed surface (`send_request(req: Request) -> Result`) that Connection/Context/Client add in later PRs. Mechanical rename across Outbound, Dispatcher, DispatchContext, DirectDispatcher, _DirectDispatchContext, and all tests. `can_send_request` (the transport capability flag) is unchanged — it names the capability, not the method.
|
@maxisbey I need more context here. The reference design document is huge, and I was not present in the discussions about this. What is the motivation for it, and what problem does it try to solve? Also, please remove the double `. |
This started from looking into replacing SessionMessage and allowing for pluggable transports. The main problem being that everything in the SDK has been tied into the BaseSession/ServerSession/ClientSession class monolith with three main issues:
So, with the initial draft design I put up of a dispatcher I was wanting to continue from there and do it properly and add actual distinct layers to the SDK for Transport (shttp, stdio, etc.), wire (jsonrpc, which will be the Dispatcher here, and hopefully much easier to swap out for something like gRPC), and then MCP layer (which will be the ServerRunner and Server class layer). The other thing coming along with this in a later PR is a cleaned up Context object which can properly represent the different paths of communication server -> client and what not. So you can send via the response stream to a tool call, or via the GET stream in the current spec. That's the main motivation though :) With the design doc it is big, but the WALKTHROUGH in that link is the better file to read TBH. Shorter and more clearly explains what I was thinking. |
Design doc: https://gist.github.com/maxisbey/1e14e741d774acf52b80e69db292c5d7
First in a stack of PRs reworking the SDK internals to decouple MCP request handling from JSON-RPC framing. This PR lands only the abstraction and an in-memory implementation; nothing is wired into
BaseSession/Serveryet.Motivation and Context
The current
BaseSessionhard-couples three concerns: JSON-RPC framing, request/response correlation, and MCP semantics. That makes alternative transports (gRPC, in-process) impossible without smuggling JSON through them, and makes the receive loop hard to follow and test.The
DispatcherProtocol is the call/return boundary:send_request(method, params) -> dict,notify(method, params), andrun(on_request, on_notify)to drive the inbound side. Method names are strings, params/results are dicts. MCP types live above it; wire encoding lives below it.Outboundis a two-method base Protocol (send_request+notify) that bothDispatcherandDispatchContextextend. It names the surface thatPeerMixin(a later PR) will wrap to provide typed MCP methods — one Protocol covers both top-level outbound and the per-request back-channel, with no aliases or adapter classes.DirectDispatcheris an in-memory implementation that wires two peers with no transport. It serves as the second-impl proof for the Protocol and as a fast test substrate for the layers that will sit above the dispatcher in later PRs.Files
shared/dispatcher.py—Outbound,Dispatcher,DispatchContextProtocols;CallOptions,OnRequest/OnNotify,ProgressFnT,DispatchMiddlewareshared/transport_context.py—TransportContextbase dataclassshared/direct_dispatcher.py— in-memoryDispatcherimplshared/exceptions.py—NoBackChannelError(MCPError)for transports without a server-to-client request channeltypes/—REQUEST_CANCELLEDSDK error codeHow Has This Been Tested?
tests/shared/test_dispatcher.py— 13 behavioral tests covering thesend_requestcontract (round-trip, MCPError passthrough, exception normalization, timeout),notify, theDispatchContextback-channel (send_request,notify,progress,NoBackChannelError), and therun/closelifecycle. 100% coverage on the new modules.Breaking Changes
None. New code only; nothing existing is touched beyond adding
NoBackChannelErrorand theREQUEST_CANCELLEDconstant.Types of changes
Checklist
Additional context
Stack:
send_requestcontract: returnsdict[str, Any]or raisesMCPError. Implementations normalize all handler exceptions toMCPErrorso callers see one exception type. Timeout maps toMCPError(REQUEST_TIMEOUT).AI Disclaimer