Skip to content

feat: add Dispatcher Protocol and DirectDispatcher#2452

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

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

Conversation

@maxisbey
Copy link
Copy Markdown
Contributor

@maxisbey maxisbey commented Apr 16, 2026

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

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