Skip to content

feat: add Dispatcher Protocol and DirectDispatcher#2452

Draft
maxisbey wants to merge 4 commits intomainfrom
maxisbey/v2-dispatcher-protocol
Draft

feat: add Dispatcher Protocol and DirectDispatcher#2452
maxisbey wants to merge 4 commits intomainfrom
maxisbey/v2-dispatcher-protocol

Conversation

@maxisbey
Copy link
Copy Markdown
Contributor

@maxisbey maxisbey commented Apr 16, 2026

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/Server yet.

Motivation and Context

The current BaseSession hard-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 Dispatcher Protocol is the call/return boundary: send_request(method, params) -> dict, notify(method, params), and run(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.

Outbound is a two-method base Protocol (send_request + notify) that both Dispatcher and DispatchContext extend. It names the surface that PeerMixin (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.

DirectDispatcher is 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.pyOutbound, Dispatcher, DispatchContext Protocols; CallOptions, OnRequest/OnNotify, ProgressFnT, DispatchMiddleware
  • shared/transport_context.pyTransportContext base dataclass
  • shared/direct_dispatcher.py — in-memory Dispatcher impl
  • shared/exceptions.pyNoBackChannelError(MCPError) for transports without a server-to-client request channel
  • types/REQUEST_CANCELLED SDK error code

How Has This Been Tested?

tests/shared/test_dispatcher.py — 13 behavioral tests covering the send_request contract (round-trip, MCPError passthrough, exception normalization, timeout), notify, the DispatchContext back-channel (send_request, notify, progress, NoBackChannelError), and the run/close lifecycle. 100% coverage on the new modules.

Breaking Changes

None. New code only; nothing existing is touched beyond adding NoBackChannelError and the REQUEST_CANCELLED constant.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

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

Stack:

  1. (this PR) Dispatcher Protocol + DirectDispatcher
  2. JSONRPCDispatcher
  3. BaseContext / Context / Peer / Connection
  4. ServerRunner

send_request contract: returns dict[str, Any] or raises MCPError. Implementations normalize all handler exceptions to MCPError so callers see one exception type. Timeout maps to MCPError(REQUEST_TIMEOUT).

AI Disclaimer

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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mark this once at the top of the file

@maxisbey maxisbey mentioned this pull request Apr 16, 2026
9 tasks
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.
@Kludex
Copy link
Copy Markdown
Member

Kludex commented Apr 21, 2026

@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 `.

@maxisbey
Copy link
Copy Markdown
Contributor Author

maxisbey commented Apr 22, 2026

@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:

  • It's very spaghetti
  • It is hard coupled to JSONRPC, making it not possible to do pluggable transports
  • Hard to test with a lot of state in one class

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.

@Kludex
Copy link
Copy Markdown
Member

Kludex commented Apr 22, 2026

It is hard coupled to JSONRPC, making it not possible to do pluggable transports

This seems the real motivation from the bullet list. Why not having a parameter onto the transport that would be passed down to the BaseSession?

Copy link
Copy Markdown
Member

@Kludex Kludex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've stopped reviewing. I don't think it makes sense for me to continue without proper context.

Comment on lines +1 to +17
"""Dispatcher Protocol — the call/return boundary between transports and handlers.

A Dispatcher turns a duplex message channel into two things:

* an outbound API: ``send_raw_request(method, params)`` and ``notify(method, params)``
* an inbound pump: ``run(on_request, on_notify)`` that drives the receive loop
and invokes the supplied handlers for each incoming request/notification

It is deliberately *not* MCP-aware. Method names are strings, params and
results are ``dict[str, Any]``. The MCP type layer (request/result models,
capability negotiation, ``Context``) sits above this; the wire encoding
(JSON-RPC, gRPC, in-process direct calls) sits below it.

See ``JSONRPCDispatcher`` for the production implementation and
``DirectDispatcher`` for an in-memory implementation used in tests and for
embedding a server in-process.
"""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please single `, not double.

Subclass per transport and add fields as needed. Instances are immutable.
"""

kind: str
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
kind: str
kind: Literal['stdio', 'streamable-http'] | str

Comment on lines +165 to +166
A ``(left, right)`` pair. Conventionally ``left`` is the client side
and ``right`` is the server side, but the wiring is symmetric.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So there is no validation of messages according to this?

It's far easier to understand to have client/server instead of left/right.

Comment on lines +24 to +30
can_send_request: bool
"""Whether the transport can deliver server-initiated requests to the peer.

``False`` for stateless HTTP and HTTP with JSON response mode; ``True`` for
stdio, SSE, and stateful streamable HTTP. When ``False``,
`DispatchContext.send_raw_request` raises `NoBackChannelError`.
"""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not used in this PR, can you remove it, please?

raise ValueError("oops")

async with running_pair(server_on_request=on_request) as (client, *_):
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 seconds is too much to be a limit.

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