runner
ServerRunner - the per-connection handler kernel.
ServerRunner bridges the dispatch layer (on_request / on_notify, untyped
dicts) and the user's handler layer (typed Context, typed params). It is a
pure kernel: it holds a pre-populated Connection and reads
connection.protocol_version / connection.outbound as facts. Driving a
dispatcher loop and tearing down the connection live in the free-function
drivers (serve_connection, serve_loop, serve_dual_era_loop, serve_one);
the entry constructs the Connection, the driver tears it down.
ServerRunner holds a Server directly - Server is the registry.
aclose_shielded
async
aclose_shielded(connection: Connection) -> None
Unwind connection.exit_stack under a shielded, bounded scope.
Called from a driver's finally: the shield lets per-connection cleanup
callbacks run even when the driver itself is being cancelled, the
_EXIT_STACK_CLOSE_TIMEOUT bound stops a hung callback wedging shutdown,
and a raising callback is logged-and-swallowed so it never masks the
driver's own exception.
Source code in src/mcp/server/runner.py
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | |
modern_error_data
Map a modern request's handler exception to its wire ErrorData.
The exception-to-wire fact shared by the modern entries (the
single-exchange HTTP path and the dual-era stream loop), so an identical
modern request fails identically on every transport: MCPError and
ValidationError map via the shared handler_exception_to_error_data
ladder; anything else is logged server-side and surfaced as a generic
INTERNAL_ERROR so handler internals never reach the wire.
Source code in src/mcp/server/runner.py
481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 | |
modern_on_request
Return an OnRequest callback that serves each call via serve_one with a fresh per-request Connection.
Wire this into the server side of a DirectDispatcher peer-pair to drive an
in-process server on the modern per-request-envelope path (each request
carries protocol version, client info, and capabilities in params._meta;
no initialize handshake). The dispatch context is wrapped in the
server-requests denial, so the modern prohibition on server-initiated
JSON-RPC requests holds on this entry like on the others. Like serve_one,
this raises whatever the handler chain raises - the dispatcher owns the
exception-to-error mapping.
Source code in src/mcp/server/runner.py
765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 | |
serve_connection
async
serve_connection(
server: Server[LifespanT],
dispatcher: Dispatcher[Any],
*,
connection: Connection,
lifespan_state: LifespanT,
init_options: InitializationOptions | None = None,
task_status: TaskStatus[None] = TASK_STATUS_IGNORED
) -> None
Drive dispatcher until the underlying channel closes.
The loop-mode driver: builds the kernel, hands on_request/on_notify
to dispatcher.run(), and tears down connection.exit_stack (shielded)
on the way out. The entry constructs the Connection; this only consumes
it.
Source code in src/mcp/server/runner.py
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 | |
serve_dual_era_loop
async
serve_dual_era_loop(
server: Server[LifespanT],
read_stream: ReadStream[SessionMessage | Exception],
write_stream: WriteStream[SessionMessage],
*,
lifespan_state: LifespanT,
session_id: str | None = None,
init_options: InitializationOptions | None = None,
raise_exceptions: bool = False
) -> None
Drive server over a duplex stream pair, serving both protocol eras.
The stream-pair counterpart of the modern HTTP entry's era router. Era is a property of the connection, decided by how the client opens it, and mid-stream switching is undefined - so the first era-distinctive message to SUCCEED locks the connection (matching the typescript-sdk):
- A successful
initializelocks legacy: the connection behaves exactly likeserve_loopfor its lifetime, and modern envelope traffic is then rejected with INVALID_REQUEST.initializenever routes modern - the method is legacy-distinctive by definition - even when a confused client stamps the envelope triple on it. - A request carrying the modern
_metaenvelope triple - orserver/discover, a modern-only method - is classified (classify_inbound_request) and served single-exchange viaserve_onewith a born-ready per-requestConnection, the same dispatch model as the modern HTTP entry. The first such request to succeed locks the connection modern; a laterinitializeis then rejected with UNSUPPORTED_PROTOCOL_VERSION naming the modern versions.
Modern connections push notifications over the duplex pipe but refuse
server-initiated requests on both channels (the modern protocol forbids
them). A request that fails - rejected classification, malformed envelope
content, unknown method - never locks either era, so a failed probe
leaves the legacy handshake available: released auto-negotiating clients
fall back on any error code except -32022, and that code is only emitted
for genuine version negotiation or for initialize on an
already-modern connection.
The modern lock commits on the FIRST client-visible success frame the
classified request produces - a request-scoped notification (progress and
the subscriptions/listen ack ride those) or its result - immediately
before that frame is written, with no checkpoint in between. One
definition for every method: a plain request locks at its response
exactly as before, and a streaming request (subscriptions/listen, whose
response arrives only at close) locks at its ack, so a pipelined
initialize behind the ack can never hijack a live stream back to
legacy. For the inline methods (initialize, server/discover) the
dispatch completes before the next frame is read, so the canonical
probe-then-go flow is race-free; a pinned-modern client that pipelines
frames ahead of its first response should expect envelope-less
notifications sent in that window to be dropped. The lock settles exactly
once: a request from the other era that was already in flight when the
lock committed may still complete and its response stands, but the era
does not move; and a peer cancel observed before the first frame's write
begins prevents the lock (the commit re-checks cancel_requested at fire
time).
One residual corner is accepted, deliberately: the lock commits
immediately before the frame's transport write, and that write's own
checkpoint is the first point a pending peer cancel can land - so a
cancel arriving DURING the write cancels the frame away after the lock
already committed, leaving the connection locked modern with ZERO frames
delivered. Only mid-handler frames (the listen ack, progress) have this
window; a plain request's response cannot be cancelled away (its
in-flight entry is removed before the response write starts, with no
checkpoint in between, so a peer cancel no longer reaches it). The
converse exists too: a handler that shields its own send past a pending
cancel delivers a frame WITHOUT the lock - era_settles declined at fire
time - but that frame is output the handler forced after the cancel said
stop, not a state this loop produces. The
orphaned lock is self-consistent and client-recoverable: only a
validly-classified modern envelope reaches this path, so the peer already
committed to modern; a follow-up initialize is answered with
UNSUPPORTED_PROTOCOL_VERSION (-32022) naming the modern versions, which
released auto-negotiating clients treat as modern evidence and re-probe;
and the connection keeps serving modern traffic. Closing the corner would
require committing the lock only after the write is known delivered,
which reopens the worse race this design exists to prevent (an
initialize pipelined behind an ack the client already read, flipping a
live stream legacy).
Classified-modern requests are also governed by the 2026 cancellation
rule for their whole lifetime: after an inbound notifications/cancelled
the server sends nothing further for that request - no result, no error
(the transport spec's MUST NOT). Once the connection is LOCKED modern the
rule widens from classification-scoped to connection-scoped: every
request - including one rejected before classification succeeds
(envelope-less, malformed envelope) - carries the silence commitment, so
a rejection error computed after the peer's cancel never reaches the wire
(the rejection itself, absent a cancel, is of course still answered).
Legacy-era requests keep the byte-identical "Request cancelled" answer
released 2025 clients expect - including a legacy-classified request
still in flight when the modern lock commits (the straggler carve-out
covers its cancel answer, not just its response) - as does a request that
fails classification on a still-unlocked connection: an unclassifiable
request has not proven modern semantics, so the legacy answer stands
there.
Source code in src/mcp/server/runner.py
498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 | |
serve_loop
async
serve_loop(
server: Server[LifespanT],
read_stream: ReadStream[SessionMessage | Exception],
write_stream: WriteStream[SessionMessage],
*,
lifespan_state: LifespanT,
session_id: str | None = None,
init_options: InitializationOptions | None = None,
raise_exceptions: bool = False
) -> None
Drive server in handshake-only loop mode over a stream pair until the channel closes.
Builds the loop-mode JSONRPCDispatcher + Connection and hands them to
serve_connection. The streamable-HTTP manager (which owns its lifespan
and serves the modern era on the single-exchange entry instead) calls
this; Server.run drives serve_dual_era_loop, which extends the same
dispatcher recipe (notably the inline_methods={"initialize"} rule) with
era routing.
Source code in src/mcp/server/runner.py
418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 | |
serve_one
async
serve_one(
server: Server[LifespanT],
dctx: DispatchContext[TransportContext],
method: str,
params: Mapping[str, Any] | None,
*,
connection: Connection,
lifespan_state: LifespanT
) -> dict[str, Any]
Handle a single request (method, params) and return its result dict.
The single-exchange driver: builds the kernel, runs on_request once under
dctx, and tears down connection.exit_stack (shielded) on the way out.
The entry constructs the (born-ready) Connection and the dctx; this
only consumes them.
Raises whatever the handler chain raises (MCPError / ValidationError /
unmapped); callers own the exception-to-wire mapping.
Source code in src/mcp/server/runner.py
739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 | |
ServerRunner
dataclass
Bases: Generic[LifespanT]
Per-connection handler kernel. One instance per client connection.
Source code in src/mcp/server/runner.py
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 | |
init_options
class-attribute
instance-attribute
init_options: InitializationOptions | None = None
InitializeResult payload. Defaults to server.create_initialization_options().
Classes
ServerMiddleware— Context-tier middleware:(ctx, call_next) -> result.ServerRunner— Per-connection handler kernel. One instance per client connection.
Functions
aclose_shielded— Unwindconnection.exit_stackunder a shielded, bounded scope.modern_error_data— Map a modern request's handler exception to its wireErrorData.modern_on_request— Return anOnRequestcallback that serves each call viaserve_onewith a fresh per-requestConnection.serve_connection— Drivedispatcheruntil the underlying channel closes.serve_dual_era_loop— Driveserverover a duplex stream pair, serving both protocol eras.serve_loop— Driveserverin handshake-only loop mode over a stream pair until the channel closes.serve_one— Handle a single request(method, params)and return its result dict.
Attributes
CallNext— Invokes the rest of the chain. Pass thectxthrough; rewritemethodorparamswithdataclasses.replace(ctx, ...)to alter what the handler sees.