Skip to content

[v2] Dispatcher/ServerRunner receive-path swap — replaces BaseSession#2710

Open
maxisbey wants to merge 62 commits into
mainfrom
maxisbey/v2-dispatcher-swap
Open

[v2] Dispatcher/ServerRunner receive-path swap — replaces BaseSession#2710
maxisbey wants to merge 62 commits into
mainfrom
maxisbey/v2-dispatcher-swap

Conversation

@maxisbey
Copy link
Copy Markdown
Contributor

Status: WIP — opened as a base for the swap work; commits will accumulate here.

V2 server-side receive path: Transport → JSONRPCDispatcher → ServerRunner → Server registry, replacing the BaseSession/ServerSession message loop. Consolidates the previously-stacked PR #2562 (and the four below it) into a single PR targeting main.

Goal

The tests/interaction/ suite (#2691) is the bar: this PR is done when that suite passes on the new runtime path with BaseSession/ServerSession removed and the handler-facing context object shimmed to the existing ServerRequestContext surface.

What's already here (from the consolidated stack)

  • Dispatcher / DispatchContext / Outbound Protocols (mcp/shared/dispatcher.py)
  • JSONRPCDispatcher over the existing SessionMessage stream contract — request-ID correlation, per-request task isolation, cancel/progress interception, exception→wire boundary
  • ServerRunner[L] per-connection orchestrator; Connection (state, exit_stack, session_id); new Context[L]
  • Server[L] registry: HandlerEntry dataclass, add_request_handler, zero-arg capabilities()
  • DirectDispatcher in-memory pair; ServerMiddleware / DispatchMiddleware
  • TransportContext.headers; ctx.session_id / ctx.headers properties

What's coming

  • Server.run() rewritten to drive JSONRPCDispatcher + ServerRunner
  • StreamableHTTPSessionManager / SseServerTransport route through ServerRunner
  • ServerRequestContext compat shim so the 200+ handler annotations in tests/interaction/ keep typechecking and the runtime object satisfies the surface
  • Outbound otel/W3C _meta parity on send_raw_request
  • Delete BaseSession / ServerSession / the old _handle_* path

Supersedes

#2562 and the four stacked below it. Those stay open as drafts for reference until this is reviewable.

AI Disclaimer

maxisbey added 27 commits June 1, 2026 14:46
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])
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.
Chunk (a) of JSONRPCDispatcher: constructor, _Pending/_InFlight/_JSONRPCDispatchContext,
send_request/notify and helpers. run() is stubbed.

The Dispatcher contract tests are now parametrized over a pair_factory fixture
(direct + jsonrpc). The 9 jsonrpc cases are strict-xfail until run()/
_handle_request land in the next commits; once those pass, strict xfail flips
to XPASS and forces removal of the marker.

Factories return (client, server, close) so running_pair can shut down any
implementation uniformly.
run() drives the receive loop in a per-request task group;
task_status.started() fires once send_request is usable. _dispatch routes each
inbound message synchronously (no awaits — send_nowait/_spawn only) to avoid
head-of-line blocking. _spawn propagates the sender's contextvars via
Context.run(tg.start_soon, ...) so auth/OTel set by ASGI middleware survive.
_fan_out_closed wakes pending send_request waiters with CONNECTION_CLOSED on
shutdown (called both post-EOF and in finally; idempotent).

Wire-param extraction (progressToken, cancelled.requestId, progress fields)
uses structural match patterns — runtime narrowing, no casts, no mcp.types
model coupling; malformed input fails to match and the correlation is skipped.

_handle_request is happy-path only here (run on_request, write response); the
exception-to-wire boundary lands in the next commit.

Dispatcher.run() Protocol gained a task_status kwarg (it's a contract-level
guarantee). DirectDispatcher.run() updated to match. running_pair now uses
tg.start so the test body runs only once the dispatcher is ready.

20 contract tests pass; the 2 needing the exception boundary are strict-xfail.
_handle_request is now the single exception-to-wire boundary:
- MCPError -> JSONRPCError(e.error)
- pydantic ValidationError -> INVALID_PARAMS
- Exception -> INTERNAL_ERROR(str(e)), logged, optionally re-raised
- outer-cancel (run() TG shutdown) -> shielded REQUEST_CANCELLED write, re-raise
- peer-cancel (notifications/cancelled) -> scope swallows, no response written

