Skip to content

feat: ServerRunner — per-connection orchestrator#2491

Draft
maxisbey wants to merge 3 commits intomaxisbey/v2-peer-connection-contextfrom
maxisbey/v2-server-runner
Draft

feat: ServerRunner — per-connection orchestrator#2491
maxisbey wants to merge 3 commits intomaxisbey/v2-peer-connection-contextfrom
maxisbey/v2-server-runner

Conversation

@maxisbey
Copy link
Copy Markdown
Contributor

@maxisbey maxisbey commented Apr 22, 2026

Design doc: https://gist.github.com/maxisbey/1e14e741d774acf52b80e69db292c5d7

Stacked on #2460. ServerRunner is the per-connection orchestrator that bridges the dispatcher layer (raw (dctx, method, params) -> dict) and the user's handler layer (typed Context, validated params).

Motivation and Context

One ServerRunner per client connection. It handles the initialize handshake (populates Connection), gates requests until initialized (ping exempt), looks up the handler in the server's registry, validates params, builds Context, runs the middleware chain, and returns the result dict. run() composes dispatch_middleware and drives dispatcher.run().

Consumes any ServerRegistry Protocol — the lowlevel Server satisfies it via four additive methods (get_request_handler, get_notification_handler, .middleware, .connection_lifespan) so the existing Server.run() path is unaffected. Nothing is wired in until PR6 swaps Server.run() over.

Two-tier middleware:

  • dispatch_middleware: list[DispatchMiddleware] on ServerRunner — wraps raw _on_request, sees everything including initialize/METHOD_NOT_FOUND. Includes otel_middleware (mirrors the existing span shape).
  • Server.middleware: list[ContextMiddleware[L]] — runs inside _on_request after validate/ctx-build; wraps registered handlers only.

ContextMiddleware is a contravariant Protocol[L] so Server[L].middleware is properly typed: app-specific middleware sees ctx.lifespan: L; reusable ContextMiddleware[object] registers on any Server via contravariance. Context is covariant in both params (#2460's last commit) so Context[L, ST] <: Context[object, TransportContext] and the chain composes without casts.

How Has This Been Tested?

tests/server/test_runner.py — 14 tests over DirectDispatcher + a real lowlevel Server. Initialize handshake, init-gate, METHOD_NOT_FOUND, stateless, _on_notify, both middleware tiers + ordering, run() end-to-end, otel_middleware pass-through. 92% coverage on runner.py; remaining gaps (otel target/_meta branches, _dump_result BaseModel/TypeError) to follow.

Breaking Changes

None. Server gains additive attrs only; the existing run() path is untouched.

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

Additional context

Stack:

  1. feat: add Dispatcher Protocol and DirectDispatcher #2452 — Dispatcher Protocol + DirectDispatcher
  2. feat: JSONRPCDispatcher #2458 — JSONRPCDispatcher
  3. feat: PeerMixin, BaseContext, Connection, server Context #2460 — PeerMixin / BaseContext / Connection / server Context
  4. (this PR) ServerRunner
  5. (future) Tasks rebuild
  6. (future) Swap Server.run()ServerRunner

Scaffolding (marked TODO, cleaned up in PR6): _PARAMS_FOR_METHOD static lookup until the registry stores params types; placeholder version negotiation in _handle_initialize. connection_lifespan enter-late plumbing is deferred — Server.connection_lifespan is None today and the dance (TG + dispatcher_done event raced with connection.initialized) lands when PR6 adds the registration API.

AI Disclaimer

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