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.
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.
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.
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.
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.
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.
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.
_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.
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.
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.
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.
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])
- 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())
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.