dctx.close() runs in an inner finally so the back-channel shuts the moment the
handler exits. _write_result/_write_error swallow Broken/ClosedResourceError so
a dropped connection during the response write doesn't crash the dispatcher.

All 22 contract tests now pass against both DirectDispatcher and
JSONRPCDispatcher; chunk-c xfail markers removed.
Covers behaviors with no DirectDispatcher analog: out-of-order response
correlation, INTERNAL_ERROR over the wire, peer-cancel in interrupt and signal
modes, CONNECTION_CLOSED on stream EOF mid-await, late-response drop,
raise_handler_exceptions propagation, ServerMessageMetadata tagging on
ctx.send_request, null-id JSONRPCError drop, ValidationError->INVALID_PARAMS,
contextvar propagation via _spawn, and the defensive Broken/Closed/WouldBlock
catches.

Two small src tweaks for coverage:
- _cancel_outbound: combine the two except arms into one tuple
- _dispatch: pragma no-branch on the final case (match is exhaustive over
  JSONRPCMessage; the no-match arc is unreachable)

43 tests, 100% coverage on all PR2 modules, 0.15s wall-clock.
The pull_request branch filter meant the test/lint/coverage matrix only ran
on PRs targeting main or v1.x. Stacked PRs (targeting feature branches) only
got the conformance checks, which are continue-on-error and don't exercise
unit tests. Removing the filter so the full matrix runs on every PR.
3.14: nested async-with arc misreporting on three create_task_group lines
(the documented AGENTS.md case) — pragma: no branch.

3.11: lines after async-CM exit with pytest.raises mis-traced in one test —
moved the asserts inside the context manager.
Follows the Outbound Protocol rename in the previous commit. Mechanical rename
across JSONRPCDispatcher, _JSONRPCDispatchContext, and tests.
PeerMixin defines the typed server-to-client request methods (sample with
overloads, elicit_form, elicit_url, list_roots, ping) once. Each method
constrains `self: Outbound` so any class with send_request/notify can mix it
in — pyright checks the host structurally at the call site. The mixin does no
capability gating; that's the host's send_request's job.

Peer is a trivial standalone wrapper for when you have a bare Outbound (e.g.
a dispatcher) and want the typed sugar without writing your own host class.

6 tests over DirectDispatcher, 0.03s.
Composition over a DispatchContext: forwards transport/cancel_requested/
send_request/notify/progress and adds meta. Satisfies Outbound so PeerMixin
works on it (proven by Peer(bctx).ping() round-tripping).

The server Context (next commit) extends this with lifespan/connection;
ClientContext will be an alias once ClientSession is reworked.
…ntext

PeerMixin methods and Peer/BaseContext now call/expose send_raw_request.
The typed send_request lands on Connection/Context in the next commit.
TypedServerRequestMixin (server/_typed_request.py) provides shape-2 typed
send_request: per-spec overloads (CreateMessage/Elicit/ListRoots/Ping) infer
the result type; custom requests pass result_type explicitly. Mixed into both
Connection and the server Context.

Connection (server/connection.py) wraps an Outbound for the standalone stream.
notify is best-effort (never raises); send_raw_request gated on
has_standalone_channel; check_capability mirrors v1 for now (FOLLOWUP). Holds
peer info populated at initialize time and the per-connection lifespan state.

