[v2] Dispatcher/ServerRunner receive-path swap — replaces BaseSession#2710
[v2] Dispatcher/ServerRunner receive-path swap — replaces BaseSession#2710maxisbey wants to merge 50 commits into
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])
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.
f2d4cba to
47989e7
Compare
… 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.
Replace double-backticks with single, em-dashes with hyphens, and the one fat-arrow with => across files this branch touches. Also drop the unused TransportBuilder type alias (stale after the request_id arg was removed from the builder signature).
…s EOF run() now enters write_stream alongside read_stream so the write end is released when the read loop exits (BaseSession did this; without it every [sse] interaction leg leaks a MemoryObjectSendStream and fails under filterwarnings=error). ClosedResourceError from the read iterator is caught and treated as clean EOF. Stateless SHTTP teardown closes the dispatcher's receive end after the request is handled; the next __anext__ call on the now-closed stream raises, which previously surfaced as 'Stateless session crashed'.
Three pinned wire shapes the interaction suite locks in: - Peer-cancelled requests are answered with ErrorData(code=0, message="Request cancelled"). Spec says SHOULD NOT respond, but the existing server always has. - Unhandled handler exceptions become code=0 (not INTERNAL_ERROR), message=str(e). Matches Server._handle_request. - ValidationError becomes the fixed "Invalid request parameters" / data="" shape rather than leaking the pydantic error text. All three carry TODO(maxisbey) markers; they're compat with current behavior, not the intended end state.
…s _meta Mirrors BaseSession.send_request: outbound requests are wrapped in an otel CLIENT span and W3C trace context is injected into params._meta (SEP-414). A side effect is that _meta is always present on the wire (empty under a no-op tracer), which the interaction suite's sampling/elicitation snapshots pin. The contract-test echo recorder now strips _meta so JSON-RPC and direct dispatch parametrizations record identically. TODO(maxisbey) marker added: this belongs in an outbound middleware once that seam exists; the dispatcher should not own otel.
…D, pre-init) METHOD_NOT_FOUND message is now bare "Method not found" (no method suffix); the interaction suite pins that. The pre-init gate now returns the generic INVALID_PARAMS / "Invalid request parameters" / data="" shape. The existing server has no dedicated pre-init check; the request dies in ClientRequest validation, so clients see this shape. TODO(maxisbey) marked. Also: loosen the gap-8 _meta wire test to be tracer-agnostic (it was order-dependent on the SpanCapture fixture).
… lookup Parity with BaseSession._receive_loop: a spec method with malformed params surfaces as INVALID_PARAMS via the dispatcher's ValidationError boundary even when no handler is registered (the existing server validates against the discriminated union before any handler lookup). Gated on the set of spec method names (derived from the ClientRequest union discriminator) so custom methods registered via add_request_handler still route. The existing server rejects those too, but nothing pins that and routing them is strictly better. DirectDispatcher gains the same ValidationError -> INVALID_PARAMS mapping JSONRPCDispatcher has, so runner-over-direct unit tests see the same shape.
ServerRunner gains init_options (defaults to server.create_initialization_options()). _handle_initialize builds the full InitializeResult from it (name/title/description/version/website_url/icons/ instructions) and negotiates requested-if-in-SUPPORTED_PROTOCOL_VERSIONS- else-LATEST, matching ServerSession._received_request.
…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.
| right = DirectDispatcher(ctx) | ||
| left.connect_to(right) | ||
| right.connect_to(left) | ||
| return left, right |
There was a problem hiding this comment.
make client and server instead of left and right
| on_progress: ProgressFnT | ||
| """Receive ``notifications/progress`` updates for this request.""" | ||
|
|
||
| resumption_token: str |
There was a problem hiding this comment.
investigate how this will work on new spec
| ``` | ||
|
|
||
| In v2, the lowlevel `Server` no longer has decorator methods (handlers are constructor-only), so the equivalent workaround is `_add_request_handler`: | ||
| In v2, the lowlevel `Server` no longer has decorator methods (handlers are constructor-only), so the equivalent workaround is `add_request_handler`: |
There was a problem hiding this comment.
probably wouldn't say workaround here. Instead would say "this is now supported by ___" or something
|
|
||
| - `InitializationState` enum and `ServerSession._initialization_state` — initialization tracking is now on `Connection` (`connection.initialized` is an `anyio.Event`, `connection.client_params` holds the init params). | ||
| - `ServerRequestResponder` type alias. | ||
| - `ServerSession.incoming_messages` stream — there is no longer a public stream of inbound messages to iterate. Register handlers via the `on_*` constructor params (or `add_request_handler`) and use `Server.middleware` to observe every request. |
There was a problem hiding this comment.
need to make sure middleware works even if a method doesn't exist I think
| params_type: type[BaseModel] | ||
| handler: RequestHandler[LifespanResultT, Any] |
There was a problem hiding this comment.
guessing we can't make params_type generic here with it being set the same as the handelr any?
| _progress_token=progress_token, | ||
| ) | ||
| scope = anyio.CancelScope() | ||
| self._in_flight[req.id] = _InFlight(scope=scope, dctx=dctx) |
There was a problem hiding this comment.
need to handle the case of if a client sends a request id that already exists in session. check spec language
| """Type alias for the `_meta` field carried on request/notification params.""" | ||
|
|
||
|
|
||
| def dump_params(model: BaseModel | None, meta: Meta | None = None) -> dict[str, Any] | None: |
There was a problem hiding this comment.
ah I see this, yea probably worth putting somewhere module private I'd think? unless you can see a reason for an sdk consumer to use this?
| return out | ||
|
|
||
|
|
||
| class PeerMixin: |
There was a problem hiding this comment.
yea probably wroth renaming to ClientPeer or something? idk have a think
|
|
||
|
|
||
| @pytest.mark.anyio | ||
| async def test_malformed_initialize_request_does_not_crash_server(): |
There was a problem hiding this comment.
sanity check we don't need to test this as well
| @@ -1,264 +0,0 @@ | |||
| from typing import Any | |||
There was a problem hiding this comment.
do we still have or need progress notofication tests? or do we just rely on the interaction/ tests now?
Status: WIP — opened as a base for the swap work; commits will accumulate here.
V2 server-side receive path:
Transport → JSONRPCDispatcher → ServerRunner → Serverregistry, replacing theBaseSession/ServerSessionmessage loop. Consolidates the previously-stacked PR #2562 (and the four below it) into a single PR targetingmain.Goal
The
tests/interaction/suite (#2691) is the bar: this PR is done when that suite passes on the new runtime path withBaseSession/ServerSessionremoved and the handler-facing context object shimmed to the existingServerRequestContextsurface.What's already here (from the consolidated stack)
Dispatcher/DispatchContext/OutboundProtocols (mcp/shared/dispatcher.py)JSONRPCDispatcherover the existingSessionMessagestream contract — request-ID correlation, per-request task isolation, cancel/progress interception, exception→wire boundaryServerRunner[L]per-connection orchestrator;Connection(state, exit_stack, session_id); newContext[L]Server[L]registry:HandlerEntrydataclass,add_request_handler, zero-argcapabilities()DirectDispatcherin-memory pair;ServerMiddleware/DispatchMiddlewareTransportContext.headers;ctx.session_id/ctx.headerspropertiesWhat's coming
Server.run()rewritten to driveJSONRPCDispatcher+ServerRunnerStreamableHTTPSessionManager/SseServerTransportroute throughServerRunnerServerRequestContextcompat shim so the 200+ handler annotations intests/interaction/keep typechecking and the runtime object satisfies the surface_metaparity onsend_raw_requestBaseSession/ServerSession/ the old_handle_*pathSupersedes
#2562 and the four stacked below it. Those stay open as drafts for reference until this is reviewable.
AI Disclaimer