Skip to content

[v2] Server registry + per-connection state; ServerRunner consumes Server[L] directly#2562

Draft
maxisbey wants to merge 3 commits intomaxisbey/v2-server-runnerfrom
maxisbey/v2-swap
Draft

[v2] Server registry + per-connection state; ServerRunner consumes Server[L] directly#2562
maxisbey wants to merge 3 commits intomaxisbey/v2-server-runnerfrom
maxisbey/v2-swap

Conversation

@maxisbey
Copy link
Copy Markdown
Contributor

@maxisbey maxisbey commented May 8, 2026

PR5 of the V2 server-side refactor (Transport → Dispatcher → ServerRunner → Server registry). Stacked on #PR4 (maxisbey/v2-server-runner).

Status: WIP — chunks A+B landed, chunk C (additive new-path entry points) in progress. Opening as draft so the registry/typing changes are reviewable while the rest lands.

Motivation and Context

ServerRunner (PR4) needs to consume the lowlevel Server as a typed handler registry rather than via the old _handle_request callback path. This PR reshapes Server so the runner can read (params_type, handler) entries directly, and adds the per-connection scaffolding (state, exit_stack, session_id, headers) that the new Context exposes.

The Server generics were settled at Server[LifespanResultT] only — no transport generic, no connection-state generic. A transport generic makes Server invariant in a way that breaks bare def deploy(server: Server) plumbing helpers; the connection-state generic doesn't earn its place when stateless is the default deployment. Both remain additive later via PEP 696 trailing defaults if demand appears. The transport-layer types (Dispatcher[TT_co], DispatchContext[TT], BaseContext[TT]) keep their generic; the server layer consumes base TransportContext.

What's in here so far

  • HandlerEntry[L] frozen dataclass replaces bare callables in the registry; public add_request_handler / add_notification_handler; zero-arg capabilities() (notification_options/experimental_capabilities are now ctor kwargs)
  • ServerRunner reads Server[L] directly — the PR4 ServerRegistry Protocol scaffold is gone
  • Connection.session_id, Connection.state: dict[str, Any], Connection.exit_stack: AsyncExitStack (unwound shielded by ServerRunner.run() after the dispatcher closes — handlers/middleware register per-connection teardown with stdlib enter_async_context/push_async_callback)
  • TransportContext.headers: Mapping[str, str] | None on the base; ctx.session_id / ctx.headers convenience properties on Context
  • JSONRPCDispatcher coerces string response/progress IDs to int for correlation (matches BaseSession and the TS SDK)

Still to come in this PR

Additive only — nothing in the existing Server.run() / _handle_* / ServerSession path is touched.

  • OutboundMiddleware on JSONRPCDispatcher + otel_outbound_middleware (span + W3C _meta inject on send_raw_request, mirroring BaseSession.send_request)
  • stdio.serve(server) + StdioTransportContext — new-path stdio entry for lowlevel users
  • Compat properties on lowlevel Context (.lifespan_context/.session proxy) so the high-level mcpserver.Context works on either backend — temporary parity scaffolding
  • MCPServerNext sibling class routing through ServerRunner for the parametrized e2e parity suite (next PR)

How Has This Been Tested?

tests/server/test_runner.py end-to-end over DirectDispatcher; new tests for state persistence, exit_stack unwound on close/error, session_id/headers round-trip. tests/shared/test_jsonrpc_dispatcher.py for the ID coercion. 100% coverage on touched files.

Breaking Changes

None yet. This PR is additive; the old runtime path is untouched. The eventual breaking changes (delete _handle_*/ServerSession/BaseSession) are deferred until the parity suite proves equivalence.

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

AI Disclaimer

maxisbey added 3 commits May 7, 2026 17:42
…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.
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