Context (server/context.py, alongside v1's ServerRequestContext) composes
BaseContext + PeerMixin + TypedServerRequestMixin and adds lifespan/connection.
Request-scoped log() rides the request's back-channel; ctx.connection.log()
uses the standalone stream.

dump_params(model, meta) merges user-supplied meta into _meta; threaded
through every PeerMixin and Connection convenience method.

31 tests, 0.06s.
- Connection.check_capability per-field branches (parametrized)
- Context.log with logger and meta supplied
- Peer.notify forwards to wrapped Outbound
coverage.py on Python 3.11 doesn't record statements after an
'async with running_pair(...)' exit when there's a nested
'with anyio.fail_after()' inside. Same workaround as 0a8f0f4 in PR2 —
move the asserts inside the async-with block.
Remove references to PR numbers, internal scratch notes, and design-spike
shorthand that won't make sense to a fresh reader of the codebase.
LifespanT and TransportT are only exposed via read-only properties (lifespan,
transport), so covariance is sound. This lets a Context[AppState, HttpTC] be
passed where a Context[object, TransportContext] is expected — needed for
ServerRunner's middleware chain to compose without casts, and for reusable
middleware to be typed Context[object, TransportContext] instead of relying
on Any-slack.
ServerRunner is the per-connection orchestrator over a Dispatcher. This commit
lands the skeleton: ServerRegistry Protocol, _on_request (lookup → validate →
build Context → call handler → dump), _handle_initialize (populates
Connection, opens the init-gate), and a basic _on_notify.

Additive methods on lowlevel Server (get_request_handler /
get_notification_handler / middleware / connection_lifespan) so it satisfies
ServerRegistry without touching the existing run() path. _PARAMS_FOR_METHOD is
scaffolding (marked TODO) until the registry stores params types directly.

5 tests over DirectDispatcher + a real lowlevel Server.
ContextMiddleware is a Protocol[L] (contravariant) so Server[L].middleware:
list[ContextMiddleware[L]] is properly typed. App-specific middleware sees
ctx.lifespan: L; reusable middleware typed ContextMiddleware[object] registers
on any Server via contravariance. Context's covariance (previous PR3 commit)
makes Context[L, ST] <: Context[L, TransportContext] so the chain composes
without casts.

dispatch_middleware (DispatchMiddleware list on ServerRunner) wraps the raw
_on_request and sees everything including initialize/METHOD_NOT_FOUND.
server.middleware (ContextMiddleware) runs inside _on_request after
validation/ctx-build and wraps registered handlers only.

_on_notify routes notifications/initialized (sets the flag), drops
before-init and unknown methods, otherwise builds Context and calls the
registered handler.

11 tests over DirectDispatcher + a real lowlevel Server.
run() composes dispatch_middleware over _on_request and forwards task_status
to dispatcher.run() so callers can 'await tg.start(runner.run)'.

otel_middleware is a DispatchMiddleware that wraps each request in a span,
mirroring the existing Server._handle_request span shape: name 'MCP handle
<method> [<target>]', mcp.method.name attribute, W3C trace context extracted
from params._meta (SEP-414), and ERROR status if the handler raises.

connection_lifespan plumbing (the enter-late dance) is deferred to a separate
commit since Server.connection_lifespan is None today.
…d_runner harness

- Add opentelemetry-sdk as a dev dep and a tests/server/conftest.py 'spans'
  fixture (TracerProvider + InMemorySpanExporter) so otel_middleware's span
  contract is observable.
- Replace the otel pass-through test with four span-asserting tests (name +
  target, _meta traceparent → parent, MCPError → ERROR status without
  traceback, unexpected exception → ERROR status + exception event). These
  surfaced that start_as_current_span's default set_status_on_exception /
  record_exception was overwriting the middleware's explicit set_status and
  attaching tracebacks to protocol-level MCPErrors — now disabled and handled
  explicitly.
- Add handler-return contract tests (None → {}, unsupported → INTERNAL_ERROR).
- Introduce connected_runner async-contextmanager test harness and retrofit all
  tests through runner.run(); drop two tests made redundant by that. Harness
  closes dispatchers gracefully and re-raises body exceptions outside the task
  group so failures aren't ExceptionGroup-wrapped (and to avoid a coverage.py
  trace-loss false-negative on cancel-during-aexit).
- Remove the unused Server.connection_lifespan placeholder; it lands with its
  consumer.
The previous tests/server/conftest.py called trace.set_tracer_provider()
directly, which is set-once per process and raced against logfire's capfire
fixture (tests/shared/test_otel.py) under xdist — whichever ran first in a
worker won, the other's tests broke.

Converge on capfire as the single span-capture owner since logfire.configure()
already handles repeat calls by swapping span processors instead of re-setting
the provider:

- tests/conftest.py: set LOGFIRE_DISTRIBUTED_TRACING=true so propagation tests
  don't trip logfire's 'found propagated trace context' RuntimeWarning.
- tests/server/conftest.py: SpanCapture adapter over capfire.exporter — filters
  to the mcp-python-sdk instrumentation scope and excludes logfire's
  pending_span markers, so tests assert on raw ReadableSpan without importing
  logfire types.
