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