- tests/shared/test_otel.py: drop the now-unneeded filterwarnings decorator.
…er[L] directly

Server is generic in LifespanResultT only — no TransportContextT. Spike
(scratch/spike-tt-on-server) found a third generic breaks bare-Server
plumbing helpers via invariance and only buys one None-check; it remains
additive later via PEP 696 default if demand materialises. TT stays on
the transport layer (Dispatcher/DispatchContext/BaseContext in mcp.shared);
the server layer (Server/Context/ServerRunner/ServerMiddleware) consumes
base TransportContext.

- HandlerEntry[L] frozen dataclass (params_type, handler) replaces bare
  callables in the registry; params type erased to Any in storage,
  correlated at add_request_handler[P]
- Public add_request_handler/add_notification_handler; capabilities()
  zero-arg (notification_options/experimental_capabilities now ctor kwargs)
- ServerRunner drops the ServerRegistry Protocol scaffold and reads
  Server[L] directly; _make_context no longer narrows dctx
- ServerMiddleware[L] (one contravariant param)
- Context[L] (BaseContext[TransportContext] fixed)
…tContext.headers

Per-connection state without a connection_lifespan CM or a second Server
generic. Stateless is the default deployment, where a per-connection
lifespan would wrap a single request; the enter-late mechanics it would
need (race init vs dispatcher-done, ready-gate) were more machinery than
the use case warrants.

- Connection.session_id: str | None — set by the mount via
  ServerRunner(session_id=...); per-connection, not per-message
- Connection.state: dict[str, Any] — scratch that persists across
  requests; handlers/middleware read and write freely
- Connection.exit_stack: AsyncExitStack — handlers/middleware push CMs
  or callbacks for per-connection teardown; ServerRunner.run() unwinds
  it (shielded) in a finally after dispatcher.run() returns
- TransportContext.headers: Mapping[str, str] | None on the base —
  populated by HTTP transports, None on stdio
- Context.session_id / Context.headers convenience properties
- create_direct_dispatcher_pair(headers=...) and
  connected_runner(session_id=..., headers=...) for tests
…r correlation

Matches BaseSession._normalize_request_id and the TypeScript SDK: a peer
that echoes the request ID as a JSON string still resolves the waiter.
Applied at both lookup sites (_resolve_pending and the progress-token
match). Parity prep for the PR6 e2e suite.
@maxisbey maxisbey force-pushed the maxisbey/v2-dispatcher-swap branch from f2d4cba to 47989e7 Compare June 1, 2026 15:46
maxisbey added 2 commits June 2, 2026 12:07
… through verbatim

Scaffolding for the swap: ServerRunner._make_context will read this to
populate ServerRequestContext.request / close_sse_stream / etc. the same
way the current Server._handle_request does.

Marked TODO(maxisbey): remove for Context rework — the redesign replaces
this with the per-transport context shape.
…uilder

request_id is the wire-format correlation id (JSON-RPC message id; None for
notifications and for dispatchers without one). Lives on DispatchContext
because it's wire-format-shaped (dispatcher domain), not transport-shaped.
ServerRunner._make_context will read it to populate
ServerRequestContext.request_id.

transport_builder no longer takes RequestId: that arg existed so the builder
could put the id on a TransportContext subclass, which is now redundant with
dctx.request_id. Nothing read it.
maxisbey added 14 commits June 2, 2026 13:49
…sent

Matches Server._handle_notification: when the wire omits params, the
handler receives None, not an empty model. _make_context now accepts
typed_params=None.
The first overload (no transport_builder) was missing peer_cancel_mode and
raise_handler_exceptions, so callers couldn't pass them without also
supplying a builder. Pure typing fix; the impl already handled them.
Restores the Server.run() behaviour the dispatcher rework dropped: at
read-stream EOF the task group cancels in-flight handler tasks instead of
joining on them. Without this, a handler that outlives its caller (its
request timed out client-side, or the client disconnected mid-call) keeps
run() from returning forever, leaking the handler task and over SSE the
GET request that hosts the session.

Regression test parks a handler in sleep_forever(), EOFs the read stream,
asserts run() returns within fail_after(5). Confirmed to hang on the
unpatched code.
Added earlier in this branch for ServerRunner._handle_initialize, which
now reads from InitializationOptions instead. No callers remain.
…on is a dispatcher proxy

The swap. Production server traffic now flows through the dispatcher/runner
path; BaseSession is no longer reached on the server side.

ServerRequestContext: standalone dataclass (drops RequestContext base,
inlines session/request_id/meta). ServerMiddleware retyped to take it;
_MwLifespanT no longer contravariant while the ctx is the invariant
mutable dataclass (TODO marked).

Connection: client_params holds the full InitializeRequestParams;
client_info / client_capabilities are read-through properties.

ServerSession: rewritten as a connection-scoped proxy over
JSONRPCDispatcher + Connection. send_request / send_notification
model-dump and forward to dispatcher.send_raw_request / notify, threading
related_request_id so SHTTP routing is unchanged. The typed helpers
(create_message, elicit_*, send_log_message, send_*_list_changed,
list_roots, send_ping, send_progress_notification, send_elicit_complete,
check_client_capability) are kept verbatim. Deleted: the
BaseSession-derived receive loop, _received_*, incoming_messages,
InitializationState, ServerRequestResponder, send_message, the
tasks-only _build_* helpers.

ServerRunner: dispatcher is JSONRPCDispatcher concretely (the
ServerSession shim needs its _related_request_id kwarg). __post_init__
builds the connection-scoped session. _make_context builds
ServerRequestContext from dctx.request_id and dctx.message_metadata
(the same isinstance(ServerMessageMetadata) narrow the previous
Server._handle_request did). _handle_initialize sets
connection.client_params. Both cast(Any, entry.handler) and the
getattr(typed_params, 'meta', ...) are gone (meta read via
isinstance(typed_params, RequestParams)). Server import is under
TYPE_CHECKING to break the cycle with lowlevel/server.

Server.run(): builds JSONRPCDispatcher(read, write,
raise_handler_exceptions=...) and ServerRunner(...,
dispatch_middleware=[otel_middleware]) inside the lifespan, then awaits
runner.run(). _handle_message / _handle_request / _handle_notification
deleted.
…_main__

server/__main__.py: use Server.run() instead of manual ServerSession +
incoming_messages. 49 -> 24 lines.

tests/server/test_runner.py: connected_runner now drives a JSONRPCDispatcher
pair (was DirectDispatcher); ctx is ServerRequestContext; dropped 6 tests
of dormant Context features that return in the Context rework.

tests/server/test_connection.py: set client_params instead of the now
read-only client_info/client_capabilities properties.

tests/server/test_stateless_mode.py: ServerSession(dispatcher, connection)
fixtures.

tests/conftest.py: capfire override resets _otel._tracer to NoOpTracer on
teardown so tests after a span-capture test don't see traceparent injected
into _meta (pre-existing order-dep, surfaced once both code paths inject).

Deleted (covered by tests/interaction/ or test_runner.py):
test_session.py, test_session_race_condition.py,
test_progress_notifications.py, test_malformed_input.py.

test_lowlevel_exception_handling.py: dropped 3 tests of the deleted
_handle_message; kept the real-stream Server.run() regression test.
…nnection

Both run as bare tasks in the dispatcher's task group; an uncaught
exception cancels every sibling (read loop + in-flight requests) and
tears down run(). The previous receive-loop swallowed and logged both.

ServerRunner._on_notify: ValidationError -> warning + drop; handler
Exception -> logger.exception + swallow.

JSONRPCDispatcher: user on_progress callbacks are wrapped so a raise is
logged and swallowed instead of cascading.

Regression tests confirmed to fail before the fix.
…s typed params

JSONRPCDispatcher._handle_request: pop from _in_flight in the inner
finally (right after the handler returns, before _write_result). No
checkpoint between handler return and pop, so a late
notifications/cancelled finds nothing and is a no-op; scope.cancel_called
is only true if the cancel landed during the handler. Previously a cancel
arriving during _write_result's checkpoint after the result was buffered
would send both the result and a code=0 "Request cancelled" for the
same id.

mcpserver/server.py: register completion/complete via
add_request_handler with CompleteRequestParams (was the legacy
_add_request_handler which defaulted params_type=RequestParams, so the
handler got base RequestParams and params.ref AttributeErrored). Delete
_add_request_handler (last caller).
…request.id

inline_methods: request methods in this set are awaited inline in the
read loop instead of spawned, so their side effects are visible to the
next dequeued message. Server.run() passes {"initialize"} so a client
that pipelines initialize + the next request without awaiting
InitializeResult (spec says SHOULD NOT, not MUST NOT) sees the
initialized state instead of failing the init-gate. Matches the go-sdk's
explicit carve-out. _dispatch / _dispatch_request are now async (only
await for inline methods).

otel_middleware: restore the jsonrpc.request.id span attribute that the
previous Server._handle_request set.
…e cleanup

src/mcp/client/_memory.py: replace tg.cancel_scope.cancel() with stream
aclose(). Cancelling here throws CancelledError into the host (test's)
task; on CPython 3.11 (gh-106749) coro.throw() drops 'call' trace
events for the outer await chain, underflowing coverage's CTracer past
the test frame. Post-swap the dispatcher's empty-at-EOF inner task group
takes a one-tick fast path, so the join no longer suspends a second time
to heal via .send(). EOF teardown is equivalent (the dispatcher's run()
cancels its own in-flight handlers on read-stream EOF).

Also bundled (coverage cleanup):
- shared/session.py: delete server-only BaseSession code now unreachable
  after the swap (RequestResponder.cancel/_cancel_scope/_on_complete,
  CancelledNotification handling, _in_flight dict, deferred-respond
  arm). ClientSession is the only remaining subclass.
- tests/client/test_session.py: add tests for the client-reachable
  BaseSession paths (malformed inbound request -> INVALID_PARAMS,
  sampling callback raises -> INVALID_PARAMS, progress callback
  exception swallowed, malformed notification dropped, transport
  exception forwarded to message_handler).
- tests/shared/test_session.py: drop test_in_flight_requests_cleared
  (asserts the deleted _in_flight dict).
- tests/shared/test_dispatcher.py: add contract test for ValidationError
  -> INVALID_PARAMS (covers DirectDispatcher's arm).
- tests/server/test_validation.py: cover the previous-message-has-no-
  tool-use branch.
- examples/everything-server: _add_request_handler (deleted) ->
  add_request_handler with explicit params type.
…client_capability delegates

server/session.py: check_client_capability now delegates to
Connection.check_capability instead of duplicating it. Connection's
version gains the sampling.context/sampling.tools sub-checks and
experimental value-equality so the delegation is complete.

server/runner.py: otel_middleware sets jsonrpc.request.id
unconditionally (DispatchMiddleware wraps on_request only;
JSONRPCRequest.id is required, so the None guard was dead).

tests/server/test_session.py: re-created for the new
ServerSession(dispatcher, connection) shape - covers send_request
timeout/progress_callback opts paths and the create_message tools
branch.

tests/server/test_server_context.py: assert Context.session_id and
Context.headers.
…notify-drop test

cancelled_by_peer was set by _dispatch_notification but never read; the
peer-vs-outer-cancel distinction in _handle_request relies on
scope.cancel_called alone (and works because nothing else cancels the
per-request scope).

test_runner_on_notify_drops_before_init_and_unknown_methods now
registers a handler and asserts only the post-init notification reaches
it (was assertionless before).
ServerSession proxy shape, lowlevel _handle_* removal,
add_request_handler going public with params_type,
raise_exceptions semantics narrowing, BaseSession/RequestResponder
server-side cancellation tracking removal.
Comment thread src/mcp/shared/direct_dispatcher.py Outdated
Comment thread src/mcp/shared/dispatcher.py
Comment thread docs/migration.md Outdated
Comment thread docs/migration.md Outdated
Comment thread src/mcp/server/lowlevel/server.py
Comment thread src/mcp/shared/jsonrpc_dispatcher.py Outdated
Comment thread src/mcp/shared/peer.py
Comment thread src/mcp/shared/peer.py Outdated
Comment thread tests/issues/test_malformed_input.py
Comment thread tests/shared/test_progress_notifications.py
maxisbey added 11 commits June 3, 2026 19:31
ServerRunner._dump_result: ErrorData returned by a handler now raises
MCPError so it reaches the wire as a JSON-RPC error (was serialized as
a success result; the previous Server._handle_request supported this).

Validation: by_name=False at every wire-validation boundary (runner,
session, peer, _typed_request) so snake_case wire keys are rejected as
before. Absent params on requests reach handlers as None (matching the
| None annotations) after a required-field check, not as an empty model.

Server.run: has_standalone_channel=not stateless (was hardcoded True;
made the new NoBackChannelError path inert in stateless SHTTP).

ServerRunner: connection.initialized event is set on construction in
stateless mode (was never set). exit_stack.aclose() is wrapped so a
raising user cleanup callback is logged, not propagated.

InMemoryTransport: bounded fallback cancel after EOF aclose(). If user
teardown (lifespan __aexit__, exit_stack callbacks) doesn't complete in
SERVER_SHUTDOWN_GRACE seconds the task group is cancelled. Healthy path
still avoids the gh-106749 throw().

BaseSession._receive_loop: server->client notifications/cancelled is
silently consumed again (the deletion let it reach message_handler).

JSONRPCDispatcher: courtesy notifications/cancelled on timeout/cancel is
tagged with related_request_id so SHTTP routes it onto the per-request
stream. The inline_methods branch now spawns via _spawn (so sender
contextvars apply) and awaits an Event to preserve ordering.
_JSONRPCDispatchContext.notify drops when closed.

DispatchContext Protocol: gains can_send_request (predicts whether
send_raw_request will raise NoBackChannelError); BaseContext delegates
to it.
…3.14)

The second gh-106749 site (the first being _memory.py): BaseSession's
task-group cancel is also delivered via coro.throw() into the host task,
desyncing CTracer past the caller's frame on 3.11. A
cancel_shielded_checkpoint() after the join resumes via .send() and
re-stamps the missing 'call' events. Shielded so a pending outer cancel
isn't re-delivered here.

The added tick shifts whether tests reach streamable_http.py's outer
POST-error handler (it was timing-dependent before too); marked lax no
cover.

Also: collapse a nested with in test_jsonrpc_dispatcher.py and pragma: no
branch it (3.14 misreports the 254->255 arc on the nested-with bytecode
shape).
….10 on 3.14

The BaseSession.__aexit__ checkpoint only heals throws at or before
session exit. Under xdist per-worker test ordering, the unmasked
victims sit after later cancel-scope sites: client/streamable_http.py,
client/sse.py, client/websocket.py, server/streamable_http_manager.py
(finally-cancel after task-group join), and
shared/memory.py:create_client_server_memory_streams (heals
caller-driven cancels). Same shielded-checkpoint pattern at each. Also
updated the _memory.py comment to reference the new memory.py heal.

3.14 lowest-direct: anyio 4.9.0 from_thread.py has return-in-finally
which Python 3.14 (PEP 765) warns about at compile time; the warning
lands in the stdio test child stderr. Fixed in anyio 4.10
(agronholm/anyio#816); marker-split the floor (locked already has
4.10).
…ests

Under xdist, a desync at the end of one test carried into the start of
the next on the same worker; the missed lines moved with worker test
ordering (last seen as tests/shared/test_streamable_http.py:1399-1404,
previously :2072-2079). The per-throw-site heals in src/ covered every
SDK site we found; this backstops any remaining ones in test code or
test deps.

The heal line itself runs while the tracer is desynced (and is dead on
non-3.11), hence lax no cover.
The test cancels a task group mid-body (line 1381) to kill the first
client session while a tool is blocked; that cancel is the throw, and
the second session's lines (1399-1404) fall in the desynced window.
Same checkpoint pattern, applied at the actual site (the autouse
fixture is a cross-test backstop and cannot help an intra-test
desync).
Conflict in tests/shared/test_streamable_http.py: kept the gh-106749
heal at the resumption-test cancel site, took main's in-process
make_client/BASE_URL.
The in-process ASGI bridge (tests/interaction/transports/_bridge.py)
cancels its task group on close. After main's #2764-2767 moved the
shared SHTTP/SSE/security tests to this transport, this became the
remaining unhealed throw site on 3.11. Same checkpoint pattern,
applied at the actual cancel site (_bridge.py is a helper, not a
snapshot test).
…s the site)

The fixture ran at a different frame depth than the desync and had no
effect on CI. The actual remaining throw site was
StreamingASGITransport.__aexit__, healed in 7cbfd0f.
t01: direct_dispatcher locals left/right -> client/server
t03: migration.md drop "(workaround)", add_request_handler is the supported path
t06+07: Server.middleware comment rewritten + TODO (was stale post-swap)
t09: delete stale ServerRegistry section header
t11: drop Connection.client_info/.client_capabilities properties
     (no readers; v1 only exposed client_params; check_capability reads through)
t12: send_raw_request docstrings point at CallOptions keys
t14+26: drop dump_params from peer.__all__
t20+02: resumption_token/on_resumption_token annotated client-side/SHTTP-only,
     and as 2025-11-25-and-earlier (resumption removed in next protocol rev)
t24: _route_notification docstring (correlation rationale)
t15+19: drop unneeded `from __future__ import annotations` from
     server/context.py and server/session.py (no cycle, no forward refs)

t05+08 answered (no code change): HandlerEntry.handler Any is required storage
erasure; correlation enforced at add_request_handler.
…ession test

t27: rename PeerMixin/Peer -> ClientPeerMixin/ClientPeer (the contents are
exactly the spec ServerRequest set, server->client; sibling
TypedServerRequestMixin already encodes direction). Fix the stale module/
class docstring claiming Connection mixes it in (it does not).

t28: re-cover the deleted tests/issues/test_malformed_input.py regression
on the new path - initialize with params=None returns INVALID_PARAMS and
the runner keeps serving.

t13: keep `notify` (asymmetry justified; layer-consistent).
t29: deletion safe (interaction test_progress.py + dispatcher units cover it).
…18); t25 TODO

ServerMiddleware now wraps the entire _on_request and _on_notify body so it
observes initialize, notifications/initialized, METHOD_NOT_FOUND, validation
failures, and pre-init drops - everything the old incoming_messages stream
saw. params is the raw Mapping[str, Any] | None (not the typed model);
ctx.request_id is None distinguishes a notification; call_next() raises
MCPError for request-side failures so middleware can observe them, returns
None for notifications. _on_notify keeps its swallow-at-boundary behavior
but middleware sees the raise first.

ServerRunner._on_request/_on_notify restructured: build ctx from raw inputs
first (_extract_meta validates only _meta via RequestParams so ctx.meta
keeps the alias-converted shape), then compose Server.middleware around an
_inner() containing validation/init/gate/lookup/handler.
_compose_server_middleware helper shared by both paths.
_handle_initialize returns InitializeResult (outer _dump_result serializes)
so middleware can transform it. _make_context now takes meta directly.

t25: TODO at the duplicate-inbound-request-id site (parity with v1/TS;
spec puts uniqueness on the sender).
@maxisbey maxisbey marked this pull request as ready for review June 4, 2026 11:56
@maxisbey maxisbey requested a review from felixweinberger June 4, 2026 11:56
Dispatcher:
- notify omits the params key entirely when params is None; the spec
  requires absent over null and the TS SDK client rejects null.
- transport_builder calls on the read-loop path are guarded: a raising
  builder answers the request with INTERNAL_ERROR (or drops the
  notification) instead of killing the dispatcher.
- bool no longer matches the int() structural patterns (progressToken,
  cancelled requestId, progress), so true cannot alias to request id 1.
  The cancelled arm and the _in_flight table now coerce ids the same way
  responses do, so string/int id forms correlate both ways.
- Dropped the redundant outer _in_flight pop in _handle_request; it
  could evict a newer request that reused the id. Resumption hints
  passed alongside related_request_id are dropped with a debug log and
  the precedence is documented.
- notifications/cancelled now flows through to on_notify after the
  dispatcher applies its cancellation, so Server.middleware observes it
  and servers can register custom handling.

Runner / peer:
- dump_params serializes meta through RequestParams by alias (the
  inverse of _extract_meta), so a handler passing meta=ctx.meta emits
  progressToken on the wire, not progress_token.
- A handler returning ErrorData now raises MCPError inside call_next()
  so middleware observes the failure; _dump_result keeps the conversion
  as a backstop for middleware that itself returns ErrorData.

docs/migration.md: middleware example takes the raw mapping; remove the
unreachable DispatchMiddleware pointer; fix the inverted
raise_exceptions claim.
Comment thread src/mcp/server/runner.py
ctx = self._make_context(dctx, _extract_meta(params))

async def _inner() -> HandlerResult:
# TODO(maxisbey): pinned compat. `BaseSession._receive_loop`
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.

for all massive comments like this, reduce to one or two sentences

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