From 98bd125dddafeb57534a44eef4ad600469df8811 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:42:21 +0000 Subject: [PATCH 1/2] Serve the 2026-07-28 protocol over stdio by deciding the era from the opening message The 2026-07-28 protocol did not work over stdio: subscriptions/listen was hard-refused, a legacy initialize arriving during an in-flight modern request was accepted and re-locked the connection, and a peer cancel produced a trailing "Request cancelled" frame. All three share one root cause: the connection's era was derived from which requests had completed instead of being decided once, in wire order, from how the client opened the connection. Replace serve_dual_era_loop and serve_loop with a single serve_stream driver that decides the era synchronously in the dispatcher's read loop, before the request body is spawned. initialize (or any envelope-less request) opens the legacy era, an enveloped request opens the modern era, server/discover is answered without pinning, and a stray leading notification opens nothing. A conflicting era claim on a committed connection is refused (-32022 or -32600) rather than silently switching. Cancel silence is structural: each request answers through a one-shot channel whose write target becomes a powerless void on a peer cancel, so there is no cancelled-check at any write site. The generic JSON-RPC dispatcher loses the code-0 cancel frame, the code-0 str(exc) catch-all, and the inline_methods knob, and documents that handlers are invoked synchronously in receive order with the returned awaitable as the body. Add a Posture enum (DUAL default, LEGACY_ONLY, MODERN_ONLY) on the Server and MCPServer constructors, honoured by the stream driver and the streamable-HTTP manager alike. Server.run(read, write) now stands alone, Server.lifespan() is a bound context manager, and serve_listener / newline_json_transport / close_subscriptions() give a straightforward path for custom transports. See docs/migration.md for the full list of observable changes. --- docs/advanced/low-level-server.md | 46 +- docs/client/subscriptions.md | 2 +- docs/handlers/subscriptions.md | 32 +- docs/migration.md | 130 +++- docs/run/legacy-clients.md | 30 +- docs/run/opentelemetry.md | 4 +- docs/servers/prompts.md | 6 +- docs/whats-new.md | 4 +- .../mcp_everything_server/server.py | 16 +- .../mcp_simple_pagination/server.py | 2 +- .../simple-prompt/mcp_simple_prompt/server.py | 2 +- .../mcp_simple_resource/server.py | 2 +- .../simple-tool/mcp_simple_tool/server.py | 2 +- .../__main__.py | 6 +- examples/snippets/servers/lowlevel/basic.py | 6 +- .../lowlevel/direct_call_tool_result.py | 6 +- .../snippets/servers/lowlevel/lifespan.py | 6 +- .../servers/lowlevel/structured_output.py | 6 +- examples/stories/_hosting.py | 2 +- examples/stories/legacy_routing/README.md | 15 +- examples/stories/legacy_routing/server.py | 26 +- examples/stories/serve_one/README.md | 9 +- examples/stories/serve_one/client.py | 2 +- examples/stories/serve_one/server.py | 8 +- src/mcp/client/_memory.py | 10 +- src/mcp/client/client.py | 10 +- src/mcp/server/__init__.py | 13 +- src/mcp/server/__main__.py | 2 +- src/mcp/server/connection.py | 123 +-- src/mcp/server/lowlevel/server.py | 70 +- src/mcp/server/mcpserver/server.py | 43 +- src/mcp/server/runner.py | 377 +-------- src/mcp/server/serving.py | 360 +++++++++ src/mcp/server/sse.py | 4 +- src/mcp/server/stdio.py | 71 +- src/mcp/server/streamable_http.py | 31 +- src/mcp/server/streamable_http_manager.py | 21 +- src/mcp/server/subscriptions.py | 14 +- src/mcp/shared/dispatcher.py | 94 +-- src/mcp/shared/inbound.py | 198 +++-- src/mcp/shared/jsonrpc_dispatcher.py | 425 +++++----- tests/client/test_client.py | 13 +- tests/client/test_session.py | 7 +- tests/client/test_streamable_http.py | 2 +- tests/docs_src/test_prompts.py | 8 +- tests/interaction/_requirements.py | 56 +- .../interaction/lowlevel/test_cancellation.py | 53 +- tests/interaction/lowlevel/test_tools.py | 8 +- tests/interaction/mcpserver/test_prompts.py | 23 +- .../transports/test_hosting_http_modern.py | 20 +- tests/server/mcpserver/test_server.py | 33 + tests/server/test_cancel_handling.py | 39 +- tests/server/test_completion_with_context.py | 7 +- tests/server/test_http_orphan_and_posture.py | 322 ++++++++ tests/server/test_lifespan.py | 73 +- tests/server/test_runner.py | 616 +-------------- tests/server/test_serving.py | 723 ++++++++++++++++++ tests/server/test_stdio.py | 180 ++++- tests/server/test_stdio_ordering.py | 477 ++++++++++++ tests/server/test_streamable_http_manager.py | 10 +- tests/server/test_streamable_http_modern.py | 4 +- tests/shared/test_cancel_race.py | 638 ++++++++++++++++ tests/shared/test_inbound.py | 43 +- tests/shared/test_jsonrpc_dispatcher.py | 429 ++++++++--- tests/shared/test_streamable_http.py | 8 +- 65 files changed, 4284 insertions(+), 1744 deletions(-) create mode 100644 src/mcp/server/serving.py create mode 100644 tests/server/test_http_orphan_and_posture.py create mode 100644 tests/server/test_serving.py create mode 100644 tests/server/test_stdio_ordering.py create mode 100644 tests/shared/test_cancel_race.py diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index dd5fb427a2..df3d5513f8 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -187,7 +187,51 @@ Each of these is one idea you now have the vocabulary for; each has its own page * `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**). * `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives. * `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](../handlers/subscriptions.md)** for the full composition. -* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. +* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream)` drives one connection over a pair of duplex streams (stdio, a socket you framed, an in-memory pair in a test), and that one line is the whole story. Every other hosting shape is the next section. + +## Serving over your own transport + +`server.run(read_stream, write_stream)` takes any duplex message-stream pair, so a transport is just a source of those two streams. There are four rungs, from the shortest to the most explicit; take the highest one that fits your channel. + +**One connection over a stream pair: `server.run(read, write)`.** stdio is `stdio_server()`, an in-memory pair in a test is the same call. The lifespan is entered for you: one connection, one call. + +**Many connections from a byte-stream listener: `serve_listener(server, listener)`.** A whole Unix-socket server is: + +```python +import anyio + +from mcp.server import serve_listener + + +async def main() -> None: + listener = await anyio.create_unix_listener("/tmp/bookshop.sock") + await serve_listener(server, listener) +``` + +`serve_listener` enters the server's lifespan **once**, then frames and serves every connection the listener accepts, and closes the listener when it is cancelled. That is the difference from calling `server.run()` per accepted connection, which would open the lifespan (your database pool, your caches) again for each socket. + +One shared-server behaviour to know before you copy this: open `subscriptions/listen` streams belong to the server's `ListenHandler`, not to a connection, so when one connection's input ends its listen streams are closed gracefully together with those of **every** connection this server is serving; each affected client receives its listen request's result and re-listens. It never bites on stdio (one connection per process), but behind a listener it does. Until listen streams are owned per connection, a host whose departing peer must not touch its neighbours' subscriptions builds a `Server` per accepted connection, each with its own `ListenHandler(bus)`, all driven by `serve_stream(..., lifespan_state=state)` off one entered state (the last rung below); an `MCPServer`, whose one `ListenHandler` is shared, has no such workaround yet. + +**A byte stream you already hold: frame it, then `run`.** `newline_json_transport(byte_stream)` puts the same newline-delimited JSON-RPC wire over any byte stream and yields the pair `run` wants: `async with mcp.server.stdio.newline_json_transport(byte_stream) as (read, write):`, then `server.run(read, write)`. One connection, so the lifespan is again entered for you. + +**Connections that are not a byte-stream listener: enter the lifespan once, drive each one.** A WebSocket adapter that already yields messages, a broker, your own test loop: compose the same pieces yourself. The rule that bites here is the one `serve_listener` hides: enter `server.lifespan()` **once**, and hand its state to `serve_stream` for every connection, so a hundred sockets share one database pool instead of opening a hundred: + +```python +from mcp.server import serve_stream + +async with server.lifespan() as state, anyio.create_task_group() as tg: + async for read_stream, write_stream in your_connections(): + tg.start_soon(lambda r=read_stream, w=write_stream: serve_stream(server, r, w, lifespan_state=state)) +``` + +The lifespan is entered **outside** the task group on purpose: on the way out the task group joins the still-running connection tasks first and only then does the lifespan tear down, so the shared pool outlives every connection using it (the other order tears the pool down while connections are still being served). + +Two things about the shared-`Server` shape that the single-connection rungs never surface: + +* Which protocol eras a server offers is `Server(posture=...)` (`Posture.DUAL` by default, `Posture.MODERN_ONLY`, `Posture.LEGACY_ONLY`); it rides along on the server object, so none of these drivers takes a posture argument you could forget. +* The cross-connection listen behaviour called out under `serve_listener` above applies to any shared `Server`, including this rung; the per-connection-`Server` recipe there is how you sidestep it. + +An `MCPServer` reaches every one of these through `mcp.lowlevel_server`, and `mcp.close_subscriptions()` (or `Server.close_subscriptions()` down here) ends the open listen streams gracefully from the server's side. ## Recap diff --git a/docs/client/subscriptions.md b/docs/client/subscriptions.md index fc7d01308d..f328c8b2ab 100644 --- a/docs/client/subscriptions.md +++ b/docs/client/subscriptions.md @@ -57,7 +57,7 @@ The order is the point. Nothing is replayed, so an event published before your s Requests run freely beside an open stream, from the watcher task or any other, on the same client. Because *duplicate* unconsumed events coalesce, a busy main flow may produce one refetch rather than three. Events that differ do not coalesce: a filter naming many URIs queues one pending event per URI. -To stop watching, leave the block: there is no `unsubscribe` call. Cancelling the task that owns the block does that for you, and the SDK cancels the listen request the way the transport expects: over streamable HTTP, by closing that request's stream. A watcher that runs for the life of your app never returns on its own, so cancel it, or its task group's scope, at shutdown. +To stop watching, leave the block: there is no `unsubscribe` call. Cancelling the task that owns the block does that for you, and the SDK cancels the listen request the way the transport expects: over streamable HTTP by closing that request's stream, and over stdio (or any duplex stream) by sending `notifications/cancelled` for the listen request's id, after which a server built on this SDK writes nothing further for it. A watcher that runs for the life of your app never returns on its own, so cancel it, or its task group's scope, at shutdown. ## Streams end diff --git a/docs/handlers/subscriptions.md b/docs/handlers/subscriptions.md index 85b9632786..11e59a679c 100644 --- a/docs/handlers/subscriptions.md +++ b/docs/handlers/subscriptions.md @@ -52,12 +52,19 @@ Two things the stream is *not*: handler on the low-level `Server` and acknowledge a smaller filter than the client asked for; the acknowledgment is how the client learns what it actually got. -!!! warning "Streamable HTTP only, for now" - `subscriptions/listen` needs a transport that can stream a request's response, which today - means streamable HTTP. Over stdio a 2026-07-28 connection rejects the method with - METHOD_NOT_FOUND, even though `server/discover` advertises the subscription capabilities - there. Serving it over stdio is planned; the open-stream semantics for that transport are - not built yet. +!!! note "Same handler, every transport" + A subscription is a request that stays in flight, so `subscriptions/listen` is served the + same way over stdio (or any duplex stream) as over streamable HTTP: the acknowledgment is + the first frame, events follow, and closing the stream sends the empty result. On stdio a + client ends a subscription by sending `notifications/cancelled` for the listen request + id, and the server sends nothing further for that id. + + That silence is this SDK's reading, and not every SDK reads it the same way: the Go and C# + listen handlers return the empty result once the client cancels, so a server built on + them may write the listen request's result as a final frame after your cancel. A client + written against this SDK never notices, because a result arriving for a request it + already ended is a late response and is dropped. If you hand-roll a client, expect that + trailing result from those servers and ignore it. ## The client end @@ -109,19 +116,20 @@ mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) The bus carries typed `ServerEvent` values, four small dataclasses, never JSON-RPC. Stamping, filtering, and stream lifecycles stay in the SDK, so a bus implementation cannot break the protocol. It can only move events between processes. -To publish from outside a request, construct the bus yourself so you hold the reference. `MCPServer` builds one internally when you pass nothing, and does not expose it. +To publish from outside a request, use the bus the server owns. `MCPServer` builds one when you pass nothing, and exposes it as `mcp.subscriptions`: ```python -from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged +from mcp.server.subscriptions import ToolsListChanged -bus = InMemorySubscriptionBus() -mcp = MCPServer("Sprint Board", subscriptions=bus) +mcp = MCPServer("Sprint Board") async def tools_reloaded() -> None: - await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere + await mcp.subscriptions.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere ``` +When you want the streams to end from your side (a clean shutdown, an event source going away), `mcp.close_subscriptions()` closes every open stream gracefully: each one drains what it had buffered and then receives the listen request's result as its final frame, which is the spec's way of saying the server ended the subscription deliberately, and the connection carries on. Without it, streams end when their client cancels them or disconnects. + ## The low-level composition Down on the low-level `Server` there is no pre-wired anything, and the same parts assemble in three lines: @@ -132,7 +140,7 @@ Down on the low-level `Server` there is no pre-wired anything, and the same part * You own the bus, so you publish to it directly: `await bus.publish(ResourceUpdated(uri=...))`. Put it wherever your handlers can reach it: module scope here, the lifespan in a bigger app. * `ListenHandler(bus)` is the same handler `MCPServer` registers, and `on_subscriptions_listen=` is an ordinary handler slot. Put your own callable in that slot for different semantics, and the spec obligations move to you: acknowledge first, stamp every frame with the subscription id, deliver nothing outside the filter. -* `ListenHandler.close()` ends every open stream gracefully. Each one receives the listen request's result as its final frame, which is the spec's way of saying the server ended the subscription deliberately. It returns before those streams finish flushing, so give them a moment before you tear the transport down. Without it, streams end when the client disconnects. +* `ListenHandler.close()` ends every open stream gracefully, and `Server.close_subscriptions()` is the same verb on the server that registered the handler. Each stream drains its buffered events and then receives the listen request's result as its final frame, which is the spec's way of saying the server ended the subscription deliberately. The call returns before those streams finish flushing, so give them a moment before you tear the transport down yourself. Over stdio you rarely need to: when the client's input ends, the driver closes your open streams for you inside a short bounded window, in which each stream's final result is guaranteed to reach the departing peer and any events it still had buffered flush as time allows. Without any close, streams end when the client disconnects. ## Recap diff --git a/docs/migration.md b/docs/migration.md index 2f2594c4c9..180c7eb15f 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -982,7 +982,7 @@ async def handle_set_logging_level(level: str) -> None: mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage] ``` -In v2, the lowlevel `Server` supports arbitrary request handlers directly via `add_request_handler` (the decorator methods are gone; handlers are otherwise constructor-only). From `MCPServer`, access it via `_lowlevel_server`: +In v2, the lowlevel `Server` supports arbitrary request handlers directly via `add_request_handler` (the decorator methods are gone; handlers are otherwise constructor-only). From `MCPServer`, reach it through the `lowlevel_server` property: **After (v2):** @@ -1001,11 +1001,11 @@ async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestPa return EmptyResult() -mcp._lowlevel_server.add_request_handler("logging/setLevel", SetLevelRequestParams, handle_set_logging_level) # pyright: ignore[reportPrivateUsage] -mcp._lowlevel_server.add_request_handler("resources/subscribe", SubscribeRequestParams, handle_subscribe) # pyright: ignore[reportPrivateUsage] +mcp.lowlevel_server.add_request_handler("logging/setLevel", SetLevelRequestParams, handle_set_logging_level) +mcp.lowlevel_server.add_request_handler("resources/subscribe", SubscribeRequestParams, handle_subscribe) ``` -`_lowlevel_server` is private and may change. A public way to register these handlers on `MCPServer` is planned; until then, use this workaround or use the lowlevel `Server` directly. +`lowlevel_server` is the low-level `Server` the `MCPServer` is built on; use it, or build a lowlevel `Server` directly. ## Lowlevel Server @@ -1198,7 +1198,7 @@ If you prefer the convenience of automatic wrapping, use `MCPServer` which still ### Lowlevel `Server`: tool handler exceptions no longer become `CallToolResult(is_error=True)` -The v1 `@server.call_tool()` decorator caught any exception raised by the handler and returned it to the client as an error-flagged tool result (`isError: true`), so the calling LLM saw the error text as a tool result and could self-correct. In v2, `on_call_tool` is registered with no exception wrapping: a non-`MCPError` exception propagates to the dispatcher and is answered as a top-level JSON-RPC **error response** with `code=0` and `message=str(exc)`. Typical clients (including the SDK's own) raise on a protocol error instead of returning a result, so the error text is no longer LLM-visible. The server also logs a `handler for 'tools/call' raised` traceback that v1 never emitted. +The v1 `@server.call_tool()` decorator caught any exception raised by the handler and returned it to the client as an error-flagged tool result (`isError: true`), so the calling LLM saw the error text as a tool result and could self-correct. In v2, `on_call_tool` is registered with no exception wrapping: a non-`MCPError` exception propagates to the dispatcher and is answered as a top-level JSON-RPC **error response**, `-32603` with the fixed message `"Internal server error"` (the exception text goes to the server log, never the wire; see [Handler exceptions no longer leak their text](#handler-exceptions-no-longer-leak-their-text-raise-mcperror-to-say-something-to-the-client)). Typical clients (including the SDK's own) raise on a protocol error instead of returning a result, so the error is no longer LLM-visible. The server also logs a `handler for 'tools/call' raised` traceback that v1 never emitted. **Before (v1):** @@ -1326,6 +1326,65 @@ The `stateless: bool` parameter on the lowlevel `Server.run()` has been removed. Server-initiated requests that have no channel to travel on now raise `NoBackChannelError` (an `MCPError` subclass) — the same exception regardless of why the channel is absent. In v1 there was no dedicated exception for this case: the transport silently dropped the outbound message and the awaiting call stalled. +### `Server.run(read, write)` stands alone; `Server.lifespan()` replaces `server.lifespan(server)` + +The `initialization_options` argument to `Server.run` is now optional and defaults to `create_initialization_options()`, so the third argument every example carried is gone: + +```python +# before +await server.run(read_stream, write_stream, server.create_initialization_options()) + +# after +await server.run(read_stream, write_stream) +``` + +Passing `InitializationOptions` explicitly still works when you want to shape the handshake result by hand. `server.run(read_stream, write_stream)` is the one call that serves a single connection over any duplex stream pair (stdio, a framed socket, an in-memory pair in a test). + +`Server.lifespan` is now a bound method that enters the lifespan you passed to the constructor: `async with server.lifespan() as state:`. The old attribute held the raw callable, so callers wrote the self-referential `server.lifespan(server)`; that spelling is gone (calling the method with an argument is a `TypeError`). + +`MCPServer` also exposes the low-level `Server` it is built on as `mcp.lowlevel_server`, so the stream drivers (`Server.run`, `serve_listener`, `serve_stream`) and `add_request_handler` are reachable from a high-level server without private attribute access. The graceful listen-stream close lives on the low-level `Server` too: `Server.close_subscriptions()` ends every open `subscriptions/listen` stream the server's `ListenHandler` holds, and `MCPServer.close_subscriptions()` delegates to it. + +### `serve_loop` and `serve_dual_era_loop` replaced by `serve_stream` + +The two exported stream drivers in `mcp.server.runner` are replaced by one: `mcp.server.serve_stream(server, read_stream, write_stream)`. It enters the server lifespan for you (pass `lifespan_state=` if several connections share a lifespan you entered yourself) and decides the connection's protocol era from the client's opening message, then serves that era for the connection's lifetime. `Server.run(read_stream, write_stream)` is a thin wrapper over it, and `MCPServer.run("stdio")` is unchanged. + +If you drove a server over a custom transport with `serve_loop(...)` or `serve_dual_era_loop(...)`, a single connection is now the two-argument `Server.run`, and `serve_stream` is the rung under it for connections that share a lifespan you entered yourself: + +```python +# before +from mcp.server.runner import serve_dual_era_loop + +async with server.lifespan(server) as state: + await serve_dual_era_loop(server, read_stream, write_stream, lifespan_state=state) + +# after: one connection, lifespan entered for you +await server.run(read_stream, write_stream) + +# after: several connections sharing one lifespan +from mcp.server import serve_stream + +async with server.lifespan() as state: # once + await serve_stream(server, read_stream, write_stream, lifespan_state=state) # per connection +``` + +The concrete dispatcher's `inline_methods=` constructor keyword is also gone: the "hold the read loop for this method" rule now lives with the caller, as the `hold` field of the `Admission` its `admit=` gate returns (the SDK's own admissions state `hold=(method == "initialize")`; see [Implementing your own `Dispatcher`](#implementing-your-own-dispatcher-handlers-are-admitted-synchronously-in-receive-order)). + +`server/discover` no longer pins the connection to the modern era: it is a probe, answered with modern semantics, and the era is decided by the first non-probe message. A client whose discover probe was answered can therefore still fall back to `initialize` on the same connection, and `subscriptions/listen` is now served over stdio and every other duplex stream (previously refused with `-32601`). + +A stray leading notification (for example a client's `notifications/roots/list_changed`) no longer decides the era: only `initialize` (or a bare pre-handshake request) opens the legacy era, only `notifications/initialized` opens it among notifications, and any other early notification is ignored, so a 2026 client is never pinned to the legacy era by a courtesy frame it sent first. A legacy-committed connection also now refuses a request that carries the modern envelope with `-32600` instead of processing it under legacy semantics, and `initialize` is answered `-32022` (naming the modern versions the server serves) whenever it reaches the modern side, on every transport. + +The new `posture` constructor parameter on `Server` and `MCPServer` narrows which eras a server offers, on stream transports and streamable HTTP alike: + +```python +from mcp.server import Posture, Server + +server = Server("app", posture=Posture.MODERN_ONLY) # or Posture.LEGACY_ONLY; Posture.DUAL is the default +``` + +A `MODERN_ONLY` server answers a 2025 handshake with `-32022` and its supported versions (on stdio and streamable HTTP); a `LEGACY_ONLY` server refuses envelope-bearing requests in legacy vocabulary and never enters the modern era. Posture is the one place a server restricts its eras; the walkthrough is in [Serving one era only](run/legacy-clients.md#serving-one-era-only). + +At stdin EOF a stdio server now winds down gracefully: it gives in-flight requests a short bounded window to finish, ends every open `subscriptions/listen` stream with its empty `SubscriptionsListenResult` (the spec's graceful closure), and then writes nothing further onto a stdout nobody reads. In particular, an in-flight request no longer receives a `CONNECTION_CLOSED` error at EOF, since the peer that would read it is gone. + ### Lowlevel `Server`: `request_context` property removed The `server.request_context` property has been removed. Request context is now passed directly to handlers as the first argument (`ctx`). The `request_ctx` module-level contextvar has been removed entirely. @@ -1643,9 +1702,9 @@ except MCPError as e: Behavior changes: -- **Callbacks and notifications now run concurrently.** In v1 the receive loop processed one inbound message at a time, so callbacks ran inline and in order. Now each delivery starts in arrival order but runs as its own task. Server-initiated request callbacks (`sampling`, `elicitation`, `roots`) no longer block other traffic, may themselves send requests without deadlocking, and are interrupted if the server sends `notifications/cancelled` (the request is then answered with an error). Notification callbacks (`logging_callback`, `progress_callback`, `message_handler`) may interleave, and a `progress_callback` may run after the request it reports on has returned; there is no built-in bound on concurrent deliveries. Transport-level errors reach `message_handler` the same way, and a `message_handler` that raises is logged rather than fatal to the session. Callbacks that need strict sequencing must coordinate themselves. +- **Callbacks and notifications now run concurrently.** In v1 the receive loop processed one inbound message at a time, so callbacks ran inline and in order. Now each delivery starts in arrival order but runs as its own task. Server-initiated request callbacks (`sampling`, `elicitation`, `roots`) no longer block other traffic, may themselves send requests without deadlocking, and are interrupted if the server sends `notifications/cancelled` (no response is written for the cancelled request; see [A cancelled request receives no response](#a-cancelled-request-receives-no-response)). Notification callbacks (`logging_callback`, `progress_callback`, `message_handler`) may interleave, and a `progress_callback` may run after the request it reports on has returned; there is no built-in bound on concurrent deliveries. Transport-level errors reach `message_handler` the same way, and a `message_handler` that raises is logged rather than fatal to the session. Callbacks that need strict sequencing must coordinate themselves. - **Timeouts**: a timed-out or abandoned request is now followed by `notifications/cancelled`, so the server stops the handler instead of leaving it running. -- **A raising request callback** is answered with `code=0` and the exception text; v1 flattened every callback exception to `INVALID_PARAMS`. For a specific error response, return `ErrorData` (unchanged) or raise `MCPError`. One carve-out: pydantic's `ValidationError` is still answered with `INVALID_PARAMS`, as in v1. +- **A raising request callback** is answered with the generic internal error, code `-32603` and message `"Internal server error"`, with the exception's own text kept in the log rather than sent to the server; v1 flattened every callback exception to `INVALID_PARAMS`. For a specific error response, return `ErrorData` (unchanged) or raise `MCPError`. One carve-out: pydantic's `ValidationError` is still answered with `INVALID_PARAMS`, as in v1. - **`send_request` before entering the context manager** raises `RuntimeError` immediately; v1 wrote to the transport and hung until the timeout. After the connection has closed it raises `MCPError` (`CONNECTION_CLOSED`) instead. `send_notification` before entry still works. - **`send_notification` after the connection has closed is dropped with a debug log instead of raising.** In v1 the send raised `anyio.BrokenResourceError` (peer gone) or `anyio.ClosedResourceError` (session torn down), and this applied to the typed helpers (`send_roots_list_changed`, `send_progress_notification`) too. Code that used the exception as its disconnect signal should probe with a request instead (`send_request` still raises `MCPError` after close, see above) or scope the sending task to the session's lifetime. - **`send_notification` no longer takes `related_request_id`, and `send_request` no longer accepts `ServerMessageMetadata`.** No client transport ever serialized these hints; progress and response correlation via `progressToken` and the request id is unaffected. @@ -1653,6 +1712,12 @@ Behavior changes: `mcp.shared.session` is now a compatibility module: `ProgressFnT` is re-exported (its home is `mcp.shared.dispatcher`), and `RequestResponder` remains as a typing-only stub so `MessageHandlerFnT` annotations keep importing. `RequestResponder.respond()` no longer exists, and neither do the cancellation-tracking members (`cancel()`, the `cancelled` and `in_flight` properties, the `on_complete` constructor argument) or `BaseSession._in_flight`; inbound cancellation is handled by `JSONRPCDispatcher`. +### Implementing your own `Dispatcher`: handlers are admitted synchronously, in receive order + +This only concerns code that implements the `mcp.shared.dispatcher.Dispatcher` protocol itself (a custom transport engine handed to `ClientSession(dispatcher=)` or driving a server seat), not code that merely uses one. The `run(on_request, on_notify, ...)` contract now states how the handlers are invoked: **synchronously, in receive order, one message at a time**, with the awaitable the handler returns being the request body that then runs concurrently in its own task. In other words your receive loop calls `on_request(ctx, method, params)` itself, in wire order, and only afterwards schedules the awaitable it got back; it must not spawn a task that makes the call for it. Everything a caller needs to decide per connection in message order (the stream driver deciding a connection's protocol era from its opening message is the motivating case) happens in that synchronous call, and the ordering guarantee rests on the receive loop being sequential rather than on any task-scheduler behaviour. + +Handlers you are *passed* need no change: an `async def` handler has no synchronous part beyond creating its coroutine, so calling it in the loop costs nothing and satisfies the contract. The obligation is on implementers, and it is a semantic one carried by the docstring rather than the signature: a handler that does slow synchronous work before returning its awaitable now stalls your read loop, not just its own task. This is not an MCP invention; JSON-RPC engines expose the same receive-order pre-dispatch seam, for example the `Preempter` hook in Go's `jsonrpc2` package, which handles a message on the connection before it is queued to the main handler and is where that stack's cancellation lives. The concrete `JSONRPCDispatcher` also grew an optional `admit=` gate (`mcp.shared.dispatcher.Admit`) on the same seam: it returns an `Admission(handler, hold)` before the request's task exists, and `hold=True` parks the read loop until that request has answered (the `initialize` handshake uses it), so a wrapper around the concrete dispatcher can pick a handler per request without touching the protocol. + ### Experimental Tasks support removed Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp_types` as types-only definitions, except the `TaskExecutionMode` alias, whose literal is now inlined on `ToolExecution.task_support`. @@ -2072,6 +2137,55 @@ Results returned from server handlers are now validated against the negotiated p In v1, a request for a method the SDK didn't recognize failed request-union validation and was answered with `-32602` (`"Invalid request parameters"`, empty `data`). Any method the receiver doesn't serve — unrecognized on either side, or a spec method the server has no registered handler for — is now answered with the JSON-RPC-specified `-32601` (`"Method not found"`), with the method name in `data`, in every initialization state. Clients still decline sampling, elicitation, and roots requests with `-32600` when no callback is registered, as in v1. Update anything that matched on the old code for this case. +### A cancelled request receives no response + +When a peer sends `notifications/cancelled` for an in-flight request, the receiving side interrupts the handler and now writes **no** response for that id. Previously it wrote an error `{"code": 0, "message": "Request cancelled"}`, which the spec forbids ("SHOULD NOT" respond in general; over stdio, "MUST NOT send any further messages"). This applies to both seats, since it lives in the shared dispatcher: a server whose client cancels a request, and a client whose server cancels a server-initiated request. + +Callers are unaffected in practice: cancelling through a cancel scope or a request timeout already stops waiting for the response and sends the courtesy `notifications/cancelled` itself. The one pattern that changes is a test or tool that sends `notifications/cancelled` by hand while a separate task keeps awaiting the same call; that await now never completes, so cancel the awaiting task rather than waiting for an error. Over the legacy streamable-HTTP transport, the server also ends the cancelled request's response stream, so a JSON-mode POST for a peer-cancelled request completes as an empty `202 Accepted` instead of lingering until the client disconnects, and the client's transport resolves a still-pending await locally with a synthesized error (`INVALID_REQUEST`, "server answered a request with 202 Accepted", on the JSON-mode path; `CONNECTION_CLOSED` when an SSE-mode stream ends without a response). + +A cancel that races an already-computed answer keeps the answer: once a handler has returned, its result is committed and written even if `notifications/cancelled` arrives while the write is pending, which the spec allows ("MAY ignore" a cancel whose processing has completed). The one exception is the legacy streamable-HTTP JSON-mode path just described, where the transport ends the request's response stream on the cancel; if that closure lands first, the committed answer has nowhere to go and the POST still completes as the empty `202`. Both outcomes are spec-legal, since the peer withdrew the request. + +### Handler exceptions no longer leak their text; raise `MCPError` to say something to the client + +An exception a request handler raises that the SDK has no mapping for (anything other than `MCPError`, or the `ValidationError` the params check raises) is now answered with the JSON-RPC internal error, code `-32603`, message `"Internal server error"`, on **both** eras and both seats; the exception's own text goes to the receiver's log and never reaches the wire. Previously the legacy path answered with the non-standard code `0` and `str(exc)` as the message. + +If your handler relied on that to send a message to the client, raise `MCPError` deliberately instead; its code and message pass through untouched: + +```python +# before: leaked as {"code": 0, "message": "Please select a database first"} +raise ValueError("Please select a database first") + +# after: the message travels as a proper protocol error +from mcp import MCPError +from mcp_types import INVALID_PARAMS + +raise MCPError(code=INVALID_PARAMS, message="Please select a database first") +``` + +`MCPServer` does this for you on its own prompt errors: an unknown prompt, a missing required argument, or a failed render is now answered `-32602` (Invalid params) carrying `MCPServer`'s message (`"Unknown prompt: nope"`, `"Missing required arguments: {'name'}"`, ...), where it previously came through as code `0`. The same change is visible in-process: `MCPServer.get_prompt()` now raises `MCPError` for these cases where it raised `ValueError`, so an `except ValueError` around a direct call must become `except MCPError` (the message is unchanged). + +### Custom transports over a socket: `newline_json_transport` and `serve_listener` + +If you drive a server over a raw byte channel (a Unix or TCP socket, an in-process pipe), you no longer need to hand-roll the newline-delimited framing. `mcp.server.stdio.newline_json_transport(byte_stream)` wraps any anyio byte stream in the stdio wire format and yields the `(read_stream, write_stream)` pair the drivers consume, and `mcp.server.serve_listener(server, listener)` serves every connection an anyio `Listener` accepts over that wire: + +```python +import anyio +from mcp.server import serve_listener + +listener = await anyio.create_unix_listener("/tmp/mcp.sock") +await serve_listener(server, listener) +``` + +`serve_listener` enters the server lifespan once for all connections and closes the listener when cancelled. Use it (or `serve_stream(..., lifespan_state=)` over a lifespan you entered yourself) rather than calling `server.run(...)` per accepted connection, which would re-enter the lifespan for each one. + +One caveat when several connections share a `Server`: open `subscriptions/listen` streams are owned by the server's `ListenHandler`, not by a connection, so when one connection's input ends and the driver closes the handler's streams gracefully, the streams of every connection sharing that server end with it. It never bites on stdio (one connection per process). Behind a listener, if a departing peer must not close its neighbours' subscriptions, build a `Server` with its own `ListenHandler(bus)` per accepted connection and drive each with `serve_stream(..., lifespan_state=)` off one shared state; per-connection subscription ownership is the planned fix that retires this recipe. + +### Modern requests need only the required `_meta` envelope pair + +The per-request envelope now matches the spec: `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities` are required, and `io.modelcontextprotocol/clientInfo` is optional. A modern request that omits `clientInfo` is classified and routed as modern instead of being mistaken for legacy traffic. + +Two caveats survive until the ecosystem catches up. First, the vendored 2026-07-28 params models still mark `clientInfo` as required until the schema is re-generated from the current specification, so a pair-only spec method is routed modern (the connection's era proves it) and then still fails the typed-validation step with `-32602`; custom methods with your own params model are unaffected. Second, the Go SDK still enforces the full triple on the servers it ships and answers a pair-only request with `-32602 missing or invalid _meta field "io.modelcontextprotocol/clientInfo"`, so keep sending `clientInfo` when a Go server may be on the other end. This SDK's `Client` always sends the triple, so both caveats only bite hand-built requests. + ### Every outbound request now carries a `_meta` envelope; OpenTelemetry is on by default v2 sends `"_meta": {}` in the params of every request it emits, at every negotiated protocol version. Requests that had no params in v1, such as `ping` and `tools/list`, now carry `"params": {"_meta": {}}`; server-initiated requests get the same envelope. This is spec-valid and accepted by all peers, but wire traffic differs from v1 on every call, and no configuration restores the v1 wire shape. Update any test or tooling that asserts on raw outbound request bytes. @@ -2129,7 +2243,7 @@ from mcp.shared.memory import create_client_server_memory_streams async with create_client_server_memory_streams() as (client_streams, server_streams): async with anyio.create_task_group() as tg: - tg.start_soon(lambda: server.run(*server_streams, server.create_initialization_options())) + tg.start_soon(lambda: server.run(*server_streams)) async with ClientSession(*client_streams) as session: await session.initialize() ... diff --git a/docs/run/legacy-clients.md b/docs/run/legacy-clients.md index c7a1096db6..0bcc1f6886 100644 --- a/docs/run/legacy-clients.md +++ b/docs/run/legacy-clients.md @@ -9,10 +9,14 @@ The SDK routes every request by its `MCP-Protocol-Version` header. A request nam So a legacy client is not something you build *for*. It is something that connects *to* the server you already wrote. You configure nothing. !!! note - Nothing, literally. There is no `legacy=` option, no version allowlist, no way to reject or - disable an era: not on `streamable_http_app()`, not on `run()`, not on the session manager. - Both eras are always on. The nearest thing to a per-era switch in that signature is - `stateless_http`, and it is most of this page. + Nothing on the transport, literally. There is no `legacy=` option and no version + allowlist on `streamable_http_app()`, `run()`, or the session manager; by default both + eras are always on, and the version header routes each request to the right one. The one + place a server can restrict eras is its own posture: + `MCPServer(name, posture=Posture.LEGACY_ONLY)` (or `MODERN_ONLY`) is a constructor + property every transport honours (see **[Serving one era only](#serving-one-era-only)**), + and the default `DUAL` is this page's assumption. The nearest thing to a per-request + switch in the HTTP signature is `stateless_http`, and it is most of this page. ## One handler, both eras @@ -110,9 +114,25 @@ Over HTTP, neither call reaches the other era's clients. To tell everyone, call Two lines, no `if`, no version check, and you are done. That is the entire list of things a handler does differently because a legacy client exists. +## Serving one era only + +Everything above assumes the default: your server offers both eras. That default is a constructor property called the server's **posture**, and it is the one place you narrow it: + +```python +from mcp.server import Posture +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("Bookshop", posture=Posture.MODERN_ONLY) +``` + +`Posture.DUAL` is the default and the whole rest of this page. `Posture.MODERN_ONLY` retires the handshake era: a pre-2026 client's `initialize` is answered with a `-32022` error naming the modern versions the server does speak, so a well-behaved client learns to go modern rather than being silently dropped. `Posture.LEGACY_ONLY` is the mirror image, a server that speaks only the `initialize` handshake and refuses requests carrying the modern envelope. The low-level `Server` takes the same argument. + +Posture lives on the server object, not on any driver, so `run()`, `streamable_http_app()`, and the stream drivers all honour the same declaration and there is no per-transport switch to keep in step. Reach for it deliberately: `MODERN_ONLY` turns away every deployed pre-2026 client, and `LEGACY_ONLY` turns away 2026 ones, so `DUAL` is what you want unless you know exactly who connects. + ## Recap -* One `streamable_http_app()` serves both protocol eras. The SDK routes each request by its `MCP-Protocol-Version` header; there is nothing to configure and no era knob to look for. +* One `streamable_http_app()` serves both protocol eras. The SDK routes each request by its `MCP-Protocol-Version` header; there is nothing to configure on the transport and no per-request era knob to look for. +* The one era switch is the server's posture: `MCPServer(name, posture=Posture.MODERN_ONLY)` or `LEGACY_ONLY`, a constructor property every transport honours. The default `DUAL` is what this whole page assumes. * A legacy client costs you a session: an in-process `Mcp-Session-Id` record with no distributed store behind it. More than one worker means **sticky routing**, or the wrong worker answers `404 Session not found`. **[Deploy & scale](deploy.md)** has the multi-worker story. * `stateless_http=True` is the one knob, and it is **legacy-leg-only**. It buys free load balancing for legacy clients at the price of both server-to-client channels on that leg: server-initiated requests raise `NoBackChannelError` (a top-level error at the client, not an `is_error` result), and notifications are dropped. * A `2026-07-28` connection is sessionless either way. `stateless_http` never touches it. diff --git a/docs/run/opentelemetry.md b/docs/run/opentelemetry.md index c36f5ceaa4..eeae9cc4ec 100644 --- a/docs/run/opentelemetry.md +++ b/docs/run/opentelemetry.md @@ -83,8 +83,8 @@ emits no spans, take it off: ```python from mcp.server._otel import OpenTelemetryMiddleware -mcp._lowlevel_server.middleware[:] = [ - m for m in mcp._lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware) +mcp.lowlevel_server.middleware[:] = [ + m for m in mcp.lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware) ] ``` diff --git a/docs/servers/prompts.md b/docs/servers/prompts.md index c49860dfd6..b62db7eb2a 100644 --- a/docs/servers/prompts.md +++ b/docs/servers/prompts.md @@ -56,14 +56,14 @@ That is the entire life of a prompt: listed by name, rendered on demand, dropped !!! check `required` is enforced before your function runs. Render `review_code` without `code` and the - request itself fails with a JSON-RPC error (code `-32603`): + request itself fails with a JSON-RPC error (code `-32602`, Invalid params) that says why: ```text - mcp.shared.exceptions.MCPError: Internal server error + mcp.shared.exceptions.MCPError: Missing required arguments: {'code'} ``` There is no tool-style error result to hand back to a model, because no model is in the loop: - the call raises. The reason (`Missing required arguments: {'code'}`) lands in your server's log. + the call raises, and the reason travels in the error itself. ### Try it diff --git a/docs/whats-new.md b/docs/whats-new.md index 9c7a9be911..6e806b1618 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -153,7 +153,7 @@ Each of these is a section in the **[Migration Guide](migration.md)**: ## The protocol: 2025-11-25 to 2026-07-28 -v2 implements the 2026-07-28 revision, and it serves **both** revisions at once: the same `streamable_http_app()` (and the same stdio server) answers a 2025-era client's `initialize` and a 2026-era client's requests with nothing to configure, no flag to flip, and no separate deployment. Serving the new revision does not strand a client on the old one. What follows is what the new revision itself changes. +v2 implements the 2026-07-28 revision, and it serves **both** revisions at once: the same `streamable_http_app()` (and the same stdio server) answers a 2025-era client's `initialize` and a 2026-era client's requests with nothing to configure, no flag to flip, and no separate deployment. Serving the new revision does not strand a client on the old one. If you want to serve only one revision, that is a single constructor property, `Server(posture=...)` / `MCPServer(posture=...)`, honoured by every transport; see [Serving one era only](run/legacy-clients.md#serving-one-era-only). What follows is what the new revision itself changes. ### No handshake, no session @@ -191,7 +191,7 @@ That file is the pitch in one place: one server, one `Resolve`-backed tool, and ### Change notifications become one stream -At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. On the client (since `2.0.0b2`), `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver. One honest caveat: over stdio the server does not serve the stream yet. +At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box, over streamable HTTP and stdio alike (a listen stream is just a request that stays in flight, so it rides any duplex stream); you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. On the client (since `2.0.0b2`), `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver. **[Subscriptions](handlers/subscriptions.md)** covers publishing and serving, **[its Clients twin](client/subscriptions.md)** the watching end, and **[Deploy & scale](run/deploy.md)** the bus. diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index 4b03421f44..fc17287918 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -710,9 +710,7 @@ async def test_input_required_result_prompt(ctx: Context) -> list[UserMessage] | ) -# Custom request handlers -# TODO(felix): Add public APIs to MCPServer for subscribe_resource, unsubscribe_resource, -# and set_logging_level to avoid accessing protected _lowlevel_server attribute. +# Custom request handlers, registered on the low-level Server underneath. async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult: """Handle logging level changes""" logger.info(f"Log level set to: {params.level}") @@ -733,15 +731,9 @@ async def handle_unsubscribe(ctx: ServerRequestContext, params: UnsubscribeReque return EmptyResult() -mcp._lowlevel_server.add_request_handler( # pyright: ignore[reportPrivateUsage] - "logging/setLevel", SetLevelRequestParams, handle_set_logging_level -) -mcp._lowlevel_server.add_request_handler( # pyright: ignore[reportPrivateUsage] - "resources/subscribe", SubscribeRequestParams, handle_subscribe -) -mcp._lowlevel_server.add_request_handler( # pyright: ignore[reportPrivateUsage] - "resources/unsubscribe", UnsubscribeRequestParams, handle_unsubscribe -) +mcp.lowlevel_server.add_request_handler("logging/setLevel", SetLevelRequestParams, handle_set_logging_level) +mcp.lowlevel_server.add_request_handler("resources/subscribe", SubscribeRequestParams, handle_subscribe) +mcp.lowlevel_server.add_request_handler("resources/unsubscribe", UnsubscribeRequestParams, handle_unsubscribe) @mcp.completion() diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/server.py b/examples/servers/simple-pagination/mcp_simple_pagination/server.py index 9aca87f730..1882791aaf 100644 --- a/examples/servers/simple-pagination/mcp_simple_pagination/server.py +++ b/examples/servers/simple-pagination/mcp_simple_pagination/server.py @@ -169,7 +169,7 @@ def main(port: int, transport: str) -> int: async def arun(): async with stdio_server() as streams: - await app.run(streams[0], streams[1], app.create_initialization_options()) + await app.run(streams[0], streams[1]) anyio.run(arun) diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/server.py b/examples/servers/simple-prompt/mcp_simple_prompt/server.py index 31e3eb7d76..4347cc4039 100644 --- a/examples/servers/simple-prompt/mcp_simple_prompt/server.py +++ b/examples/servers/simple-prompt/mcp_simple_prompt/server.py @@ -91,7 +91,7 @@ def main(port: int, transport: str) -> int: async def arun(): async with stdio_server() as streams: - await app.run(streams[0], streams[1], app.create_initialization_options()) + await app.run(streams[0], streams[1]) anyio.run(arun) diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py index fe9dcfb709..cdd77b3377 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/server.py +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -84,7 +84,7 @@ def main(port: int, transport: str) -> int: async def arun(): async with stdio_server() as streams: - await app.run(streams[0], streams[1], app.create_initialization_options()) + await app.run(streams[0], streams[1]) anyio.run(arun) diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py index b16249e068..f349b2c65e 100644 --- a/examples/servers/simple-tool/mcp_simple_tool/server.py +++ b/examples/servers/simple-tool/mcp_simple_tool/server.py @@ -73,7 +73,7 @@ def main(port: int, transport: str) -> int: async def arun(): async with stdio_server() as streams: - await app.run(streams[0], streams[1], app.create_initialization_options()) + await app.run(streams[0], streams[1]) anyio.run(arun) diff --git a/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py index 393ff7a5a0..61e903c9e7 100644 --- a/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py +++ b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py @@ -79,11 +79,7 @@ async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequ async def run(): """Run the low-level server using stdio transport.""" async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) + await server.run(read_stream, write_stream) if __name__ == "__main__": diff --git a/examples/snippets/servers/lowlevel/basic.py b/examples/snippets/servers/lowlevel/basic.py index ff9b0a2c49..c0c5443d64 100644 --- a/examples/snippets/servers/lowlevel/basic.py +++ b/examples/snippets/servers/lowlevel/basic.py @@ -53,11 +53,7 @@ async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRe async def run(): """Run the basic low-level server.""" async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) + await server.run(read_stream, write_stream) if __name__ == "__main__": diff --git a/examples/snippets/servers/lowlevel/direct_call_tool_result.py b/examples/snippets/servers/lowlevel/direct_call_tool_result.py index 4d6607d2ff..45c598e71f 100644 --- a/examples/snippets/servers/lowlevel/direct_call_tool_result.py +++ b/examples/snippets/servers/lowlevel/direct_call_tool_result.py @@ -52,11 +52,7 @@ async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequ async def run(): """Run the server.""" async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) + await server.run(read_stream, write_stream) if __name__ == "__main__": diff --git a/examples/snippets/servers/lowlevel/lifespan.py b/examples/snippets/servers/lowlevel/lifespan.py index 46db9ecc07..524d92e00a 100644 --- a/examples/snippets/servers/lowlevel/lifespan.py +++ b/examples/snippets/servers/lowlevel/lifespan.py @@ -89,11 +89,7 @@ async def handle_call_tool( async def run(): """Run the server with lifespan management.""" async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) + await server.run(read_stream, write_stream) if __name__ == "__main__": diff --git a/examples/snippets/servers/lowlevel/structured_output.py b/examples/snippets/servers/lowlevel/structured_output.py index 84e411ff55..36626d7f0d 100644 --- a/examples/snippets/servers/lowlevel/structured_output.py +++ b/examples/snippets/servers/lowlevel/structured_output.py @@ -70,11 +70,7 @@ async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequ async def run(): """Run the structured output server.""" async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) + await server.run(read_stream, write_stream) if __name__ == "__main__": diff --git a/examples/stories/_hosting.py b/examples/stories/_hosting.py index f7ef1ad56d..b91287c2f7 100644 --- a/examples/stories/_hosting.py +++ b/examples/stories/_hosting.py @@ -67,7 +67,7 @@ async def _serve_stdio(server: AnyServer) -> None: await server.run_stdio_async() # becomes await serve_stdio(server) else: async with stdio_server() as (read, write): # becomes await serve_stdio(server) - await server.run(read, write, server.create_initialization_options()) + await server.run(read, write) async def _serve_http(server: AnyServer, port: int, path: str) -> None: diff --git a/examples/stories/legacy_routing/README.md b/examples/stories/legacy_routing/README.md index 84a4528c96..7697392146 100644 --- a/examples/stories/legacy_routing/README.md +++ b/examples/stories/legacy_routing/README.md @@ -41,12 +41,15 @@ kill "$SERVER_PID" SDK's built-in router; the predicate itself is exercised as a pure function — see the user-land composition recipe below for wiring it into your own ingress. -- `server.py` `classify_era` — the tri-state wrapper. `InboundModernRoute` → - `"modern"`; rung-1 `INVALID_PARAMS` (no envelope keys) → `"legacy"`; any - other `InboundLadderRejection` is a malformed-modern request to **reject**, - not route to legacy. When headers are supplied, both `Mcp-Protocol-Version` - and `Mcp-Method` must mirror the body — a disagreement (or an unsupported - version) is what produces that third arm; `client.py` shows both. +- `server.py` `classify_era` — the tri-state wrapper. A body with no envelope + claim (`parse_envelope()` returns `None`: `initialize` and other claim-less + requests) → `"legacy"`; a body that claims the modern era runs the full + ladder, so `InboundModernRoute` → `"modern"` and any `InboundLadderRejection` + is a malformed-modern request to **reject**, not route to legacy (a present + claim is never silently downgraded). When headers are supplied, both + `Mcp-Protocol-Version` and `Mcp-Method` must mirror the body — a disagreement + (or an unsupported version) is what produces that third arm; `client.py` + shows both. - `server.py` `build_app` — `streamable_http_app()` + `CORSMiddleware`. The `which_arm` tool reads `ctx.request_context.protocol_version` to prove which path the built-in router took. diff --git a/examples/stories/legacy_routing/server.py b/examples/stories/legacy_routing/server.py index 79cc2afa67..9d7430c8ed 100644 --- a/examples/stories/legacy_routing/server.py +++ b/examples/stories/legacy_routing/server.py @@ -1,15 +1,14 @@ """Exported era classifier: the body-primary predicate, the built-in dual-era app, and CORS — exports `build_app()`.""" from collections.abc import Mapping -from typing import Any, Literal +from typing import Any, Literal, cast -from mcp_types import INVALID_PARAMS from mcp_types.version import MODERN_PROTOCOL_VERSIONS from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware from mcp.server.mcpserver import Context, MCPServer -from mcp.shared.inbound import InboundLadderRejection, InboundModernRoute, classify_inbound_request +from mcp.shared.inbound import InboundLadderRejection, InboundModernRoute, classify_inbound_request, parse_envelope from stories._hosting import NO_DNS_REBIND, run_app_from_args #: Response headers a browser-based MCP client must be able to read. @@ -23,18 +22,21 @@ def classify_era( body: Mapping[str, Any], headers: Mapping[str, str] ) -> Literal["modern", "legacy"] | InboundLadderRejection: - """Tri-state era classifier built on the exported `classify_inbound_request` predicate. + """Tri-state era classifier built on the exported envelope primitives. Compose this in your own ASGI/ingress layer when the two eras need different - backends. Only a rung-1 ``INVALID_PARAMS`` rejection (no envelope keys) means - "treat as legacy"; other rejections are malformed-modern and should be refused. + backends. A body with no envelope claim (`parse_envelope` returns None) is + 2025-era traffic - `initialize` and other claim-less requests - so route it to + the legacy backend; a body that claims the modern era is validated by the full + ladder, and anything the ladder rejects is malformed-modern and should be refused + (never silently downgraded to legacy). """ - verdict = classify_inbound_request(body, headers=headers) - if isinstance(verdict, InboundModernRoute): - return "modern" - if verdict.code == INVALID_PARAMS: - return "legacy" - return verdict + raw_params = body.get("params") + params = cast(Mapping[str, Any], raw_params) if isinstance(raw_params, Mapping) else None + if body.get("method") != "initialize" and parse_envelope(params) is not None: + verdict = classify_inbound_request(body, headers=headers) + return "modern" if isinstance(verdict, InboundModernRoute) else verdict + return "legacy" def build_app() -> Starlette: diff --git a/examples/stories/serve_one/README.md b/examples/stories/serve_one/README.md index dd75486164..761ddfff10 100644 --- a/examples/stories/serve_one/README.md +++ b/examples/stories/serve_one/README.md @@ -36,14 +36,13 @@ uv run python -m stories.serve_one.client - **Deep imports** — `serve_one`, `serve_connection`, and `Connection` are only reachable at `mcp.server.runner` / `mcp.server.connection` today; a shorter `mcp.server.*` re-export is tracked for beta. -- **Lowlevel-only.** The drivers take a `lowlevel.Server` and `MCPServer` has - no public accessor for its underlying one (`_lowlevel_server` is private), so - there is no `MCPServer`-tier variant of this story. Build the lowlevel - `Server` directly until that accessor lands. +- **Lowlevel-only.** The drivers take a `lowlevel.Server`; an `MCPServer` + reaches them through `mcp.lowlevel_server`, but this story builds the lowlevel + `Server` directly to keep the wiring visible. - **No public `DispatchContext`** — `SingleExchangeContext` is hand-rolled boilerplate; a public helper (or a `serve_one` overload that builds one) is tracked for beta. -- **Lifespan** — the transport entry enters `server.lifespan(server)` **once** +- **Lifespan** — the transport entry enters `server.lifespan()` **once** and threads `lifespan_state` to every `handle_one()` call; never enter it per-request. - `ServerRunner` is kernel-internal; never construct it directly. The diff --git a/examples/stories/serve_one/client.py b/examples/stories/serve_one/client.py index 73bd457e10..2dda5f7fb0 100644 --- a/examples/stories/serve_one/client.py +++ b/examples/stories/serve_one/client.py @@ -21,7 +21,7 @@ async def main(target: Target, *, mode: str = "auto") -> None: types.CLIENT_CAPABILITIES_META_KEY: {}, }, } - async with server.lifespan(server) as lifespan_state: + async with server.lifespan() as lifespan_state: raw = await handle_one(server, "tools/call", params, lifespan_state=lifespan_state) assert raw["structuredContent"] == {"result": 5}, raw assert raw["content"][0] == {"type": "text", "text": "5"}, raw diff --git a/examples/stories/serve_one/server.py b/examples/stories/serve_one/server.py index 447e4a82b8..6b57adfce3 100644 --- a/examples/stories/serve_one/server.py +++ b/examples/stories/serve_one/server.py @@ -75,7 +75,7 @@ async def handle_one( Reads the envelope from `params._meta` (the 2026 wire shape), builds a born-ready `Connection.from_envelope`, and drives `serve_one`. The transport - entry enters `server.lifespan(server)` once and threads `lifespan_state` to + entry enters `server.lifespan()` once and threads `lifespan_state` to every call — never enter the lifespan per-request. """ meta = params.get("_meta", {}) @@ -97,11 +97,9 @@ async def handle_one( async def main() -> None: """Serve over stdio by building the dispatcher + Connection by hand (loop mode).""" server = build_server() - async with server.lifespan(server) as lifespan_state: + async with server.lifespan() as lifespan_state: async with stdio_server() as (read_stream, write_stream): - dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( - read_stream, write_stream, inline_methods=frozenset({"initialize"}) - ) + dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read_stream, write_stream) connection = Connection.for_loop(dispatcher) await serve_connection(server, dispatcher, connection=connection, lifespan_state=lifespan_state) diff --git a/src/mcp/client/_memory.py b/src/mcp/client/_memory.py index 187131e380..aeae76e820 100644 --- a/src/mcp/client/_memory.py +++ b/src/mcp/client/_memory.py @@ -42,8 +42,7 @@ async def _connect(self) -> AsyncIterator[TransportStreams]: """Connect to the server and yield streams for communication.""" # Unwrap MCPServer to get underlying Server if isinstance(self._server, MCPServer): - # TODO(Marcelo): Make `lowlevel_server` public. - actual_server: Server[Any] = self._server._lowlevel_server # type: ignore[reportPrivateUsage] + actual_server: Server[Any] = self._server.lowlevel_server else: actual_server = self._server @@ -55,12 +54,7 @@ async def _connect(self) -> AsyncIterator[TransportStreams]: async def _run_server() -> None: try: - await actual_server.run( - server_read, - server_write, - actual_server.create_initialization_options(), - raise_exceptions=self._raise_exceptions, - ) + await actual_server.run(server_read, server_write, raise_exceptions=self._raise_exceptions) finally: server_done.set() diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index e840675011..8290da8dc3 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -109,8 +109,12 @@ async def connect(exit_stack: AsyncExitStack, mode: ConnectMode, raise_exception transport = InMemoryTransport(server, raise_exceptions=raise_exceptions) read_stream, write_stream = await exit_stack.enter_async_context(transport) return JSONRPCDispatcher(read_stream, write_stream) - lifespan_state = await exit_stack.enter_async_context(server.lifespan(server)) - client_disp, server_disp = create_direct_dispatcher_pair(raise_handler_exceptions=raise_exceptions) + lifespan_state = await exit_stack.enter_async_context(server.lifespan()) + # No back-channel: the modern protocol forbids server-initiated requests, + # so the transport context (not a wrapper) carries the denial. + client_disp, server_disp = create_direct_dispatcher_pair( + can_send_request=False, raise_handler_exceptions=raise_exceptions + ) tg = await exit_stack.enter_async_context(anyio.create_task_group()) exit_stack.callback(server_disp.close) on_request = modern_on_request(server, lifespan_state) @@ -380,7 +384,7 @@ def __post_init__(self) -> None: srv = self.server if isinstance(srv, MCPServer): - srv = srv._lowlevel_server # pyright: ignore[reportPrivateUsage] + srv = srv.lowlevel_server if isinstance(srv, Server): self._connect = _connect_inproc(srv) elif isinstance(srv, str): diff --git a/src/mcp/server/__init__.py b/src/mcp/server/__init__.py index 5897e8aa8b..d95a56c49c 100644 --- a/src/mcp/server/__init__.py +++ b/src/mcp/server/__init__.py @@ -3,5 +3,16 @@ from .lowlevel import NotificationOptions, Server from .mcpserver import MCPServer from .models import InitializationOptions +from .serving import Posture, serve_listener, serve_stream -__all__ = ["CacheHint", "Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"] +__all__ = [ + "CacheHint", + "Server", + "ServerRequestContext", + "MCPServer", + "NotificationOptions", + "InitializationOptions", + "Posture", + "serve_listener", + "serve_stream", +] diff --git a/src/mcp/server/__main__.py b/src/mcp/server/__main__.py index 4305b87e22..1610bcf010 100644 --- a/src/mcp/server/__main__.py +++ b/src/mcp/server/__main__.py @@ -17,7 +17,7 @@ async def main() -> None: server: Server[dict[str, object]] = Server("mcp") async with stdio_server() as (read_stream, write_stream): - await server.run(read_stream, write_stream, server.create_initialization_options()) + await server.run(read_stream, write_stream) if __name__ == "__main__": diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index ca05928dff..dbec5d416f 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -1,20 +1,11 @@ """`Connection` - per-client connection state and the standalone outbound channel. -Always present on `Context` (never `None`), even in stateless deployments. -Holds peer info, per-connection scratch `state` and an `exit_stack` for -teardown, and an `Outbound` for the standalone stream (the SSE GET stream in -streamable HTTP, or the single duplex stream in stdio). - -Construct via the factories: `Connection.from_envelope` for the 2026-era -single-exchange path (born ready, no back-channel) and `Connection.for_loop` -for the handshake-driven loop path. Both populate `protocol_version` so the -kernel reads it as a fact. - -`notify` is best-effort: it never raises. If there's no standalone channel -or the stream has been dropped, the notification is debug-logged and silently -discarded - server-initiated notifications are inherently advisory. -`send_raw_request` raises `NoBackChannelError` when there's no channel; `ping` -is the only spec-sanctioned standalone request. +Always present on `Context`, even in stateless deployments: peer info, +per-connection `state`, an `exit_stack` for teardown, and an `Outbound` for +the standalone stream. Construct via `Connection.from_envelope` (modern +single-exchange path) or `Connection.for_loop` (handshake-driven loop path). +`notify` is best-effort and never raises; `send_raw_request` raises +`NoBackChannelError` when there is no channel. """ from __future__ import annotations @@ -55,11 +46,7 @@ ResultT = TypeVar("ResultT", bound=BaseModel) -# Result types for the spec's server-to-client request set, used by -# `Connection.send_request` to infer the result type. If the spec's request -# set grows substantially, consider declaring the result mapping on the -# request types themselves (a `__mcp_result__` ClassVar read via a structural -# protocol) so this table and the overload ladder don't need maintaining. +# Result types for the spec's server-to-client request set; `send_request` infers from this. _RESULT_FOR: dict[type[Request[Any, Any]], type[BaseModel]] = { CreateMessageRequest: CreateMessageResult, ElicitRequest: ElicitResult, @@ -72,13 +59,7 @@ def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None: - """Validate a raw envelope value into a typed model. - - A missing, null or mis-shaped value falls through to `ValidationError` - and is treated as not supplied so the request still routes. Spec methods - are separately re-validated by the kernel's per-version params surface, - which types the reserved `_meta` keys strictly. - """ + """Validate a raw envelope value into `model`; `None` when missing or mis-shaped, so the request still routes.""" try: return model.model_validate(raw, by_name=False) except ValidationError: @@ -94,13 +75,8 @@ def _notification_params(payload: dict[str, Any] | None, meta: Meta | None) -> d class _NoChannelOutbound: - """Connection-scoped `Outbound` for the no-back-channel case. - - The structural answer to "this connection cannot push to its peer": - `send_raw_request` raises `NoBackChannelError`; `notify` drops with a - debug log. `Connection.from_envelope` installs this so the modern - single-exchange path never needs a mode flag - the channel itself says no. - """ + """Connection-scoped `Outbound` for the no-back-channel case: `send_raw_request` + raises `NoBackChannelError`; `notify` drops with a debug log.""" async def send_raw_request( self, @@ -118,13 +94,8 @@ async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Call class NotifyOnlyOutbound(_NoChannelOutbound): - """Connection-scoped `Outbound` that forwards notifications and refuses requests. - - Installed by `serve_dual_era_loop` for modern (2026-07-28+) connections - over duplex stream transports: the pipe is real, so server notifications - ride it, but the modern protocol forbids server-initiated JSON-RPC - requests, so `send_raw_request` (inherited) refuses by construction. - """ + """Connection-scoped `Outbound` for modern (2026-07-28+) duplex-stream connections: + forwards notifications, refuses server-initiated requests (the protocol forbids them).""" def __init__(self, outbound: Outbound) -> None: self._outbound = outbound @@ -136,10 +107,8 @@ async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Call class Connection: """Per-client connection state and standalone-stream `Outbound`. - Construct via `from_envelope` (modern single-exchange: born ready, no - back-channel) or `for_loop` (handshake-driven: ready once the client's - `notifications/initialized` arrives). Either way `protocol_version` is - populated at construction. + Construct via `from_envelope` (born ready) or `for_loop` (handshake-driven); + `protocol_version` is populated at construction. """ outbound: Outbound @@ -218,20 +187,13 @@ def from_envelope( ) -> Connection: """A born-ready connection populated from a request's `_meta` envelope. - `protocol_version` must be an already-validated version string - the - inbound classification ladder owns rejecting non-string or unsupported - values. `client_info` and `client_capabilities` are the raw envelope - values: this constructor owns turning them into connection identity, - identically on every modern entry, so a mis-shaped value degrades to - not-supplied rather than failing the request. `initialized` is set, - well-formed capabilities are recorded as `client_capabilities` (client - info is optional per spec PR #3002, so capability checks never depend on - it), and the full `client_params` is additionally synthesized when - client info was supplied too. `outbound` defaults to the no-channel - sentinel for the single-exchange HTTP path; duplex modern transports - (e.g. stdio) pass a notify-only wrapper around the dispatcher so - server notifications ride the pipe while server-initiated requests - stay refused. + `protocol_version` must already be validated. Well-formed + `client_capabilities` are recorded as `client_capabilities` (client info + is optional per spec PR #3002, so capability checks never depend on it), + and the full `client_params` is additionally synthesized when + well-formed `client_info` was supplied too; mis-shaped values are + treated as not supplied. `outbound` defaults to the no-channel + sentinel; duplex modern transports pass a notify-only wrapper. """ info = _typed(Implementation, client_info) capabilities = _typed(ClientCapabilities, client_capabilities) @@ -255,13 +217,9 @@ def for_loop( session_id: str | None = None, protocol_version_hint: str | None = None, ) -> Connection: - """A connection for the handshake-driven loop path. - - Not born-ready: `initialized` is set later by the kernel when - `notifications/initialized` arrives. `protocol_version` is seeded from - the transport hint (or `LATEST_HANDSHAKE_VERSION`) so it's never `None`; - the handshake overwrites it once negotiated. - """ + """A connection for the handshake-driven loop path: not born-ready; + `protocol_version` is seeded from the hint (or `LATEST_HANDSHAKE_VERSION`) + until the handshake overwrites it.""" return cls( outbound, protocol_version=protocol_version_hint if protocol_version_hint is not None else LATEST_HANDSHAKE_VERSION, @@ -270,14 +228,9 @@ def for_loop( @property def has_standalone_channel(self) -> bool: - """Whether this connection has a real back-channel for server-initiated - messages. Derived from `outbound` - the no-channel sentinel is the only - case that doesn't. - - Channel presence, not request permission: a modern (2026-07-28+) - duplex connection has a channel that carries notifications while - `send_raw_request` still refuses, because the protocol forbids - server-initiated requests.""" + """Whether this connection has a real channel for server-initiated messages + (`False` only for the no-channel sentinel). Presence, not request permission: + a modern duplex connection has a channel yet refuses server-initiated requests.""" return self.outbound is not _NO_CHANNEL @property @@ -293,18 +246,12 @@ async def send_raw_request( params: Mapping[str, Any] | None, opts: CallOptions | None = None, ) -> dict[str, Any]: - """Send a raw request on the standalone stream. - - Low-level `Outbound` channel. Prefer the typed `send_request` or the - convenience methods below; use this directly only for off-spec - messages. `opts` carries per-call `timeout` / `on_progress` / - resumption hints; see `CallOptions`. + """Send a raw request on the standalone stream (low-level `Outbound`; prefer `send_request`). Raises: MCPError: The peer responded with an error. - NoBackChannelError: no back-channel for server-initiated requests - - `has_standalone_channel` is `False`, or a modern (2026-07-28+) - connection, where the protocol forbids them. + NoBackChannelError: No back-channel for server-initiated requests + (`has_standalone_channel` is `False`, or a modern connection). """ return await self.outbound.send_raw_request(method, params, opts) @@ -365,9 +312,8 @@ async def ping(self, *, meta: Meta | None = None, opts: CallOptions | None = Non Raises: MCPError: The peer responded with an error. - NoBackChannelError: no back-channel for server-initiated requests - - `has_standalone_channel` is `False`, or a modern (2026-07-28+) - connection, where the protocol forbids them. + NoBackChannelError: No back-channel for server-initiated requests + (`has_standalone_channel` is `False`, or a modern connection). """ await self.send_raw_request("ping", dump_params(None, meta), opts) @@ -422,10 +368,7 @@ def check_capability(self, capability: ClientCapabilities) -> bool: if k not in have.experimental or have.experimental[k] != v: return False if capability.extensions is not None: - # SEP-2133: an extension is supported when the client declares its - # identifier. Settings are negotiated per-extension (the client may - # advertise more than the server asks for), so presence - not value - # equality - is the meaningful check. + # SEP-2133: presence of the identifier, not value equality, is the check. if have.extensions is None: return False for identifier in capability.extensions: diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 5088c92788..1e7eeb23c8 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -19,16 +19,12 @@ async def my_call_tool(ctx, params): on_call_tool=my_call_tool, ) -3. Run the server: +3. Run the server (stdio here; any duplex stream pair works the same way): async def main(): async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) + await server.run(read_stream, write_stream) - asyncio.run(main()) + anyio.run(main) The Server class dispatches incoming requests and notifications to registered handler callables by method string. @@ -63,13 +59,14 @@ async def main(): from mcp.server.caching import CacheableMethod, CacheHint, validate_cache_hints from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext from mcp.server.models import InitializationOptions -from mcp.server.runner import serve_dual_era_loop +from mcp.server.serving import Posture, serve_stream from mcp.server.streamable_http import EventStore from mcp.server.streamable_http_manager import ( DEFAULT_MAX_REQUEST_BODY_SIZE, StreamableHTTPASGIApp, StreamableHTTPSessionManager, ) +from mcp.server.subscriptions import ListenHandler from mcp.server.transport_security import TransportSecuritySettings from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.exceptions import MCPDeprecationWarning @@ -138,6 +135,7 @@ def __init__( website_url: str | None = None, icons: list[types.Icon] | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, + posture: Posture = Posture.DUAL, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -221,6 +219,7 @@ def __init__( website_url: str | None = None, icons: list[types.Icon] | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, + posture: Posture = Posture.DUAL, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -313,6 +312,7 @@ def __init__( website_url: str | None = None, icons: list[types.Icon] | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, + posture: Posture = Posture.DUAL, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -420,10 +420,12 @@ def __init__( self.instructions = instructions self.website_url = website_url self.icons = icons + # Which protocol eras this server offers, on every transport. + self.posture: Posture = posture # Per-method `ttl_ms`/`cache_scope` fills, applied by `ServerRunner` # after the handler returns; fields the handler set explicitly win. self.cache_hints: dict[str, CacheHint] = validate_cache_hints(cache_hints) - self.lifespan = lifespan + self._lifespan_factory = lifespan self._request_handlers: dict[str, HandlerEntry[LifespanResultT]] = {} self._notification_handlers: dict[str, HandlerEntry[LifespanResultT]] = {} self._session_manager: StreamableHTTPSessionManager | None = None @@ -469,6 +471,15 @@ def __init__( {m: HandlerEntry(pt, h) for m, pt, h in _spec_notifications if h is not None} ) + def lifespan(self) -> AbstractAsyncContextManager[LifespanResultT]: + """Enter the server's lifespan: `async with server.lifespan() as state:`. + + `Server.run` and `serve_listener` enter it for you; call it yourself + only when several connections share it, passing `state` to + `serve_stream(lifespan_state=)`. + """ + return self._lifespan_factory(self) + def add_request_handler( self, method: str, @@ -519,6 +530,17 @@ def get_notification_handler(self, method: str) -> HandlerEntry[LifespanResultT] """Return the registered entry for a notification method, or `None`.""" return self._notification_handlers.get(method) + def close_subscriptions(self) -> None: + """Gracefully close every open `subscriptions/listen` stream this server serves. + + Streams drain and end with the listen request's own result while the + connection carries on; returns before they finish flushing. On a + `Server` shared across connections this ends every connection's streams. + """ + entry = self._request_handlers.get("subscriptions/listen") + if entry is not None and isinstance(entry.handler, ListenHandler): + entry.handler.close() + # TODO(L53): Rethink capabilities API. Currently capabilities are derived from registered # handlers but require NotificationOptions to be passed externally for list_changed # flags, and experimental_capabilities as a separate dict. Consider deriving capabilities @@ -692,30 +714,28 @@ async def run( self, read_stream: ReadStream[SessionMessage | Exception], write_stream: WriteStream[SessionMessage], - initialization_options: InitializationOptions, + initialization_options: InitializationOptions | None = None, # When False, exceptions are returned as messages to the client. # When True, exceptions are raised, which will cause the server to shut down # but also make tracing exceptions much easier during testing and when using # in-process servers. raise_exceptions: bool = False, ) -> None: - """Serve a single connection over the given streams until the read side closes. + """Serve one connection over a duplex message stream until the read side closes. - Thin wrapper over `serve_dual_era_loop`: enters the server lifespan, - then drives the loop, serving the legacy handshake era and the modern - per-request-envelope era (the first era-distinctive message to succeed - locks the connection). Transports with their own lifespan owner (the - streamable-HTTP manager) call `serve_loop` directly instead. + Enters the server's lifespan, lets the client's opening message pick + the connection's protocol era (subject to `self.posture`), and serves + that era until EOF; `initialization_options` defaults to + `create_initialization_options()`. For a socket host use `serve_listener`; + for connections sharing a lifespan, `serve_stream(..., lifespan_state=)`. """ - async with self.lifespan(self) as lifespan_context: - await serve_dual_era_loop( - self, - read_stream, - write_stream, - lifespan_state=lifespan_context, - init_options=initialization_options, - raise_exceptions=raise_exceptions, - ) + await serve_stream( + self, + read_stream, + write_stream, + initialization_options=initialization_options, + raise_exceptions=raise_exceptions, + ) def streamable_http_app( self, diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index c6a75ec925..adddb75e0b 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -84,6 +84,7 @@ from mcp.server.mcpserver.utilities.context_injection import find_context_parameter from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity +from mcp.server.serving import Posture from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server from mcp.server.streamable_http import EventStore @@ -184,6 +185,7 @@ def __init__( request_state_security: RequestStateSecurity | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, subscriptions: SubscriptionBus | None = None, + posture: Posture = Posture.DUAL, ): self._resource_security = resource_security self.settings = Settings( @@ -207,6 +209,7 @@ def __init__( # in-process; pass an `SubscriptionBus` implementation over an external pub/sub # backend to fan events out across replicas. self._subscriptions: SubscriptionBus = subscriptions if subscriptions is not None else InMemorySubscriptionBus() + # The `subscriptions/listen` handler lives on the low-level server; `close_subscriptions()` reaches it there. self._lowlevel_server = Server( name=name or "mcp-server", title=title, @@ -216,6 +219,7 @@ def __init__( icons=icons, version=version, cache_hints=cache_hints, + posture=posture, on_list_tools=self._handle_list_tools, on_call_tool=self._handle_call_tool, on_list_resources=self._handle_list_resources, @@ -293,6 +297,31 @@ def icons(self) -> list[Icon] | None: def version(self) -> str: return self._lowlevel_server.version + @property + def lowlevel_server(self) -> Server[LifespanResultT]: + """The low-level `Server` this instance is built on. + + Hand it to the stream drivers `run()` does not name: + + listener = await anyio.create_unix_listener("/tmp/mcp.sock") + await serve_listener(mcp.lowlevel_server, listener) + """ + return self._lowlevel_server + + @property + def subscriptions(self) -> SubscriptionBus: + """The server's subscription bus; publish here to reach open listen streams: + `await mcp.subscriptions.publish(ToolsListChanged())`.""" + return self._subscriptions + + def close_subscriptions(self) -> None: + """Gracefully close every open `subscriptions/listen` stream (all connections). + + Streams drain their pending events and end with the listen request's + result while the connection carries on; returns before they finish flushing. + """ + self._lowlevel_server.close_subscriptions() + @property def session_manager(self) -> StreamableHTTPSessionManager: """Get the StreamableHTTP session manager. @@ -1015,11 +1044,7 @@ def decorator( async def run_stdio_async(self) -> None: """Run the server using stdio transport.""" async with stdio_server() as (read_stream, write_stream): - await self._lowlevel_server.run( - read_stream, - write_stream, - self._lowlevel_server.create_initialization_options(), - ) + await self._lowlevel_server.run(read_stream, write_stream) async def run_sse_async( # pragma: no cover self, @@ -1108,9 +1133,7 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no # Add client ID from auth context into request context if available async with sse.connect_sse(scope, receive, send) as streams: - await self._lowlevel_server.run( - streams[0], streams[1], self._lowlevel_server.create_initialization_options() - ) + await self._lowlevel_server.run(streams[0], streams[1]) return Response() # Create routes @@ -1290,8 +1313,10 @@ async def get_prompt( except MCPError: raise except Exception as e: + # An unknown prompt, missing argument or failed render is the client's + # request that could not be served: surface it as INVALID_PARAMS. logger.exception(f"Error getting prompt {name}") - raise ValueError(str(e)) from e + raise MCPError(code=INVALID_PARAMS, message=str(e)) from e def _version_gated(method: MethodBinding) -> RequestHandler: diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 98bc133a7e..792c26f4a7 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -1,23 +1,19 @@ """`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. +Bridges the dispatch layer (`on_request` / `on_notify`, untyped dicts) and +the user's handler layer (typed `Context`, typed params). It holds a +pre-populated `Connection` and a `Server` (the handler registry); the drivers +(`serve_stream`, `serve_connection`, `serve_one`) construct the `Connection` +and tear it down. """ from __future__ import annotations import logging from collections.abc import Awaitable, Mapping -from dataclasses import KW_ONLY, dataclass, replace +from dataclasses import KW_ONLY, dataclass from functools import cached_property, partial -from typing import TYPE_CHECKING, Any, Generic, Literal, cast +from typing import TYPE_CHECKING, Any, Generic, cast import anyio import anyio.abc @@ -27,20 +23,16 @@ CORE_RESULT_TYPES, INTERNAL_ERROR, INVALID_PARAMS, - INVALID_REQUEST, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, SERVER_INFO_META_KEY, - UNSUPPORTED_PROTOCOL_VERSION, CacheableResult, ErrorData, Implementation, InitializeRequestParams, InitializeResult, - RequestId, RequestParams, RequestParamsMeta, - UnsupportedProtocolVersionErrorData, ) from mcp_types import methods as _methods from mcp_types.version import ( @@ -53,16 +45,14 @@ from typing_extensions import TypeVar from mcp.server.caching import apply_cache_hint -from mcp.server.connection import Connection, NotifyOnlyOutbound +from mcp.server.connection import Connection from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession -from mcp.shared._stream_protocols import ReadStream, WriteStream -from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, OnNotify, OnRequest -from mcp.shared.exceptions import MCPError, NoBackChannelError -from mcp.shared.inbound import InboundLadderRejection, classify_inbound_request +from mcp.shared.dispatcher import Admission, Admit, DispatchContext, OnNotify, OnRequest +from mcp.shared.exceptions import MCPError from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, handler_exception_to_error_data -from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage +from mcp.shared.message import ServerMessageMetadata from mcp.shared.transport_context import TransportContext if TYPE_CHECKING: @@ -75,8 +65,6 @@ "aclose_shielded", "modern_on_request", "serve_connection", - "serve_dual_era_loop", - "serve_loop", "serve_one", ] @@ -437,106 +425,44 @@ def _handle_initialize(self, params: Mapping[str, Any] | None) -> InitializeResu ) +def _legacy_admission(runner: ServerRunner[Any]) -> Admit: + """The receive-order admission for a handshake-driven connection: every request goes + to the kernel, and only `initialize` holds the read loop until it has answered.""" + + def admit(method: str, params: Mapping[str, Any] | None) -> Admission: + return Admission(runner.on_request, hold=method == "initialize") + + return admit + + async def serve_connection( server: Server[LifespanT], - dispatcher: Dispatcher[Any], + dispatcher: JSONRPCDispatcher[Any], *, connection: Connection, lifespan_state: LifespanT, init_options: InitializationOptions | None = None, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: - """Drive ``dispatcher`` until the underlying channel closes. + """Drive `dispatcher` in handshake mode 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. + The legacy loop driver over a caller-built dispatcher and `Connection`; tears + down `connection.exit_stack` (shielded) on the way out. Stream transports use + `serve_stream`; this is for entries that already own the connection's era. """ runner = ServerRunner(server, connection, lifespan_state, init_options=init_options) try: - await dispatcher.run(runner.on_request, runner.on_notify, task_status=task_status) + await dispatcher.run( + runner.on_request, runner.on_notify, admit=_legacy_admission(runner), task_status=task_status + ) finally: await aclose_shielded(connection) -async def 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. - """ - dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( - read_stream, - write_stream, - raise_handler_exceptions=raise_exceptions, - # Handle `initialize` inline so a client that pipelines it with the - # next request (spec: SHOULD NOT, not MUST NOT) sees the initialized - # state instead of failing the init-gate. - inline_methods=frozenset({"initialize"}), - ) - connection = Connection.for_loop(dispatcher, session_id=session_id) - await serve_connection( - server, dispatcher, connection=connection, lifespan_state=lifespan_state, init_options=init_options - ) - - -def _has_modern_envelope(params: Mapping[str, Any] | None) -> bool: - """Whether `params._meta` carries the reserved protocol-version key. - - Era evidence is the client's explicit version declaration: the - `io.modelcontextprotocol/protocolVersion` key exists only in 2026-07-28+ - envelopes, and the `io.modelcontextprotocol/` prefix is spec-reserved, so - legacy traffic never mints it (bare `_meta` is NOT evidence - legacy - requests carry `progressToken` there). Presence of the version key alone - is the rule, not the full required pair, so a half-built envelope - (version present, capabilities missing) still routes modern and gets the - classifier's INVALID_PARAMS naming the missing key instead of the legacy - path's generic one - and, like every failed classification, locks no era. - """ - if not params: - return False - meta = params.get("_meta") - return isinstance(meta, Mapping) and PROTOCOL_VERSION_META_KEY in meta - - -def _initialize_after_modern_data(params: Mapping[str, Any] | None) -> dict[str, Any]: - """Error data for an `initialize` arriving on a modern-locked connection. - - The typed -32022 payload when the client's proposed version is parseable; - otherwise just the supported list (the point is naming what we serve). - """ - requested = (params or {}).get("protocolVersion") - if isinstance(requested, str): - return UnsupportedProtocolVersionErrorData( - supported=list(MODERN_PROTOCOL_VERSIONS), requested=requested - ).model_dump(mode="json") - return {"supported": list(MODERN_PROTOCOL_VERSIONS)} - - def modern_error_data(exc: Exception) -> ErrorData: - """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. - """ + """Map a modern request's handler exception to its wire `ErrorData` via the shared + `handler_exception_to_error_data` ladder; anything unmapped is logged and becomes a + generic INTERNAL_ERROR so handler internals never reach the wire.""" error = handler_exception_to_error_data(exc) if error is not None: return error @@ -544,224 +470,6 @@ def modern_error_data(exc: Exception) -> ErrorData: return ErrorData(code=INTERNAL_ERROR, message="Internal server error") -@dataclass -class _NoServerRequestsDispatchContext: - """Delegating `DispatchContext` that refuses server-initiated requests. - - Wraps the loop dispatcher's per-message context for modern-era dispatch: - the modern protocol forbids server-initiated JSON-RPC requests, so - `send_raw_request` refuses while notifications and progress still ride - the duplex pipe. - """ - - _inner: DispatchContext[TransportContext] - - @property - def transport(self) -> TransportContext: - # Mask the per-message flag so the transport metadata agrees with this - # wrapper's denial: the modern HTTP entry builds its context with - # can_send_request=False, while the loop's default builder says True. - transport = self._inner.transport - return replace(transport, can_send_request=False) if transport.can_send_request else transport - - @property - def can_send_request(self) -> bool: - return False - - @property - def request_id(self) -> RequestId | None: - return self._inner.request_id - - @property - def message_metadata(self) -> MessageMetadata: - return self._inner.message_metadata - - @property - def cancel_requested(self) -> anyio.Event: - return self._inner.cancel_requested - - async def send_raw_request( - self, - method: str, - params: Mapping[str, Any] | None, - opts: CallOptions | None = None, - ) -> dict[str, Any]: - raise NoBackChannelError(method) - - async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: - await self._inner.notify(method, params, opts) - - async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: - await self._inner.progress(progress, total, message) - - -async def 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 `initialize` locks legacy: the connection behaves exactly - like `serve_loop` for its lifetime, and modern envelope traffic is then - rejected with INVALID_REQUEST. `initialize` never routes modern - the - method is legacy-distinctive by definition - even when a confused - client stamps the envelope keys on it. - - A request whose `_meta` declares the modern protocol version - or - `server/discover`, a modern-only method - is classified - (`classify_inbound_request`) and served single-exchange via `serve_one` - with a born-ready per-request `Connection`, the same dispatch model as - the modern HTTP entry. The first such request to succeed locks the - connection modern; a later `initialize` is 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 era lock rides the request's own dispatch. For the inline methods - (`initialize`, `server/discover`) that 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 success the peer cancelled away - (it sees "Request cancelled", not the result) does not lock either. - """ - dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( - read_stream, - write_stream, - raise_handler_exceptions=raise_exceptions, - # `initialize` inline for the same pipelining reason as `serve_loop`; - # `server/discover` inline so the modern era lock commits before the - # next pipelined message is read. - inline_methods=frozenset({"initialize", "server/discover"}), - ) - loop_connection = Connection.for_loop(dispatcher, session_id=session_id) - loop_runner = ServerRunner(server, loop_connection, lifespan_state, init_options=init_options) - standalone_outbound = NotifyOnlyOutbound(dispatcher) - era: Literal["unlocked", "legacy", "modern"] = "unlocked" - modern_version = LATEST_MODERN_VERSION - - def era_settles(dctx: DispatchContext[TransportContext]) -> bool: - # The one definition of "this request may lock the era": it settled as - # a client-visible success on a still-unlocked connection. The lock is - # monotone - the first success wins, so a straggling request from the - # other era can never overwrite a committed lock. A pending peer - # cancel means the dispatcher is about to replace this response with - # "Request cancelled": the client never sees the success, no lock. - return era == "unlocked" and not dctx.cancel_requested.is_set() - - async def serve_modern( - dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None - ) -> dict[str, Any]: - nonlocal era, modern_version - route = classify_inbound_request({"method": method, "params": params}) - if isinstance(route, InboundLadderRejection): - raise MCPError(code=route.code, message=route.message, data=route.data) - if method == "subscriptions/listen": - # The registered listen handler assumes the HTTP entry's stream - # semantics; served over a stream pair it would wedge. Reject until - # this transport grows its own listen design. - raise MCPError( - code=METHOD_NOT_FOUND, message="subscriptions/listen is not served over this transport", data=method - ) - connection = Connection.from_envelope( - route.protocol_version, - route.client_info, - route.client_capabilities, - outbound=standalone_outbound, - ) - try: - result = await serve_one( - server, - _NoServerRequestsDispatchContext(dctx), - method, - params, - connection=connection, - lifespan_state=lifespan_state, - ) - except (MCPError, ValidationError): - # The dispatcher's shared ladder maps these to the same wire error - # the modern HTTP entry produces. - raise - except Exception as exc: - if raise_exceptions: - raise - error = modern_error_data(exc) - raise MCPError(code=error.code, message=error.message, data=error.data) from exc - if era_settles(dctx): - era, modern_version = "modern", route.protocol_version - return result - - async def on_request( - dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None - ) -> dict[str, Any]: - nonlocal era - if era == "legacy": - if _has_modern_envelope(params): - raise MCPError( - code=INVALID_REQUEST, - message="connection is locked to the legacy handshake era; " - "modern envelope requests are not accepted", - ) - # Bare modern-only methods (e.g. `server/discover`) fall through to - # the loop runner's per-version surface validation - the same - # METHOD_NOT_FOUND a handshake-only server produced, byte for byte. - return await loop_runner.on_request(dctx, method, params) - if era == "modern": - if method == "initialize": - raise MCPError( - code=UNSUPPORTED_PROTOCOL_VERSION, - message="connection already negotiated a modern protocol version", - data=_initialize_after_modern_data(params), - ) - return await serve_modern(dctx, method, params) - # Unlocked. `initialize` is legacy-distinctive by definition (the - # method does not exist at modern versions), so it takes the handshake - # path even when the envelope keys are stamped on it. - if method != "initialize" and (method == "server/discover" or _has_modern_envelope(params)): - return await serve_modern(dctx, method, params) - result = await loop_runner.on_request(dctx, method, params) - if method == "initialize" and era_settles(dctx): - # Lock only on success: a failed handshake leaves both eras open. - era = "legacy" - return result - - async def on_notify(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None: - if era != "modern": - return await loop_runner.on_notify(dctx, method, params) - # The envelope is request-only, so notifications inherit the - # connection's locked version. - connection = Connection.from_envelope(modern_version, None, None, outbound=standalone_outbound) - notify_runner = ServerRunner(server, connection, lifespan_state) - try: - await notify_runner.on_notify(_NoServerRequestsDispatchContext(dctx), method, params) - finally: - await aclose_shielded(connection) - - try: - await dispatcher.run(on_request, on_notify) - finally: - await aclose_shielded(loop_connection) - - async def serve_one( server: Server[LifespanT], dctx: DispatchContext[TransportContext], @@ -791,14 +499,10 @@ async def serve_one( def modern_on_request(server: Server[LifespanT], lifespan_state: LifespanT) -> OnRequest: """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. + Wire this into the server side of a `DirectDispatcher` peer-pair (built + with `can_send_request=False`) to drive an in-process server on the modern + per-request-envelope path. Like `serve_one`, raises whatever the handler + chain raises. """ async def handle( @@ -810,13 +514,6 @@ async def handle( meta.get(CLIENT_INFO_META_KEY), meta.get(CLIENT_CAPABILITIES_META_KEY), ) - return await serve_one( - server, - _NoServerRequestsDispatchContext(dctx), - method, - params, - connection=connection, - lifespan_state=lifespan_state, - ) + return await serve_one(server, dctx, method, params, connection=connection, lifespan_state=lifespan_state) return handle diff --git a/src/mcp/server/serving.py b/src/mcp/server/serving.py new file mode 100644 index 0000000000..ba2d58bfcd --- /dev/null +++ b/src/mcp/server/serving.py @@ -0,0 +1,360 @@ +"""Serving a `Server` over a duplex message stream (stdio, SSE, custom sockets). + +`serve_stream` is the driver for stream transports. The client's opening +messages decide the connection's protocol era, in receive order, among those +`Server(posture=)` offers, and that era serves every later message; `Posture` +names which eras a server offers at all. +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Mapping +from enum import Enum +from typing import TYPE_CHECKING, Any, Generic, Literal + +import anyio +import anyio.abc +from mcp_types import INVALID_REQUEST +from mcp_types.version import LATEST_MODERN_VERSION +from typing_extensions import TypeVar + +from mcp.server.connection import Connection, NotifyOnlyOutbound +from mcp.server.models import InitializationOptions +from mcp.server.runner import ServerRunner, aclose_shielded, serve_one +from mcp.server.stdio import newline_json_transport +from mcp.shared._stream_protocols import ReadStream, WriteStream +from mcp.shared.dispatcher import Admission, DispatchContext +from mcp.shared.exceptions import MCPError +from mcp.shared.inbound import InboundLadderRejection, classify_inbound_request, has_envelope_intent +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.message import MessageMetadata, SessionMessage +from mcp.shared.transport_context import TransportContext + +if TYPE_CHECKING: + from mcp.server.lowlevel.server import Server + +__all__ = ["Posture", "serve_listener", "serve_stream"] + +logger = logging.getLogger(__name__) + +LifespanT = TypeVar("LifespanT", default=Any) + +_EOF_DRAIN_WINDOW: float = 2 +"""Seconds to let in-flight work drain after the peer closes our input.""" + +_HANDSHAKE_NOTIFICATION = "notifications/initialized" +"""The one notification that completes a legacy handshake and so opens the legacy era.""" + + +class Posture(Enum): + """Which protocol eras a server offers on a connection; `DUAL` lets the opening message decide.""" + + DUAL = "dual" + """Both eras: the legacy `initialize` handshake and the modern per-request envelope.""" + + LEGACY_ONLY = "legacy-only" + """Only the legacy `initialize` handshake era.""" + + MODERN_ONLY = "modern-only" + """Only the modern per-request-envelope era (2026-07-28+).""" + + +class _Unset: + """Sentinel type for an omitted `lifespan_state` (`None` is a valid state).""" + + +_UNSET = _Unset() + + +async def _refuse(code: int, message: str, data: Any = None) -> dict[str, Any]: + """The body of a request the connection's era refuses: raising is its whole answer.""" + raise MCPError(code=code, message=message, data=data) + + +def _opening_intent(method: str, params: Mapping[str, Any] | None) -> Literal["legacy", "modern", "probe"]: + """The era an undecided connection's opening request declares; `initialize` is always legacy.""" + if method == "initialize" or not has_envelope_intent(params): + return "legacy" + return "probe" if method == "server/discover" else "modern" + + +class _LegacyEra(Generic[LifespanT]): + """The handshake (2025) era of a stream connection: the loop kernel over the shared dispatcher. + + Refuses envelope-carrying requests (except `initialize`) with `INVALID_REQUEST` + rather than serving an era-ambiguous method under legacy semantics. + """ + + def __init__(self, runner: ServerRunner[LifespanT]) -> None: + self.runner = runner + + def on_request( + self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> Awaitable[dict[str, Any]]: + if method != "initialize" and has_envelope_intent(params): + return _refuse( + INVALID_REQUEST, + "connection speaks the legacy handshake era; modern per-request-envelope requests are not accepted", + ) + return self.runner.on_request(dctx, method, params) + + def on_notify( + self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> Awaitable[None]: + return self.runner.on_notify(dctx, method, params) + + +class _ModernEra(Generic[LifespanT]): + """The modern (2026-07-28+) era of a stream connection. + + Every request is a single exchange served through `serve_one` with a born-ready + per-request `Connection`; server-initiated requests are refused. + """ + + def __init__(self, server: Server[LifespanT], lifespan_state: LifespanT, outbound: NotifyOnlyOutbound) -> None: + self._server = server + self._lifespan_state = lifespan_state + self._outbound = outbound + + def on_request( + self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> Awaitable[dict[str, Any]]: + return self._serve(dctx, method, params) + + def on_notify( + self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> Awaitable[None]: + return self._notify(dctx, method, params) + + async def _serve( + self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + route = classify_inbound_request({"method": method, "params": params}) + if isinstance(route, InboundLadderRejection): + raise MCPError(code=route.code, message=route.message, data=route.data) + connection = Connection.from_envelope( + route.protocol_version, route.client_info, route.client_capabilities, outbound=self._outbound + ) + return await serve_one( + self._server, dctx, method, params, connection=connection, lifespan_state=self._lifespan_state + ) + + async def _notify( + self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> None: + # Notifications carry no envelope, so they take the era's (single) modern version. + connection = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=self._outbound) + runner = ServerRunner(self._server, connection, self._lifespan_state) + try: + await runner.on_notify(dctx, method, params) + finally: + await aclose_shielded(connection) + + +async def _ignore_stray_notification() -> None: + """The body of a notification that opened nothing on an undecided connection: it is ignored.""" + + +class _StreamConnection(Generic[LifespanT]): + """One duplex stream connection: admits every message and routes it to the connection's era. + + `_era` starts from the server's posture and, under `DUAL`, is decided once, in + receive order, by the first era-distinctive message the dispatcher admits. + """ + + def __init__( + self, + server: Server[LifespanT], + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], + *, + posture: Posture, + lifespan_state: LifespanT, + init_options: InitializationOptions | None, + session_id: str | None, + raise_exceptions: bool, + ) -> None: + self._server = server + self._dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( + read_stream, + write_stream, + transport_builder=self._transport_context, + raise_handler_exceptions=raise_exceptions, + on_read_eof=self._drain, + ) + loop_connection = Connection.for_loop(self._dispatcher, session_id=session_id) + self._legacy: _LegacyEra[LifespanT] = _LegacyEra( + ServerRunner(server, loop_connection, lifespan_state, init_options=init_options) + ) + self._modern: _ModernEra[LifespanT] = _ModernEra(server, lifespan_state, NotifyOnlyOutbound(self._dispatcher)) + # Posture is consumed here, once: which eras exist for this connection. + starting_era: dict[Posture, _LegacyEra[LifespanT] | _ModernEra[LifespanT] | None] = { + Posture.DUAL: None, + Posture.LEGACY_ONLY: self._legacy, + Posture.MODERN_ONLY: self._modern, + } + self._era: _LegacyEra[LifespanT] | _ModernEra[LifespanT] | None = starting_era[posture] + + def _transport_context(self, _metadata: MessageMetadata) -> TransportContext: + # Admission has already decided this frame's era; only the legacy era + # allows server-initiated requests (the modern protocol forbids them). + return TransportContext(kind="jsonrpc", can_send_request=self._era is self._legacy) + + async def run(self, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED) -> None: + try: + await self._dispatcher.run( + self._on_request, + self._on_notify, + self._admit_notification, + admit=self._admit, + task_status=task_status, + ) + finally: + await aclose_shielded(self._legacy.runner.connection) + + def _admit(self, method: str, params: Mapping[str, Any] | None) -> Admission: + """Admit one request in receive order, deciding the era before its transport context is built. + + `initialize` holds the read loop so requests pipelined behind it see the committed handshake. + """ + self._era_for(method, params) + return Admission(self._on_request, hold=method == "initialize") + + def _era_for(self, method: str, params: Mapping[str, Any] | None) -> _LegacyEra[LifespanT] | _ModernEra[LifespanT]: + """The era that serves this request; the connection's era is decided by its opening request.""" + if self._era is not None: + return self._era + intent = _opening_intent(method, params) + if intent == "probe": + # Answered with modern semantics; the connection stays undecided. + return self._modern + self._era = self._legacy if intent == "legacy" else self._modern + return self._era + + def _admit_notification(self, method: str, params: Mapping[str, Any] | None) -> bool: + """Notification-side sibling of `_admit`: only the bare handshake notification opens + (the legacy) era. Never consumes; `_on_notify` serves the frame afterwards.""" + if self._era is None and method == _HANDSHAKE_NOTIFICATION: + self._era = self._legacy + return False + + def _on_request( + self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> Awaitable[dict[str, Any]]: + return self._era_for(method, params).on_request(dctx, method, params) + + def _on_notify( + self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> Awaitable[None]: + if self._era is None: + # A stray notification on an undecided connection is ignored (the spec's word). + logger.debug("ignored stray notification %s on an undecided connection", method) + return _ignore_stray_notification() + return self._era.on_notify(dctx, method, params) + + async def _drain(self) -> None: + """The peer closed our input: close listen streams, then let in-flight work end + gracefully within `_EOF_DRAIN_WINDOW`, while the output is still up.""" + self._server.close_subscriptions() + with anyio.move_on_after(_EOF_DRAIN_WINDOW, shield=True): + await self._dispatcher.wait_for_in_flight() + + +async def serve_stream( + server: Server[LifespanT], + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], + *, + initialization_options: InitializationOptions | None = None, + lifespan_state: LifespanT | _Unset = _UNSET, + raise_exceptions: bool = False, + task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, +) -> None: + """Serve `server` over a duplex message stream until the read side closes. + + The driver for stream transports: the client's opening messages decide the + connection's era among those `server.posture` offers. Enters + `server.lifespan()` unless `lifespan_state` is given. + + Args: + initialization_options: The legacy handshake's `InitializeResult` + payload; defaults to `server.create_initialization_options()`. + lifespan_state: An already-entered lifespan state, for several + connections sharing one lifespan. + raise_exceptions: Also re-raise handler exceptions out of this call + after the peer has been answered (an in-process testing aid). + """ + if isinstance(lifespan_state, _Unset): + async with server.lifespan() as state: + await serve_stream( + server, + read_stream, + write_stream, + initialization_options=initialization_options, + lifespan_state=state, + raise_exceptions=raise_exceptions, + task_status=task_status, + ) + return + connection = _StreamConnection( + server, + read_stream, + write_stream, + posture=server.posture, + lifespan_state=lifespan_state, + init_options=initialization_options, + session_id=None, + raise_exceptions=raise_exceptions, + ) + await connection.run(task_status=task_status) + + +async def serve_legacy_stream( + server: Server[LifespanT], + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], + *, + lifespan_state: LifespanT, + session_id: str | None = None, +) -> None: + """Serve a stream the transport has already routed to the legacy handshake era. + + Transport-internal (the streamable-HTTP manager's stateful sessions are + born legacy); not part of the author-facing surface. + """ + connection = _StreamConnection( + server, + read_stream, + write_stream, + posture=Posture.LEGACY_ONLY, + lifespan_state=lifespan_state, + init_options=None, + session_id=session_id, + raise_exceptions=False, + ) + await connection.run() + + +async def serve_listener(server: Server[LifespanT], listener: anyio.abc.Listener[anyio.abc.ByteStream]) -> None: + """Serve every connection `listener` accepts, over the stdio wire, until cancelled. + + Enters the server's lifespan once (shared by every connection), frames each + accepted byte stream with `newline_json_transport`, and drives it with + `serve_stream`; takes ownership of `listener` and closes it on the way out. + + listener = await anyio.create_unix_listener("/tmp/mcp.sock") + await serve_listener(server, listener) + + Caveat: `subscriptions/listen` streams belong to the shared `server`, so + one connection's disconnect gracefully ends the open listen streams of + every connection this server is serving. + """ + async with listener, server.lifespan() as lifespan_state: + + async def handle(stream: anyio.abc.ByteStream) -> None: + # Accepted here, so closed here. + async with stream, newline_json_transport(stream) as (read_stream, write_stream): + await serve_stream(server, read_stream, write_stream, lifespan_state=lifespan_state) + + await listener.serve(handle) diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index 4d02fc4a73..c8eaab319f 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -18,9 +18,7 @@ async def handle_sse(request): async with sse.connect_sse( request.scope, request.receive, request._send ) as streams: - await app.run( - streams[0], streams[1], app.create_initialization_options() - ) + await app.run(streams[0], streams[1]) # Return empty response to avoid NoneType error return Response() diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 876d256ddb..df23e50e7c 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -11,23 +11,31 @@ async def run_server(): # read_stream contains incoming JSONRPCMessages from stdin # write_stream allows sending JSONRPCMessages to stdout server = await create_my_server() - await server.run(read_stream, write_stream, init_options) + await server.run(read_stream, write_stream) anyio.run(run_server) ``` """ import sys +from collections.abc import AsyncIterator from contextlib import asynccontextmanager from io import TextIOWrapper import anyio +import anyio.abc import anyio.lowlevel import mcp_types as types +from anyio.streams.buffered import BufferedByteReceiveStream -from mcp.shared._context_streams import create_context_streams +from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams from mcp.shared.message import SessionMessage +__all__ = ["newline_json_transport", "stdio_server"] + +_MAX_FRAME_BYTES = 64 * 1024 * 1024 +"""Upper bound on one newline-delimited frame; a longer frame ends the read side.""" + @asynccontextmanager async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.AsyncFile[str] | None = None): @@ -75,3 +83,62 @@ async def stdout_writer(): tg.start_soon(stdin_reader) tg.start_soon(stdout_writer) yield read_stream, write_stream + + +@asynccontextmanager +async def newline_json_transport( + stream: anyio.abc.ByteStream, *, max_frame_bytes: int = _MAX_FRAME_BYTES +) -> AsyncIterator[tuple[ContextReceiveStream[SessionMessage | Exception], ContextSendStream[SessionMessage]]]: + """The stdio wire over any byte stream: newline-delimited JSON-RPC framing. + + Yields the `(read_stream, write_stream)` pair a server driver consumes, + framing `stream` exactly as stdio frames its process handles: + + ```python + async with newline_json_transport(sock) as (read_stream, write_stream): + await server.run(read_stream, write_stream) + ``` + + A malformed line reaches the read stream as an exception item and the + connection carries on; a frame longer than `max_frame_bytes` ends the read + side. This framer never closes `stream` itself. + """ + read_writer, read_stream = create_context_streams[SessionMessage | Exception](0) + write_stream, write_reader = create_context_streams[SessionMessage](0) + buffered = BufferedByteReceiveStream(stream) + + async def frame_reader() -> None: + async with read_writer: + while True: + try: + line = await buffered.receive_until(b"\n", max_frame_bytes) + except (anyio.EndOfStream, anyio.IncompleteRead, anyio.ClosedResourceError): + return # peer closed: end of the inbound stream, the driver's EOF signal + except anyio.DelimiterNotFound as exc: + # A frame overran the bound: the byte stream can no longer be resynchronised. + await read_writer.send(exc) + return + try: + message = types.jsonrpc_message_adapter.validate_json(line, by_name=False) + except Exception as exc: + await read_writer.send(exc) + continue + await read_writer.send(SessionMessage(message)) + + async def frame_writer() -> None: + async with write_reader: + async for session_message in write_reader: + data = session_message.message.model_dump_json(by_alias=True, exclude_unset=True) + try: + await stream.send(data.encode("utf-8") + b"\n") + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + return # peer gone: outbound frames after this are undeliverable + + async with anyio.create_task_group() as tg: + tg.start_soon(frame_reader) + tg.start_soon(frame_writer) + try: + yield read_stream, write_stream + finally: + # The driver has returned: end both pumps. + tg.cancel_scope.cancel() diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index d316345c7e..8121da66d5 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -28,6 +28,7 @@ ErrorData, JSONRPCError, JSONRPCMessage, + JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, RequestId, @@ -44,6 +45,7 @@ from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER +from mcp.shared.jsonrpc_dispatcher import cancelled_request_id_from_params from mcp.shared.message import ServerMessageMetadata, SessionMessage logger = logging.getLogger(__name__) @@ -390,6 +392,16 @@ def _create_event_data(self, event_message: EventMessage) -> SSEEvent: return event_data + def _end_cancelled_request_stream(self, message: JSONRPCMessage) -> None: + """End the response stream of a request the peer just cancelled, if any. Closes + only the writer side, so an already-buffered answer is still read out first.""" + if not isinstance(message, JSONRPCNotification) or message.method != "notifications/cancelled": + return + request_id = cancelled_request_id_from_params(message.params) + streams = self._request_streams.get(str(request_id)) if request_id is not None else None + if streams is not None: + streams[0].close() + async def _clean_up_memory_streams(self, request_id: RequestId) -> None: """Clean up memory streams for a given request ID.""" if request_id in self._request_streams: # pragma: no branch @@ -535,6 +547,9 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re metadata = ServerMessageMetadata(request_context=request) session_message = SessionMessage(message, metadata=metadata) await writer.send(session_message) + # A peer cancel is the request's last word: end its response stream so the + # waiting POST (or SSE writer) completes; a buffered answer is still delivered. + self._end_cancelled_request_stream(message) return @@ -578,13 +593,10 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re # Create JSON response response = self._create_json_response(response_message) await response(scope, receive, send) - else: # pragma: no cover - # This shouldn't happen in normal operation - logger.error("No response message received before stream closed") - response = self._create_error_response( - "Error processing request: No response received", - HTTPStatus.INTERNAL_SERVER_ERROR, - ) + else: + # The stream ended with no response (the peer cancelled the request): + # complete the POST with an empty 202 Accepted rather than leaving it pending. + response = self._create_json_response(None, HTTPStatus.ACCEPTED) await response(scope, receive, send) except Exception: # pragma: no cover logger.exception("Error processing JSON response") @@ -1012,8 +1024,9 @@ async def message_router(): try: # Send both the message and the event ID await self._request_streams[request_stream_id][0].send(EventMessage(message, event_id)) - except (anyio.BrokenResourceError, anyio.ClosedResourceError): # pragma: no cover - # Stream might be closed, remove from registry + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + # The stream was ended (peer cancel or response + # already sent): drop the frame and the entry. self._request_streams.pop(request_stream_id, None) else: logger.debug( diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index 31f587ee66..31dc832498 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -21,7 +21,8 @@ from mcp.server._streamable_http_modern import handle_modern_request from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context from mcp.server.connection import Connection -from mcp.server.runner import serve_connection, serve_loop +from mcp.server.runner import serve_connection +from mcp.server.serving import Posture, serve_legacy_stream from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, EventStore, StreamableHTTPServerTransport from mcp.server.transport_security import TransportSecuritySettings from mcp.shared._compat import resync_tracer @@ -142,7 +143,7 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: ) self._has_started = True - async with self.app.lifespan(self.app) as lifespan_state, anyio.create_task_group() as tg: + async with self.app.lifespan() as lifespan_state, anyio.create_task_group() as tg: # Store for handle_request: lifespan is entered once for the # manager's lifetime, not per request (per-connection cleanup # belongs on `connection.exit_stack`). @@ -178,9 +179,14 @@ async def _handle_request(self, scope: Scope, receive: Receive, send: Send) -> N # initialize-handshake versions; anything else (including unknown # values) goes to the modern entry so the classifier can validate it # and return a structured rejection. 2025 paths below remain unchanged. + # `Server.posture` routes: modern-only sends every request to the + # modern entry; legacy-only never does. header = MCP_PROTOCOL_VERSION_HEADER.encode("ascii") pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == header), None) - if pv is not None and pv not in HANDSHAKE_PROTOCOL_VERSIONS: + posture = self.app.posture + if posture is Posture.MODERN_ONLY or ( + posture is Posture.DUAL and pv is not None and pv not in HANDSHAKE_PROTOCOL_VERSIONS + ): await handle_modern_request( self.app, self.security_settings, self.json_response, self._lifespan_state, scope, receive, send ) @@ -213,7 +219,6 @@ async def run_stateless_server(*, task_status: TaskStatus[None] = anyio.TASK_STA dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( read_stream, write_stream, - inline_methods=frozenset({"initialize"}), # No session ID means a server-to-client request can be # written to this POST's response stream, but the client's # reply has nowhere to land — `can_send_request=False` @@ -318,10 +323,10 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE http_transport.idle_scope = idle_scope with idle_scope: - # Drive via `serve_loop` (not `Server.run()`) so the - # manager's already-entered lifespan is reused - # rather than re-entered per session. - await serve_loop( + # Reuse the manager's already-entered lifespan + # rather than re-entering it per session; the + # header routed this stream to the legacy era. + await serve_legacy_stream( self.app, read_stream, write_stream, diff --git a/src/mcp/server/subscriptions.py b/src/mcp/server/subscriptions.py index 6b0b3d49b5..28a0b6bed7 100644 --- a/src/mcp/server/subscriptions.py +++ b/src/mcp/server/subscriptions.py @@ -160,19 +160,13 @@ class ListenHandler: Register on a lowlevel `Server` via `on_subscriptions_listen=` (or `add_request_handler`); `MCPServer` does so automatically. Each call acknowledges the honored filter first, then forwards matching bus events - onto the request's response stream until the client disconnects (which - cancels the handler; the stream just ends, per the spec's abrupt-close - contract) or `close` ends all streams gracefully. - - Requires a transport that can stream a request's response (streamable - HTTP's SSE mode). + onto the request's response channel until the client ends the stream + (which cancels the handler) or `close` ends all streams gracefully. `max_subscriptions` bounds concurrent streams (further listen requests are rejected with `INTERNAL_ERROR`, before the ack). `max_buffered_events` - bounds each stream's event backlog: a stream whose client has stopped - reading is ended at the cap (the client re-listens and refetches - there - is no replay, so ending the stream loses nothing the backlog wasn't - already losing). + bounds each stream's backlog: a stream whose client has stopped reading is + ended at the cap (there is no replay, so the client re-listens). """ def __init__(self, bus: SubscriptionBus, *, max_subscriptions: int = 1024, max_buffered_events: int = 1024) -> None: diff --git a/src/mcp/shared/dispatcher.py b/src/mcp/shared/dispatcher.py index f109638f2a..842168a3cf 100644 --- a/src/mcp/shared/dispatcher.py +++ b/src/mcp/shared/dispatcher.py @@ -18,6 +18,7 @@ import logging from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass from typing import Any, Protocol, TypedDict, TypeVar, runtime_checkable import anyio @@ -30,6 +31,8 @@ logger = logging.getLogger(__name__) __all__ = [ + "Admission", + "Admit", "CallOptions", "DispatchContext", "Dispatcher", @@ -54,11 +57,8 @@ def as_request_id(value: object) -> RequestId | None: def coerce_request_id(request_id: RequestId) -> RequestId: - """Coerce a stringified int request id back to int so a peer-echoed id still correlates (matches the TS SDK). - - This is the collision/correlation domain dispatchers share: "7" and 7 are one - id for correlation purposes, even where the wire carries the verbatim value. - """ + """Coerce a stringified int request id back to int so "7" and 7 correlate as one id + (matches the TS SDK); the wire still carries the verbatim value.""" if isinstance(request_id, str): try: return int(request_id) @@ -82,13 +82,8 @@ class CallOptions(TypedDict, total=False): request_id: RequestId """Send the request under this caller-supplied id instead of a dispatcher-minted one. - The peer sees the value verbatim ("7" stays a string). A value that collides - with one of the sender's own in-flight request ids raises `ValueError`. - Callers that need to know a request's id before its result arrives (a - `subscriptions/listen` stream is demultiplexed by it) mint their own ids - here; string ids that don't parse as integers can never collide with the - dispatcher's minted sequence. Per the class contract, dispatchers that - predate this key ignore it and mint as usual. + The peer sees the value verbatim ("7" stays a string); a collision with one of + the sender's in-flight request ids raises `ValueError`. """ timeout: float @@ -97,32 +92,19 @@ class CallOptions(TypedDict, total=False): cancel_on_abandon: bool """Whether abandoning this request (timeout or caller cancellation) sends `notifications/cancelled`. - Defaults to `True`. Set `False` for requests the protocol forbids cancelling, such as `initialize`. - Also suppressed when resumption hints reach the transport, or when the request was never written. + Defaults to `True`; set `False` for requests the protocol forbids cancelling, such as `initialize`. """ on_progress: ProgressFnT """Receive `notifications/progress` updates for this request.""" resumption_token: str - """Opaque token to resume a previously interrupted request. - - Client-side, streamable-HTTP only. Ignored by server dispatchers and other - transports, and also ignored (with a debug log) for requests sent from a - `DispatchContext`, where routing onto the inbound request's stream takes - precedence. Supports protocol version 2025-11-25 and earlier; SSE-stream - resumption is removed in the next protocol revision. - """ + """Opaque token to resume an interrupted request (client-side, streamable HTTP, + protocol 2025-11-25 and earlier); ignored elsewhere and from a `DispatchContext`.""" on_resumption_token: Callable[[str], Awaitable[None]] - """Receive a resumption token when the transport issues one for this request. - - Client-side, streamable-HTTP only. Ignored by server dispatchers and other - transports, and also ignored (with a debug log) for requests sent from a - `DispatchContext`, where routing onto the inbound request's stream takes - precedence. Supports protocol version 2025-11-25 and earlier; SSE-stream - resumption is removed in the next protocol revision. - """ + """Receive a resumption token when the transport issues one for this request; + same scope as `resumption_token`.""" headers: dict[str, str] """Transport-layer hint: HTTP transports merge these onto the outgoing request; non-HTTP transports ignore.""" @@ -219,17 +201,45 @@ async def progress(self, progress: float, total: float | None = None, message: s OnRequest = Callable[[DispatchContext[TransportContext], str, Mapping[str, Any] | None], Awaitable[dict[str, Any]]] -"""Handler for inbound requests: `(ctx, method, params) -> result`. Raise `MCPError` to send an error response.""" +"""Handler for one inbound request: `(ctx, method, params) -> awaitable result`. + +Invoked synchronously in receive order; the awaitable it returns is the request +body and runs in its own task. Raise `MCPError` from the body to send an error +response. +""" OnNotify = Callable[[DispatchContext[TransportContext], str, Mapping[str, Any] | None], Awaitable[None]] -"""Handler for inbound notifications: `(ctx, method, params)`.""" +"""Handler for one inbound notification: `(ctx, method, params) -> awaitable`; +same admission contract as `OnRequest`.""" + + +@dataclass(frozen=True, slots=True) +class Admission: + """The receive-order verdict for one inbound request. + + `handler` serves it; when `hold` is true the receive loop parks until the + request finishes answering, so its body must not await the peer. + """ + + handler: OnRequest + hold: bool = False + + +Admit = Callable[[str, Mapping[str, Any] | None], Admission] +"""Synchronous receive-order gate for inbound requests: `(method, params) -> Admission`. + +Invoked in the receive loop before the request's transport context is built; +must not block or talk to the peer. The request-side sibling of +`OnNotifyIntercept`; a concrete dispatcher feature, not part of the minimal +`Dispatcher` Protocol. +""" OnNotifyIntercept = Callable[[str, Mapping[str, Any] | None], bool] """Synchronous receive-order intercept for inbound notifications: `(method, params) -> consumed`. -Runs before `on_notify` is scheduled so correlation state advances in wire order -relative to response resolution (the client's listen demux depends on this). -Returning True consumes the notification. Must not block the receive path. +Runs before `on_notify` is scheduled, in wire order relative to response +resolution. Returning True consumes the notification. Must not block the +receive path. """ @@ -263,14 +273,10 @@ async def run( ) -> None: """Drive the receive loop until the underlying channel closes. - Each inbound request is dispatched to `on_request` in its own task; - the returned dict (or raised `MCPError`) is sent back as the response. - Implementations MUST offer every inbound notification to - `on_notify_intercept` synchronously in receive order (via - `run_notify_intercept`), handing only unconsumed ones to `on_notify`. - - `task_status.started()` is called once the dispatcher is ready to - accept `send_request`/`notify` calls, so callers can use - `await tg.start(dispatcher.run, on_request, on_notify)`. + `on_request` / `on_notify` are invoked synchronously in receive order + (see `OnRequest`); a request's result (or raised `MCPError`) is sent + back as its response. Every notification is offered to + `on_notify_intercept` first, in receive order. `task_status.started()` + is called once the dispatcher can accept `send_request`/`notify`. """ ... diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index c28aa7fb71..c9037b0ea0 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -44,14 +44,18 @@ "MCP_NAME_HEADER", "MCP_PARAM_HEADER_PREFIX", "MCP_PROTOCOL_VERSION_HEADER", + "ModernEnvelope", "NAME_BEARING_METHODS", "X_MCP_HEADER_KEY", + "check_supported_version", "classify_inbound_request", "decode_header_value", "encode_header_value", "find_duplicated_routing_header", "find_invalid_x_mcp_header", + "has_envelope_intent", "mcp_param_headers", + "parse_envelope", "validate_mcp_param_headers", "x_mcp_header_map", ] @@ -338,6 +342,19 @@ class InboundModernRoute: client_capabilities: Any +@dataclass(frozen=True) +class ModernEnvelope: + """The per-request modern envelope parsed from `params._meta`: the required pair is present. + + Values are raw (`protocol_version` is checked by :func:`check_supported_version`); + `client_info` is `None` when the client omitted it. + """ + + protocol_version: Any + client_info: Any + client_capabilities: Any + + @dataclass(frozen=True) class InboundLadderRejection: """The first ladder rung that failed, as JSON-RPC error fields.""" @@ -347,6 +364,92 @@ class InboundLadderRejection: data: Any = None +_ENVELOPE_KEYS: Final = frozenset({PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY}) +"""The reserved `_meta` keys that make up the modern per-request envelope.""" + +_ENVELOPE_REQUIRED_MESSAGE: Final = ( + "params._meta must be an object carrying the required " + f"{PROTOCOL_VERSION_META_KEY!r} and {CLIENT_CAPABILITIES_META_KEY!r} envelope keys" +) + + +def has_envelope_intent(params: Mapping[str, Any] | None) -> bool: + """Whether `params._meta` carries any modern per-request envelope key: presence, + not validity (a bare `progressToken` is not envelope evidence).""" + if not params: + return False + meta = params.get("_meta") + return isinstance(meta, Mapping) and not _ENVELOPE_KEYS.isdisjoint(cast("Mapping[str, Any]", meta)) + + +def parse_envelope(params: Mapping[str, Any] | None) -> ModernEnvelope | InboundLadderRejection | None: + """Parse the per-request modern envelope out of `params._meta`. + + `None` when `_meta` carries no envelope key; an INVALID_PARAMS rejection + naming the missing key(s) when a claim is present but the required pair + (protocol version, client capabilities) is incomplete; else the envelope + (unchecked). Client info is optional (SHOULD-include, spec PR #3002), so + absent reads as `None`. + """ + if not has_envelope_intent(params): + return None + # `has_envelope_intent` proved `params._meta` is a mapping carrying a claim. + meta = cast("Mapping[str, Any]", (params or {})["_meta"]) + if missing := [key for key in (PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY) if key not in meta]: + return InboundLadderRejection( + code=INVALID_PARAMS, + message=f"params._meta is missing the required envelope key(s): {', '.join(missing)}", + ) + return ModernEnvelope( + protocol_version=meta[PROTOCOL_VERSION_META_KEY], + client_info=meta.get(CLIENT_INFO_META_KEY), + client_capabilities=meta[CLIENT_CAPABILITIES_META_KEY], + ) + + +def check_supported_version( + envelope: ModernEnvelope, supported_modern_versions: Sequence[str] = MODERN_PROTOCOL_VERSIONS +) -> InboundModernRoute | InboundLadderRejection: + """Promote a parsed envelope to a route: non-string version → INVALID_PARAMS; + unserved → UNSUPPORTED_PROTOCOL_VERSION with `{"supported": [...], "requested": ...}`.""" + if not isinstance(envelope.protocol_version, str): + return InboundLadderRejection( + code=INVALID_PARAMS, message="the protocol-version envelope value must be a string" + ) + if envelope.protocol_version not in supported_modern_versions: + return InboundLadderRejection( + code=UNSUPPORTED_PROTOCOL_VERSION, + message="Unsupported protocol version", + data=UnsupportedProtocolVersionErrorData( + supported=list(supported_modern_versions), requested=envelope.protocol_version + ).model_dump(mode="json"), + ) + return InboundModernRoute( + protocol_version=envelope.protocol_version, + client_info=envelope.client_info, + client_capabilities=envelope.client_capabilities, + ) + + +def _handshake_refusal( + params: Mapping[str, Any] | None, supported_modern_versions: Sequence[str] +) -> InboundLadderRejection: + """The modern era's answer to `initialize`: -32022 naming the served versions, + with `requested` set when the handshake proposed a string version.""" + requested = (params or {}).get("protocolVersion") + if isinstance(requested, str): + data: dict[str, Any] = UnsupportedProtocolVersionErrorData( + supported=list(supported_modern_versions), requested=requested + ).model_dump(mode="json") + else: + data = {"supported": list(supported_modern_versions)} + return InboundLadderRejection( + code=UNSUPPORTED_PROTOCOL_VERSION, + message="initialize is not served: this connection speaks a per-request-envelope protocol version", + data=data, + ) + + _ROUTING_HEADER_NAMES: Final = frozenset({MCP_PROTOCOL_VERSION_HEADER, MCP_METHOD_HEADER, MCP_NAME_HEADER}) @@ -375,21 +478,22 @@ def classify_inbound_request( ) -> InboundModernRoute | InboundLadderRejection: """Run the modern-protocol validation ladder over a decoded JSON-RPC body. - Rungs, in order — first failure wins: + Rungs, in order; first failure wins: - 1. `params._meta` is a mapping carrying the required envelope pair - (protocol version, client capabilities) → else - :data:`~mcp_types.jsonrpc.INVALID_PARAMS` naming the missing key(s) + 0. `body.method` is not `initialize` → else + :data:`~mcp_types.jsonrpc.UNSUPPORTED_PROTOCOL_VERSION` naming the served + versions (:func:`_handshake_refusal`). + 1. :func:`parse_envelope`: `params._meta` is a mapping carrying the required + envelope pair (protocol version, client capabilities) → else + :data:`~mcp_types.jsonrpc.INVALID_PARAMS`, naming the missing key(s) (basic/index.mdx "Per-request protocol fields"). Client info is optional (SHOULD-include, spec PR #3002); absent reads as `None`. - 2. When `headers` is given, `MCP-Protocol-Version` equals the envelope's - protocol version, `Mcp-Method` equals `body.method`, and — for the - methods in :data:`NAME_BEARING_METHODS` — `Mcp-Name` equals the named - body param → else :data:`~mcp_types.jsonrpc.HEADER_MISMATCH`. Runs - before the supported-version rung so a client that disagrees with itself - is told so, rather than told the body's version is unsupported. - 3. The envelope's protocol version is a string in - `supported_modern_versions` → non-string values are + 2. When `headers` is given: `MCP-Protocol-Version`, `Mcp-Method`, and (for + :data:`NAME_BEARING_METHODS`) `Mcp-Name` agree with the body → else + :data:`~mcp_types.jsonrpc.HEADER_MISMATCH`. Runs before the + supported-version rung so a client that disagrees with itself is told + so, rather than told the body's version is unsupported. + 3. :func:`check_supported_version` → non-string values are :data:`~mcp_types.jsonrpc.INVALID_PARAMS` (a shape defect, not a negotiation outcome), else :data:`~mcp_types.jsonrpc.UNSUPPORTED_PROTOCOL_VERSION` with @@ -399,37 +503,26 @@ def classify_inbound_request( custom-registered methods route and the answer lives in one place. Args: - body: The decoded JSON-RPC request mapping. Envelope shape - (`jsonrpc` / `id`) is not checked here. + body: The decoded JSON-RPC request mapping. headers: Transport headers keyed by lowercase name, or `None` to - skip the header rung (non-HTTP callers). + skip the header rung. supported_modern_versions: Modern protocol revisions this server accepts on the per-request-envelope path. """ - try: - meta_value = body["params"]["_meta"] - except (KeyError, TypeError): - meta_value = None - if not isinstance(meta_value, Mapping): - return InboundLadderRejection( - code=INVALID_PARAMS, - message="params._meta must be an object carrying the required " - f"{PROTOCOL_VERSION_META_KEY!r} and {CLIENT_CAPABILITIES_META_KEY!r} envelope keys", - ) - meta = cast("Mapping[str, Any]", meta_value) - if missing := [key for key in (PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY) if key not in meta]: - return InboundLadderRejection( - code=INVALID_PARAMS, - message=f"params._meta is missing the required envelope key(s): {', '.join(missing)}", - ) - protocol_version: Any = meta[PROTOCOL_VERSION_META_KEY] - client_info: Any = meta.get(CLIENT_INFO_META_KEY) - client_capabilities: Any = meta[CLIENT_CAPABILITIES_META_KEY] + raw_params = body.get("params") + params = cast("Mapping[str, Any]", raw_params) if isinstance(raw_params, Mapping) else None + if body.get("method") == "initialize": + return _handshake_refusal(params, supported_modern_versions) + envelope = parse_envelope(params) + if envelope is None: + return InboundLadderRejection(code=INVALID_PARAMS, message=_ENVELOPE_REQUIRED_MESSAGE) + if isinstance(envelope, InboundLadderRejection): + return envelope if headers is not None: version_header = headers.get(MCP_PROTOCOL_VERSION_HEADER) # Presence is checked explicitly: a null body version would otherwise # slip the equality check (None == None) and mask the absent header. - if version_header is None or version_header != protocol_version: + if version_header is None or version_header != envelope.protocol_version: return InboundLadderRejection( code=HEADER_MISMATCH, message=f"{MCP_PROTOCOL_VERSION_HEADER} header does not match the request envelope's protocol version", @@ -441,43 +534,14 @@ def classify_inbound_request( message=f"{MCP_METHOD_HEADER} header does not match the request body's method", ) name_key = NAME_BEARING_METHODS.get(method) - if name_key is not None: - # Rung 1 already proved body["params"] is a mapping (its `_meta` is one). - body_value = cast("Mapping[str, Any]", body["params"]).get(name_key) + if name_key is not None and params is not None: + body_value = params.get(name_key) if body_value is not None and decode_header_value(headers.get(MCP_NAME_HEADER)) != body_value: return InboundLadderRejection( code=HEADER_MISMATCH, message=f"{MCP_NAME_HEADER} header does not match the request body's {name_key!r} parameter", ) - - if not isinstance(protocol_version, str): - # Rung 3's precondition: a shape defect, not a version-negotiation - # outcome - -32022 is the one code auto-negotiating clients do NOT - # fall back from, and the typed rung-3 payload itself requires a - # string `requested`. Sits after the header rung, which fires first - # for every header-bearing entry (an absent version header is a - # mismatch, and a present one is a string that can never equal a - # non-string body value) - so this rejection is reachable only on - # header-less transports. - return InboundLadderRejection( - code=INVALID_PARAMS, - message="the protocol-version envelope value must be a string", - ) - - if protocol_version not in supported_modern_versions: - return InboundLadderRejection( - code=UNSUPPORTED_PROTOCOL_VERSION, - message="Unsupported protocol version", - data=UnsupportedProtocolVersionErrorData( - supported=list(supported_modern_versions), requested=protocol_version - ).model_dump(mode="json"), - ) - - return InboundModernRoute( - protocol_version=protocol_version, - client_info=client_info, - client_capabilities=client_capabilities, - ) + return check_supported_version(envelope, supported_modern_versions) # Header values eligible for the spec's numeric-comparison SHOULD; scientific diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 42798fdc54..665a17ad69 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -1,8 +1,10 @@ """JSON-RPC `Dispatcher` over the `SessionMessage` stream contract all transports speak. Owns request-id correlation, the receive loop, per-request task isolation, -cancellation/progress wiring, and the single exception-to-wire boundary; -methods and params are otherwise opaque strings and dicts. +cancellation/progress wiring, and the exception-to-wire boundaries; methods and +params are otherwise opaque strings and dicts. Each request writes through its +channel's current target, which its own answer, a peer cancel, or a gone peer +swaps for a void, so nothing further is written for the id. """ from __future__ import annotations @@ -40,8 +42,9 @@ from mcp.shared._otel import inject_trace_context, otel_span from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.dispatcher import ( + Admission, + Admit, CallOptions, - DispatchContext, Dispatcher, OnNotify, OnNotifyIntercept, @@ -70,8 +73,7 @@ logger = logging.getLogger(__name__) _ABANDON_WRITE_TIMEOUT: float = 5 -"""Bound for courtesy-cancel writes on the abandon paths; the caller-cancel -arm shields its write, so a wedged transport would otherwise hang it uncancellably.""" +"""Bound for the courtesy-cancel writes on the abandon paths.""" _SHUTDOWN_WRITE_TIMEOUT: float = 1 """Tighter bound for the shutdown-arm error write so a wedged transport can't hold session close.""" @@ -86,12 +88,9 @@ def handler_exception_to_error_data(exc: BaseException) -> ErrorData | None: """Map a handler-raised exception to its wire `ErrorData`. - The two rungs every dispatcher shares: an `MCPError` carries its own - `ErrorData`; a pydantic `ValidationError` is the spec's INVALID_PARAMS - with empty ``data`` (no pydantic text on the wire). Returns ``None`` for - any other exception so each caller applies its own catch-all - - `JSONRPCDispatcher` currently pins ``code=0`` for v1 compat, - the modern HTTP entry uses `INTERNAL_ERROR`. + `MCPError` carries its own `ErrorData`; a pydantic `ValidationError` is + INVALID_PARAMS with empty `data`. Returns `None` for any other exception so + the caller applies its own catch-all. """ if isinstance(exc, MCPError): return exc.error @@ -125,23 +124,72 @@ class _Pending: @dataclass(slots=True) class _InFlight(Generic[TransportT]): - """An inbound request currently being handled.""" + """An inbound request a peer cancel can still interrupt, keyed until its answer commits.""" scope: anyio.CancelScope dctx: _JSONRPCDispatchContext[TransportT] +class _Wire(Generic[TransportT]): + """A request's live write target: the connection it arrived on.""" + + def __init__( + self, + dispatcher: JSONRPCDispatcher[TransportT], + write_frame: Callable[[JSONRPCMessage], Awaitable[None]], + transport: TransportT, + rid: RequestId | None, + ): + self._dispatcher, self._write_frame, self._related = dispatcher, write_frame, rid + self.can_send_request = transport.can_send_request + + async def write(self, frame: JSONRPCMessage) -> None: + await self._write_frame(frame) + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None) -> None: + await self._dispatcher.notify(method, params, opts, _related_request_id=self._related) + + async def request(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None) -> dict[str, Any]: + if not self.can_send_request: + raise NoBackChannelError(method) + return await self._dispatcher.send_raw_request(method, params, opts, _related_request_id=self._related) + + +class _Void: + """The write target of a spent request: drops frames and refuses back-channel requests.""" + + can_send_request = False + + async def write(self, frame: JSONRPCMessage) -> None: + logger.debug("dropped %s: request channel spent", type(frame).__name__) + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None) -> None: + logger.debug("dropped %s: request channel spent", method) + + async def request(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None) -> dict[str, Any]: + raise NoBackChannelError(method) + + +_VOID = _Void() + + @dataclass class _JSONRPCDispatchContext(Generic[TransportT]): - """Concrete `DispatchContext` produced for each inbound JSON-RPC message.""" + """Concrete `DispatchContext` for one inbound JSON-RPC message and its outbound channel. + + Everything the request writes goes through `_target`; answering (`_spend`) or + a revocation (`close`) swaps it for the void, so at most one terminal frame + reaches the wire and nothing further is written for the id. + """ transport: TransportT - _dispatcher: JSONRPCDispatcher[TransportT] + _target: _Wire[TransportT] | _Void _request_id: RequestId | None + _cancellable: dict[RequestId, _InFlight[TransportT]] + """The dispatcher's table of requests a peer cancel can still interrupt.""" message_metadata: MessageMetadata = None # TODO(maxisbey): remove for Context rework """Transport-attached `SessionMessage.metadata` that the server lifts onto its request context.""" _progress_token: ProgressToken | None = None - _closed: bool = False cancel_requested: anyio.Event = field(default_factory=anyio.Event) @property @@ -150,13 +198,10 @@ def request_id(self) -> RequestId | None: @property def can_send_request(self) -> bool: - return self.transport.can_send_request and not self._closed + return self._target.can_send_request async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: - if self._closed: - logger.debug("dropped %s: dispatch context closed", method) - return - await self._dispatcher.notify(method, params, opts, _related_request_id=self._request_id) + await self._target.notify(method, params, opts) async def send_raw_request( self, @@ -164,9 +209,7 @@ async def send_raw_request( params: Mapping[str, Any] | None, opts: CallOptions | None = None, ) -> dict[str, Any]: - if not self.can_send_request: - raise NoBackChannelError(method) - return await self._dispatcher.send_raw_request(method, params, opts, _related_request_id=self._request_id) + return await self._target.request(method, params, opts) async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: if self._progress_token is None: @@ -178,14 +221,39 @@ async def progress(self, progress: float, total: float | None = None, message: s params["message"] = message await self.notify("notifications/progress", params) + async def reply(self, result: dict[str, Any]) -> None: + """Write the request's result as its terminal frame, spending the channel.""" + target, request_id = self._spend() + await target.write(JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)) + + async def reply_error(self, error: ErrorData) -> None: + """Write `error` as the request's terminal frame, spending the channel.""" + target, request_id = self._spend() + await target.write(JSONRPCError(jsonrpc="2.0", id=request_id, error=error)) + def close(self) -> None: - self._closed = True + """Revoke the channel: nothing further is written for this request. Idempotent.""" + self._target = _VOID + + def _spend(self) -> tuple[_Wire[TransportT] | _Void, RequestId]: + """Commit the request and take its write target in one synchronous step: it leaves + the cancellable table and the void is left behind (the one commit point).""" + assert self._request_id is not None, "only an inbound request writes a terminal frame" + key = coerce_request_id(self._request_id) + if (entry := self._cancellable.get(key)) is not None and entry.dctx is self: + del self._cancellable[key] + target, self._target = self._target, _VOID + return target, self._request_id def _default_transport_builder(_meta: MessageMetadata) -> TransportContext: return TransportContext(kind="jsonrpc", can_send_request=True) +async def _no_drain() -> None: + """Default `on_read_eof`: nothing to drain before shutdown.""" + + def _shielded_progress(fn: ProgressFnT) -> ProgressFnT: """Wrap a user progress callback so an exception can't cancel the dispatcher's task group.""" @@ -198,18 +266,6 @@ async def _wrapped(progress: float, total: float | None, message: str | None) -> return _wrapped -def _contained_notify(fn: OnNotify) -> OnNotify: - """Wrap a notification handler so it can't crash the dispatcher (same boundary as `_shielded_progress`).""" - - async def _wrapped(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None: - try: - await fn(dctx, method, params) - except Exception: - logger.exception("notification handler for %r raised", method) - - return _wrapped - - @dataclass(slots=True, frozen=True) class _OutboundPlan: """Outbound metadata plus whether abandoning the request sends a courtesy `notifications/cancelled`.""" @@ -221,9 +277,8 @@ class _OutboundPlan: def _plan_outbound(related_request_id: RequestId | None, opts: CallOptions | None) -> _OutboundPlan: """Choose the outbound `SessionMessage.metadata` and the abandon-cancellation policy. - `related_request_id` wins over resumption hints (they are dropped). Only - hints that actually reach the transport suppress the courtesy cancel - a - request that is neither resumable nor cancelled would leak the peer's work. + `related_request_id` wins over resumption hints (they are dropped); only hints + that reach the transport suppress the courtesy cancel. """ opts = opts or {} cancel_on_abandon = opts.get("cancel_on_abandon", True) @@ -260,22 +315,21 @@ def __init__( transport_builder: Callable[[MessageMetadata], TransportT] | None = None, peer_cancel_mode: PeerCancelMode = "interrupt", raise_handler_exceptions: bool = False, - inline_methods: frozenset[str] = frozenset(), on_stream_exception: Callable[[Exception], Awaitable[None]] | None = None, + on_read_eof: Callable[[], Awaitable[None]] | None = None, ) -> None: """Wire a dispatcher over a transport's `SessionMessage` stream pair. Args: transport_builder: Builds each message's `TransportContext` from - its `SessionMessage.metadata`. + its `SessionMessage.metadata`; runs after admission. raise_handler_exceptions: Re-raise handler exceptions out of `run()` after the error response is written. - inline_methods: Methods awaited in the read loop before the next - message is dequeued (e.g. `initialize`); an inline handler - that awaits the peer deadlocks the parked loop. on_stream_exception: Observer for `Exception` items on the read - stream; without it they are debug-logged and dropped. Awaited - inline in the read loop, so a slow observer stalls dispatch. + stream, awaited inline in the read loop; without it they are + debug-logged and dropped. + on_read_eof: Awaited once when the read stream ends, before + in-flight handlers are cancelled. """ self._read_stream = read_stream self._write_stream = write_stream @@ -287,16 +341,19 @@ def __init__( ) self._peer_cancel_mode: PeerCancelMode = peer_cancel_mode self._raise_handler_exceptions = raise_handler_exceptions - self._inline_methods = inline_methods self.on_stream_exception = on_stream_exception - """Observer for ``Exception`` items on the read stream. Mutable so a session can - bind it after the dispatcher is built (e.g. ``ClientSession`` routing into - ``message_handler``); only consulted inside ``run()`` so pre-enter assignment is safe.""" + """Observer for `Exception` items on the read stream; mutable, consulted only inside `run()`.""" + self._on_read_eof = on_read_eof or _no_drain self._next_id = 0 self._pending: dict[RequestId, _Pending] = {} self._in_flight: dict[RequestId, _InFlight[TransportT]] = {} + self._request_tasks = 0 + """Admitted requests not yet finished answering (their answer writes included).""" + self._all_answered: anyio.Event | None = None + """Armed while `wait_for_in_flight` waits; set when `_request_tasks` reaches zero.""" self._on_notify_intercept: OnNotifyIntercept | None = None + self._admit: Admit | None = None self._tg: anyio.abc.TaskGroup | None = None self._running = False self._closed = False @@ -311,8 +368,8 @@ async def send_raw_request( ) -> dict[str, Any]: """Send a JSON-RPC request and await its response. - `_related_request_id` is set only by `_JSONRPCDispatchContext` so that - mid-handler requests route onto the inbound request's SSE stream. + `_related_request_id` (set only by `_JSONRPCDispatchContext`) routes + mid-handler requests onto the inbound request's SSE stream. Raises: MCPError: Peer error response; `REQUEST_TIMEOUT` if @@ -329,15 +386,12 @@ async def send_raw_request( supplied_id = opts.get("request_id") if supplied_id is not None: request_id: RequestId = supplied_id - # The pending key gets the same coercion `_resolve_pending` applies - # to inbound response ids, so a supplied "7" still correlates - # whether the peer echoes "7" or 7. The wire id stays verbatim. + # Same coercion `_resolve_pending` applies to inbound ids; the wire id stays verbatim. pending_key = coerce_request_id(request_id) if pending_key in self._pending: raise ValueError(f"request id {request_id!r} is already in flight") else: - # Mint past any key a supplied id occupies: the collision error is - # reserved for the caller who actually chose the id. + # Mint past any key a supplied id occupies. request_id = self._allocate_id() while request_id in self._pending: request_id = self._allocate_id() @@ -350,18 +404,14 @@ async def send_raw_request( out_meta["progressToken"] = request_id out_params["_meta"] = out_meta - # buffer=1: a close signal can arrive before the waiter parks in receive(); - # a WouldBlock later just means the waiter already has its one outcome. + # buffer=1: a close signal can arrive before the waiter parks in receive(). send, receive = anyio.create_memory_object_stream[dict[str, Any] | ErrorData](1) pending = _Pending(send=send, receive=receive, on_progress=on_progress) self._pending[pending_key] = pending plan = _plan_outbound(_related_request_id, opts) - # Spec MUST: only previously-issued requests may be cancelled. A write - # interrupted by cancellation may still have delivered (a memory-stream - # send can hand its item to the receiver and still raise), so a started - # write counts as issued: the peer ignores a cancel for an id it never - # saw, while skipping it would leak a delivered request's handler. + # A write interrupted by cancellation may still have delivered, so a started + # write counts as issued (spec MUST: only issued requests may be cancelled). request_write_started = False timeout_armed = False @@ -378,8 +428,7 @@ async def send_raw_request( # SEP-414: inject W3C trace context; `_meta` stays on the wire even with a no-op tracer. inject_trace_context(out_meta) msg = JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=out_params) - # Surface a pre-existing cancellation while the request provably - # never started; past this point a cancelled write counts as issued. + # Surface a pre-existing cancellation while the request provably never started. await anyio.lowlevel.checkpoint_if_cancelled() request_write_started = True try: @@ -392,13 +441,10 @@ async def send_raw_request( outcome = await receive.receive() except TimeoutError: if not timeout_armed: - # `fail_after` arms only after the write, so this TimeoutError is the - # transport's own bounded send() failing - a transport error, not - # `opts["timeout"]` elapsing. Propagate it raw (v1 kept the write - # outside the timeout-catching try and did the same). + # `fail_after` arms only after the write, so this is the transport's + # own bounded send() failing, not `opts["timeout"]`: propagate it raw. raise - # Courtesy cancel (spec-recommended, new vs v1) so the peer stops work; - # unshielded so an outer caller cancellation can still interrupt the write. + # Courtesy cancel so the peer stops work; unshielded so an outer cancel can interrupt it. if plan.cancel_on_abandon: await self._final_write( partial( @@ -413,8 +459,7 @@ async def send_raw_request( ) raise MCPError(code=REQUEST_TIMEOUT, message=f"Request {method!r} timed out") from None except anyio.get_cancelled_exc_class(): - # Caller cancelled: bare awaits re-raise here, so the shielded helper - # lets the courtesy cancel go out before we propagate. + # Caller cancelled: the shielded helper sends the courtesy cancel before we propagate. if plan.cancel_on_abandon and request_write_started: await self._final_write( partial(self._cancel_outbound, request_id, "caller cancelled", _related_request_id), @@ -441,12 +486,8 @@ async def notify( *, _related_request_id: RequestId | None = None, ) -> None: - """Send a fire-and-forget notification. - - Fire-and-forget all the way: a post-close send or a write onto a - torn-down transport drops the notification with a debug log instead - of raising (same policy as the response writes and `ctx.notify`). - """ + """Send a fire-and-forget notification; a closed dispatcher or torn-down + transport drops it with a debug log instead of raising.""" if self._closed: logger.debug("dropped %s: dispatcher closed", method) return @@ -462,20 +503,35 @@ async def notify( # Transport tore down before run() noticed EOF. logger.debug("dropped %s: write stream closed", method) + async def wait_for_in_flight(self) -> None: + """Return once every admitted request has finished answering, answer writes included. + + Unbounded; the caller supplies the timeout. Concurrent waiters all wake. + """ + while self._request_tasks: + if self._all_answered is None: + self._all_answered = anyio.Event() + await self._all_answered.wait() + async def run( self, on_request: OnRequest, on_notify: OnNotify, on_notify_intercept: OnNotifyIntercept | None = None, *, + admit: Admit | None = None, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: """Drive the receive loop until the read stream closes. - `task_status.started()` fires once `send_raw_request` is usable. - Single-shot: once the loop ends the dispatcher stays closed and cannot be restarted. + `admit`, when given, is consulted synchronously in receive order for each + inbound request before its transport context is built; without it every + request goes to `on_request`, unheld. `task_status.started()` fires once + `send_raw_request` is usable. Single-shot: once the loop ends the + dispatcher stays closed. """ self._on_notify_intercept = on_notify_intercept + self._admit = admit try: # LIFO exits: the write stream closes only after the task-group join, so teardown writes still land. async with self._write_stream: @@ -496,9 +552,12 @@ async def run( except anyio.ClosedResourceError: # Receive end closed under us (stateless SHTTP teardown); same as EOF. logger.debug("read stream closed by transport; treating as EOF") - # EOF: wake blocked `send_raw_request` waiters with CONNECTION_CLOSED. + # EOF: the peer is gone. After the owner's drain, wake + # blocked senders and revoke open channels. self._running = False self._closed = True + await self._on_read_eof() + self._revoke_in_flight() self._fan_out_closed() finally: # Cancel in-flight handlers; otherwise the task-group join @@ -519,11 +578,8 @@ async def _dispatch( on_notify: OnNotify, sender_ctx: contextvars.Context | None, ) -> None: - """Route one inbound item. - - Only `inline_methods` requests and the `on_stream_exception` observer - are awaited; any other `await` would head-of-line block the read loop. - """ + """Route one inbound item; only held requests and the `on_stream_exception` + observer are awaited, so nothing else head-of-line blocks the read loop.""" if isinstance(item, Exception): if self.on_stream_exception is None: logger.debug("transport yielded exception: %r", item) @@ -554,45 +610,58 @@ async def _dispatch_request( sender_ctx: contextvars.Context | None, ) -> None: progress_token = progress_token_from_params(req.params) + # Admission runs first, in the read loop, in receive order; a raising admission + # or transport builder costs only this message, never the connection. + try: + admission = self._admit(req.method, req.params) if self._admit is not None else Admission(on_request) + except Exception: + logger.exception("admission raised; rejecting request %r", req.id) + self._reject(req.id, ErrorData(code=INTERNAL_ERROR, message="Internal server error"), sender_ctx) + return try: transport_ctx = self._transport_builder(metadata) except Exception: - # A raising builder must cost only this message, not the connection. logger.exception("transport_builder raised; rejecting request %r", req.id) - self._spawn( - self._write_error, - req.id, - ErrorData(code=INTERNAL_ERROR, message="transport context unavailable"), - sender_ctx=sender_ctx, - ) + self._reject(req.id, ErrorData(code=INTERNAL_ERROR, message="transport context unavailable"), sender_ctx) return dctx = _JSONRPCDispatchContext( transport=transport_ctx, - _dispatcher=self, + _target=self._wire(transport_ctx, req.id), _request_id=req.id, + _cancellable=self._in_flight, message_metadata=metadata, _progress_token=progress_token, ) scope = anyio.CancelScope() + # The admitted handler is invoked here; the awaitable it returns is the body. + try: + body = admission.handler(dctx, req.method, req.params) + except Exception: + logger.exception("handler raised during admission; rejecting request %r", req.id) + self._spawn( + dctx.reply_error, ErrorData(code=INTERNAL_ERROR, message="Internal server error"), sender_ctx=sender_ctx + ) + return # TODO(maxisbey): duplicate ids blind-overwrite (v1/TS parity); revisit # rejecting with INVALID_REQUEST. Key coerced so a stringified # `notifications/cancelled` id still correlates. self._in_flight[coerce_request_id(req.id)] = _InFlight(scope=scope, dctx=dctx) - if req.method in self._inline_methods: + self._request_tasks += 1 + if admission.hold: # Spawn so `sender_ctx` applies, but park the read loop until the - # handler returns - that's the inline ordering guarantee. + # request finishes answering. done = anyio.Event() - async def _run_inline() -> None: + async def _run_held() -> None: try: - await self._handle_request(req, dctx, scope, on_request) + await self._handle_request(req, dctx, scope, body) finally: done.set() - self._spawn(_run_inline, sender_ctx=sender_ctx) + self._spawn(_run_held, sender_ctx=sender_ctx) await done.wait() else: - self._spawn(self._handle_request, req, dctx, scope, on_request, sender_ctx=sender_ctx) + self._spawn(self._handle_request, req, dctx, scope, body, sender_ctx=sender_ctx) def _dispatch_notification( self, @@ -603,17 +672,17 @@ def _dispatch_notification( ) -> None: """Route one inbound notification. - `notifications/cancelled` and `notifications/progress` are intercepted - here (they correlate against the `_in_flight`/`_pending` tables this - layer owns) and still teed to `on_notify` afterwards. The caller's - `on_notify_intercept` then runs in receive order; only unconsumed - notifications reach the spawned `on_notify`. + `notifications/cancelled` and `notifications/progress` are correlated here + first; then the caller's `on_notify_intercept` runs in receive order, and + only unconsumed notifications are admitted through `on_notify`. """ if msg.method == "notifications/cancelled": rid = cancelled_request_id_from_params(msg.params) if rid is not None and (in_flight := self._in_flight.get(coerce_request_id(rid))) is not None: in_flight.dctx.cancel_requested.set() if self._peer_cancel_mode == "interrupt": + # The peer withdrew the request: revoke its channel, then interrupt the work. + in_flight.dctx.close() in_flight.scope.cancel() elif msg.method == "notifications/progress": match msg.params: @@ -643,9 +712,19 @@ def _dispatch_notification( logger.exception("transport_builder raised; dropping notification %r", msg.method) return dctx = _JSONRPCDispatchContext( - transport=transport_ctx, _dispatcher=self, _request_id=None, message_metadata=metadata + transport=transport_ctx, + _target=self._wire(transport_ctx, None), + _request_id=None, + _cancellable=self._in_flight, + message_metadata=metadata, ) - self._spawn(_contained_notify(on_notify), dctx, msg.method, msg.params, sender_ctx=sender_ctx) + # Admission mirrors requests (see `OnNotify`). + try: + body = on_notify(dctx, msg.method, msg.params) + except Exception: + logger.exception("on_notify raised during admission; dropping notification %r", msg.method) + return + self._spawn(self._run_notification, msg.method, body, sender_ctx=sender_ctx) def _resolve_pending(self, request_id: RequestId | None, outcome: dict[str, Any] | ErrorData) -> None: pending = self._pending.get(coerce_request_id(request_id)) if request_id is not None else None @@ -663,11 +742,8 @@ def _spawn( *args: object, sender_ctx: contextvars.Context | None, ) -> None: - """Schedule `fn(*args)` in the run() task group, propagating the sender's contextvars. - - ASGI middleware (auth, OTel) sets contextvars on the task that wrote the - message; `Context.run` makes the spawned handler inherit that context. - """ + """Schedule `fn(*args)` in the run() task group under the sender's contextvars + context (ASGI middleware sets contextvars on the task that wrote the message).""" assert self._tg is not None if sender_ctx is not None: sender_ctx.run(self._tg.start_soon, fn, *args) @@ -675,10 +751,7 @@ def _spawn( self._tg.start_soon(fn, *args) def _fan_out_closed(self) -> None: - """Wake every pending `send_raw_request` waiter with `CONNECTION_CLOSED`. - - Synchronous: callers may be inside a cancelled scope. Idempotent. - """ + """Wake every pending `send_raw_request` waiter with `CONNECTION_CLOSED`; synchronous, idempotent.""" closed = ErrorData(code=CONNECTION_CLOSED, message="Connection closed") for pending in self._pending.values(): try: @@ -692,65 +765,59 @@ async def _handle_request( req: JSONRPCRequest, dctx: _JSONRPCDispatchContext[TransportT], scope: anyio.CancelScope, - on_request: OnRequest, + body: Awaitable[dict[str, Any]], ) -> None: - """Run `on_request` for one inbound request and write its response. + """Run one admitted request's `body` inside its cancel scope and answer through `dctx`. - The single exception-to-wire boundary: handler exceptions become `JSONRPCError` here. + The exception-to-wire boundary for the body: handler exceptions become + the request's error frame here. Holds `_request_tasks` until the answer + write is done, which is what a drain waits for. """ - answer_write_started = False try: with scope: - try: - result = await on_request(dctx, req.method, req.params) - finally: - # Close the back-channel and drop from `_in_flight`; no checkpoint - # since handler return, so a peer cancel can't interleave. - # Identity guard: don't evict a duplicate id's newer entry. - dctx.close() - key = coerce_request_id(req.id) - if (entry := self._in_flight.get(key)) is not None and entry.dctx is dctx: - del self._in_flight[key] - # A write interrupted by cancellation may still have delivered - # (a memory-stream send can hand its item to the receiver and - # still raise), so a started answer write counts as sent below: - # peers drop late responses, while a second answer for one id - # would break JSON-RPC. - answer_write_started = True - await self._write_result(req.id, result) - if scope.cancelled_caught: - # anyio absorbs the scope's own cancel at __exit__, and - # `cancelled_caught` (unlike `cancel_called`) guarantees the - # result write above did not happen - no double response. - # TODO(L38): spec says SHOULD NOT respond after cancel; - # the existing server always has, so match that for now. - answer_write_started = True - await self._write_error(req.id, ErrorData(code=0, message="Request cancelled")) + result = await body + await dctx.reply(result) except anyio.get_cancelled_exc_class(): - # Shutdown: answer the request so the peer isn't left waiting - unless - # an answer write already started (it may have reached the transport; - # prefer possibly-zero answers over possibly-two). The shielded helper - # is needed because bare awaits re-raise here. - if not answer_write_started: - await self._final_write( - partial(self._write_error, req.id, ErrorData(code=CONNECTION_CLOSED, message="Connection closed")), - shield=True, - timeout=_SHUTDOWN_WRITE_TIMEOUT, - describe=f"shutdown error response for request {req.id!r}", - ) + # Outer cancel (shutdown): answer a peer that may still be waiting; a + # spent or revoked channel drops it. Shielded: bare awaits re-raise here. + await self._final_write( + partial(dctx.reply_error, ErrorData(code=CONNECTION_CLOSED, message="Connection closed")), + shield=True, + timeout=_SHUTDOWN_WRITE_TIMEOUT, + describe=f"shutdown error response for request {req.id!r}", + ) raise except Exception as e: error = handler_exception_to_error_data(e) if error is not None: - await self._write_error(req.id, error) + await dctx.reply_error(error) else: logger.exception("handler for %r raised", req.method) - # TODO(L58): code=0 pins existing-server compat; JSON-RPC says - # INTERNAL_ERROR. Revisit per the suite's divergence entry. - await self._write_error(req.id, ErrorData(code=0, message=str(e))) + # Only the generic error reaches the wire; the exception text stays in the log. + await dctx.reply_error(ErrorData(code=INTERNAL_ERROR, message="Internal server error")) if self._raise_handler_exceptions: raise - # No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id. + finally: + self._release_request_task() + + async def _run_notification(self, method: str, body: Awaitable[None]) -> None: + """Run one admitted notification's `body`, containing a raise to that message.""" + try: + await body + except Exception: + logger.exception("notification handler for %r raised", method) + + def _release_request_task(self) -> None: + """A request task has finished: wake `wait_for_in_flight` when it was the last one.""" + self._request_tasks -= 1 + if not self._request_tasks and (answered := self._all_answered) is not None: + self._all_answered = None + answered.set() + + def _revoke_in_flight(self) -> None: + """The peer is gone: revoke every open request's channel so nothing further is written.""" + for entry in self._in_flight.values(): + entry.dctx.close() def _allocate_id(self) -> int: self._next_id += 1 @@ -759,17 +826,20 @@ def _allocate_id(self) -> int: async def _write(self, message: JSONRPCMessage, metadata: MessageMetadata = None) -> None: await self._write_stream.send(SessionMessage(message=message, metadata=metadata)) - async def _write_result(self, request_id: RequestId, result: dict[str, Any]) -> None: + async def _write_frame(self, frame: JSONRPCMessage) -> None: + """Write one pre-built frame onto the connection; a torn-down transport drops it.""" try: - await self._write(JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)) + await self._write(frame) except (anyio.BrokenResourceError, anyio.ClosedResourceError): - logger.debug("dropped result for %r: write stream closed", request_id) + logger.debug("dropped %s: write stream closed", type(frame).__name__) - async def _write_error(self, request_id: RequestId, error: ErrorData) -> None: - try: - await self._write(JSONRPCError(jsonrpc="2.0", id=request_id, error=error)) - except (anyio.BrokenResourceError, anyio.ClosedResourceError): - logger.debug("dropped error for %r: write stream closed", request_id) + def _reject(self, request_id: RequestId, error: ErrorData, sender_ctx: contextvars.Context | None) -> None: + """Answer a request the read loop could not admit; the error frame is its whole fate.""" + self._spawn(self._write_frame, JSONRPCError(jsonrpc="2.0", id=request_id, error=error), sender_ctx=sender_ctx) + + def _wire(self, transport: TransportT, rid: RequestId | None) -> _Wire[TransportT]: + """The live write target of a message arriving on this connection.""" + return _Wire(self, self._write_frame, transport, rid) async def _final_write( self, @@ -779,21 +849,16 @@ async def _final_write( timeout: float, describe: str, ) -> None: - """Attempt one last write under the shared abandon/teardown policy. - - `shield=True` is for arms already inside a cancelled scope (a bare - `await` would re-raise); the bound keeps a wedged transport write - from becoming an uncancellable hang. - """ + """One last write under the shared abandon/teardown policy: bounded so a wedged + transport cannot hang; `shield=True` for arms already inside a cancelled scope.""" with anyio.move_on_after(timeout, shield=shield) as scope: await write() if scope.cancelled_caught: logger.warning("%s gave up: transport write blocked", describe) async def _cancel_outbound(self, request_id: RequestId, reason: str, related_request_id: RequestId | None) -> None: - # Thread `related_request_id` so streamable HTTP routes the cancel onto - # the request's own SSE stream instead of a possibly-absent GET stream. - # `notify` swallows connection-state errors itself, so no guard here. + # Thread `related_request_id` so streamable HTTP routes the cancel onto the + # request's own SSE stream; `notify` swallows connection-state errors itself. await self.notify( "notifications/cancelled", {"requestId": request_id, "reason": reason}, diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 088c714001..c288aca072 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -497,13 +497,14 @@ async def test_client_legacy_mode_still_handshakes_over_a_stream_loop(simple_ser assert (await client.list_resources()).resources[0].name == "Test Resource" -async def test_client_auto_mode_recovers_from_a_timed_out_probe_over_a_stream_loop( +async def test_client_auto_mode_timed_out_probe_falls_back_cleanly_over_a_stream_loop( simple_server: Server, monkeypatch: pytest.MonkeyPatch ) -> None: - """A probe that outlives the client's discover timeout still succeeds on the - (slow-starting) server and locks the connection modern; the fallback - handshake's -32022 is modern evidence, so one corrective re-probe completes - the connect instead of stranding `mode='auto'`.""" + """A probe that outlives the client's discover timeout leaves the connection + open: `server/discover` never pins the era, so the fallback `initialize` + the client sends next opens the legacy handshake era on the first try and + `mode='auto'` lands at the handshake version - no corrective re-probe is + needed against this server.""" monkeypatch.setattr("mcp.client.session.DISCOVER_TIMEOUT_SECONDS", 0.05) c2relay_send, c2relay_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) relay2s_send, relay2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) @@ -535,7 +536,7 @@ async def transport() -> AsyncIterator[TransportStreams]: with anyio.fail_after(10): async with Client(transport(), mode="auto") as client: - assert client.protocol_version == "2026-07-28" + assert client.protocol_version == LATEST_HANDSHAKE_VERSION assert (await client.list_resources()).resources[0].name == "Test Resource" diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 9c935ef18a..c06f696a21 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -959,8 +959,9 @@ async def call() -> None: @pytest.mark.anyio -async def test_raising_sampling_callback_answers_with_code_zero(): - """A raising sampling callback is answered with code 0 and `str(exc)` (SDK-defined). +async def test_raising_sampling_callback_answers_with_generic_internal_error(): + """A raising sampling callback is answered with a generic INTERNAL_ERROR: the callback's + exception text stays in the client's log and never crosses the wire (SDK-defined). Raw streams because the assertion is the outbound `JSONRPCError` envelope itself.""" async def boom(ctx: object, params: object) -> types.CreateMessageResult: @@ -976,7 +977,7 @@ async def boom(ctx: object, params: object) -> types.CreateMessageResult: ) out = await from_client.receive() assert isinstance(out.message, JSONRPCError) - assert out.message.error == types.ErrorData(code=0, message="sampling boom") + assert out.message.error == types.ErrorData(code=types.INTERNAL_ERROR, message="Internal server error") @pytest.mark.anyio diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index d21f520daf..c74e5a19d6 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -467,7 +467,7 @@ async def test_scope_cancel_aborts_a_modern_listen_post_end_to_end() -> None: server = Server("test", on_subscriptions_listen=ListenHandler(bus)) async def app(scope: Scope, receive: Receive, send: Send) -> None: - async with server.lifespan(server) as lifespan_state: + async with server.lifespan() as lifespan_state: await handle_modern_request(server, None, False, lifespan_state, scope, receive, send) posted_methods: list[str] = [] diff --git a/tests/docs_src/test_prompts.py b/tests/docs_src/test_prompts.py index c375ab6149..6570a6d816 100644 --- a/tests/docs_src/test_prompts.py +++ b/tests/docs_src/test_prompts.py @@ -54,11 +54,11 @@ async def test_missing_required_argument_is_a_protocol_error() -> None: async with Client(tutorial001.mcp) as client: with pytest.raises(MCPError) as exc_info: await client.get_prompt("review_code") - assert exc_info.value.code == -32603 - assert exc_info.value.message == "Internal server error" - # The line a traceback prints, exactly as the page quotes it: the code is not in the message. + assert exc_info.value.code == -32602 + assert exc_info.value.message == "Missing required arguments: {'code'}" + # The line a traceback prints, exactly as the page quotes it. assert traceback.format_exception_only(exc_info.value) == snapshot( - ["mcp.shared.exceptions.MCPError: Internal server error\n"] + ["mcp.shared.exceptions.MCPError: Missing required arguments: {'code'}\n"] ) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index a9fa7ea87f..938e81d75e 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -539,14 +539,6 @@ def __post_init__(self) -> None: "A cancellation notification for an in-flight request stops the server-side handler, and the " "receiver does not send a response for the cancelled request." ), - divergence=Divergence( - note=( - "The spec says receivers of a cancellation SHOULD NOT send a response for the cancelled " - "request; both seats send an error response (code 0, 'Request cancelled') instead — the " - "server for cancelled client requests, and the client for cancelled server-initiated " - "requests — which is what unblocks the sender's pending call." - ), - ), arm_exclusions=( ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), ArmExclusion(reason="requires-session", spec_version="2026-07-28"), @@ -618,24 +610,7 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic#responses", behavior=( "An unhandled exception in a request handler is returned to the caller as JSON-RPC error " - "-32603 Internal error." - ), - divergence=Divergence( - note=( - "The low-level Server returns code 0 (not a defined JSON-RPC code) instead of -32603 and " - "leaks str(exc) as the error message." - ), - ), - arm_exclusions=( - ArmExclusion( - reason="modern-error-surface", - spec_version="2026-07-28", - note=( - "The modern entry maps Exception->INTERNAL_ERROR (-32603) with an opaque message, so the " - "2026 arm SATISFIES this requirement; the test pins the legacy code-0 divergence and " - "needs an era-aware assertion before re-admission." - ), - ), + "-32603 Internal error, with a generic message rather than the exception's own text." ), ), "protocol:error:invalid-params": Requirement( @@ -1353,13 +1328,6 @@ def __post_init__(self) -> None: "prompts:get:missing-required-args": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#error-handling", behavior="prompts/get omitting a required argument returns JSON-RPC error -32602 (Invalid params).", - divergence=Divergence( - note=( - "MCPServer's prompt renderer raises a plain ValueError before the prompt function runs, " - "which the low-level server converts to error code 0 with the exception text as the message." - ), - ), - arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),), ), "prompts:get:multi-message": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#getting-a-prompt", @@ -1402,7 +1370,6 @@ def __post_init__(self) -> None: "mcpserver:prompt:args-validation": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#implementation-considerations", behavior="prompts/get arguments that fail the prompt's argument schema are rejected before the function runs.", - arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),), ), "mcpserver:prompt:decorated": Requirement( source="sdk", @@ -1428,13 +1395,6 @@ def __post_init__(self) -> None: "mcpserver:prompt:unknown-name": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#error-handling", behavior="prompts/get for a name that was never registered returns JSON-RPC error -32602 (Invalid params).", - divergence=Divergence( - note=( - "The spec's example uses -32602 Invalid params for unknown prompts; MCPServer raises " - "ValueError, which the low-level server converts to error code 0." - ), - ), - arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),), ), # ═══════════════════════════════════════════════════════════════════════════ # Completion @@ -3229,11 +3189,19 @@ def __post_init__(self) -> None: note="Only observable over streamable HTTP: Mcp-Session-Id is a streamable-HTTP response header.", ), "hosting:http:modern:initialize-removed": Requirement( - source=f"{SPEC_2026_BASE_URL}/basic/index", - behavior="A 2026-07-28 initialize request is answered with METHOD_NOT_FOUND.", + source=f"{SPEC_2026_BASE_URL}/basic/versioning", + behavior=( + "A 2026-07-28 initialize request is answered with UNSUPPORTED_PROTOCOL_VERSION (-32022) naming " + "the modern protocol versions the server serves." + ), added_in="2026-07-28", transports=("streamable-http",), - note=("Only observable over streamable HTTP: the modern entry's method registry omits initialize."), + note=( + "initialize does not exist at modern versions, and versioning asks a modern server to name its " + "versions in any error it returns to initialize, on any transport; the shared ladder's rung 0 " + "gives every transport that one answer. Only observable over streamable HTTP here: the SDK " + "client at 2026-07-28 never sends initialize, so only a raw POST can drive the negative." + ), ), "hosting:http:modern:legacy-fallthrough": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/versioning", diff --git a/tests/interaction/lowlevel/test_cancellation.py b/tests/interaction/lowlevel/test_cancellation.py index 8361865db0..c5be4cc6f2 100644 --- a/tests/interaction/lowlevel/test_cancellation.py +++ b/tests/interaction/lowlevel/test_cancellation.py @@ -12,10 +12,10 @@ import pytest from inline_snapshot import snapshot from mcp_types import ( + CONNECTION_CLOSED, REQUEST_TIMEOUT, CallToolResult, EmptyResult, - ErrorData, Implementation, InitializeResult, JSONRPCNotification, @@ -41,19 +41,25 @@ pytestmark = pytest.mark.anyio +_CANCEL_SILENCE_WINDOW = 0.2 +"""Observation window for the receiver's MUST-NOT-respond rule: absence of a +response can only be observed by waiting, so this is a bounded silence check.""" + + @requirement("protocol:cancel:in-flight") @requirement("protocol:cancel:handler-abort-propagates") async def test_cancellation_stops_in_flight_handler(connect: Connect) -> None: - """Cancelling an in-flight request interrupts its handler and fails the pending call. + """Cancelling an in-flight request interrupts its handler and the receiver sends + no response for it, so the caller's still-pending call neither resolves nor errors. - The server answers the cancelled request with an error response (the spec says it should - not respond at all; see the divergence note on the requirement), so the caller's pending - request raises rather than hanging. + The caller here keeps awaiting after cancelling out of band, which the spec leaves + to the caller ("SHOULD ignore any response that arrives afterward"); real callers + cancel through a scope and stop waiting, so the test cancels the pending await itself. """ started = anyio.Event() handler_cancelled = anyio.Event() request_ids: list[types.RequestId] = [] - errors: list[ErrorData] = [] + outcomes: list[object] = [] async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: assert params.name == "block" @@ -71,24 +77,31 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: with anyio.fail_after(5): - async with anyio.create_task_group() as task_group: + async with anyio.create_task_group() as task_group: # pragma: no branch - async def call_and_capture_error() -> None: - with pytest.raises(MCPError) as exc_info: - await client.call_tool("block", {}) - errors.append(exc_info.value.error) + async def call_and_record_outcome() -> None: + try: + outcomes.append(await client.call_tool("block", {})) + except MCPError as exc: + outcomes.append(f"error:{exc.error.code}") - task_group.start_soon(call_and_capture_error) + task_group.start_soon(call_and_record_outcome) await started.wait() await client.session.send_notification( types.CancelledNotification( params=types.CancelledNotificationParams(request_id=request_ids[0], reason="user aborted") ) ) + await handler_cancelled.wait() + await anyio.sleep(_CANCEL_SILENCE_WINDOW) + task_group.cancel_scope.cancel() # stop the caller's own pending await - await handler_cancelled.wait() - - assert errors == snapshot([ErrorData(code=0, message="Request cancelled")]) + # The receiver must not respond to the cancelled request. Over the HTTP + # transports it also tears down the cancelled request's own response stream, + # and the client's transport then resolves the still-pending await locally + # with CONNECTION_CLOSED - a client-side synthesis, not a server frame - so the + # only outcome ever recorded is that local resolution; a served result never appears. + assert all(outcome == f"error:{CONNECTION_CLOSED}" for outcome in outcomes) @requirement("protocol:cancel:server-survives") @@ -121,16 +134,14 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: with anyio.fail_after(5): async with anyio.create_task_group() as task_group: - - async def call_and_swallow_cancellation_error() -> None: - with pytest.raises(MCPError): - await client.call_tool("block", {}) - - task_group.start_soon(call_and_swallow_cancellation_error) + block_args: dict[str, object] = {} + task_group.start_soon(client.call_tool, "block", block_args) await started.wait() await client.session.send_notification( types.CancelledNotification(params=types.CancelledNotificationParams(request_id=request_ids[0])) ) + # No response follows the cancel; the caller abandons its pending await. + task_group.cancel_scope.cancel() result = await client.call_tool("echo", {}) diff --git a/tests/interaction/lowlevel/test_tools.py b/tests/interaction/lowlevel/test_tools.py index 86fec356bc..98ec0b2d00 100644 --- a/tests/interaction/lowlevel/test_tools.py +++ b/tests/interaction/lowlevel/test_tools.py @@ -98,10 +98,10 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @requirement("protocol:error:internal-error") async def test_call_tool_uncaught_exception_becomes_error_response(connect: Connect) -> None: - """An uncaught exception in the tool handler surfaces to the client as a JSON-RPC error. + """An uncaught exception in the tool handler surfaces to the client as JSON-RPC -32603. - The low-level server reports it with code 0 and the exception text as the message; see the - divergence note on the requirement. + The generic internal error carries no exception text: the handler's message stays in the + server log, and a handler that wants the client to see a message raises `MCPError` instead. """ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: @@ -114,7 +114,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara with pytest.raises(MCPError) as exc_info: await client.call_tool("explode", {}) - assert exc_info.value.error == snapshot(ErrorData(code=0, message="boom")) + assert exc_info.value.error == snapshot(ErrorData(code=types.INTERNAL_ERROR, message="Internal server error")) @requirement("tools:list:basic") diff --git a/tests/interaction/mcpserver/test_prompts.py b/tests/interaction/mcpserver/test_prompts.py index 8409e50207..274358a1ec 100644 --- a/tests/interaction/mcpserver/test_prompts.py +++ b/tests/interaction/mcpserver/test_prompts.py @@ -3,6 +3,7 @@ import pytest from inline_snapshot import snapshot from mcp_types import ( + INVALID_PARAMS, ErrorData, GetPromptResult, ListPromptsResult, @@ -76,10 +77,11 @@ def greet(name: str) -> str: @requirement("mcpserver:prompt:unknown-name") async def test_get_unknown_prompt_is_error(connect: Connect) -> None: - """Getting a prompt name that was never registered fails with a JSON-RPC error. + """Getting a prompt name that was never registered fails with the spec's -32602 Invalid params. - The spec reserves -32602 for this case; the SDK reports code 0 (see the divergence note on - the requirement). + MCPServer's prompt errors are typed protocol errors: this is the precondition that let the + unmapped-exception catch-all become opaque (-32603, generic message), so a regression to a + bare exception here would surface as 'Internal server error'. """ mcp = MCPServer("prompter") @@ -92,16 +94,17 @@ def greet(name: str) -> str: with pytest.raises(MCPError) as exc_info: await client.get_prompt("nope") - assert exc_info.value.error == snapshot(ErrorData(code=0, message="Unknown prompt: nope")) + assert exc_info.value.error == snapshot(ErrorData(code=INVALID_PARAMS, message="Unknown prompt: nope")) @requirement("prompts:get:missing-required-args") async def test_get_prompt_with_a_missing_required_argument_is_an_error(connect: Connect) -> None: """Getting a prompt without one of its required arguments fails with a JSON-RPC error. - The missing argument is detected before the prompt function is called, but the spec's -32602 - Invalid params is reported as error code 0 with the bare exception text (see the divergence - note on the requirement). + The missing argument is detected before the prompt function is called and reported as the + spec's -32602 Invalid params, carrying MCPServer's own message. This typed error is the + precondition on the opaque -32603 catch-all: prompt errors reach the wire because they are + protocol errors, not because handler exceptions leak. """ mcp = MCPServer("prompter") @@ -114,7 +117,9 @@ def greet(name: str) -> str: with pytest.raises(MCPError) as exc_info: await client.get_prompt("greet") - assert exc_info.value.error == snapshot(ErrorData(code=0, message="Missing required arguments: {'name'}")) + assert exc_info.value.error == snapshot( + ErrorData(code=INVALID_PARAMS, message="Missing required arguments: {'name'}") + ) @requirement("mcpserver:prompt:args-validation") @@ -137,7 +142,7 @@ def repeat(phrase: str, count: int) -> str: with pytest.raises(MCPError) as exc_info: await client.get_prompt("repeat", {"phrase": "hi", "count": "many"}) - assert exc_info.value.error.code == 0 + assert exc_info.value.error.code == INVALID_PARAMS assert exc_info.value.error.message.startswith("Error rendering prompt repeat: 1 validation error") diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 9ebaa71460..731551a891 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -23,6 +23,7 @@ METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, SERVER_INFO_META_KEY, + UNSUPPORTED_PROTOCOL_VERSION, CallToolRequestParams, CallToolResult, DiscoverResult, @@ -39,7 +40,7 @@ TextContent, Tool, ) -from mcp_types.version import LATEST_MODERN_VERSION +from mcp_types.version import LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS from mcp import MCPError from mcp.client.client import Client @@ -154,13 +155,12 @@ async def test_modern_response_carries_no_session_id_header() -> None: @requirement("hosting:http:modern:initialize-removed") -async def test_modern_initialize_is_method_not_found() -> None: - """A 2026-07-28 initialize request that carries a valid envelope is answered METHOD_NOT_FOUND at HTTP 404. +async def test_modern_initialize_is_answered_with_the_supported_versions() -> None: + """A 2026-07-28 initialize request is answered -32022 naming the modern versions, at HTTP 400. - Spec-mandated under the draft: initialize is not a defined method at 2026-07-28, so the kernel's - method/version gate rejects it before any handler runs. The body must carry the per-request - ``_meta`` envelope so the classifier ladder admits it as far as kernel dispatch -- without the - envelope the request is INVALID_PARAMS at rung 1, never METHOD_NOT_FOUND. Asserted at the wire + initialize is not a method at modern versions, and versioning asks a modern server to name + the versions it serves in any error it returns to initialize, on any transport - so the shared + ladder's rung 0 gives this answer before any handler or envelope check. Asserted at the wire because the SDK client at 2026-07-28 never sends initialize, so only a raw POST can drive the negative. """ @@ -168,8 +168,10 @@ async def test_modern_initialize_is_method_not_found() -> None: async with mounted_app(_server()) as (http, _): response = await http.post("/mcp", json=body, headers=_modern_headers(method="initialize")) - assert response.status_code == 404 - assert JSONRPCError.model_validate(response.json()).error.code == METHOD_NOT_FOUND + assert response.status_code == 400 + error = JSONRPCError.model_validate(response.json()).error + assert error.code == UNSUPPORTED_PROTOCOL_VERSION + assert error.data["supported"] == list(MODERN_PROTOCOL_VERSIONS) @requirement("hosting:http:modern:legacy-fallthrough") diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 98d59e98cb..33c40e5a29 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -2345,3 +2345,36 @@ def greeting() -> str: # pragma: no cover assert mcp._prompt_manager.list_prompts() == [] with pytest.raises(ValueError, match="Unknown prompt: greeting"): mcp.remove_prompt("greeting") + + +def test_lowlevel_server_is_the_server_the_decorators_registered_on() -> None: + mcp = MCPServer("wrapped", version="1.2.3") + + @mcp.tool() + def echo(text: str) -> str: # pragma: no cover + return text + + lowlevel = mcp.lowlevel_server + assert lowlevel is mcp.lowlevel_server # one server underneath, not a copy per access + assert (lowlevel.name, lowlevel.version) == ("wrapped", "1.2.3") + assert lowlevel.get_request_handler("tools/list") is not None + + +def test_subscriptions_exposes_the_bus_the_server_was_built_with() -> None: + bus = InMemorySubscriptionBus() + assert MCPServer("with-bus", subscriptions=bus).subscriptions is bus + assert isinstance(MCPServer("default-bus").subscriptions, InMemorySubscriptionBus) + + +async def test_close_subscriptions_ends_open_listen_streams_gracefully_after_draining_events() -> None: + """`mcp.close_subscriptions()` is the server-side end: the stream delivers its pending + event, then ends cleanly (its listen result), with no ListenHandler reference held.""" + mcp = MCPServer("closer") + events: list[object] = [] + async with Client(mcp) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await mcp.subscriptions.publish(ToolsListChanged()) + mcp.close_subscriptions() + events.extend([event async for event in sub]) + assert events == [ToolsListChanged()] diff --git a/tests/server/test_cancel_handling.py b/tests/server/test_cancel_handling.py index 3d32adb3c8..2706bf550b 100644 --- a/tests/server/test_cancel_handling.py +++ b/tests/server/test_cancel_handling.py @@ -22,7 +22,6 @@ from mcp import Client from mcp.server import Server, ServerRequestContext -from mcp.shared.exceptions import MCPError from mcp.shared.message import SessionMessage @@ -60,31 +59,27 @@ async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestPar server = Server("test-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) async with Client(server, mode="legacy") as client: - # First request (will be cancelled) - async def first_request(): - try: - await client.session.send_request( + # First request: cancelled out of band. The receiver sends no response for + # a cancelled request, so the caller abandons its pending await via the scope. + with anyio.fail_after(10): + async with anyio.create_task_group() as tg: + tg.start_soon( + client.session.send_request, CallToolRequest(params=CallToolRequestParams(name="test_tool", arguments={})), CallToolResult, ) - pytest.fail("First request should have been cancelled") # pragma: no cover - except MCPError: - pass # Expected - - # Start first request - async with anyio.create_task_group() as tg: - tg.start_soon(first_request) - - # Wait for it to start - await ev_first_call.wait() - - # Cancel it - assert first_request_id is not None - await client.session.send_notification( - CancelledNotification( - params=CancelledNotificationParams(request_id=first_request_id, reason="Testing server recovery"), + + # Wait for it to start + await ev_first_call.wait() + + # Cancel it + assert first_request_id is not None + await client.session.send_notification( + CancelledNotification( + params=CancelledNotificationParams(request_id=first_request_id, reason="Testing recovery"), + ) ) - ) + tg.cancel_scope.cancel() # Second request (should work normally) result = await client.call_tool("test_tool", {}) diff --git a/tests/server/test_completion_with_context.py b/tests/server/test_completion_with_context.py index 79e4223ed4..5a8b449594 100644 --- a/tests/server/test_completion_with_context.py +++ b/tests/server/test_completion_with_context.py @@ -2,6 +2,7 @@ import pytest from mcp_types import ( + INVALID_PARAMS, CompleteRequestParams, CompleteResult, Completion, @@ -9,7 +10,7 @@ ResourceTemplateReference, ) -from mcp import Client +from mcp import Client, MCPError from mcp.server import Server, ServerRequestContext @@ -129,7 +130,9 @@ async def handle_completion(ctx: ServerRequestContext, params: CompleteRequestPa assert params.argument.name == "table" if not params.context or not params.context.arguments or "database" not in params.context.arguments: - raise ValueError("Please select a database first to see available tables") + # A message the client should see travels as an MCPError; a bare + # exception would reach the wire only as an opaque internal error. + raise MCPError(code=INVALID_PARAMS, message="Please select a database first to see available tables") db = params.context.arguments.get("database") assert db == "test_db" diff --git a/tests/server/test_http_orphan_and_posture.py b/tests/server/test_http_orphan_and_posture.py new file mode 100644 index 0000000000..6c36a222d3 --- /dev/null +++ b/tests/server/test_http_orphan_and_posture.py @@ -0,0 +1,322 @@ +"""Two streamable-HTTP obligations that the stream-driver work must not break. + +(i) The request-coroutine orphan. A cancelled request no longer receives the +`{"code":0,"message":"Request cancelled"}` frame the JSON-RPC dispatcher used to write, so +the legacy streamable-HTTP JSON-response-mode POST loop must not wait for an answer to the +cancelled request that never comes (which would leave the POST coroutine and its +`_request_streams` entry lingering until the client disconnects). This module cancels an +in-flight request over legacy HTTP JSON mode and requires that the POST completes, its +stream entry is released, and the session keeps serving. The transport closes the +cancelled request's response stream; the dispatcher writes nothing for the id. + +(ii) Posture honoured over streamable HTTP. A `MODERN_ONLY` server refuses a 2025 +`initialize` with -32022 whose data names the versions it serves; a `LEGACY_ONLY` server +refuses an enveloped modern request in its own (legacy) vocabulary instead of serving it. +`Server(posture=)` is one constructor property every transport reads; this file measures +the HTTP half of that claim. + +Everything runs in process: `StreamableHTTPSessionManager` mounted in Starlette, an httpx2 +client on the suite's streaming ASGI bridge, raw JSON-RPC over HTTP - because the properties +under test (a POST's completion, a stream table, statuses and error bodies) are things the +high-level client cannot observe. +""" + +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Any + +import anyio +import httpx2 +import pytest +from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + UNSUPPORTED_PROTOCOL_VERSION, + CallToolRequestParams, + CallToolResult, + TextContent, +) +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server import Posture, Server, ServerRequestContext +from mcp.server.streamable_http import MCP_SESSION_ID_HEADER +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER +from tests.interaction.transports import StreamingASGITransport + +pytestmark = pytest.mark.anyio + +Ctx = ServerRequestContext[dict[str, Any], Any] + +# The in-process app is mounted at this origin purely so URLs are well-formed. +BASE_URL = "http://127.0.0.1:8000" + +JSON_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"} + +# The modern per-request envelope for a request that claims the 2026 era over HTTP. +MODERN_ENVELOPE: dict[str, Any] = { + PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION, + CLIENT_INFO_META_KEY: {"name": "http-suite", "version": "0"}, + CLIENT_CAPABILITIES_META_KEY: {}, +} + + +@dataclass +class Probe: + """Per-test observation points; created inside the running backend.""" + + entered: anyio.Event = field(default_factory=anyio.Event) + """The `slow` tool handler has started: the request is genuinely in flight.""" + + release: anyio.Event = field(default_factory=anyio.Event) + """Lets a parked `slow` call return; the peer-cancelled one never gets here.""" + + +def tool_handler(probe: Probe) -> Callable[[Ctx, CallToolRequestParams], Awaitable[CallToolResult]]: + """One `tools/call` handler for every server in this file: `slow` parks on the probe, + anything else returns immediately.""" + + async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult: + if params.name == "slow": + probe.entered.set() + await probe.release.wait() + return CallToolResult(content=[TextContent(type="text", text=f"{params.name} done")]) + + return call_tool + + +def make_server(probe: Probe) -> Server[dict[str, Any]]: + """A dual-era lowlevel server (default posture) exposing the probe-driven tool.""" + return Server("http-orphan", version="0.0.0", on_call_tool=tool_handler(probe)) + + +def make_postured_server(posture: Posture, probe: Probe) -> Server[dict[str, Any]]: + """A lowlevel server declaring `posture`, over the same tool surface as `make_server`.""" + return Server("http-posture", version="0.0.0", posture=posture, on_call_tool=tool_handler(probe)) + + +@asynccontextmanager +async def running_app( + server: Server[dict[str, Any]], *, json_response: bool +) -> AsyncIterator[tuple[Starlette, StreamableHTTPSessionManager]]: + """Serve `server`'s streamable-HTTP surface in process; yield the ASGI app and its manager.""" + # DNS-rebinding protection guards a network path an in-process app does not have. + manager = StreamableHTTPSessionManager( + app=server, + json_response=json_response, + security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False), + ) + app = Starlette(routes=[Mount("/mcp", app=manager.handle_request)]) + async with manager.run(): + yield app, manager + + +def http_client(app: Starlette) -> httpx2.AsyncClient: + """An httpx2 client served in process by `app` (a Mount 307-redirects the bare path).""" + return httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL, follow_redirects=True) + + +async def legacy_handshake(client: httpx2.AsyncClient) -> dict[str, str]: + """Open a 2025-era session over streamable HTTP; return the headers that address it.""" + init = await client.post( + "/mcp", + headers=JSON_HEADERS, + json={ + "jsonrpc": "2.0", + "id": "init-1", + "method": "initialize", + "params": { + "protocolVersion": LATEST_HANDSHAKE_VERSION, + "capabilities": {}, + "clientInfo": {"name": "http-suite", "version": "0"}, + }, + }, + ) + assert init.status_code == 200, f"handshake failed: {init.status_code} {init.text}" + session_headers = { + **JSON_HEADERS, + MCP_SESSION_ID_HEADER: init.headers[MCP_SESSION_ID_HEADER], + MCP_PROTOCOL_VERSION_HEADER: init.json()["result"]["protocolVersion"], + } + initialized = await client.post( + "/mcp", headers=session_headers, json={"jsonrpc": "2.0", "method": "notifications/initialized"} + ) + assert initialized.status_code == 202, f"initialized notification refused: {initialized.status_code}" + return session_headers + + +def open_request_streams(manager: StreamableHTTPSessionManager) -> set[str]: + """The per-request response streams the legacy transport still holds open. + + Private state by necessity: the orphan IS a stream-table entry that outlives its POST, + which nothing on the public surface exposes. + """ + open_keys: set[str] = set() + for transport in manager._server_instances.values(): + open_keys.update(str(key) for key in transport._request_streams) + return open_keys + + +# --- (i) the request-coroutine orphan ----------------------------------------------------- + + +async def test_peer_cancel_in_legacy_json_mode_completes_the_post_and_releases_its_stream() -> None: + """Legacy streamable HTTP, JSON-response mode: the peer cancels an in-flight + `tools/call` with `notifications/cancelled`. The POST awaiting that call MUST complete, + its `_request_streams` entry MUST be released, and the session MUST keep serving - all + without a resurrected dispatcher cancel frame. A peer cancel is also not a server fault, + so the completed POST must not be a 5xx. + + Steps: handshake; POST a slow tool call in the background and wait until it is in + flight (its stream entry exists); POST the cancel; note whether the pending POST + finished within the bound and whether its stream entry is gone; make a follow-up call to + prove the session still serves. Observations are gathered on the connection and + asserted after it closes, so a failure reads as the assertion, not the bridge's group. + """ + probe = Probe() + server = make_server(probe) + outcome: dict[str, httpx2.Response] = {} + call_returned = anyio.Event() + in_flight_stream_seen = completed = False + still_open: set[str] = set() + async with running_app(server, json_response=True) as (app, manager), http_client(app) as client: + session_headers = await legacy_handshake(client) + + async def post_the_slow_call() -> None: + outcome["call"] = await client.post( + "/mcp", + headers=session_headers, + json={ + "jsonrpc": "2.0", + "id": "call-to-cancel", + "method": "tools/call", + "params": {"name": "slow", "arguments": {}}, + }, + ) + call_returned.set() + + async with anyio.create_task_group() as tg: + tg.start_soon(post_the_slow_call) + with anyio.fail_after(5): + await probe.entered.wait() # the call is in flight... + # ...so its response stream exists; the release check below is not vacuous. + in_flight_stream_seen = "call-to-cancel" in open_request_streams(manager) + outcome["cancel_ack"] = await client.post( + "/mcp", + headers=session_headers, + json={ + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": {"requestId": "call-to-cancel", "reason": "test"}, + }, + ) + with anyio.move_on_after(5): + await call_returned.wait() + completed = call_returned.is_set() + still_open = open_request_streams(manager) + # Tear down a POST that never returned so nothing else waits on the orphan. + tg.cancel_scope.cancel() + + # The session is still alive: the same tool, released this time, is served. + probe.release.set() + followup = await client.post( + "/mcp", + headers=session_headers, + json={ + "jsonrpc": "2.0", + "id": "call-followup", + "method": "tools/call", + "params": {"name": "fast", "arguments": {}}, + }, + ) + + assert in_flight_stream_seen, ( + "no response stream was open for the in-flight request; if the legacy transport " + "renamed its stream table, update `open_request_streams`" + ) + assert outcome["cancel_ack"].status_code == 202, ( + f"the cancel notification must be accepted: {outcome['cancel_ack']}" + ) + assert completed, ( + "the POST awaiting the peer-cancelled request never completed (orphaned request " + f"coroutine, still pending 5s after the cancel); streams still open: {still_open}" + ) + assert "call-to-cancel" not in still_open, ( + f"the cancelled request's response stream must be released, still open: {still_open}" + ) + assert outcome["call"].status_code < 500, ( + f"a peer cancel is not a server fault, but the POST completed as {outcome['call'].status_code}: " + f"{outcome['call'].text}" + ) + assert followup.status_code == 200, f"the session must keep serving after a cancel: {followup.text}" + assert followup.json()["result"]["content"][0]["text"] == "fast done" + + +# --- (ii) posture is honoured over streamable HTTP ----------------------------------------- + + +async def test_modern_only_server_refuses_a_2025_handshake_over_http_with_the_version_error() -> None: + """A server declared MODERN_ONLY refuses a 2025 `initialize` over streamable HTTP with + -32022 whose data lists the versions it does serve - the modern era's own answer to a + handshake attempt (versioning.mdx: a modern-only server SHOULD name its versions), not a + legacy handshake completed by a transport that never read the posture.""" + server = make_postured_server(Posture.MODERN_ONLY, Probe()) + async with running_app(server, json_response=True) as (app, _), http_client(app) as client: + response = await client.post( + "/mcp", + headers=JSON_HEADERS, + json={ + "jsonrpc": "2.0", + "id": "init-1", + "method": "initialize", + "params": { + "protocolVersion": LATEST_HANDSHAKE_VERSION, + "capabilities": {}, + "clientInfo": {"name": "http-suite", "version": "0"}, + }, + }, + ) + body = response.json() + assert "error" in body, ( + f"a modern-only server must refuse the 2025 handshake, but answered {response.status_code}: {body}" + ) + assert body["error"]["code"] == UNSUPPORTED_PROTOCOL_VERSION + assert LATEST_MODERN_VERSION in body["error"]["data"]["supported"] + + +async def test_legacy_only_server_refuses_an_enveloped_modern_request_over_http_in_legacy_vocabulary() -> None: + """A server declared LEGACY_ONLY meets an enveloped 2026 request over streamable HTTP: + it must not serve it under the modern era, and it refuses in legacy vocabulary - an HTTP + 4xx or a legacy-space JSON-RPC error, never a served result and never the modern-only + -32022 that would tell an auto-negotiating client not to fall back to `initialize`.""" + server = make_postured_server(Posture.LEGACY_ONLY, Probe()) + async with running_app(server, json_response=True) as (app, _), http_client(app) as client: + response = await client.post( + "/mcp", + headers={**JSON_HEADERS, MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}, + json={ + "jsonrpc": "2.0", + "id": "call-1", + "method": "tools/call", + "params": {"name": "fast", "arguments": {}, "_meta": dict(MODERN_ENVELOPE)}, + }, + ) + # Every refusal path an HTTP entry has today carries a JSON body; a bodyless 4xx + # would surface here as a decode error, which still fails the test with its reason. + payload: dict[str, Any] = response.json() + assert "result" not in payload, ( + f"a legacy-only server must not serve an enveloped modern request, but did: {payload}" + ) + error_code: Any = payload.get("error", {}).get("code") + assert response.status_code >= 400 or error_code is not None, ( + f"the refusal must be an HTTP 4xx or a JSON-RPC error, got {response.status_code}: {payload}" + ) + assert error_code != UNSUPPORTED_PROTOCOL_VERSION, ( + "a legacy-only server must refuse in legacy vocabulary; -32022 tells an auto-negotiating " + f"client not to fall back to initialize: {payload}" + ) diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py index 4cfff47c39..11d10bb6ef 100644 --- a/tests/server/test_lifespan.py +++ b/tests/server/test_lifespan.py @@ -2,6 +2,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from typing import Any import anyio import pytest @@ -15,6 +16,7 @@ JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, + ListToolsResult, TextContent, ) from pydantic import TypeAdapter @@ -153,12 +155,8 @@ def check_lifespan(ctx: Context) -> bool: async with anyio.create_task_group() as tg, send_stream1, receive_stream1, send_stream2, receive_stream2: async def run_server(): - await server._lowlevel_server.run( - receive_stream1, - send_stream2, - server._lowlevel_server.create_initialization_options(), - raise_exceptions=True, - ) + # `lowlevel_server` is how an MCPServer reaches the stream drivers. + await server.lowlevel_server.run(receive_stream1, send_stream2, raise_exceptions=True) tg.start_soon(run_server) @@ -205,3 +203,66 @@ async def run_server(): # Cancel server task tg.cancel_scope.cancel() + + +@pytest.mark.anyio +async def test_server_lifespan_is_the_bound_way_to_enter_the_constructor_lifespan(): + """`server.lifespan()` enters the constructor's `lifespan=` and yields its state.""" + events: list[str] = [] + + @asynccontextmanager + async def tracked(server: Server[dict[str, str]]) -> AsyncIterator[dict[str, str]]: + events.append(f"enter:{server.name}") + try: + yield {"db": "connected"} + finally: + events.append("exit") + + server: Server[dict[str, str]] = Server("bound", lifespan=tracked) + + async with server.lifespan() as state: + assert state == {"db": "connected"} + assert events == ["enter:bound"] + + assert events == ["enter:bound", "exit"] + + +@pytest.mark.anyio +async def test_server_run_without_initialization_options_derives_them_from_the_server(): + """`server.run(read, write)` is complete: the handshake result carries the derived options.""" + + async def list_tools(ctx: ServerRequestContext[dict[str, bool]], params: Any) -> ListToolsResult: + raise NotImplementedError + + server = Server[dict[str, bool]]("derived-options", version="9.9.9", on_list_tools=list_tools) + + send_stream1, receive_stream1 = anyio.create_memory_object_stream[SessionMessage](100) + send_stream2, receive_stream2 = anyio.create_memory_object_stream[SessionMessage](100) + + async with anyio.create_task_group() as tg, send_stream1, receive_stream1, send_stream2, receive_stream2: + tg.start_soon(server.run, receive_stream1, send_stream2) + + params = InitializeRequestParams( + protocol_version="2024-11-05", + capabilities=ClientCapabilities(), + client_info=Implementation(name="test-client", version="0.1.0"), + ) + with anyio.fail_after(5): + await send_stream1.send( + SessionMessage( + JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="initialize", + params=params.model_dump(by_alias=True, exclude_none=True), + ) + ) + ) + handshake = (await receive_stream2.receive()).message + + assert isinstance(handshake, JSONRPCResponse) + assert handshake.result["serverInfo"] == {"name": "derived-options", "version": "9.9.9"} + # The derived options describe this server: it registered a tools handler. + assert "tools" in handshake.result["capabilities"] + + tg.cancel_scope.cancel() diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index 174cb853ae..f40b89aa0b 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -21,12 +21,10 @@ CLIENT_INFO_META_KEY, INTERNAL_ERROR, INVALID_PARAMS, - INVALID_REQUEST, LATEST_PROTOCOL_VERSION, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, SERVER_INFO_META_KEY, - UNSUPPORTED_PROTOCOL_VERSION, CallToolRequestParams, ClientCapabilities, EmptyResult, @@ -34,7 +32,6 @@ Icon, Implementation, InitializeRequestParams, - JSONRPCRequest, ListToolsResult, NotificationParams, PaginatedRequestParams, @@ -46,32 +43,27 @@ from mcp_types.version import ( LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, - MODERN_PROTOCOL_VERSIONS, OLDEST_SUPPORTED_VERSION, ) import mcp.server.runner from mcp.server.caching import CacheHint -from mcp.server.connection import Connection, NotifyOnlyOutbound +from mcp.server.connection import Connection from mcp.server.context import ServerRequestContext from mcp.server.lowlevel.server import NotificationOptions, Server from mcp.server.models import InitializationOptions from mcp.server.runner import ( ServerRunner, _extract_meta, - _has_modern_envelope, - _initialize_after_modern_data, - _NoServerRequestsDispatchContext, aclose_shielded, serve_connection, - serve_dual_era_loop, serve_one, ) from mcp.server.session import ServerSession from mcp.shared.dispatcher import CallOptions -from mcp.shared.exceptions import MCPError, NoBackChannelError +from mcp.shared.exceptions import MCPError from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher -from mcp.shared.message import MessageMetadata, SessionMessage +from mcp.shared.message import MessageMetadata from mcp.shared.peer import dump_params from mcp.shared.transport_context import TransportContext @@ -89,6 +81,18 @@ def _initialize_params() -> dict[str, Any]: ).model_dump(by_alias=True, exclude_none=True) +def _modern_envelope(version: str = LATEST_MODERN_VERSION) -> dict[str, Any]: + return { + PROTOCOL_VERSION_META_KEY: version, + CLIENT_INFO_META_KEY: {"name": "test-client", "version": "1.0"}, + CLIENT_CAPABILITIES_META_KEY: {}, + } + + +def _modern_params(version: str = LATEST_MODERN_VERSION, **params: Any) -> dict[str, Any]: + return {**params, "_meta": _modern_envelope(version)} + + _seen_ctx: list[Ctx] = [] SrvT = Server[dict[str, Any]] @@ -796,8 +800,9 @@ async def bad_return(ctx: Ctx, params: PaginatedRequestParams | None) -> int: async with connected_runner(server) as (client, _): with pytest.raises(MCPError) as exc: await client.send_raw_request("tools/list", None) - assert exc.value.error.code == 0 - assert "int" in exc.value.error.message + # The handler's TypeError is a server bug: it is logged there, and the + # wire carries only the generic internal error, never its text. + assert exc.value.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error") @pytest.mark.anyio @@ -1429,588 +1434,3 @@ async def test_serve_connection_drives_dispatcher_loop_and_tears_down(server: Sr assert cleaned == [1] assert conn.protocol_version == LATEST_HANDSHAKE_VERSION assert conn.client_params is not None - - -# --- serve_dual_era_loop ---------------------------------------------------- - - -def _modern_envelope(version: str = LATEST_MODERN_VERSION) -> dict[str, Any]: - return { - PROTOCOL_VERSION_META_KEY: version, - CLIENT_INFO_META_KEY: {"name": "test-client", "version": "1.0"}, - CLIENT_CAPABILITIES_META_KEY: {}, - } - - -def _modern_params(version: str = LATEST_MODERN_VERSION, **params: Any) -> dict[str, Any]: - return {**params, "_meta": _modern_envelope(version)} - - -@asynccontextmanager -async def dual_era_client(server: SrvT) -> AsyncIterator[tuple[JSONRPCDispatcher[TransportContext], Recorder]]: - """Yield `(client, recorder)` speaking raw frames to a `serve_dual_era_loop` server. - - The driver owns its dispatcher and connection, so unlike `connected_runner` - the harness hands it bare streams and performs no handshake: each test - drives the era lock itself. - """ - c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) - s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) - - def builder(_meta: object) -> TransportContext: - return TransportContext(kind="jsonrpc", can_send_request=True) - - client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send, transport_builder=builder) - recorder = Recorder() - c_req, c_notify = echo_handlers(recorder) - body_exc: BaseException | None = None - async with anyio.create_task_group() as tg: - await tg.start(client.run, c_req, c_notify) - tg.start_soon(partial(serve_dual_era_loop, server, c2s_recv, s2c_send, lifespan_state=_LIFESPAN)) - try: - with anyio.fail_after(5): - yield client, recorder - except BaseException as e: - body_exc = e - tg.cancel_scope.cancel() - if body_exc is not None: - raise body_exc - - -@pytest.mark.anyio -async def test_dual_era_loop_discover_locks_modern_and_serves_envelope_requests(server: SrvT): - """`server/discover` over the loop returns a DiscoverResult and locks the - connection modern: envelope-bearing feature requests are then served - single-exchange, and a later legacy `initialize` is rejected with -32022 - naming the modern versions.""" - async with dual_era_client(server) as (client, _): - discover = await client.send_raw_request("server/discover", _modern_params()) - assert LATEST_MODERN_VERSION in discover["supportedVersions"] - assert "tools" in discover["capabilities"] - result = await client.send_raw_request("tools/list", _modern_params()) - assert result["tools"][0]["name"] == "t" - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("initialize", _initialize_params()) - assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION - assert exc_info.value.error.data == { - "supported": list(MODERN_PROTOCOL_VERSIONS), - "requested": LATEST_HANDSHAKE_VERSION, - } - - -@pytest.mark.anyio -async def test_dual_era_loop_pinned_modern_request_locks_without_a_probe(server: SrvT): - """A pinned-modern client sends no probe: its first envelope-bearing - feature request locks the connection modern directly.""" - async with dual_era_client(server) as (client, _): - result = await client.send_raw_request("tools/list", _modern_params()) - assert result["tools"][0]["name"] == "t" - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("initialize", _initialize_params()) - assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION - - -@pytest.mark.anyio -async def test_dual_era_loop_initialize_after_modern_lock_without_a_parseable_version(server: SrvT): - """An `initialize` with no string protocolVersion still gets the supported - list in the -32022 data (the typed payload needs a `requested` string).""" - async with dual_era_client(server) as (client, _): - await client.send_raw_request("server/discover", _modern_params()) - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("initialize", {"capabilities": {}}) - assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION - assert exc_info.value.error.data == {"supported": list(MODERN_PROTOCOL_VERSIONS)} - - -@pytest.mark.anyio -async def test_dual_era_loop_initialize_locks_legacy_and_rejects_modern_traffic(server: SrvT): - """After a successful handshake the connection is legacy for its lifetime: - envelope-bearing requests (including a triple-stamped `server/discover`) - are rejected with INVALID_REQUEST while plain legacy requests keep - working.""" - async with dual_era_client(server) as (client, _): - init = await client.send_raw_request("initialize", _initialize_params()) - assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION - with pytest.raises(MCPError) as discover_exc: - await client.send_raw_request("server/discover", _modern_params()) - with pytest.raises(MCPError) as envelope_exc: - await client.send_raw_request("tools/list", _modern_params()) - result = await client.send_raw_request("tools/list", None) - assert result["tools"][0]["name"] == "t" - assert discover_exc.value.error.code == INVALID_REQUEST - assert envelope_exc.value.error.code == INVALID_REQUEST - assert "locked to the legacy handshake era" in discover_exc.value.error.message - - -@pytest.mark.anyio -async def test_dual_era_loop_bare_discover_after_legacy_lock_is_byte_identical(server: SrvT): - """A bare `server/discover` on a legacy-locked connection falls through to - the loop runner's per-version surface validation - the same - METHOD_NOT_FOUND shape a handshake-only server produced, byte for byte - (released probing clients key on it).""" - async with dual_era_client(server) as (client, _): - await client.send_raw_request("initialize", _initialize_params()) - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("server/discover", None) - assert exc_info.value.error.code == METHOD_NOT_FOUND - assert exc_info.value.error.message == "Method not found" - assert exc_info.value.error.data == "server/discover" - - -@pytest.mark.anyio -async def test_dual_era_loop_unsupported_modern_version_rejects_without_locking(server: SrvT): - """A probe at an unknown modern version gets -32022 with the supported - list, and the rejection does not lock the era: the legacy handshake still - succeeds afterwards (the released auto clients' retry/fallback contract).""" - async with dual_era_client(server) as (client, _): - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("server/discover", _modern_params(version="2099-01-01")) - init = await client.send_raw_request("initialize", _initialize_params()) - assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION - assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION - assert exc_info.value.error.data == { - "supported": list(MODERN_PROTOCOL_VERSIONS), - "requested": "2099-01-01", - } - - -@pytest.mark.anyio -async def test_dual_era_loop_bare_discover_rejects_without_locking(server: SrvT): - """A `server/discover` with no envelope triple is INVALID_PARAMS - never - -32022, so a released auto client's code-keyed fallback predicate takes the - legacy branch - and the connection can still complete the handshake.""" - async with dual_era_client(server) as (client, _): - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("server/discover", None) - init = await client.send_raw_request("initialize", _initialize_params()) - assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION - assert exc_info.value.error.code == INVALID_PARAMS - assert exc_info.value.error.code != UNSUPPORTED_PROTOCOL_VERSION - - -@pytest.mark.anyio -async def test_dual_era_loop_ping_before_any_lock_stays_exempt_and_neutral(server: SrvT): - """A pre-handshake `ping` is answered (the init-gate exemption) and does - not lock an era: the connection can still go modern.""" - async with dual_era_client(server) as (client, _): - assert await client.send_raw_request("ping", None) == {} - result = await client.send_raw_request("tools/list", _modern_params()) - assert result["tools"][0]["name"] == "t" - - -@pytest.mark.anyio -async def test_dual_era_loop_modern_request_without_envelope_rejects(server: SrvT): - """On a modern-locked connection every request is classified: one without - the envelope triple is INVALID_PARAMS.""" - async with dual_era_client(server) as (client, _): - await client.send_raw_request("server/discover", _modern_params()) - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("tools/list", None) - assert exc_info.value.error.code == INVALID_PARAMS - - -@pytest.mark.anyio -async def test_dual_era_loop_rejects_subscriptions_listen_on_modern(server: SrvT): - """`subscriptions/listen` is rejected before dispatch on the stream-pair - modern path (the registered handler assumes the HTTP entry's stream - semantics) - and like every failed request it does not lock the era, so - the legacy handshake stays available.""" - async with dual_era_client(server) as (client, _): - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("subscriptions/listen", _modern_params()) - init = await client.send_raw_request("initialize", _initialize_params()) - assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION - assert exc_info.value.error.code == METHOD_NOT_FOUND - assert "not served over this transport" in exc_info.value.error.message - - -@pytest.mark.anyio -async def test_dual_era_loop_malformed_envelope_content_never_locks(server: SrvT): - """The envelope triple with mis-shaped values fails the request but never - locks the era: the lock commits only when a modern request SUCCEEDS, so a - buggy client's initialize fallback still works (it must never see -32022 - for a request that failed).""" - params: dict[str, Any] = {"_meta": {**_modern_envelope(), CLIENT_INFO_META_KEY: 42}} - async with dual_era_client(server) as (client, _): - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("tools/list", params) - init = await client.send_raw_request("initialize", _initialize_params()) - assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION - assert exc_info.value.error.code == INVALID_PARAMS - assert exc_info.value.error.code != UNSUPPORTED_PROTOCOL_VERSION - - -@pytest.mark.anyio -async def test_dual_era_loop_pair_only_envelope_serves_modern_and_locks(server: SrvT): - """Spec-mandated (spec PR #3002): the required envelope pair (protocol version - + client capabilities) without the optional clientInfo is a complete - modern request - it is served, records the declared capabilities without - client params, locks the era modern, and a later legacy `initialize` is - rejected with -32022.""" - params = _modern_params() - del params["_meta"][CLIENT_INFO_META_KEY] - async with dual_era_client(server) as (client, _): - result = await client.send_raw_request("tools/list", params) - assert result["tools"][0]["name"] == "t" - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("initialize", _initialize_params()) - assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION - ctx = _seen_ctx[-1] - assert ctx.session.client_params is None - assert ctx.session.client_capabilities == ClientCapabilities() - - -@pytest.mark.anyio -async def test_dual_era_loop_version_without_capabilities_rejects_naming_the_key_and_never_locks(server: SrvT): - """A `_meta` declaring the protocol version but missing the required - client-capabilities key routes modern - never the legacy path with its - generic 'Invalid request parameters' - and is rejected INVALID_PARAMS - naming the missing key; like every failed classification it locks no era, - so the legacy handshake stays available.""" - params = _modern_params() - del params["_meta"][CLIENT_CAPABILITIES_META_KEY] - async with dual_era_client(server) as (client, _): - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("tools/list", params) - init = await client.send_raw_request("initialize", _initialize_params()) - assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION - assert exc_info.value.error.code == INVALID_PARAMS - assert CLIENT_CAPABILITIES_META_KEY in exc_info.value.error.message - - -@pytest.mark.anyio -async def test_dual_era_loop_failed_modern_request_never_locks(server: SrvT): - """A well-formed modern request for an unknown method fails without - locking; the next modern request locks on its own success.""" - async with dual_era_client(server) as (client, _): - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("nope/missing", _modern_params()) - init = await client.send_raw_request("initialize", _initialize_params()) - assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION - assert exc_info.value.error.code == METHOD_NOT_FOUND - - -@pytest.mark.anyio -async def test_dual_era_loop_initialize_with_envelope_takes_the_handshake_path(server: SrvT): - """`initialize` is legacy-distinctive by definition - it does not exist at - modern versions - so stamping the envelope triple on it still runs the - handshake and locks legacy.""" - init_params = {**_initialize_params(), **_modern_params()} - async with dual_era_client(server) as (client, _): - init = await client.send_raw_request("initialize", init_params) - assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("tools/list", _modern_params()) - assert exc_info.value.error.code == INVALID_REQUEST - - -@pytest.mark.anyio -async def test_dual_era_loop_modern_notification_dispatches_at_locked_version(server: SrvT): - """Notifications carry no envelope, so on a modern-locked connection they - dispatch with the locked protocol version.""" - seen_versions: list[str] = [] - handled = anyio.Event() - - async def on_custom(ctx: Ctx, params: NotificationParams | None) -> None: - seen_versions.append(ctx.protocol_version) - handled.set() - - server.add_notification_handler("notifications/custom", NotificationParams, on_custom) - async with dual_era_client(server) as (client, _): - await client.send_raw_request("server/discover", _modern_params()) - await client.notify("notifications/custom", None) - await handled.wait() - assert seen_versions == [LATEST_MODERN_VERSION] - - -@pytest.mark.anyio -async def test_dual_era_loop_legacy_notifications_reach_the_loop_runner(server: SrvT): - """Before/after a legacy lock, notifications flow through the loop runner - exactly as under `serve_loop`.""" - handled = anyio.Event() - - async def on_custom(ctx: Ctx, params: NotificationParams | None) -> None: - handled.set() - - server.add_notification_handler("notifications/custom", NotificationParams, on_custom) - async with dual_era_client(server) as (client, _): - await client.send_raw_request("initialize", _initialize_params()) - await client.notify("notifications/custom", None) - await handled.wait() - - -@pytest.mark.anyio -async def test_dual_era_loop_modern_server_notifications_ride_the_pipe(server: SrvT): - """A modern handler's standalone notification reaches the client over the - duplex stream - the notify-only outbound forwards it.""" - - async def emit(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]: - await ctx.session.send_tool_list_changed() - return {} - - server.add_request_handler("x/emit", RequestParams, emit) - async with dual_era_client(server) as (client, recorder): - await client.send_raw_request("x/emit", _modern_params()) - await recorder.notified.wait() - assert recorder.notifications[0][0] == "notifications/tools/list_changed" - - -@pytest.mark.anyio -async def test_dual_era_loop_modern_refuses_server_initiated_requests(server: SrvT): - """A modern handler attempting a server-initiated request gets - `NoBackChannelError` from the standalone channel: the modern protocol - forbids the frame, duplex pipe or not.""" - - async def wants_roots(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]: - await ctx.session.list_roots() # pyright: ignore[reportDeprecated] - return {} # pragma: no cover - list_roots raises - - server.add_request_handler("x/roots", RequestParams, wants_roots) - async with dual_era_client(server) as (client, _): - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("x/roots", _modern_params()) - assert exc_info.value.error.code == INVALID_REQUEST - assert "no back-channel" in exc_info.value.error.message - - -@pytest.mark.anyio -async def test_dual_era_loop_late_modern_success_does_not_overwrite_a_committed_legacy_lock(): - """The era settles exactly once, on the FIRST client-visible success: a - modern request that was already in flight when a legacy handshake - committed may still complete - its response stands - but the connection - stays legacy, so the handshaked client is never stranded.""" - entered = anyio.Event() - release = anyio.Event() - - async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: - entered.set() - await release.wait() - return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]) - - parked = Server(name="parked-server", version="0.0.1", on_list_tools=list_tools) - async with dual_era_client(parked) as (client, _): - modern_result: dict[str, Any] = {} - - async def modern_call() -> None: - modern_result.update(await client.send_raw_request("tools/list", _modern_params())) - - async with anyio.create_task_group() as tg: - tg.start_soon(modern_call) - # The modern dispatch is parked in its handler before the - # handshake frame is even written, so the initialize commits first. - await entered.wait() - init = await client.send_raw_request("initialize", _initialize_params()) - assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION - release.set() - assert modern_result["tools"][0]["name"] == "t" - # The straggler's success did not move the era: plain legacy requests - # still serve (a modern overwrite would demand the envelope triple). - result = await client.send_raw_request("tools/list", None) - assert result["tools"][0]["name"] == "t" - - -@pytest.mark.anyio -async def test_dual_era_loop_modern_success_cancelled_away_at_the_response_write_never_locks(): - """A peer cancel that lands while the handler is finishing means the - dispatcher replaces the computed result with "Request cancelled" - the - client never sees the success, so the era must not lock and the legacy - handshake must stay available.""" - entered = anyio.Event() - release = anyio.Event() - - async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: - entered.set() - # Survive the interrupt-mode scope cancel so the handler completes - # with the cancel pending - the cancellation is then delivered at the - # dispatcher's response-write checkpoint, after the era commit ran. - with anyio.CancelScope(shield=True): - await release.wait() - return ListToolsResult(tools=[]) - - parked = Server(name="parked-server", version="0.0.1", on_list_tools=list_tools) - async with dual_era_client(parked) as (client, _): - failures: list[MCPError] = [] - - async def modern_call() -> None: - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("tools/list", _modern_params()) - failures.append(exc_info.value) - - async with anyio.create_task_group() as tg: - tg.start_soon(modern_call) - await entered.wait() - # First request on a fresh dispatcher pair, so its id is 1. - await client.notify("notifications/cancelled", {"requestId": 1}) - # The read loop handles frames in order: this marker's response - # proves the cancel was processed before the handler resumes. - with pytest.raises(MCPError): - await client.send_raw_request("probe/marker", None) - release.set() - assert failures[0].error.message == "Request cancelled" - # The cancelled-away success never locked the era. - init = await client.send_raw_request("initialize", _initialize_params()) - assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION - - -@pytest.mark.anyio -async def test_dual_era_loop_maps_unmapped_handler_exceptions_like_the_modern_http_entry(): - """An unmapped handler exception on a modern request surfaces as the - generic INTERNAL_ERROR - the same boundary as the modern HTTP entry - so - handler internals never reach the wire. (The dispatcher's code-0 - catch-all is a handshake-era compat pin and stays legacy-only.) The - failed request never locks, so the handshake stays available.""" - - async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: - raise RuntimeError("handler internals") - - exploding = Server(name="exploding-server", version="0.0.1", on_list_tools=list_tools) - async with dual_era_client(exploding) as (client, _): - with pytest.raises(MCPError) as exc_info: - await client.send_raw_request("tools/list", _modern_params()) - init = await client.send_raw_request("initialize", _initialize_params()) - assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION - assert exc_info.value.error.code == INTERNAL_ERROR - assert exc_info.value.error.message == "Internal server error" - assert "handler internals" not in str(exc_info.value.error) - - -@pytest.mark.anyio -async def test_dual_era_loop_raise_exceptions_reraises_unmapped_modern_handler_exceptions(): - """Debug mode keeps its contract on the modern path: an unmapped handler - exception still propagates out of the loop instead of being swallowed - into the generic INTERNAL_ERROR mapping.""" - - async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: - raise RuntimeError("boom") - - exploding = Server(name="exploding-server", version="0.0.1", on_list_tools=list_tools) - c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) - s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) - frame = JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list", params=_modern_params()) - with pytest.RaisesGroup(RuntimeError, flatten_subgroups=True, allow_unwrapped=True): - async with anyio.create_task_group() as tg, c2s_send, c2s_recv, s2c_send, s2c_recv: - tg.start_soon( - partial( - serve_dual_era_loop, exploding, c2s_recv, s2c_send, lifespan_state=_LIFESPAN, raise_exceptions=True - ) - ) - with anyio.fail_after(5): # pragma: no branch - group exit misreports the with arcs - await c2s_send.send(SessionMessage(message=frame)) - # The dispatcher answers on the wire before re-raising; waiting - # for the answer keeps the streams open until the handler ran. - await s2c_recv.receive() - - -@pytest.mark.anyio -async def test_dual_era_loop_custom_method_with_mis_shaped_envelope_values_still_routes(): - """A mis-shaped clientInfo envelope value degrades to not-supplied - the - `Connection.from_envelope` coercion the modern HTTP entry uses - so a - custom method (no kernel params surface to re-reject it) serves - identically on both transports, with `client_params is None`.""" - seen: list[object] = [] - - async def greet(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]: - seen.append(ctx.session.client_params) - return {"ok": True} - - greeter = Server(name="greeter-server", version="0.0.1") - greeter.add_request_handler("custom/greet", RequestParams, greet) - params = _modern_params() - params["_meta"][CLIENT_INFO_META_KEY] = "not-an-object" - async with dual_era_client(greeter) as (client, _): - result = await client.send_raw_request("custom/greet", params) - assert result == { - "ok": True, - "resultType": "complete", - "_meta": {SERVER_INFO_META_KEY: {"name": "greeter-server", "version": "0.0.1"}}, - } - assert seen == [None] - - -def test_has_modern_envelope_keys_on_the_protocol_version_key(): - """Era evidence is the reserved protocol-version `_meta` key: legacy traffic - never mints it (bare `_meta` / `progressToken` is not evidence), and a - half-built envelope still routes modern so the classifier - not the legacy - path - names its missing required key.""" - assert not _has_modern_envelope(None) - assert not _has_modern_envelope({}) - assert not _has_modern_envelope({"_meta": None}) - assert not _has_modern_envelope({"_meta": {"progressToken": 1}}) - version_only = {PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION} - assert _has_modern_envelope({"_meta": version_only}) - pair_only = {k: v for k, v in _modern_envelope().items() if k != CLIENT_INFO_META_KEY} - assert _has_modern_envelope({"_meta": pair_only}) - assert _has_modern_envelope(_modern_params()) - - -def test_initialize_after_modern_data_arms(): - typed = _initialize_after_modern_data({"protocolVersion": LATEST_HANDSHAKE_VERSION}) - assert typed == {"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": LATEST_HANDSHAKE_VERSION} - assert _initialize_after_modern_data(None) == {"supported": list(MODERN_PROTOCOL_VERSIONS)} - assert _initialize_after_modern_data({"protocolVersion": 7}) == {"supported": list(MODERN_PROTOCOL_VERSIONS)} - - -class _RecordingInnerDctx: - """Minimal `DispatchContext` double recording delegated calls.""" - - def __init__(self) -> None: - self.transport = TransportContext(kind="jsonrpc", can_send_request=True) - self.can_send_request = True - self.request_id = 7 - self.message_metadata = None - self.cancel_requested = anyio.Event() - self.notifies: list[str] = [] - self.progresses: list[float] = [] - - async def send_raw_request( - self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None - ) -> dict[str, Any]: - raise AssertionError("must never be reached through the denying wrapper") # pragma: no cover - - async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: - self.notifies.append(method) - - async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: - self.progresses.append(progress) - - -@pytest.mark.anyio -async def test_no_server_requests_dispatch_context_denies_requests_and_delegates_the_rest(): - inner = _RecordingInnerDctx() - wrapper = _NoServerRequestsDispatchContext(inner) - assert wrapper.can_send_request is False - # The transport metadata is masked to agree with the wrapper's denial. - assert wrapper.transport == TransportContext(kind="jsonrpc", can_send_request=False) - assert wrapper.request_id == 7 - assert wrapper.message_metadata is None - assert wrapper.cancel_requested is inner.cancel_requested - with pytest.raises(NoBackChannelError): - await wrapper.send_raw_request("roots/list", None) - await wrapper.notify("notifications/progress", None) - await wrapper.progress(0.5) - assert inner.notifies == ["notifications/progress"] - assert inner.progresses == [0.5] - - -def test_no_server_requests_dispatch_context_passes_an_already_denying_transport_through(): - inner = _RecordingInnerDctx() - inner.transport = TransportContext(kind="jsonrpc", can_send_request=False) - assert _NoServerRequestsDispatchContext(inner).transport is inner.transport - - -@pytest.mark.anyio -async def test_notify_only_outbound_forwards_notifications_and_refuses_requests(): - inner = _RecordingInnerDctx() - outbound = NotifyOnlyOutbound(inner) - await outbound.notify("notifications/tools/list_changed", None) - assert inner.notifies == ["notifications/tools/list_changed"] - with pytest.raises(NoBackChannelError): - await outbound.send_raw_request("ping", None) - - -@pytest.mark.anyio -async def test_dual_era_client_propagates_body_exception_unwrapped(server: SrvT): - """The harness re-raises body exceptions as-is, not as `ExceptionGroup`.""" - with pytest.raises(RuntimeError, match="boom"): - async with dual_era_client(server): - raise RuntimeError("boom") diff --git a/tests/server/test_serving.py b/tests/server/test_serving.py new file mode 100644 index 0000000000..22922298c7 --- /dev/null +++ b/tests/server/test_serving.py @@ -0,0 +1,723 @@ +"""Tests for `serve_stream`, the era-deciding stream driver. + +Each test speaks raw JSON-RPC frames to a `serve_stream` server through an +in-memory `JSONRPCDispatcher` client, so the wire behaviour under test is the +opening exchange: which era the connection opens in, what `server/discover` +does (answered, never opening), and how each posture answers traffic from the +other era. The ordering suite (`test_stdio_ordering.py`) covers pipelined +ordering on both anyio backends; these are the seam-level companions. +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from functools import partial +from typing import Any + +import anyio +import anyio.abc +import pytest +from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, + METHOD_NOT_FOUND, + PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, + UNSUPPORTED_PROTOCOL_VERSION, + CallToolRequestParams, + CallToolResult, + ClientCapabilities, + Implementation, + InitializeRequestParams, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + ListToolsResult, + NotificationParams, + PaginatedRequestParams, + TextContent, + Tool, + ToolListChangedNotification, +) +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS + +from mcp.server.connection import NotifyOnlyOutbound +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel.server import Server +from mcp.server.serving import Posture, _opening_intent, serve_listener, serve_stream +from mcp.server.stdio import newline_json_transport +from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ToolsListChanged +from mcp.shared.exceptions import MCPError, NoBackChannelError +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.message import SessionMessage +from mcp.shared.transport_context import TransportContext + +from ..shared.test_dispatcher import Recorder, echo_handlers + +pytestmark = pytest.mark.anyio + +Ctx = ServerRequestContext[dict[str, Any], Any] + + +@pytest.fixture(params=["asyncio", "trio"]) +def anyio_backend(request: pytest.FixtureRequest) -> str: + """Run every test in this module on both anyio backends: the driver's ordering claims + are owed to the dispatcher's sequential read loop, not to a task scheduler, and trio's + scheduler is the check on that.""" + return request.param + + +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + +SrvT = Server[dict[str, Any]] + +_TOOL = Tool(name="t", input_schema={"type": "object"}) + + +def _envelope(version: str = LATEST_MODERN_VERSION, *, with_client_info: bool = True) -> dict[str, Any]: + meta: dict[str, Any] = {PROTOCOL_VERSION_META_KEY: version, CLIENT_CAPABILITIES_META_KEY: {}} + if with_client_info: + meta[CLIENT_INFO_META_KEY] = {"name": "test-client", "version": "1.0"} + return meta + + +def _modern_params(version: str = LATEST_MODERN_VERSION, **params: Any) -> dict[str, Any]: + return {**params, "_meta": _envelope(version)} + + +def _initialize_params() -> dict[str, Any]: + return InitializeRequestParams( + protocol_version=LATEST_HANDSHAKE_VERSION, + capabilities=ClientCapabilities(), + client_info=Implementation(name="test-client", version="1.0"), + ).model_dump(by_alias=True, exclude_none=True) + + +def _server(*, posture: Posture = Posture.DUAL, **handlers: Any) -> SrvT: + async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[_TOOL]) + + return Server(name="serve-stream-test", version="0.0.1", posture=posture, on_list_tools=list_tools, **handlers) + + +@asynccontextmanager +async def _raw_client(server: SrvT) -> AsyncIterator[tuple[JSONRPCDispatcher[TransportContext], Recorder]]: + """Yield `(client, recorder)` speaking raw frames to a `serve_stream` server. + + The driver owns its dispatcher, connection, and lifespan, so the client here + performs no handshake: each test drives the opening exchange itself. + """ + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + + def builder(_meta: object) -> TransportContext: + return TransportContext(kind="jsonrpc", can_send_request=True) + + client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send, transport_builder=builder) + recorder = Recorder() + c_req, c_notify = echo_handlers(recorder) + body_exc: BaseException | None = None + async with anyio.create_task_group() as tg: + await tg.start(client.run, c_req, c_notify) + tg.start_soon(serve_stream, server, c2s_recv, s2c_send) + try: + with anyio.fail_after(5): + yield client, recorder + except BaseException as e: + body_exc = e + tg.cancel_scope.cancel() + if body_exc is not None: + raise body_exc + + +async def test_raw_client_harness_relays_a_failing_body_exception_unwrapped() -> None: + """The `_raw_client` harness's own contract, pinned so its coverage stays whole: + a failure raised inside the connection block surfaces as itself rather than as + the task group's exception group, so a red test always reads as the assertion.""" + + class _BodyFailed(Exception): + pass + + with pytest.raises(_BodyFailed): + async with _raw_client(_server()): + raise _BodyFailed + + +# --- the opening decision --------------------------------------------------- + + +def test_opening_intent_is_read_off_the_opening_request_alone(): + """The one place an undecided connection's request intent is read. Posture is + not an input here: it was consumed as the connection's starting era, so a + single-era connection never asks this question.""" + envelope = {"_meta": _envelope()} + assert _opening_intent("server/discover", envelope) == "probe" + assert _opening_intent("initialize", envelope) == "legacy" # legacy-distinctive even if stamped + assert _opening_intent("tools/list", envelope) == "modern" + assert _opening_intent("tools/list", {"_meta": {"progressToken": 1}}) == "legacy" # not envelope evidence + assert _opening_intent("tools/list", None) == "legacy" # bare pre-handshake traffic + assert _opening_intent("server/discover", None) == "legacy" # an envelope-less discover is no probe + + +# --- dual-era posture -------------------------------------------------------- + + +async def test_modern_request_opens_the_modern_era_and_refuses_the_handshake(): + """The first modern-envelope request pins the modern era; a later `initialize` + is answered with the version error naming the modern versions.""" + async with _raw_client(_server()) as (client, _): + result = await client.send_raw_request("tools/list", _modern_params()) + assert result["tools"][0]["name"] == "t" + with pytest.raises(MCPError) as exc: + await client.send_raw_request("initialize", _initialize_params()) + assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION + assert exc.value.error.data["supported"] == list(MODERN_PROTOCOL_VERSIONS) + assert exc.value.error.data["requested"] == LATEST_HANDSHAKE_VERSION + + +async def test_initialize_opens_the_legacy_era_and_serves_the_handshake(): + async with _raw_client(_server()) as (client, _): + init = await client.send_raw_request("initialize", _initialize_params()) + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION + await client.notify("notifications/initialized", None) + result = await client.send_raw_request("tools/list", None) + assert result["tools"][0]["name"] == "t" + + +async def test_legacy_era_refuses_a_modern_envelope_rather_than_serving_it(): + """A handshake connection speaks envelope-less traffic: an envelope-stamped + request on it means the client is mixing eras, so the legacy era refuses it + (-32600) instead of processing an era-ambiguous method under legacy + semantics - a second, conflicting era claim on a committed connection is a + client error, and the refusal is the deterministic answer.""" + async with _raw_client(_server()) as (client, _): + await client.send_raw_request("initialize", _initialize_params()) + await client.notify("notifications/initialized", None) + with pytest.raises(MCPError) as exc: + await client.send_raw_request("tools/list", _modern_params()) + # ...and the connection is still the same legacy connection afterwards. + result = await client.send_raw_request("tools/list", None) + assert exc.value.error.code == INVALID_REQUEST + assert result["tools"][0]["name"] == "t" + + +async def test_discover_probe_is_answered_without_pinning_the_era(): + """`server/discover` answers with modern semantics but leaves the connection + undecided, so the fallback `initialize` that follows is still served (the + stdio backward-compatibility probe flow).""" + async with _raw_client(_server()) as (client, _): + discover = await client.send_raw_request("server/discover", _modern_params()) + assert discover["supportedVersions"] == list(MODERN_PROTOCOL_VERSIONS) + init = await client.send_raw_request("initialize", _initialize_params()) + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION + + +async def test_slow_modern_request_pins_the_era_at_arrival_not_completion(): + """The straddle defect: a modern request that has not returned yet has still + pinned the modern era, so a legacy handshake arriving meanwhile is refused.""" + tool_started = anyio.Event() + release_tool = anyio.Event() + + async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult: + tool_started.set() + await release_tool.wait() + return CallToolResult(content=[TextContent(text="done")]) + + error_code: int | None = None + async with _raw_client(_server(on_call_tool=call_tool)) as (client, _): + async with anyio.create_task_group() as tg: + tg.start_soon(client.send_raw_request, "tools/call", _modern_params(name="slow")) + await tool_started.wait() + with pytest.raises(MCPError) as exc: + await client.send_raw_request("initialize", _initialize_params()) + error_code = exc.value.error.code + release_tool.set() + assert error_code == UNSUPPORTED_PROTOCOL_VERSION + + +async def test_bare_initialized_notification_opens_the_legacy_era(): + """`notifications/initialized` is handshake vocabulary: a handshake completed + without the `initialize` request. It is the one notification that opens an + era (the legacy one), so the requests that follow are past the initialize + gate.""" + async with _raw_client(_server()) as (client, _): + await client.notify("notifications/initialized", None) + result = await client.send_raw_request("tools/list", None) + assert result["tools"][0]["name"] == "t" + + +async def test_bare_initialized_notification_is_admitted_before_its_context_is_built(): + """The handshake notification that opens the legacy era decides the era in + receive order, before its own transport context is built: a handler that + observes it already sees a legacy connection, whose channel offers the + back-channel a duplex stream has (an undecided or modern connection would + refuse server-initiated requests).""" + seen: list[bool] = [] + observed = anyio.Event() + + async def on_initialized(ctx: Ctx, params: NotificationParams) -> None: + seen.append(ctx.session.can_send_request) + observed.set() + + server = _server() + server.add_notification_handler("notifications/initialized", NotificationParams, on_initialized) + async with _raw_client(server) as (client, _): + await client.notify("notifications/initialized", None) + with anyio.fail_after(5): + await observed.wait() + assert seen == [True], f"the initialized handler saw a not-yet-decided connection: {seen}" + + +async def test_stray_notification_opens_nothing_and_is_ignored(): + """Any other leading notification decides nothing: it is ignored, the + connection stays undecided, and the modern request that follows still opens + the modern era (a later handshake is refused with the version error).""" + async with _raw_client(_server()) as (client, _): + await client.notify("notifications/roots/list_changed", None) + result = await client.send_raw_request("tools/list", _modern_params()) + assert result["tools"][0]["name"] == "t" + with pytest.raises(MCPError) as exc: + await client.send_raw_request("initialize", _initialize_params()) + assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION + + +async def test_modern_client_notification_during_in_flight_work_is_delivered(): + """Once the modern era is pinned, an envelope-less client notification routes + to the modern kernel and reaches its handler (it is no longer dropped as + 'received before initialization').""" + seen = anyio.Event() + + async def on_ping_note(ctx: Ctx, params: NotificationParams) -> None: + seen.set() + + server = _server() + server.add_notification_handler("notifications/x/ping-note", NotificationParams, on_ping_note) + async with _raw_client(server) as (client, _): + await client.send_raw_request("tools/list", _modern_params()) + await client.notify("notifications/x/ping-note", None) + await seen.wait() + + +async def test_pair_only_envelope_routes_to_the_modern_kernel(): + """The spec-required envelope pair (no clientInfo) is a modern request: it + routes to the modern kernel rather than the legacy init-gate. A custom + method (no per-version params surface) shows the routing directly, and + its modern result carries the required `resultType` discriminator and the + serverInfo `_meta` stamp (spec 2026-07-28, #3002).""" + + async def echo(ctx: Ctx, params: NotificationParams) -> dict[str, Any]: + return {"protocolVersion": ctx.protocol_version} + + server = _server() + server.add_request_handler("x/echo", NotificationParams, echo) + params: dict[str, Any] = {"_meta": _envelope(with_client_info=False)} + async with _raw_client(server) as (client, _): + result = await client.send_raw_request("x/echo", params) + assert result == { + "protocolVersion": LATEST_MODERN_VERSION, + "resultType": "complete", + "_meta": {SERVER_INFO_META_KEY: {"name": "serve-stream-test", "version": "0.0.1"}}, + } + + +async def test_bare_request_on_a_modern_connection_is_a_malformed_modern_request(): + """After the modern era is pinned, an envelope-less request is answered in + modern vocabulary: the envelope pair is required, so it is INVALID_PARAMS.""" + async with _raw_client(_server()) as (client, _): + await client.send_raw_request("tools/list", _modern_params()) + with pytest.raises(MCPError) as exc: + await client.send_raw_request("tools/list", None) + assert exc.value.error.code == INVALID_PARAMS + + +async def test_pair_only_spec_request_is_served_and_records_capabilities_without_client_params(): + """Spec-mandated (spec PR #3002): the required envelope pair (protocol version + + client capabilities) without the optional clientInfo is a complete modern + spec request - it is served (not -32602), pins the modern era, and records + the declared capabilities while client params stay unset.""" + seen: list[ServerRequestContext[Any, Any]] = [] + + async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult: + seen.append(ctx) + return CallToolResult(content=[TextContent(type="text", text=params.name)]) + + server = _server(on_call_tool=call_tool) + params = {"name": "echo", "_meta": _envelope(with_client_info=False)} + async with _raw_client(server) as (client, _): + result = await client.send_raw_request("tools/call", params) + assert result["content"][0]["text"] == "echo" + with pytest.raises(MCPError) as exc: + await client.send_raw_request("initialize", _initialize_params()) + assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION + assert seen[0].session.client_params is None + assert seen[0].session.client_capabilities == ClientCapabilities() + + +async def test_version_without_capabilities_is_rejected_naming_the_missing_key(): + """A `_meta` declaring the protocol version but missing the required + client-capabilities key is a malformed modern request: it is answered + INVALID_PARAMS naming the missing key, in modern vocabulary. The opening + request declared modern intent, so the connection is a modern one and the + corrected pair is served without a handshake.""" + params: dict[str, Any] = {"_meta": {PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION}} + async with _raw_client(_server()) as (client, _): + with pytest.raises(MCPError) as exc: + await client.send_raw_request("tools/list", params) + result = await client.send_raw_request("tools/list", _modern_params()) + assert result["tools"][0]["name"] == "t" + assert exc.value.error.code == INVALID_PARAMS + assert CLIENT_CAPABILITIES_META_KEY in exc.value.error.message + + +async def test_modern_era_refuses_server_initiated_requests_but_carries_notifications(): + """The modern protocol forbids server-initiated requests: the request-scoped + channel refuses (via the transport context), while notifications still + ride the duplex pipe.""" + + async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult: + assert isinstance(ctx.session._connection.outbound, NotifyOnlyOutbound) # pyright: ignore[reportPrivateUsage] + assert ctx.session.can_send_request is False + with pytest.raises(NoBackChannelError): + await ctx.session.send_ping() + await ctx.session.send_notification(ToolListChangedNotification(), related_request_id=ctx.request_id) + return CallToolResult(content=[TextContent(text="ok")]) + + async with _raw_client(_server(on_call_tool=call_tool)) as (client, recorder): + await client.send_raw_request("tools/call", _modern_params(name="t")) + await recorder.notified.wait() + assert recorder.notifications[0][0] == "notifications/tools/list_changed" + + +async def test_modern_era_carries_standalone_notifications_over_the_duplex_pipe(): + """A server notification sent with no `related_request_id` rides the connection's + standalone channel (`NotifyOnlyOutbound`): over a duplex stream it reaches the peer.""" + + async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult: + await ctx.session.send_notification(ToolListChangedNotification()) # no related_request_id + return CallToolResult(content=[TextContent(text="ok")]) + + async with _raw_client(_server(on_call_tool=call_tool)) as (client, recorder): + await client.send_raw_request("tools/call", _modern_params(name="t")) + with anyio.fail_after(5): + await recorder.notified.wait() + assert recorder.notifications[0][0] == "notifications/tools/list_changed" + + +async def test_modern_era_passes_a_handler_mcp_error_through_with_its_own_code(): + """An `MCPError` a modern handler raises is not sanitized: it is the handler's + deliberate wire error, so it reaches the client with its code and message intact.""" + + async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult: + raise MCPError(code=INVALID_PARAMS, message="tool refused these arguments") + + async with _raw_client(_server(on_call_tool=call_tool)) as (client, _): + with pytest.raises(MCPError) as exc: + await client.send_raw_request("tools/call", _modern_params(name="t")) + assert exc.value.error.code == INVALID_PARAMS + assert exc.value.error.message == "tool refused these arguments" + + +async def test_serve_stream_with_raise_exceptions_reraises_a_modern_handler_exception(): + """`raise_exceptions=True` (an in-process testing aid) re-raises a modern handler's + unmapped exception out of `serve_stream` after the peer has been answered.""" + + class _Kaboom(Exception): + pass + + async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult: + raise _Kaboom("surfaces out of the driver") + + server = _server(on_call_tool=call_tool) + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send) + c_req, c_notify = echo_handlers(Recorder()) + with pytest.raises(BaseException) as excinfo: + async with anyio.create_task_group() as tg: + await tg.start(client.run, c_req, c_notify) + tg.start_soon(partial(serve_stream, server, c2s_recv, s2c_send, raise_exceptions=True)) + # The client is still answered (generically) before the exception escapes. + with anyio.fail_after(5), pytest.raises(MCPError) as wire: + await client.send_raw_request("tools/call", _modern_params(name="t")) + assert wire.value.error.code == INTERNAL_ERROR + assert excinfo.group_contains(_Kaboom) + + +async def test_modern_era_sanitizes_unmapped_handler_exceptions_to_internal_error(): + """An unmapped handler exception on the modern era is answered like the modern + HTTP entry answers it: a generic INTERNAL_ERROR, so handler internals never + reach the wire (unlike the legacy era's `code=0, str(e)`).""" + + async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult: + raise RuntimeError("db password is hunter2") + + async with _raw_client(_server(on_call_tool=call_tool)) as (client, _): + with pytest.raises(MCPError) as exc: + await client.send_raw_request("tools/call", _modern_params(name="t")) + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Internal server error" + + +async def test_modern_only_posture_refuses_a_handshake_with_no_parseable_version(): + """When `initialize` carries no parseable `protocolVersion`, the modern era's + refusal still names the versions it serves (there is nothing to echo back).""" + async with _raw_client(_server(posture=Posture.MODERN_ONLY)) as (client, _): + with pytest.raises(MCPError) as exc: + await client.send_raw_request("initialize", {"capabilities": {}}) + assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION + assert exc.value.error.data == {"supported": list(MODERN_PROTOCOL_VERSIONS)} + + +# --- single-era postures ----------------------------------------------------- + + +async def test_modern_only_posture_refuses_the_handshake_naming_its_versions(): + """A modern-only server answers `initialize` with -32022 listing the modern + versions (versioning.mdx: modern-only servers SHOULD name them), and serves + modern requests.""" + async with _raw_client(_server(posture=Posture.MODERN_ONLY)) as (client, _): + with pytest.raises(MCPError) as exc: + await client.send_raw_request("initialize", _initialize_params()) + assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION + assert exc.value.error.data["supported"] == list(MODERN_PROTOCOL_VERSIONS) + result = await client.send_raw_request("tools/list", _modern_params()) + assert result["tools"][0]["name"] == "t" + + +async def test_legacy_only_posture_serves_the_handshake_and_answers_probes_in_legacy_vocabulary(): + """A legacy-only server is born a handshake connection: an enveloped probe is + a client mixing eras and is refused (-32600), a bare probe is answered with + the handshake era's own method-not-found - both trigger an auto client's + fallback, since fallback is not keyed to one code - and the handshake works.""" + async with _raw_client(_server(posture=Posture.LEGACY_ONLY)) as (client, _): + with pytest.raises(MCPError) as enveloped: + await client.send_raw_request("server/discover", _modern_params()) + with pytest.raises(MCPError) as bare: + await client.send_raw_request("server/discover", None) + init = await client.send_raw_request("initialize", _initialize_params()) + assert enveloped.value.error.code == INVALID_REQUEST + assert bare.value.error.code == METHOD_NOT_FOUND + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION + + +# --- subscriptions/listen over a stream -------------------------------------- + + +async def test_stdin_eof_ends_open_listen_streams_gracefully_before_the_connection_closes() -> None: + """The peer closing our input is the server's cue to wind down: an open + `subscriptions/listen` stream is told to close inside the shielded drain, so + its `SubscriptionsListenResult` reaches the (still-open) output before the + write side closes - the drain waits for every request task to finish, and a + task finishes only once its answer is written.""" + bus = InMemorySubscriptionBus() + server = _server(on_subscriptions_listen=ListenHandler(bus)) + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) + s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage](8) + envelope = _envelope() + listen = JSONRPCRequest( + jsonrpc="2.0", + id="sub-1", + method="subscriptions/listen", + params={"notifications": {"toolsListChanged": True}, "_meta": envelope}, + ) + frames: list[Any] = [] + async with c2s_send, s2c_recv, anyio.create_task_group() as tg: + tg.start_soon(serve_stream, server, c2s_recv, s2c_send) + await c2s_send.send(SessionMessage(listen)) + with anyio.fail_after(5): + frames.append((await s2c_recv.receive()).message) # the acknowledgment + c2s_send.close() # peer EOF with the stream open + with anyio.fail_after(5): + frames.extend([item.message async for item in s2c_recv]) # the drain, then close + kinds: list[type] = [type(frame) for frame in frames] + assert kinds == [JSONRPCNotification, JSONRPCResponse] + result = frames[1] + assert isinstance(result, JSONRPCResponse) + assert result.id == "sub-1" and result.result["resultType"] == "complete" + + +async def test_stdin_eof_on_one_connection_ends_the_listen_streams_of_every_connection_sharing_the_server(): + """Pins the multi-connection caveat: the drain at read EOF tells the *server's* + `ListenHandler` to close, and that handler is shared by every connection the + `Server` serves, so connection A's input closing also ends connection B's open + listen stream - B is handed its graceful `SubscriptionsListenResult` unprompted. + SDK-defined, not spec-mandated: when subscription streams become per-connection + (the bus-first `Server` follow-up), B's stream must stay open here and the + marked expectation below flips. + """ + bus = InMemorySubscriptionBus() + server = _server(on_subscriptions_listen=ListenHandler(bus)) + listen_params = {"notifications": {"toolsListChanged": True}, "_meta": _envelope()} + + def listen_request(request_id: str) -> SessionMessage: + return SessionMessage( + JSONRPCRequest(jsonrpc="2.0", id=request_id, method="subscriptions/listen", params=listen_params) + ) + + a_in_send, a_in_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) + a_out_send, a_out_recv = anyio.create_memory_object_stream[SessionMessage](8) + b_in_send, b_in_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) + b_out_send, b_out_recv = anyio.create_memory_object_stream[SessionMessage](8) + a_frames: list[JSONRPCMessage] = [] + b_frames: list[JSONRPCMessage] = [] + async with ( + a_in_send, + a_out_recv, + b_in_send, + b_out_recv, + server.lifespan() as lifespan_state, # one entered lifespan shared by both connections + anyio.create_task_group() as tg, + ): + tg.start_soon(partial(serve_stream, server, a_in_recv, a_out_send, lifespan_state=lifespan_state)) + tg.start_soon(partial(serve_stream, server, b_in_recv, b_out_send, lifespan_state=lifespan_state)) + await a_in_send.send(listen_request("sub-a")) + await b_in_send.send(listen_request("sub-b")) + with anyio.fail_after(5): # both streams open: each connection's first frame is its ack + a_frames.append((await a_out_recv.receive()).message) + b_frames.append((await b_out_recv.receive()).message) + a_in_send.close() # connection A's peer EOF, with A's stream open + with anyio.fail_after(5): + a_frames.extend([item.message async for item in a_out_recv]) # A's graceful result, then close + # TODAY (the documented caveat): A's drain closed the server-wide ListenHandler, + # so B's stream ended with it and B receives its listen result without asking. When + # streams are owned per connection, flip this: nothing arrives on B's output here + # and B's stream keeps delivering events. + with anyio.fail_after(5): + b_frames.append((await b_out_recv.receive()).message) + tg.cancel_scope.cancel() + assert [type(frame) for frame in a_frames] == [JSONRPCNotification, JSONRPCResponse] + assert [type(frame) for frame in b_frames] == [JSONRPCNotification, JSONRPCResponse] + a_result, b_result = a_frames[1], b_frames[1] + assert isinstance(a_result, JSONRPCResponse) and a_result.id == "sub-a" + assert a_result.result["resultType"] == "complete" + assert isinstance(b_result, JSONRPCResponse) and b_result.id == "sub-b" # B never asked to close + assert b_result.result["resultType"] == "complete" + + +async def test_listen_is_served_over_a_stream_ack_first_event_then_graceful_close(): + """A subscription is a request in flight: over a duplex stream the listen + handler acks first, delivers a stamped event, and closes gracefully with the + empty `subscriptions/listen` result when the server ends the stream through + its `close_subscriptions()` verb.""" + bus = InMemorySubscriptionBus() + server = _server(on_subscriptions_listen=ListenHandler(bus)) + results: list[dict[str, Any]] = [] + listen_id = "sub-1" + async with _raw_client(server) as (client, recorder): + + async def open_listen() -> None: + results.append( + await client.send_raw_request( + "subscriptions/listen", + _modern_params(notifications={"toolsListChanged": True}), + {"request_id": listen_id}, + ) + ) + + async with anyio.create_task_group() as tg: + tg.start_soon(open_listen) + await recorder.notified.wait() # the acknowledgment + await bus.publish(ToolsListChanged()) + # The stream flushes the event, then the graceful close ends it: the + # response only arrives once the handler returns its result. + recorder.notified = anyio.Event() + await recorder.notified.wait() # the delivered event + server.close_subscriptions() # server-initiated graceful close + + methods = [name for name, _ in recorder.notifications] + assert methods == ["notifications/subscriptions/acknowledged", "notifications/tools/list_changed"] + for _, params in recorder.notifications: + assert params is not None + assert params["_meta"]["io.modelcontextprotocol/subscriptionId"] == listen_id + assert results[0]["resultType"] == "complete" + assert results[0]["_meta"]["io.modelcontextprotocol/subscriptionId"] == listen_id + + +# --- serve_listener: the socket-shaped host -------------------------------------- + + +@asynccontextmanager +async def _client_over_socket( + stream: anyio.abc.ByteStream, +) -> AsyncIterator[tuple[JSONRPCDispatcher[TransportContext], Recorder]]: + """Yield a raw JSON-RPC client speaking the stdio wire over one byte stream.""" + async with newline_json_transport(stream) as (read_stream, write_stream): + client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read_stream, write_stream) + recorder = Recorder() + c_req, c_notify = echo_handlers(recorder) + async with anyio.create_task_group() as tg: + await tg.start(client.run, c_req, c_notify) + try: + yield client, recorder + finally: + tg.cancel_scope.cancel() + + +async def _serve_on_tcp(server: SrvT, task_group: anyio.abc.TaskGroup) -> tuple[int, Any]: + """Start `serve_listener` on an ephemeral loopback port; return `(port, listener)`.""" + listener = await anyio.create_tcp_listener(local_host="127.0.0.1") + port = listener.extra(anyio.abc.SocketAttribute.local_port) + task_group.start_soon(serve_listener, server, listener) + return port, listener + + +async def test_serve_listener_frames_and_serves_each_accepted_connection(): + """The socket host: a raw newline-JSON-RPC client over TCP gets its request served.""" + server = _server() + async with anyio.create_task_group() as tg: + port, _ = await _serve_on_tcp(server, tg) + with anyio.fail_after(5): + stream = await anyio.connect_tcp("127.0.0.1", port) + async with stream, _client_over_socket(stream) as (client, _): + result = await client.send_raw_request("tools/list", _modern_params()) + assert result["tools"] == [_TOOL.model_dump(by_alias=True, exclude_none=True)] + tg.cancel_scope.cancel() + + +async def test_serve_listener_enters_the_lifespan_once_for_every_connection(): + """Connections never re-enter the lifespan: the listener shares one entered state.""" + entered: list[str] = [] + + @asynccontextmanager + async def lifespan(_: SrvT) -> AsyncIterator[dict[str, Any]]: + entered.append("enter") + try: + yield {"db": "shared"} + finally: + entered.append("exit") + + async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult: + return CallToolResult(content=[TextContent(text=ctx.lifespan_context["db"])]) + + server = Server(name="shared-lifespan", version="0.0.1", lifespan=lifespan, on_call_tool=call_tool) + async with anyio.create_task_group() as tg: + port, _ = await _serve_on_tcp(server, tg) + with anyio.fail_after(5): + for _ in range(2): + stream = await anyio.connect_tcp("127.0.0.1", port) + async with stream, _client_over_socket(stream) as (client, _): + result = await client.send_raw_request("tools/call", _modern_params(name="db", arguments={})) + assert result["content"] == [{"type": "text", "text": "shared"}] + tg.cancel_scope.cancel() + + assert entered == ["enter", "exit"] + + +async def test_serve_listener_owns_the_listener_and_closes_it_when_cancelled(): + """The host runs until cancelled and takes the listener with it: nothing to close by hand.""" + listener = await anyio.create_tcp_listener(local_host="127.0.0.1") + raw = listener.extra(anyio.abc.SocketAttribute.raw_socket) + async with anyio.create_task_group() as tg: + tg.start_soon(serve_listener, _server(), listener) + with anyio.fail_after(5): # a client can connect: the listener is being served + connection = await anyio.connect_tcp("127.0.0.1", listener.extra(anyio.abc.SocketAttribute.local_port)) + await connection.aclose() + assert raw.fileno() != -1 + tg.cancel_scope.cancel() + assert raw.fileno() == -1 # closed by serve_listener on the way out diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 24d9dcf75c..bd0d24e5d7 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -4,9 +4,12 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from io import TextIOWrapper +from pathlib import Path import anyio +import anyio.abc import pytest +from anyio.streams.buffered import BufferedByteReceiveStream from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, @@ -19,11 +22,55 @@ ) from typing_extensions import Buffer +from mcp.server import serve_stream from mcp.server.mcpserver import MCPServer -from mcp.server.stdio import stdio_server +from mcp.server.stdio import newline_json_transport, stdio_server from mcp.shared.message import SessionMessage +@pytest.fixture(params=["asyncio", "trio"]) +def anyio_backend(request: pytest.FixtureRequest) -> str: + """Run every async test in this module on both anyio backends; the sync + `run("stdio")` tests drive their own loop in a worker thread and take neither.""" + return request.param + + +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + +class _ByteStreamDouble(anyio.abc.ByteStream): + """A minimal `ByteStream` over an inbound memory stream, recording what the framer writes. + + `broken=True` makes every send fail the way a peer that vanished does, so the + framer's writer can be shown to end quietly instead of crashing the connection. + """ + + def __init__(self, inbound: anyio.abc.ObjectReceiveStream[bytes], *, broken: bool = False) -> None: + self._inbound = inbound + self._broken = broken + self.sent: list[bytes] = [] + self.closed = False + + async def receive(self, max_bytes: int = 65536) -> bytes: + try: + return await self._inbound.receive() + except (anyio.EndOfStream, anyio.ClosedResourceError): + raise anyio.EndOfStream from None + + async def send(self, item: bytes) -> None: + if self._broken: + raise anyio.BrokenResourceError + self.sent.append(item) + + async def send_eof(self) -> None: + await self.aclose() + + async def aclose(self) -> None: + self.closed = True + + @pytest.mark.anyio async def test_stdio_server_round_trips_messages_over_injected_streams() -> None: """stdio_server frames JSON-RPC messages as one line each in both directions. @@ -278,3 +325,134 @@ def test_mcpserver_run_stdio_serves_a_modern_connection(monkeypatch: pytest.Monk # request was served at the discovered version, not the handshake era. assert responses[1].result["tools"] == [] assert responses[1].result["resultType"] == "complete" + + +# --- newline_json_transport: the stdio framing over any byte stream -------------- + + +@pytest.mark.anyio +async def test_custom_transport_over_a_unix_socket_needs_only_framing_plus_serve_stream(tmp_path: Path) -> None: + """The whole custom-transport story: frame a real Unix socket with + `newline_json_transport` and hand the streams to `serve_stream` - a raw + JSON-RPC client on the other end gets a served answer, one frame per line.""" + server = MCPServer(name="socket-server") + + @server.tool() + def add(a: int, b: int) -> str: + """Add two numbers.""" + return str(a + b) + + socket_path = tmp_path / "mcp.sock" + listener = await anyio.create_unix_listener(socket_path) + connection_served = anyio.Event() + + async def handle(stream: anyio.abc.SocketStream) -> None: + async with stream, newline_json_transport(stream) as (read_stream, write_stream): + await serve_stream(server._lowlevel_server, read_stream, write_stream) # pyright: ignore[reportPrivateUsage] + connection_served.set() # the driver returned once the client closed its end + + envelope = { + PROTOCOL_VERSION_META_KEY: "2026-07-28", + CLIENT_INFO_META_KEY: {"name": "socket-client", "version": "1.0"}, + CLIENT_CAPABILITIES_META_KEY: {}, + } + call = JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="tools/call", + params={"name": "add", "arguments": {"a": 3, "b": 4}, "_meta": envelope}, + ) + line = b"" + async with listener, anyio.create_task_group() as tg: + tg.start_soon(listener.serve, handle) + with anyio.fail_after(5): + async with await anyio.connect_unix(socket_path) as client: + await client.send(call.model_dump_json(by_alias=True, exclude_none=True).encode("utf-8") + b"\n") + line = await BufferedByteReceiveStream(client).receive_until(b"\n", 1_000_000) + # The client closed its socket: the driver sees EOF and ends the connection. + await connection_served.wait() + tg.cancel_scope.cancel() + + answer = jsonrpc_message_adapter.validate_json(line, by_name=False) + assert isinstance(answer, JSONRPCResponse) and answer.id == 1 + assert answer.result["content"][0]["text"] == "7" + + +@pytest.mark.anyio +async def test_newline_json_transport_frames_lines_both_ways_and_survives_a_malformed_line() -> None: + """One JSON-RPC message per line in both directions: a line that is not JSON-RPC + reaches the read stream as an exception item and the frame after it is still delivered + (one bad line costs one line, not the connection), outbound messages are written as + exactly one line each, and closing the byte stream stays the caller's job.""" + peer_send, transport_side = anyio.create_memory_object_stream[bytes](8) + good = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping") + reply = JSONRPCResponse(jsonrpc="2.0", id=1, result={}) + stream = _ByteStreamDouble(transport_side) + + async with ( + peer_send, + transport_side, + newline_json_transport(stream) as (read_stream, write_stream), + read_stream, # the driver's ends: a driver would own and close these + write_stream, + ): + await peer_send.send(b"this is not json\n") + await peer_send.send(good.model_dump_json(by_alias=True, exclude_none=True).encode("utf-8") + b"\n") + with anyio.fail_after(5): + first = await read_stream.receive() + second = await read_stream.receive() + await write_stream.send(SessionMessage(reply)) + await anyio.wait_all_tasks_blocked() # let the writer flush onto the byte stream + peer_send.close() # peer EOF: the framer ends its inbound stream + with anyio.fail_after(5): + assert [item async for item in read_stream] == [] + + assert isinstance(first, Exception) + assert isinstance(second, SessionMessage) and second.message == good + assert stream.sent == [reply.model_dump_json(by_alias=True, exclude_unset=True).encode("utf-8") + b"\n"] + assert not stream.closed # the framer never closes the byte stream itself + await stream.send_eof() + assert stream.closed + + +@pytest.mark.anyio +async def test_newline_json_transport_writer_ends_quietly_when_the_peer_is_gone() -> None: + """A write onto a byte stream whose peer has vanished ends the writer without an + error escaping: outbound frames after that are undeliverable, not a crash.""" + peer_send, transport_side = anyio.create_memory_object_stream[bytes](8) + stream = _ByteStreamDouble(transport_side, broken=True) + + async with ( + peer_send, + transport_side, + newline_json_transport(stream) as (read_stream, write_stream), + read_stream, + write_stream, + ): + with anyio.fail_after(5): + await write_stream.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=1, result={}))) + await anyio.wait_all_tasks_blocked() # the writer hits the broken send and returns + assert stream.sent == [] + + +@pytest.mark.anyio +async def test_newline_json_transport_ends_the_read_side_on_an_oversized_frame() -> None: + """A frame that overruns the bound cannot be resynchronised: the framer surfaces + the overrun as an exception item and then ends the inbound stream (EOF).""" + peer_send, transport_side = anyio.create_memory_object_stream[bytes](8) + + async with ( + peer_send, + transport_side, + newline_json_transport(_ByteStreamDouble(transport_side), max_frame_bytes=8) as ( + read_stream, + write_stream, + ), + read_stream, # the driver's ends: a driver would own and close these + write_stream, + ): + await peer_send.send(b"x" * 32) # no newline within the 8-byte bound + with anyio.fail_after(5): + items = [item async for item in read_stream] + + assert len(items) == 1 and isinstance(items[0], anyio.DelimiterNotFound) diff --git a/tests/server/test_stdio_ordering.py b/tests/server/test_stdio_ordering.py new file mode 100644 index 0000000000..cd8a1c077e --- /dev/null +++ b/tests/server/test_stdio_ordering.py @@ -0,0 +1,477 @@ +"""A connection's era is decided by the wire, in wire order, on any scheduler. + +Every scenario here drives a lowlevel `Server` through the public entrypoint - +`Server.run(read_stream, write_stream)` - over in-memory anyio object streams carrying raw +`SessionMessage` frames. There is no client-side dispatcher in the loop: the test writes +exactly the frames a client would put on stdin and reads exactly the frames the server writes +to stdout, so the assertions are about the wire and nothing else. + +The whole file is parametrized over the `asyncio` AND `trio` anyio backends. trio reorders +each batch of runnable tasks at random; an era decision that depends on which of two +in-flight tasks runs first is intermittently red here. A decision made in wire order - in the +read loop, before any `await` - is green on both, every time. This suite cannot tell read-loop +admission from a well-behaved spawned prologue, so it is the regression tripwire for anything +that puts an `await` in front of the era decision, not the proof of the ordering guarantee. + +Scenarios: + +- pipelined contradictory openers, in both orders: the FIRST frame's era wins; +- an enveloped request followed immediately by an envelope-less notification while the request + is in flight: the notification is handled under the modern era, never dropped as + pre-initialization traffic; +- a stray leading notification then a modern request: the client is not pinned legacy; +- `server/discover` then a fallback `initialize`: the handshake is still available; +- a legacy `initialize` arriving while a modern `tools/call` sleeps: -32022, call completes; +- a modern `tools/call` cancelled by `notifications/cancelled`: no further frame for that id, + connection still alive. +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Any + +import anyio +import pytest +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + INVALID_REQUEST, + PROTOCOL_VERSION_META_KEY, + UNSUPPORTED_PROTOCOL_VERSION, + CallToolRequestParams, + CallToolResult, + JSONRPCError, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + ListToolsResult, + NotificationParams, + PaginatedRequestParams, + RequestId, + TextContent, + Tool, +) +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION + +from mcp.server import Server, ServerRequestContext +from mcp.shared.message import SessionMessage + +pytestmark = pytest.mark.anyio + +Ctx = ServerRequestContext[dict[str, Any], Any] + +# The modern per-request envelope. The spec's required pair (protocolVersion + +# clientCapabilities) plus the optional clientInfo, so a still-vendored +# clientInfo-required schema cannot turn these ordering tests into a schema debate. +MODERN_ENVELOPE: dict[str, Any] = { + PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION, + CLIENT_INFO_META_KEY: {"name": "ordering-suite", "version": "0"}, + CLIENT_CAPABILITIES_META_KEY: {}, +} + +# A client-shaped notification a conforming 2026 client may send at any time; it must +# never be read as an era-opening frame. +STRAY_NOTIFICATION_METHOD = "notifications/roots/list_changed" + +# The custom notification the suite uses to prove a notification reached its handler. +NOTE_METHOD = "notifications/test/note" + +# Buffer both directions so a pipelined burst never blocks the writer: pipelining is +# expressed with `send_nowait`, which cannot yield to the scheduler between frames. +FRAME_BUFFER = 64 + +# MUST-NOT-send is only observable as absence: watch the wire for this long after a +# peer cancel before also flushing with a follow-up request. An observation window, not +# a synchronization primitive. +CANCEL_SILENCE_WINDOW = 1.0 + +# Fresh connections for the scheduler-sensitivity stress test. An era decision that +# depends on which of two runnable tasks the scheduler picks first is answered wrongly on +# roughly half of them, so 25 connections make a single accidentally-green run vanishingly +# unlikely, while costing a wire-ordered decision a few milliseconds each. +OPENING_ATTEMPTS = 25 + +_SLOW_TOOL = Tool(name="slow", description="Blocks until released.", input_schema={"type": "object"}) + + +@pytest.fixture(params=["asyncio", "trio"]) +def anyio_backend(request: pytest.FixtureRequest) -> str: + """Run every test in this module on both anyio backends: the trio scheduler is the + load-bearing half of this suite (see the module docstring).""" + return request.param + + +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + +# --- the server under test ------------------------------------------------------------ + + +@dataclass +class Probe: + """Per-connection observation points; created inside the running backend.""" + + entered: anyio.Event = field(default_factory=anyio.Event) + """The `slow` tool handler has started: a modern request is genuinely in flight.""" + + release: anyio.Event = field(default_factory=anyio.Event) + """Lets the parked `slow` tool return its result.""" + + noted: anyio.Event = field(default_factory=anyio.Event) + """The `notifications/test/note` notification reached its handler.""" + + +def make_server(probe: Probe) -> Server[dict[str, Any]]: + """A dual-era lowlevel server (default posture) with a fast `tools/list`, a `slow` + tool that parks on `probe.release`, and a handler for the suite's custom notification.""" + + async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[_SLOW_TOOL]) + + async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult: + assert params.name == "slow" + probe.entered.set() + await probe.release.wait() + return CallToolResult(content=[TextContent(type="text", text="done")]) + + async def on_note(ctx: Ctx, params: NotificationParams) -> None: + probe.noted.set() + + server: Server[dict[str, Any]] = Server( + "stdio-ordering", version="0.0.0", on_list_tools=list_tools, on_call_tool=call_tool + ) + server.add_notification_handler(NOTE_METHOD, NotificationParams, on_note) + return server + + +# --- the raw frames a client writes ----------------------------------------------------- + + +def modern_request(request_id: RequestId, method: str, **params: Any) -> JSONRPCRequest: + """A 2026-era request: its own protocol envelope rides in `params._meta`.""" + return JSONRPCRequest( + jsonrpc="2.0", id=request_id, method=method, params={**params, "_meta": dict(MODERN_ENVELOPE)} + ) + + +def legacy_initialize(request_id: RequestId) -> JSONRPCRequest: + """The 2025-era opening handshake.""" + return JSONRPCRequest( + jsonrpc="2.0", + id=request_id, + method="initialize", + params={ + "protocolVersion": LATEST_HANDSHAKE_VERSION, + "capabilities": {}, + "clientInfo": {"name": "ordering-suite", "version": "0"}, + }, + ) + + +def bare_notification(method: str, **params: Any) -> JSONRPCNotification: + """An envelope-less client notification (notifications never carry the envelope).""" + return JSONRPCNotification(jsonrpc="2.0", method=method, params=dict(params)) + + +# --- the in-memory connection ----------------------------------------------------------- + + +class Wire: + """The client's end of one in-memory connection. + + `pipeline(...)` writes a burst back-to-back with no scheduler yield between frames, so + the whole burst is on the wire before the server can answer any of it - the pipelined + conditions the ordering scenarios need. Every frame the server writes is kept in + `frames`, in arrival order, so a test can await answers in either order and inspect + what else was written. + """ + + def __init__( + self, + to_server: MemoryObjectSendStream[SessionMessage | Exception], + from_server: MemoryObjectReceiveStream[SessionMessage], + ) -> None: + self._to_server = to_server + self._from_server = from_server + self.frames: list[JSONRPCMessage] = [] + + def pipeline(self, *messages: JSONRPCMessage) -> None: + """Write frames back-to-back, atomically with respect to the scheduler.""" + for message in messages: + self._to_server.send_nowait(SessionMessage(message=message)) + + async def send(self, message: JSONRPCMessage) -> None: + """Write one frame.""" + await self._to_server.send(SessionMessage(message=message)) + + async def read_one(self) -> None: + """Wait for the next frame the server writes and record it.""" + self.frames.append((await self._from_server.receive()).message) + + def answer_for(self, request_id: RequestId) -> JSONRPCResponse | JSONRPCError | None: + """The response or error already written for `request_id`, if any.""" + for frame in self.frames: + if isinstance(frame, JSONRPCResponse | JSONRPCError) and frame.id == request_id: + return frame + return None + + async def response_to(self, request_id: RequestId) -> JSONRPCResponse | JSONRPCError: + """The response or error for `request_id`, reading frames (bounded) until it lands.""" + with anyio.fail_after(5): + while (answer := self.answer_for(request_id)) is None: + await self.read_one() + return answer + + async def watch(self, window: float) -> None: + """Record every frame the server writes during a bounded observation window. + + Absence is the only observable of a MUST-NOT-send property; this is that window + (never a synchronization primitive - the caller flushes with a follow-up request). + """ + with anyio.move_on_after(window): + while True: + await self.read_one() + + +@asynccontextmanager +async def running(server: Server[dict[str, Any]]) -> AsyncIterator[Wire]: + """Serve `server` over one in-memory stream pair via the public `Server.run` driver and + yield the client's `Wire`. The connection ends by cancelling the driver, as a stdio + process ending does; a failure raised inside the block surfaces unwrapped.""" + to_server_send, to_server_recv = anyio.create_memory_object_stream[SessionMessage | Exception](FRAME_BUFFER) + from_server_send, from_server_recv = anyio.create_memory_object_stream[SessionMessage](FRAME_BUFFER) + body_error: BaseException | None = None + try: + async with anyio.create_task_group() as tg: + tg.start_soon(server.run, to_server_recv, from_server_send, server.create_initialization_options()) + try: + yield Wire(to_server_send, from_server_recv) + except BaseException as exc: + body_error = exc + tg.cancel_scope.cancel() + finally: + to_server_send.close() + to_server_recv.close() + from_server_send.close() + from_server_recv.close() + if body_error is not None: + raise body_error + + +async def test_wire_harness_relays_a_failing_body_exception_unwrapped() -> None: + """The wire harness's own contract, pinned so its coverage stays whole: a failure raised + inside a connection block surfaces as itself rather than as the driver task's exception + group, so a red test always reads as the assertion that failed.""" + + class _BodyFailed(Exception): + pass + + server = make_server(Probe()) + with pytest.raises(_BodyFailed): + async with running(server): + raise _BodyFailed + + +# --- (1) pipelined contradictory openers ------------------------------------------------- + + +async def test_modern_opener_pipelined_before_a_legacy_handshake_opens_modern() -> None: + """Contradictory openers written back-to-back, modern first: the FIRST frame's era + wins, so the enveloped request is served and the handshake behind it is refused with + -32022 naming the modern versions. The era is a property of how the client opened the + connection, never of which request happens to finish (or start) first.""" + server = make_server(Probe()) + async with running(server) as wire: + wire.pipeline(modern_request(1, "tools/list"), legacy_initialize(2)) + tools = await wire.response_to(1) + init = await wire.response_to(2) + assert isinstance(tools, JSONRPCResponse), f"the modern opener must be served, got {tools}" + assert tools.result["tools"][0]["name"] == "slow" + assert isinstance(init, JSONRPCError), f"the handshake pipelined behind a modern opener must be refused, got {init}" + assert init.error.code == UNSUPPORTED_PROTOCOL_VERSION + assert LATEST_MODERN_VERSION in init.error.data["supported"] + + +async def test_legacy_handshake_pipelined_before_a_modern_request_opens_legacy() -> None: + """Contradictory openers written back-to-back, handshake first: the FIRST frame's era + wins, so the handshake is ACCEPTED even though a modern-enveloped request already sits + on the wire behind it. What that pipelined request receives is the separate + legacy-commitment ruling, pinned by + `test_modern_request_on_a_legacy_committed_connection_is_refused_not_downgraded`.""" + server = make_server(Probe()) + async with running(server) as wire: + wire.pipeline(legacy_initialize(1), modern_request(2, "tools/list")) + init = await wire.response_to(1) + # Drain the pipelined request: every request is owed an answer, but its content is + # not era-diagnostic here (a legacy-served tools/list and a modern one look alike). + await wire.response_to(2) + assert isinstance(init, JSONRPCResponse), f"the handshake that opened the connection must be accepted, got {init}" + assert init.result["protocolVersion"] == LATEST_HANDSHAKE_VERSION + + +async def test_modern_request_on_a_legacy_committed_connection_is_refused_not_downgraded() -> None: + """Once the handshake has committed the connection to the legacy era, a request that + claims the modern era in its envelope is refused with INVALID_REQUEST rather than + silently served under legacy semantics: a second, conflicting era claim on a committed + connection is a client error, so the answer is the deterministic refusal, never the + era-ambiguous pass-through.""" + server = make_server(Probe()) + async with running(server) as wire: + await wire.send(legacy_initialize(1)) + init = await wire.response_to(1) + assert isinstance(init, JSONRPCResponse), f"handshake must be accepted, got {init}" + await wire.send(bare_notification("notifications/initialized")) + await wire.send(modern_request(2, "tools/list")) + answer = await wire.response_to(2) + assert isinstance(answer, JSONRPCError), ( + f"an enveloped request on a legacy-committed connection must be refused, not served; got {answer}" + ) + assert answer.error.code == INVALID_REQUEST + + +async def test_pipelined_openers_are_ordered_by_the_wire_not_the_scheduler() -> None: + """The modern-first opener pair over many fresh connections. An era decision that + depends on which of two runnable tasks the scheduler picks first accepts the handshake + on some connections and refuses it on others; a decision made in wire order refuses it + on every one. One flipped connection fails the test - this is the scheduler-sensitivity + detector (see the module docstring for what it can and cannot prove).""" + for attempt in range(OPENING_ATTEMPTS): + server = make_server(Probe()) + async with running(server) as wire: + wire.pipeline(modern_request(1, "tools/list"), legacy_initialize(2)) + init = await wire.response_to(2) + await wire.response_to(1) + assert isinstance(init, JSONRPCError) and init.error.code == UNSUPPORTED_PROTOCOL_VERSION, ( + f"connection {attempt}: the handshake pipelined behind a modern opener was answered {init}; " + "the era followed the task scheduler, not the wire order" + ) + + +# --- (2) a notification pipelined behind an in-flight modern request --------------------- + + +async def test_notification_pipelined_behind_an_in_flight_modern_request_is_delivered() -> None: + """A modern-enveloped request followed immediately by an envelope-less notification: + the request opened the modern era, so the notification is handled under that era - + never dropped as "received before initialization", which is the fate a legacy-era + misroute would give it while the request is still in flight.""" + probe = Probe() + server = make_server(probe) + async with running(server) as wire: + wire.pipeline( + modern_request(1, "tools/call", name="slow", arguments={}), + bare_notification(NOTE_METHOD), + ) + with anyio.fail_after(5): + await probe.entered.wait() # the modern request is genuinely in flight... + await probe.noted.wait() # ...and the notification behind it reached its handler + probe.release.set() + result = await wire.response_to(1) + assert isinstance(result, JSONRPCResponse), ( + f"the in-flight modern request must still complete after the notification, got {result}" + ) + assert result.result["content"][0]["text"] == "done" + + +# --- (3) a stray leading notification does not decide the era ------------------------------ + + +async def test_stray_leading_notification_does_not_pin_the_connection_legacy() -> None: + """A leading `notifications/roots/list_changed` (a courtesy frame a conforming 2026 + client may send) must not open the legacy era: the modern request that follows opens the + MODERN era - proven by its being served AND by a later handshake being refused -32022 - + instead of the client being stranded behind an error on every request thereafter.""" + server = make_server(Probe()) + async with running(server) as wire: + await wire.send(bare_notification(STRAY_NOTIFICATION_METHOD)) + await wire.send(modern_request(1, "tools/list")) + tools = await wire.response_to(1) + assert isinstance(tools, JSONRPCResponse), ( + f"the modern request after a stray notification must be served, got {tools}" + ) + assert tools.result["tools"][0]["name"] == "slow" + await wire.send(legacy_initialize(2)) + init = await wire.response_to(2) + assert isinstance(init, JSONRPCError), ( + f"the modern request must have opened the MODERN era (a later handshake is refused), got {init}" + ) + assert init.error.code == UNSUPPORTED_PROTOCOL_VERSION + + +# --- (4) probe-then-fallback --------------------------------------------------------------- + + +async def test_discover_probe_leaves_the_initialize_fallback_available() -> None: + """`server/discover` is a probe, not an opening: it is answered from the real modern + surface but commits nothing, so a client that probes and then falls back to `initialize` + (the stdio spec's backward-compatibility flow) completes the handshake instead of being + told -32022 - the one code that means "server is modern, do not fall back".""" + server = make_server(Probe()) + async with running(server) as wire: + await wire.send(modern_request(1, "server/discover")) + discover = await wire.response_to(1) + assert isinstance(discover, JSONRPCResponse), f"the probe must be answered, got {discover}" + assert "capabilities" in discover.result + await wire.send(legacy_initialize(2)) + init = await wire.response_to(2) + assert isinstance(init, JSONRPCResponse), f"the fallback handshake after a probe must be accepted, got {init}" + assert init.result["protocolVersion"] == LATEST_HANDSHAKE_VERSION + + +# --- (5) a legacy handshake arriving during in-flight modern work ----------------------- + + +async def test_legacy_handshake_during_an_in_flight_modern_call_is_refused_and_the_call_completes() -> None: + """A connection already executing modern work is a modern connection: a handshake + arriving mid-flight is refused -32022 naming the modern versions - it is never accepted + and locked legacy underneath the running request - and the modern call still completes + with its own result afterwards.""" + probe = Probe() + server = make_server(probe) + async with running(server) as wire: + await wire.send(modern_request(1, "tools/call", name="slow", arguments={})) + with anyio.fail_after(5): + await probe.entered.wait() # modern work is in flight + await wire.send(legacy_initialize(2)) + init = await wire.response_to(2) + assert isinstance(init, JSONRPCError), f"a handshake during in-flight modern work must be refused, got {init}" + assert init.error.code == UNSUPPORTED_PROTOCOL_VERSION + assert LATEST_MODERN_VERSION in init.error.data["supported"] + probe.release.set() + result = await wire.response_to(1) + assert isinstance(result, JSONRPCResponse), ( + f"the modern call must complete after the refused handshake, got {result}" + ) + assert result.result["content"][0]["text"] == "done" + + +# --- (6) peer cancel: no further frame, connection alive --------------------------------- + + +async def test_cancelled_modern_call_writes_no_further_frame_and_the_connection_survives() -> None: + """A modern `tools/call` cancelled by the peer with `notifications/cancelled`: no frame + for that id is written afterwards (the stdio spec: the server MUST NOT send any further + messages for a cancelled request - so no `{"code":0,"message":"Request cancelled"}` + echo), and the connection keeps serving the request that follows.""" + probe = Probe() + server = make_server(probe) + async with running(server) as wire: + await wire.send(modern_request(1, "tools/call", name="slow", arguments={})) + with anyio.fail_after(5): + await probe.entered.wait() + await wire.send(bare_notification("notifications/cancelled", requestId=1, reason="test")) + await wire.watch(CANCEL_SILENCE_WINDOW) + # The connection is alive: a following modern request is served. Its answer also + # flushes onto the record any frame the server wrote for the cancelled id first. + await wire.send(modern_request(2, "tools/list")) + followup = await wire.response_to(2) + assert wire.answer_for(1) is None, ( + f"no frame may be written for a cancelled request, but the server wrote {wire.answer_for(1)}" + ) + assert isinstance(followup, JSONRPCResponse), f"the connection must keep serving after a cancel, got {followup}" + assert followup.result["tools"][0]["name"] == "slow" diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 70440d9d03..c86d180466 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -252,9 +252,9 @@ async def running_manager(): async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[StreamableHTTPSessionManager, Server]): manager, _app = running_manager - # The manager's `run_server` task drives `serve_loop` directly (the manager - # owns lifespan); patch that seam so the loop returns immediately and we - # can observe the cleanup that follows. + # The manager's `run_server` task drives `serve_legacy_stream` directly (the + # manager owns lifespan and has already routed the era); patch that seam so + # the loop returns immediately and we can observe the cleanup that follows. mock_serve = AsyncMock(return_value=None) sent_messages: list[Message] = [] @@ -273,7 +273,7 @@ async def mock_receive(): return {"type": "http.request", "body": b"", "more_body": False} # Trigger session creation - with patch("mcp.server.streamable_http_manager.serve_loop", mock_serve): + with patch("mcp.server.streamable_http_manager.serve_legacy_stream", mock_serve): await manager.handle_request(scope, mock_receive, mock_send) # Extract session ID from response headers @@ -331,7 +331,7 @@ async def mock_receive(): return {"type": "http.request", "body": b"", "more_body": False} # Trigger session creation - with patch("mcp.server.streamable_http_manager.serve_loop", mock_serve): + with patch("mcp.server.streamable_http_manager.serve_legacy_stream", mock_serve): await manager.handle_request(scope, mock_receive, mock_send) session_id = None diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index cbf826d3f7..6cd0a84f34 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -85,7 +85,7 @@ def _asgi_client( accept: str = "application/json, text/event-stream", ) -> httpx2.AsyncClient: async def app(scope: Scope, receive: Receive, send: Send) -> None: - async with server.lifespan(server) as lifespan_state: + async with server.lifespan() as lifespan_state: await handle_modern_request(server, security_settings, json_response, lifespan_state, scope, receive, send) return httpx2.AsyncClient( @@ -692,7 +692,7 @@ async def send(message: Message) -> None: # pragma: no cover pass with anyio.fail_after(5): - async with server.lifespan(server) as lifespan_state: + async with server.lifespan() as lifespan_state: await handle_modern_request(server, None, False, lifespan_state, scope, receive, send) await cleanup_ran.wait() diff --git a/tests/shared/test_cancel_race.py b/tests/shared/test_cancel_race.py new file mode 100644 index 0000000000..411dc7217d --- /dev/null +++ b/tests/shared/test_cancel_race.py @@ -0,0 +1,638 @@ +"""Cancellation races on the wire: what `JSONRPCDispatcher` writes when a peer's cancel, a +handler's completion, and connection teardown collide. + +The dispatcher's silence after a peer cancel is a property of the request's reply channel, +not a check before writing: a peer cancel (read while the request is still cancellable), the +request's own terminal write and a gone peer each swap the channel's write target for a +void. A body that has returned is committed - the request leaves the cancellable table +before its answer spends the channel, with no checkpoint between - so a cancel read after +that finds nothing to interrupt and the owed answer is written. These tests pin that +behaviour at the wire, in the races where a design could differ, on both anyio backends +(module `anyio_backend` fixture). Synchronisation is by events, by +`anyio.wait_all_tasks_blocked()` (a scheduler quiesce, not a sleep), and by reading the wire +until a fence frame lands; no fixed sleep is any test's only synchronisation. + +The scenarios and what each proves: + +1. A `subscriptions/listen`-shaped request emitting stamped notifications is peer + cancelled; the handler's own cleanup tries to emit one more stamped event after the + cancel was read. The straggler never follows the cancel (the request's back-channel + is revoked at cancel-read), no answer is owed for the withdrawn id, and the connection + keeps serving. +2. The completed-just-as-cancelled window, order Y (the handler RETURNED its result + before the cancel is read): the result write is parked on transport backpressure when + the cancel is read and processed, and only afterwards does anyone drain the wire. An + answer already computed when the cancel is read is written exactly once: the request + retired from the cancellable table before its write, so the racing cancel finds nothing + to interrupt. +3. The same window, order X (the cancel is READ first, the handler completes anyway + because the cancel doubles as its wakeup and the interruption is deferred), over a + transport that accepts a frame without a checkpoint. The result was produced after the + cancel took effect and must not reach the wire on any transport: the cancel revoked the + channel, so the answer writes into the void whether or not the transport checkpoints. +4. Shutdown while a handler is mid-run: stdin EOF (peer gone, nothing owed on the wire), + an owner-initiated teardown with the peer still connected (exactly one + CONNECTION_CLOSED error, never a duplicate), a teardown landing on a parked answer + write (at most one answer for the id), and a teardown after a peer cancel of a handler + that outlives its interruption (no frame for the withdrawn id). + +Together these are the cancellation rulings this SDK ships: an owed result is written; no +message follows for a cancelled id; at most one answer per id. A red test is a behavioural +regression, and the failure message prints the frames written so the difference is legible +from the output. +""" + +from collections.abc import Awaitable, Mapping +from dataclasses import dataclass, field +from types import TracebackType +from typing import Any + +import anyio +import pytest +from mcp_types import ( + CONNECTION_CLOSED, + ErrorData, + JSONRPCError, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + RequestId, +) + +from mcp.shared.dispatcher import DispatchContext +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.message import ServerMessageMetadata, SessionMessage +from mcp.shared.transport_context import TransportContext + +DCtx = DispatchContext[TransportContext] + +WAIT = 5 +"""Bound (seconds) on any wait for a frame or an event, so a hang fails instead of stalling.""" + +LISTEN_METHOD = "subscriptions/listen" +EVENT_METHOD = "notifications/gate/event" + +CONNECTION_CLOSED_ERROR = ErrorData(code=CONNECTION_CLOSED, message="Connection closed") +"""The one answer a live peer gets for a request the owner tears down mid-run.""" + + +@pytest.fixture(params=["asyncio", "trio"]) +def anyio_backend(request: pytest.FixtureRequest) -> str: + """Run every test in this module on both anyio backends: several of these races only + show on the shuffled scheduler.""" + return request.param + + +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + +class RecordingWriteStream: + """A transport that accepts each frame synchronously - `send` has no checkpoint - and + records it. The completed-answer race turns on whether the transport hands the frame off + before the writer's next checkpoint, so this is the accept-immediately extreme; the + zero-buffer memory stream in the backpressure tests is the parked extreme.""" + + def __init__(self) -> None: + self.sent: list[SessionMessage] = [] + + async def send(self, item: SessionMessage) -> None: + self.sent.append(item) + + async def aclose(self) -> None: + raise NotImplementedError # the dispatcher releases the stream via __aexit__, never aclose + + async def __aenter__(self) -> "RecordingWriteStream": + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: + return None + + @property + def frames(self) -> list[JSONRPCMessage]: + return [item.message for item in self.sent] + + +def request(request_id: RequestId, method: str, **params: Any) -> SessionMessage: + return SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=dict(params))) + + +def cancel(request_id: RequestId) -> SessionMessage: + return SessionMessage( + message=JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": request_id}) + ) + + +def answers_for(frames: list[JSONRPCMessage], request_id: RequestId) -> list[JSONRPCMessage]: + """Every response or error frame written for `request_id`, in wire order.""" + return [f for f in frames if isinstance(f, JSONRPCResponse | JSONRPCError) and f.id == request_id] + + +@dataclass +class CancelWatch: + """`on_notify` admission that flags the instant a `notifications/cancelled` is READ. + + A dispatcher admits notifications synchronously in receive order, after its own cancel + handling for that frame has run, so `read` fires exactly once the cancel has taken + effect (the request's channel is revoked and its scope interrupted) - the fence the + races below hang off, with no sleep.""" + + read: anyio.Event = field(default_factory=anyio.Event) + + def __call__(self, ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> Awaitable[None]: + # The peer in these races sends the owner one notification: the cancel it watches for. + assert method == "notifications/cancelled", f"cancel-race gate admitted an unexpected {method!r}" + self.read.set() + return self._ignore() + + async def _ignore(self) -> None: + pass + + +# --- 1. a stamped listen event racing the peer's cancel --------------------------------- + + +@pytest.mark.anyio +async def test_stamped_listen_event_emitted_after_the_cancel_is_read_never_follows_it() -> None: + """A `subscriptions/listen`-shaped request streams stamped events; the peer cancels it; + the handler's own cleanup then tries to emit one final stamped event AFTER the cancel + was read (a shielded straggler - the strongest form of the race, since an emit merely + interrupted mid-write is silenced by the scope cancel in any design). The event stamped + before the cancel is legitimately on the wire; the straggler must never follow the + cancel, no answer is owed for the withdrawn id, and the connection keeps serving.""" + read_send, read_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) + recording = RecordingWriteStream() + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read_recv, recording) + watch = CancelWatch() + first_event_sent = anyio.Event() + cleanup_ran = anyio.Event() + + async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + if method == "probe": + return {"probe": True} + assert method == LISTEN_METHOD + try: + # A stamped event: the dispatch context routes it with the listen's + # related_request_id so a stream transport can associate it. + await ctx.notify(EVENT_METHOD, {"seq": 1}) + first_event_sent.set() + await anyio.sleep_forever() + finally: + # The straggler: cleanup emitting one last stamped event once the + # cancel has already been read. Shielded, so only the design's + # silence rule (revoked channel / closed context) can drop it. + with anyio.CancelScope(shield=True): + await ctx.notify(EVENT_METHOD, {"seq": 2}) + cleanup_ran.set() + raise NotImplementedError + + try: + async with anyio.create_task_group() as tg: + await tg.start(server.run, on_request, watch) + await read_send.send(request(1, LISTEN_METHOD, notifications={})) + with anyio.fail_after(WAIT): + await first_event_sent.wait() + await read_send.send(cancel(1)) + with anyio.fail_after(WAIT): + await watch.read.wait() + await cleanup_ran.wait() + # Fence: a request admitted after the cancel is answered, so the record below + # is the complete post-cancel wire, not an observation window. + await read_send.send(request(2, "probe")) + with anyio.fail_after(WAIT): + while not answers_for(recording.frames, 2): + await anyio.wait_all_tasks_blocked() + read_send.close() # EOF: run() drains, cancels and returns; the tg then exits. + finally: + read_send.close() + read_recv.close() + + stamped = [ + (item.message, item.metadata) + for item in recording.sent + if isinstance(item.message, JSONRPCNotification) and item.message.method == EVENT_METHOD + ] + assert [event.params for event, _ in stamped] == [{"seq": 1}], ( + f"only the event sent before the cancel may reach the wire, got {[event for event, _ in stamped]}" + ) + stamp = stamped[0][1] + assert isinstance(stamp, ServerMessageMetadata), f"a listen event carries the listen's metadata, got {stamp}" + assert stamp.related_request_id == 1, f"the event is stamped with the listen's id, got {stamp}" + assert answers_for(recording.frames, 1) == [], ( + f"no answer is owed for the withdrawn listen, but the wire holds {answers_for(recording.frames, 1)}" + ) + assert answers_for(recording.frames, 2) == [JSONRPCResponse(jsonrpc="2.0", id=2, result={"probe": True})], ( + "the connection must carry on serving after the cancel" + ) + + +# --- 2. completed-just-as-cancelled, order Y: the owed answer under backpressure --------- + + +async def _owed_answer_under_backpressure() -> list[JSONRPCMessage]: + """Order Y with the transport under backpressure, deterministically. + + The handler returns its result BEFORE the cancel is read, and its result write parks on + a zero-buffer stream (nobody is draining yet). The peer's cancel is then read and its + handling is allowed to run to quiescence, and only then does the peer drain the wire, + finishing with a probe whose answer fences the record. Returns every frame written.""" + read_send, read_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) + write_send, write_recv = anyio.create_memory_object_stream[SessionMessage](0) + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read_recv, write_send) + watch = CancelWatch() + release = anyio.Event() + returned: list[dict[str, Any]] = [] + frames: list[JSONRPCMessage] = [] + + async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + if method == "probe": + return {"probe": True} + await release.wait() + answer = {"answer": 42} + returned.append(answer) # no checkpoint after this: the answer is computed and owed + return answer + + try: + async with anyio.create_task_group() as tg: + await tg.start(server.run, on_request, watch) + await read_send.send(request(1, "slow")) + await anyio.wait_all_tasks_blocked() # the handler is parked on `release` + release.set() + # The handler returns and its result write parks on the zero-buffer stream, + # BEFORE the cancel exists: this is what makes the ordering Y and not X. + await anyio.wait_all_tasks_blocked() + assert returned == [{"answer": 42}], "precondition: the handler completed before the cancel was sent" + await read_send.send(cancel(1)) + with anyio.fail_after(WAIT): + await watch.read.wait() + # Let the dispatcher finish reacting to the cancel (a design that aborts the + # parked write unwinds it here) before any reader appears on the transport. + await anyio.wait_all_tasks_blocked() + await read_send.send(request(2, "probe")) + with anyio.fail_after(WAIT): + while not answers_for(frames, 2): + frames.append((await write_recv.receive()).message) + read_send.close() # EOF; run() returns and the task group exits + with anyio.move_on_after(WAIT): + # Anything written during teardown (there should be nothing). + frames.extend([item.message async for item in write_recv]) + finally: + for stream in (read_send, read_recv, write_send, write_recv): + stream.close() + return frames + + +@pytest.mark.anyio +async def test_answer_returned_before_the_cancel_is_read_is_written_despite_transport_backpressure() -> None: + """The completed-just-as-cancelled edge, order Y: an answer the handler already computed + when the cancel is read is written, exactly once - here with the answer's write parked on + a stalled transport, so the dispatcher's cancel handling and the transport's acceptance + genuinely race. The request retired from the cancellable table before its write, so the + racing cancel finds nothing to interrupt and the parked answer is delivered when the + peer drains; a design that keeps the request cancellable until the write completes would + let the cancel interrupt the parked write and drop the owed answer.""" + frames = await _owed_answer_under_backpressure() + owed = answers_for(frames, 1) + assert owed == [JSONRPCResponse(jsonrpc="2.0", id=1, result={"answer": 42})], ( + "the handler returned {'answer': 42} before the cancel was read; the owed answer must " + f"reach the wire exactly once, but the answers for id 1 were {owed} (full wire: {frames})" + ) + assert answers_for(frames, 2) == [JSONRPCResponse(jsonrpc="2.0", id=2, result={"probe": True})] + + +# --- 3. completed-just-as-cancelled, order X: the revoked channel over a sync transport --- + + +async def _completion_after_the_cancel_over_a_sync_transport() -> list[JSONRPCMessage]: + """Order X over a transport that accepts frames without a checkpoint. + + The cancel is READ while the handler is parked on the peer's own signal, so the + dispatcher's cancel handling runs with the request still cancellable (channel revoked + and scope interrupted). The handler nonetheless completes: the cancel signal is also its + wakeup, so the interruption is deferred past its return. What reaches the recording + transport is exactly what the dispatcher writes when a body returns AFTER its cancel + took effect and no transport checkpoint stands in the way. Returns every frame written.""" + read_send, read_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) + recording = RecordingWriteStream() + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read_recv, recording) + watch = CancelWatch() + handler_started = anyio.Event() + handler_returned = anyio.Event() + + async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + if method == "probe": + return {"probe": True} + handler_started.set() + await ctx.cancel_requested.wait() # the cancel is this handler's wakeup + handler_returned.set() + return {"completed": "after-cancel"} + + try: + async with anyio.create_task_group() as tg: + await tg.start(server.run, on_request, watch) + await read_send.send(request(1, "slow")) + with anyio.fail_after(WAIT): + await handler_started.wait() + await read_send.send(cancel(1)) + with anyio.fail_after(WAIT): + await watch.read.wait() + await handler_returned.wait() + await anyio.wait_all_tasks_blocked() + await read_send.send(request(2, "probe")) + with anyio.fail_after(WAIT): + while not answers_for(recording.frames, 2): + await anyio.wait_all_tasks_blocked() + read_send.close() + finally: + read_send.close() + read_recv.close() + return recording.frames + + +@pytest.mark.anyio +async def test_answer_produced_after_the_cancel_took_effect_never_reaches_a_no_checkpoint_transport() -> None: + """stdio.mdx: a server MUST NOT send any further messages for a cancelled request. Here + the cancel took effect (was read and applied) BEFORE the handler produced its result; + that result is a further message for a cancelled request and must not reach the wire, + regardless of whether the transport happens to checkpoint before accepting a frame. The + recording transport accepts without a checkpoint, so only a design whose silence lives + in the write path itself (a revoked channel) drops it; a design whose silence is 'the + scope cancel interrupts the write' leaks it, because there is no checkpoint to interrupt.""" + frames = await _completion_after_the_cancel_over_a_sync_transport() + assert answers_for(frames, 1) == [], ( + f"the result was produced after the cancel took effect and must not follow it, but the " + f"wire holds {answers_for(frames, 1)} (full wire: {frames})" + ) + assert answers_for(frames, 2) == [JSONRPCResponse(jsonrpc="2.0", id=2, result={"probe": True})] + + +# --- 2b. the same window with a live drain: measured across fresh connections ----------- + +REPEATS = 20 +"""Fresh connections for the live-drain race. With a reader draining the wire, the +completed answer is usually accepted before any cancel handling can withdraw it, so this +scenario is a distribution, not a certainty: the invariant asserted is that whatever +happens is legal (at most one answer for the id, and it is the handler's result), and the +delivery ratio is printed for the record.""" + + +async def _completion_racing_the_cancel_with_a_live_drain() -> list[JSONRPCMessage]: + """The handler returns at the instant the cancel arrives, while a reader drains the wire. + + `release` and the cancel are made available in the same scheduler step, so which the + dispatcher acts on first is the backend's choice (deterministic FIFO on asyncio, shuffled + on trio) - the honest live-transport version of the race. Returns every frame written.""" + read_send, read_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) + write_send, write_recv = anyio.create_memory_object_stream[SessionMessage](0) + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read_recv, write_send) + watch = CancelWatch() + release = anyio.Event() + frames: list[JSONRPCMessage] = [] + + async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + if method == "probe": + return {"probe": True} + await release.wait() + return {"answer": 42} + + def record(item: SessionMessage) -> None: + frames.append(item.message) # incremental: the probe fence polls `frames` while draining + + async def drain() -> None: + async for item in write_recv: + record(item) + + try: + async with anyio.create_task_group() as tg: + tg.start_soon(drain) + await tg.start(server.run, on_request, watch) + await read_send.send(request(1, "slow")) + await anyio.wait_all_tasks_blocked() # handler parked on `release`, reader parked draining + # Same step, no yield between: the handler's wakeup and the cancel's arrival race. + release.set() + read_send.send_nowait(cancel(1)) + with anyio.fail_after(WAIT): + await watch.read.wait() + await anyio.wait_all_tasks_blocked() + await read_send.send(request(2, "probe")) + with anyio.fail_after(WAIT): + while not answers_for(frames, 2): + await anyio.wait_all_tasks_blocked() + read_send.close() # EOF; run() closes the write stream, which ends `drain` + finally: + for stream in (read_send, read_recv, write_send, write_recv): + stream.close() + return frames + + +@pytest.mark.anyio +async def test_completion_racing_the_cancel_with_a_live_reader_is_legal_on_every_connection() -> None: + """Across many fresh connections, the handler's result and the peer's cancel race with a + reader draining the wire. Either outcome is legal for the peer (cancellation.mdx: the + server MAY ignore a cancel whose processing already completed; a raced peer ignores a late + answer). What is never legal: two answers for one id, an error for the withdrawn id, or a + result other than the handler's. This test asserts only the invariants; the + delivered/dropped split it prints records which way the live race falls.""" + only_legal = [JSONRPCResponse(jsonrpc="2.0", id=1, result={"answer": 42})] + delivered = 0 + for attempt in range(REPEATS): + frames = await _completion_racing_the_cancel_with_a_live_drain() + owed = answers_for(frames, 1) + # At most one answer for the id, and if any it is the handler's result: `[]` (the cancel + # won the race) and `only_legal` (the answer won) are the two legal wires. + assert owed in ([], only_legal), ( + f"attempt {attempt}: id 1 may carry nothing or exactly its result {only_legal}, got {owed} " + f"(full wire: {frames})" + ) + delivered += len(owed) + assert answers_for(frames, 2) == [JSONRPCResponse(jsonrpc="2.0", id=2, result={"probe": True})], ( + f"attempt {attempt}: the connection must keep serving, wire: {frames}" + ) + # Recorded, not asserted: which way the live race falls is the scheduler's choice. + print(f"live-drain race delivered the completed answer {delivered}/{REPEATS}") + + +# --- 4. shutdown while a handler is mid-run --------------------------------------------- + + +@pytest.mark.anyio +async def test_stdin_eof_mid_handler_writes_nothing_and_leaves_no_duplicate() -> None: + """The peer's input ends (it is gone) while a handler runs: the request is interrupted + and nothing is written for it - there is nobody left to read a CONNECTION_CLOSED answer, + and a frame onto a wire nobody reads is exactly what the peer-gone mode forbids.""" + read_send, read_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) + recording = RecordingWriteStream() + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read_recv, recording) + watch = CancelWatch() + handler_started = anyio.Event() + handler_cancelled = anyio.Event() + + async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + handler_started.set() + try: + await anyio.sleep_forever() + finally: + handler_cancelled.set() + raise NotImplementedError + + try: + async with anyio.create_task_group() as tg: + await tg.start(server.run, on_request, watch) + await read_send.send(request(1, "slow")) + with anyio.fail_after(WAIT): + await handler_started.wait() + read_send.close() # EOF while the handler runs + with anyio.fail_after(WAIT): + await handler_cancelled.wait() + finally: + read_send.close() + read_recv.close() + assert answers_for(recording.frames, 1) == [], ( + f"at EOF the peer is gone: nothing may be written for the interrupted request, got {recording.frames}" + ) + + +@pytest.mark.anyio +async def test_owner_teardown_mid_handler_answers_the_live_peer_exactly_once() -> None: + """The owner tears the connection down (cancels the dispatcher's task) while a handler + runs and the peer is still connected: the interrupted request gets exactly one + CONNECTION_CLOSED error so the peer isn't left waiting - one answer, never two, and + never a result the handler didn't return.""" + read_send, read_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) + recording = RecordingWriteStream() + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read_recv, recording) + watch = CancelWatch() + handler_started = anyio.Event() + + async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + handler_started.set() + await anyio.sleep_forever() + raise NotImplementedError + + try: + async with anyio.create_task_group() as tg: + await tg.start(server.run, on_request, watch) + await read_send.send(request(1, "slow")) + with anyio.fail_after(WAIT): + await handler_started.wait() + tg.cancel_scope.cancel() # owner teardown; the read side is still open (peer alive) + finally: + read_send.close() + read_recv.close() + assert answers_for(recording.frames, 1) == [JSONRPCError(jsonrpc="2.0", id=1, error=CONNECTION_CLOSED_ERROR)], ( + f"an interrupted request whose peer is still there gets exactly one CONNECTION_CLOSED, got {recording.frames}" + ) + + +@pytest.mark.anyio +async def test_owner_teardown_landing_on_a_parked_answer_write_never_stacks_a_second_answer() -> None: + """The handler has returned and its result write is parked on a stalled transport when + the owner tears the connection down. The teardown must not stack a CONNECTION_CLOSED + error on top of the started result write: whatever the peer eventually reads for the id + is at most one answer, and if any answer surfaces it is the handler's result.""" + read_send, read_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) + write_send, write_recv = anyio.create_memory_object_stream[SessionMessage](0) + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read_recv, write_send) + watch = CancelWatch() + returned = anyio.Event() + frames: list[JSONRPCMessage] = [] + + async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + returned.set() + return {"answer": 42} + + try: + async with anyio.create_task_group() as tg: + await tg.start(server.run, on_request, watch) + await read_send.send(request(1, "quick")) + with anyio.fail_after(WAIT): + await returned.wait() + await anyio.wait_all_tasks_blocked() # the result write is parked: nobody reads + tg.cancel_scope.cancel() # owner teardown lands on the parked write + # run() has returned and closed its write stream; whatever survived is drainable now. + with anyio.move_on_after(WAIT): + frames.extend([item.message async for item in write_recv]) + finally: + for stream in (read_send, read_recv, write_send, write_recv): + stream.close() + owed = answers_for(frames, 1) + only_legal = [JSONRPCResponse(jsonrpc="2.0", id=1, result={"answer": 42})] + assert owed in ([], only_legal), ( + f"one request id gets at most one answer, and it can only be the handler's result; teardown left {owed}" + ) + + +async def _teardown_after_a_peer_cancel_of_a_lingering_handler(*, via_eof: bool) -> list[JSONRPCMessage]: + """The peer cancels a request whose handler shields its cleanup and so is still running + when the connection is torn down - either by the peer's EOF or by the owner cancelling + the driver with the peer still connected. Returns every frame written.""" + read_send, read_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) + recording = RecordingWriteStream() + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read_recv, recording) + watch = CancelWatch() + handler_started = anyio.Event() + lingering = anyio.Event() + finish_cleanup = anyio.Event() + + async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + handler_started.set() + try: + await anyio.sleep_forever() + finally: + # Cleanup that outlives the peer's interruption: the request is + # withdrawn but its task lingers here when the teardown lands. + with anyio.CancelScope(shield=True): + lingering.set() + await finish_cleanup.wait() + raise NotImplementedError + + try: + async with anyio.create_task_group() as tg: + await tg.start(server.run, on_request, watch) + await read_send.send(request(1, "slow")) + with anyio.fail_after(WAIT): + await handler_started.wait() + await read_send.send(cancel(1)) # the peer withdraws id 1 + with anyio.fail_after(WAIT): + await watch.read.wait() + await lingering.wait() + if via_eof: + read_send.close() # the peer goes away + else: + tg.cancel_scope.cancel() # the owner tears down; the peer is still connected + # No await from here: releasing the shield synchronously means the lingering + # task meets the pending teardown at its very next checkpoint. + finish_cleanup.set() + finally: + read_send.close() + read_recv.close() + return recording.frames + + +@pytest.mark.anyio +async def test_eof_after_a_peer_cancel_writes_nothing_for_the_withdrawn_id() -> None: + """Control for the next test: with the peer gone (EOF), a withdrawn request whose task + lingers past the interruption is torn down without any frame for its id.""" + frames = await _teardown_after_a_peer_cancel_of_a_lingering_handler(via_eof=True) + assert answers_for(frames, 1) == [], f"the peer withdrew id 1 and is gone; teardown may not answer it, got {frames}" + + +@pytest.mark.anyio +async def test_owner_teardown_after_a_peer_cancel_writes_nothing_for_the_withdrawn_id() -> None: + """The peer withdrew the request; its task lingers in shielded cleanup; the owner then + tears the connection down while the peer is still connected. The withdrawal already + happened, so the teardown must not resurrect an answer for that id: the cancel revoked + the request's write target, leaving the shutdown arm nothing to write with. A design + whose only shutdown guard is 'no answer written yet, and the peer is not gone' cannot see + the withdrawal and writes a CONNECTION_CLOSED frame after the cancel.""" + frames = await _teardown_after_a_peer_cancel_of_a_lingering_handler(via_eof=False) + assert answers_for(frames, 1) == [], ( + f"the peer withdrew id 1 before teardown; teardown may not answer it, got {frames}" + ) diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 7712e73e5e..90e67089d0 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -42,6 +42,7 @@ encode_header_value, find_duplicated_routing_header, find_invalid_x_mcp_header, + has_envelope_intent, mcp_param_headers, validate_mcp_param_headers, x_mcp_header_map, @@ -94,7 +95,7 @@ def assert_rejected(result: object, code: int) -> InboundLadderRejection: return result -# --- rung 1: envelope-three-keys ----------------------------------------------- +# --- rung 1: envelope-required-pair ------------------------------------------- @pytest.mark.parametrize( @@ -149,6 +150,30 @@ def test_envelope_rung_accepts_pair_only_envelope_without_client_info() -> None: assert result.client_capabilities == CLIENT_CAPS +def test_envelope_rung_accepts_the_pair_without_client_info() -> None: + """`clientInfo` is optional (spec index.mdx: clients SHOULD but need not send + it): the required pair alone is a valid modern envelope, and the absent + client info surfaces on the route as `None`.""" + result = classify_inbound_request(envelope(drop=frozenset({CLIENT_INFO_META_KEY}))) + assert isinstance(result, InboundModernRoute) + assert result.client_info is None + assert result.protocol_version == LATEST_MODERN_VERSION + + +def test_has_envelope_intent_reads_key_presence_not_validity() -> None: + """Presence of any reserved envelope key marks modern intent; validity is the + ladder's separate concern, so an incomplete envelope still reads as intent.""" + assert not has_envelope_intent(None) + assert not has_envelope_intent({}) + assert not has_envelope_intent({"_meta": None}) + # A bare progressToken is legacy-era `_meta`, not envelope evidence. + assert not has_envelope_intent({"_meta": {"progressToken": 1}}) + # Any one reserved key is intent, even without the full required pair. + assert has_envelope_intent({"_meta": {PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION}}) + assert has_envelope_intent({"_meta": {CLIENT_CAPABILITIES_META_KEY: {}}}) + assert has_envelope_intent(envelope()["params"]) + + @pytest.mark.parametrize( "body", [ @@ -331,7 +356,7 @@ def test_all_rungs_pass_yields_route() -> None: assert result.client_capabilities == CLIENT_CAPS -@pytest.mark.parametrize("method", ["initialize", "myorg/custom", "does/not/exist"]) +@pytest.mark.parametrize("method", ["myorg/custom", "does/not/exist"]) def test_classifier_passes_unknown_method_through_to_route(method: str) -> None: """SDK-defined: the classifier does not gate on method — kernel dispatch is the single owner of that decision.""" body = envelope(method) @@ -339,6 +364,20 @@ def test_classifier_passes_unknown_method_through_to_route(method: str) -> None: assert isinstance(result, InboundModernRoute) +def test_initialize_is_answered_by_rung_zero_naming_the_modern_versions() -> None: + """`initialize` does not exist at modern versions, so the modern entry answers it with + -32022 naming the versions it serves - on every transport, since the rung is shared - + echoing the handshake's proposed version as `requested` when it is a string.""" + body = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": LATEST_HANDSHAKE_VERSION}} + rejection = assert_rejected(classify_inbound_request(body), UNSUPPORTED_PROTOCOL_VERSION) + assert rejection.data == {"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": LATEST_HANDSHAKE_VERSION} + # No string version proposed: the payload still names the supported versions. + bare = assert_rejected( + classify_inbound_request({"jsonrpc": "2.0", "id": 2, "method": "initialize"}), UNSUPPORTED_PROTOCOL_VERSION + ) + assert bare.data == {"supported": list(MODERN_PROTOCOL_VERSIONS)} + + def test_ladder_first_failure_wins() -> None: """Spec-mandated: rungs evaluate in order — header-mismatch and version-unsupported would both fail; the header rung fires first so an inconsistent client is told it diff --git a/tests/shared/test_jsonrpc_dispatcher.py b/tests/shared/test_jsonrpc_dispatcher.py index 5c29c7e3ce..9d8d8eab65 100644 --- a/tests/shared/test_jsonrpc_dispatcher.py +++ b/tests/shared/test_jsonrpc_dispatcher.py @@ -3,7 +3,8 @@ import contextvars import json import logging -from collections.abc import Mapping +from collections.abc import Awaitable, Mapping +from functools import partial from types import TracebackType from typing import Any @@ -34,7 +35,7 @@ from mcp.server import Server, ServerRequestContext from mcp.shared._compat import resync_tracer from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream -from mcp.shared.dispatcher import CallOptions, DispatchContext, coerce_request_id +from mcp.shared.dispatcher import Admission, CallOptions, DispatchContext, coerce_request_id from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.jsonrpc_dispatcher import ( # pyright: ignore[reportPrivateUsage] JSONRPCDispatcher, @@ -51,6 +52,14 @@ DCtx = DispatchContext[TransportContext] +@pytest.fixture(params=["asyncio", "trio"]) +def anyio_backend(request: pytest.FixtureRequest) -> str: + """Run every test in this module on both anyio backends: the dispatcher's ordering + and cancel claims are owed to its sequential read loop, not to a scheduler, and + trio's scheduler is the check on that (a test's own `parametrize` overrides this).""" + return request.param + + @pytest.fixture(autouse=True) def _module_runner_lease() -> None: """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" @@ -108,8 +117,9 @@ async def call(method: str) -> None: @pytest.mark.anyio -async def test_handler_raising_exception_sends_code_zero_with_str_message(): - """Matches the existing server's `_handle_request`: code=0, message=str(e).""" +async def test_handler_raising_unmapped_exception_sends_internal_error_without_its_text(): + """An unmapped handler exception surfaces as a generic INTERNAL_ERROR: the exception's + text stays in the receiver's log and never reaches the wire.""" async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: raise RuntimeError("kaboom") @@ -117,19 +127,27 @@ async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | async with running_pair(jsonrpc_pair, server_on_request=server_on_request) as (client, *_): with anyio.fail_after(5), pytest.raises(MCPError) as exc: await client.send_raw_request("tools/list", None) - assert exc.value.error.code == 0 - assert exc.value.error.message == "kaboom" + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Internal server error" + assert "kaboom" not in str(exc.value.error) assert exc.value.__cause__ is None # cause does not survive the wire @pytest.mark.anyio -async def test_peer_cancel_interrupt_mode_writes_cancelled_error_response(): - """Matches the existing server: a peer-cancelled request is answered with code=0.""" +async def test_peer_cancel_interrupt_mode_interrupts_the_handler_and_writes_no_response(): + """A peer-cancelled request has its handler interrupted and no answer is written for + it (the receiver of a cancellation does not respond to the cancelled request), while + the connection carries on serving: the only frame recorded is the follow-up's answer.""" + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4) + recording = RecordingWriteStream() + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, recording) handler_started = anyio.Event() handler_exited = anyio.Event() seen_ctx: list[DCtx] = [] - async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + if method == "followup": + return {"ok": True} seen_ctx.append(ctx) handler_started.set() try: @@ -138,37 +156,60 @@ async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | handler_exited.set() raise NotImplementedError - seen_error: list[ErrorData] = [] - async with running_pair(jsonrpc_pair, server_on_request=server_on_request) as (client, *_): - with anyio.fail_after(5): - async with anyio.create_task_group() as tg: # pragma: no branch - - async def call_then_record() -> None: - with pytest.raises(MCPError) as exc: - await client.send_raw_request("slow", None) - seen_error.append(exc.value.error) + async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None: + pass # the cancelled notification is teed here; nothing to observe - tg.start_soon(call_then_record) + try: + async with anyio.create_task_group() as tg: + await tg.start(server.run, on_request, on_notify) + await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="t", params=None))) + with anyio.fail_after(5): await handler_started.wait() - await client.notify("notifications/cancelled", {"requestId": 1}) + await c2s_send.send( + SessionMessage( + message=JSONRPCNotification( + jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1} + ) + ) + ) + with anyio.fail_after(5): await handler_exited.wait() + # The connection is still alive: a follow-up request is answered. + await c2s_send.send( + SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=2, method="followup", params=None)) + ) + await anyio.wait_all_tasks_blocked() + tg.cancel_scope.cancel() + finally: + c2s_send.close() + c2s_recv.close() assert seen_ctx[0].cancel_requested.is_set() - assert seen_error == [ErrorData(code=0, message="Request cancelled")] + # Nothing for the cancelled id 1; only the follow-up's response reached the wire. + assert [m.message for m in recording.sent] == [JSONRPCResponse(jsonrpc="2.0", id=2, result={"ok": True})] @pytest.mark.anyio -async def test_peer_cancel_landing_after_handlers_last_checkpoint_writes_only_the_result(): - """A peer cancel that fails to interrupt the handler writes only the result: one answer per - id goes on the wire (SDK-defined). The recording stream is needed because a memory stream's - `send` checkpoints, letting the deferred cancellation land mid-write and hide a double answer.""" +async def test_peer_cancel_read_before_the_answer_leaves_no_frame_even_if_the_handler_completes(): + """The silence boundary is whether the cancel was READ before the body returned. Here it + was: the request is still cancellable when the cancel arrives, so its channel is revoked + and a handler that races on to a result (the cancel here is also its wakeup, so anyio + defers the interruption) has that late result dropped rather than sent to a peer that + already withdrew the request. The other side of the boundary - a body that had already + returned when the cancel is read keeps its owed answer - is pinned by + `test_cancel_race.py`. The recording stream is needed because a memory stream's `send` + checkpoints and would let the deferred cancellation reshape the race.""" c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4) recording = RecordingWriteStream() server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, recording) handler_started = anyio.Event() + handler_returned = anyio.Event() async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + if method == "followup": + return {"ok": True} handler_started.set() await ctx.cancel_requested.wait() + handler_returned.set() return {"completed": "after-cancel"} async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None: @@ -188,15 +229,62 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> ) ) ) - # Quiesce: the handler has resumed, completed, and exited its scope. + with anyio.fail_after(5): + await handler_returned.wait() + # Quiesce, then prove the connection carries on: a follow-up is answered. + await anyio.wait_all_tasks_blocked() + await c2s_send.send( + SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=2, method="followup", params=None)) + ) await anyio.wait_all_tasks_blocked() tg.cancel_scope.cancel() finally: c2s_send.close() c2s_recv.close() - assert [m.message for m in recording.sent] == [ - JSONRPCResponse(jsonrpc="2.0", id=1, result={"completed": "after-cancel"}) - ] + # No frame at all for the withdrawn id 1; only the follow-up's response was written. + assert [m.message for m in recording.sent] == [JSONRPCResponse(jsonrpc="2.0", id=2, result={"ok": True})] + + +@pytest.mark.anyio +async def test_wait_for_in_flight_wakes_every_concurrent_waiter_once_the_last_request_answers(): + """Several tasks may await `wait_for_in_flight` at once: they share the one armed + completion event, so when the last in-flight request finishes answering every waiter + returns - none is left parked on an event that will never be set.""" + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4) + recording = RecordingWriteStream() + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, recording) + handler_started = anyio.Event() + release_handler = anyio.Event() + woken: list[str] = [] + + async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + handler_started.set() + await release_handler.wait() + return {"done": True} + + _, on_notify = echo_handlers(Recorder()) # no notifications are sent in this test + + async def wait_and_record(name: str) -> None: + await server.wait_for_in_flight() + woken.append(name) + + try: + async with anyio.create_task_group() as tg: + await tg.start(server.run, on_request, on_notify) + await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="slow", params=None))) + with anyio.fail_after(5): + await handler_started.wait() + async with anyio.create_task_group() as waiters: + waiters.start_soon(wait_and_record, "first") + waiters.start_soon(wait_and_record, "second") + # Both waiters park on the shared event before the request finishes. + await anyio.wait_all_tasks_blocked() + release_handler.set() + tg.cancel_scope.cancel() + finally: + c2s_send.close() + c2s_recv.close() + assert sorted(woken) == ["first", "second"], f"a concurrent waiter was left behind: woke {woken}" @pytest.mark.anyio @@ -371,11 +459,11 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="x", params=None)) ) assert exc.group_contains(RuntimeError, match="propagate me") - # The error response was still written before re-raising. + # The (generic) error response was still written before re-raising. sent = s2c_recv.receive_nowait() assert isinstance(sent, SessionMessage) assert isinstance(sent.message, JSONRPCError) - assert sent.message.error.code == 0 + assert sent.message.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error") finally: for s in (c2s_send, c2s_recv, s2c_send, s2c_recv): s.close() @@ -1014,17 +1102,22 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> @pytest.mark.anyio -async def test_shutdown_answers_in_flight_request_with_connection_closed(): - """Read-stream EOF answers a still-running request with CONNECTION_CLOSED (SDK-defined): - run() keeps the write stream open until the task-group join, so the shielded teardown write lands.""" +async def test_read_eof_treats_the_peer_as_gone_and_writes_nothing_for_in_flight_requests(): + """Read-stream EOF means the peer has gone away: run() gives its owner the drain hook, + then revokes every open channel and cancels the in-flight handler, writing nothing + further for it - there is nobody left to read a CONNECTION_CLOSED answer.""" c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4) s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4) server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send) handler_started = anyio.Event() + handler_cancelled = anyio.Event() async def park(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: handler_started.set() - await anyio.sleep_forever() + try: + await anyio.sleep_forever() + finally: + handler_cancelled.set() raise NotImplementedError async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None: @@ -1036,13 +1129,12 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="t", params=None))) with anyio.fail_after(5): await handler_started.wait() - c2s_send.close() # EOF: run() cancels the parked handler, which must still answer + c2s_send.close() # EOF: the peer is gone; run() cancels the parked handler silently with anyio.fail_after(5): - answer = await s2c_recv.receive() - assert isinstance(answer, SessionMessage) - assert answer.message == JSONRPCError( - jsonrpc="2.0", id=1, error=ErrorData(code=CONNECTION_CLOSED, message="Connection closed") - ) + await handler_cancelled.wait() + # run() closed the write side without answering id 1: draining it yields nothing. + written = [frame async for frame in s2c_recv] + assert written == [] finally: for s in (c2s_send, c2s_recv, s2c_send, s2c_recv): s.close() @@ -1370,13 +1462,11 @@ async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | @pytest.mark.anyio -async def test_inline_methods_are_handled_before_next_message_is_dequeued(): - """An `inline_methods` method runs to completion before the next message is dispatched.""" +async def test_held_admission_completes_before_next_message_is_dequeued(): + """A request the `admit` gate marks `hold` finishes before the next message is dispatched.""" c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) - server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( - c2s_recv, s2c_send, inline_methods=frozenset({"first"}) - ) + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send) state = {"initialized": False} seen: list[bool] = [] @@ -1391,12 +1481,15 @@ async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) - async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None: raise NotImplementedError + def admit(method: str, params: Mapping[str, Any] | None) -> Admission: + return Admission(on_request, hold=method == "first") + # Buffer both requests before run() reads anything. await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="first", params=None))) await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=2, method="second", params=None))) c2s_send.close() with anyio.fail_after(5): - await server.run(on_request, on_notify) + await server.run(on_request, on_notify, admit=admit) assert seen == [True] s2c_recv.close() @@ -1590,31 +1683,30 @@ async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | async with running_pair(jsonrpc_pair, server_on_request=server_on_request) as (client, _server, _crec, srec): with anyio.fail_after(5): async with anyio.create_task_group() as tg: # pragma: no branch - - async def call() -> None: - with pytest.raises(MCPError): - await client.send_raw_request("slow", None) - - tg.start_soon(call) + # No response follows a cancel, so the caller's call is abandoned via the scope. + tg.start_soon(client.send_raw_request, "slow", None) await handler_started.wait() await client.notify("notifications/cancelled", {"requestId": 1}) await handler_exited.wait() await srec.notified.wait() - assert srec.notifications == [("notifications/cancelled", {"requestId": 1})] + tg.cancel_scope.cancel() + # Abandoning the pending call sends the caller's own courtesy cancel afterwards; + # the teed one under test is the first. + assert srec.notifications[0] == ("notifications/cancelled", {"requestId": 1}) _probe: contextvars.ContextVar[str] = contextvars.ContextVar("probe", default="unset") @pytest.mark.anyio -@pytest.mark.parametrize("inline", [frozenset[str](), frozenset({"t"})], ids=["spawned", "inline"]) -async def test_handler_inherits_sender_contextvars(inline: frozenset[str]): - """The handler sees the sender's contextvars on both the spawned and the inline-method dispatch paths.""" +@pytest.mark.parametrize("hold", [False, True], ids=["spawned", "held"]) +async def test_handler_inherits_sender_contextvars(hold: bool): + """The handler sees the sender's contextvars on both the spawned and the held dispatch paths.""" raw_send, raw_recv = anyio.create_memory_object_stream[tuple[contextvars.Context, SessionMessage | Exception]](4) read_stream = ContextReceiveStream[SessionMessage | Exception](raw_recv) write_send = ContextSendStream[SessionMessage | Exception](raw_send) out_send, out_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4) - server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read_stream, out_send, inline_methods=inline) + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read_stream, out_send) seen: list[str] = [] @@ -1625,9 +1717,12 @@ async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None: raise NotImplementedError + def admit(method: str, params: Mapping[str, Any] | None) -> Admission: + return Admission(server_on_request, hold=hold) + try: async with anyio.create_task_group() as tg: - await tg.start(server.run, server_on_request, on_notify) + await tg.start(partial(server.run, server_on_request, on_notify, admit=admit)) async def sender() -> None: _probe.set("from-sender") @@ -2037,6 +2132,147 @@ def builder(_meta: MessageMetadata) -> TransportContext: assert rec.notifications == [] # the notification never reached on_notify +@pytest.mark.anyio +async def test_admission_that_raises_costs_only_that_request(): + """`on_request` is admitted synchronously in the read loop; if that admission itself + raises, the request is answered with the generic internal error and the loop survives.""" + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send) + echo_request, on_notify = echo_handlers(Recorder()) + + def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> Awaitable[dict[str, Any]]: + # A plain (non-async) admission callable: it decides in the read loop and + # returns the body awaitable - here, raising for one method instead. + if method == "explode": + raise RuntimeError("admission boom") + return echo_request(ctx, method, params) + + try: + async with anyio.create_task_group() as tg: + await tg.start(server.run, on_request, on_notify) + await c2s_send.send( + SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="explode", params=None)) + ) + with anyio.fail_after(5): + resp = await s2c_recv.receive() + assert isinstance(resp, SessionMessage) + assert isinstance(resp.message, JSONRPCError) + assert resp.message.id == 1 + assert resp.message.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error") + # The read loop survives: the next request is admitted and answered normally. + await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=2, method="t", params=None))) + with anyio.fail_after(5): + resp2 = await s2c_recv.receive() + assert isinstance(resp2, SessionMessage) + assert resp2.message == JSONRPCResponse(jsonrpc="2.0", id=2, result={"echoed": "t", "params": {}}) + tg.cancel_scope.cancel() + finally: + for s in (c2s_send, c2s_recv, s2c_send, s2c_recv): + s.close() + + +@pytest.mark.anyio +async def test_admit_gate_names_the_handler_that_serves_each_request(): + """The `admit` gate's `Admission.handler` serves the request, so the gate can route + per request; a method it does not route to the alternate handler stays on `on_request`.""" + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send) + on_request, on_notify = echo_handlers(Recorder()) + + async def alternate(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + return {"served_by": "alternate"} + + def admit(method: str, params: Mapping[str, Any] | None) -> Admission: + return Admission(alternate) if method == "reroute" else Admission(on_request) + + try: + async with anyio.create_task_group() as tg: + await tg.start(partial(server.run, on_request, on_notify, admit=admit)) + await c2s_send.send( + SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="reroute", params=None)) + ) + await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=2, method="t", params=None))) + with anyio.fail_after(5): + first = await s2c_recv.receive() + second = await s2c_recv.receive() + assert isinstance(first, SessionMessage) and isinstance(second, SessionMessage) + assert first.message == JSONRPCResponse(jsonrpc="2.0", id=1, result={"served_by": "alternate"}) + assert second.message == JSONRPCResponse(jsonrpc="2.0", id=2, result={"echoed": "t", "params": {}}) + tg.cancel_scope.cancel() + finally: + for s in (c2s_send, c2s_recv, s2c_send, s2c_recv): + s.close() + + +@pytest.mark.anyio +async def test_admit_gate_that_raises_costs_only_that_request(): + """A raising `admit` gate is answered with the generic internal error before any + transport context exists, and the read loop survives to admit the next request.""" + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send) + on_request, on_notify = echo_handlers(Recorder()) + + def admit(method: str, params: Mapping[str, Any] | None) -> Admission: + if method == "explode": + raise RuntimeError("admission gate boom") + return Admission(on_request) + + try: + async with anyio.create_task_group() as tg: + await tg.start(partial(server.run, on_request, on_notify, admit=admit)) + await c2s_send.send( + SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="explode", params=None)) + ) + with anyio.fail_after(5): + resp = await s2c_recv.receive() + assert isinstance(resp, SessionMessage) + assert isinstance(resp.message, JSONRPCError) + assert resp.message.id == 1 + assert resp.message.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error") + # The read loop survives: the next request is admitted and answered normally. + await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=2, method="t", params=None))) + with anyio.fail_after(5): + resp2 = await s2c_recv.receive() + assert isinstance(resp2, SessionMessage) + assert resp2.message == JSONRPCResponse(jsonrpc="2.0", id=2, result={"echoed": "t", "params": {}}) + tg.cancel_scope.cancel() + finally: + for s in (c2s_send, c2s_recv, s2c_send, s2c_recv): + s.close() + + +@pytest.mark.anyio +async def test_notification_admission_that_raises_drops_only_that_notification(): + """A synchronously raising `on_notify` admission drops the one notification; the + read loop survives and the next message is served.""" + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send) + on_request, _ = echo_handlers(Recorder()) + + def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> Awaitable[None]: + raise RuntimeError("notify admission boom") + + try: + async with anyio.create_task_group() as tg: + await tg.start(server.run, on_request, on_notify) + await c2s_send.send( + SessionMessage(message=JSONRPCNotification(jsonrpc="2.0", method="notifications/x", params=None)) + ) + await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="t", params=None))) + with anyio.fail_after(5): + resp = await s2c_recv.receive() + assert isinstance(resp, SessionMessage) + assert isinstance(resp.message, JSONRPCResponse) + tg.cancel_scope.cancel() + finally: + for s in (c2s_send, c2s_recv, s2c_send, s2c_recv): + s.close() + + @pytest.mark.anyio async def test_cancelled_with_bool_request_id_does_not_cancel_request_one(): """`int()` match patterns accept bool, and `True == 1` would alias the @@ -2055,12 +2291,8 @@ async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | async with running_pair(jsonrpc_pair, server_on_request=server_on_request) as (client, _server, _crec, srec): with anyio.fail_after(5): async with anyio.create_task_group() as tg: # pragma: no branch - - async def call() -> None: - with pytest.raises(MCPError): - await client.send_raw_request("slow", None) - - tg.start_soon(call) + # No response follows a cancel, so the caller's call is abandoned via the scope. + tg.start_soon(client.send_raw_request, "slow", None) await handler_started.wait() await client.notify("notifications/cancelled", {"requestId": True}) # Once the teed notification is observed, the correlation arm has already run. @@ -2068,6 +2300,7 @@ async def call() -> None: assert not handler_exited.is_set() await client.notify("notifications/cancelled", {"requestId": 1}) await handler_exited.wait() + tg.cancel_scope.cancel() @pytest.mark.anyio @@ -2158,13 +2391,18 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> ids=["string-cancel-for-int-request", "int-cancel-for-string-request"], ) async def test_cancelled_correlates_across_string_and_int_request_id_forms(request_id: RequestId, cancel_id: object): - """A peer that stringifies the id between request and cancel still cancels (same `coerce_request_id` path).""" + """A peer that stringifies the id between request and cancel still interrupts the + right handler (same `coerce_request_id` path); the cancelled request writes no response.""" c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send) + handler_cancelled = anyio.Event() async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: - await anyio.sleep_forever() + try: + await anyio.sleep_forever() + finally: + handler_cancelled.set() raise NotImplementedError async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None: @@ -2184,11 +2422,10 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> ) ) with anyio.fail_after(5): - resp = await s2c_recv.receive() - assert isinstance(resp, SessionMessage) - assert isinstance(resp.message, JSONRPCError) - assert resp.message.id == request_id # response echoes the peer's id form verbatim - assert resp.message.error == ErrorData(code=0, message="Request cancelled") + await handler_cancelled.wait() # the cross-form id correlated to the handler + # ...and being cancelled, the request writes nothing to the wire. + with pytest.raises(anyio.WouldBlock): + s2c_recv.receive_nowait() tg.cancel_scope.cancel() finally: for s in (c2s_send, c2s_recv, s2c_send, s2c_recv): @@ -2198,7 +2435,8 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> @pytest.mark.anyio async def test_completed_handler_does_not_evict_reused_request_id_from_in_flight(): """A second request reusing an id while the first handler is parked in its response write - keeps its own `_in_flight` entry (a post-write pop would evict it and break peer-cancellation).""" + keeps its own `_in_flight` entry: the first request's identity-guarded retire (before its + write and again when its task ends) must never evict the reuse and break peer-cancellation.""" c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) # buffer=0: the first handler's response write parks until the test receives. s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](0) @@ -2227,17 +2465,18 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> await tg.start(server.run, on_request, on_notify) with anyio.fail_after(5): await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=7, method="a"))) - # First handler is now parked in `_write_result`; reuse its id. + # First handler is now parked in its result write; reuse its id. await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=7, method="b"))) await second_started.wait() resp1 = await s2c_recv.receive() assert isinstance(resp1, SessionMessage) assert isinstance(resp1.message, JSONRPCResponse) assert resp1.message.result == {"first": True} - # Let the first handler task run to completion past the write. + # Let the first handler task run to completion past its retire. await anyio.wait_all_tasks_blocked() assert 7 in server._in_flight # pyright: ignore[reportPrivateUsage] - # The surviving entry must still be cancellable. + # The surviving entry must still be cancellable: the cancel interrupts + # the second handler (which then writes no response for the id). await c2s_send.send( SessionMessage( message=JSONRPCNotification( @@ -2245,11 +2484,9 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> ) ) ) - resp2 = await s2c_recv.receive() - assert isinstance(resp2, SessionMessage) - assert isinstance(resp2.message, JSONRPCError) - assert resp2.message.error == ErrorData(code=0, message="Request cancelled") - assert second_exited.is_set() + await second_exited.wait() + with pytest.raises(anyio.WouldBlock): + s2c_recv.receive_nowait() tg.cancel_scope.cancel() finally: for s in (c2s_send, c2s_recv, s2c_send, s2c_recv): @@ -2300,7 +2537,8 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> # Let the first handler task run past its pop entirely. await anyio.wait_all_tasks_blocked() assert 7 in server._in_flight # pyright: ignore[reportPrivateUsage] - # The surviving entry must still be cancellable by the peer. + # The surviving entry must still be cancellable by the peer: the cancel + # interrupts the second handler (which then writes no response for the id). await c2s_send.send( SessionMessage( message=JSONRPCNotification( @@ -2308,11 +2546,9 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> ) ) ) - resp2 = await s2c_recv.receive() - assert isinstance(resp2, SessionMessage) - assert isinstance(resp2.message, JSONRPCError) - assert resp2.message.error == ErrorData(code=0, message="Request cancelled") - assert second_exited.is_set() + await second_exited.wait() + with pytest.raises(anyio.WouldBlock): + s2c_recv.receive_nowait() tg.cancel_scope.cancel() finally: for s in (c2s_send, c2s_recv, s2c_send, s2c_recv): @@ -2364,15 +2600,12 @@ async def observe(ctx: Any, call_next: Any) -> Any: async with Client(server, mode="legacy") as client: with anyio.fail_after(5): async with anyio.create_task_group() as tg: # pragma: no branch - - async def call() -> None: - with pytest.raises(MCPError): - await client.session.send_request( - CallToolRequest(params=CallToolRequestParams(name="t", arguments={})), - CallToolResult, - ) - - tg.start_soon(call) + # No response follows a cancel, so the pending call is abandoned via the scope. + tg.start_soon( + client.session.send_request, + CallToolRequest(params=CallToolRequestParams(name="t", arguments={})), + CallToolResult, + ) await handler_started.wait() assert request_id is not None await client.session.send_notification( @@ -2381,7 +2614,9 @@ async def call() -> None: ) ) await cancel_observed.wait() - assert len(observed) == 1 + tg.cancel_scope.cancel() + # Abandoning the pending call sends the caller's own courtesy cancel afterwards; + # the middleware-observed one under test is the first. assert observed[0][0] == "notifications/cancelled" assert observed[0][1]["requestId"] == request_id assert observed[0][1]["reason"] == "user clicked stop" diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index d7eeccdfdb..4355ee9488 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -23,10 +23,12 @@ from httpx2 import ServerSentEvent from mcp_types import ( DEFAULT_NEGOTIATED_VERSION, + INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, CallToolRequestParams, CallToolResult, + ErrorData, InitializeResult, JSONRPCRequest, ListToolsResult, @@ -907,11 +909,11 @@ async def test_streamable_http_client_tool_invocation(initialized_client_session @pytest.mark.anyio async def test_streamable_http_client_error_handling(initialized_client_session: ClientSession) -> None: - """A server-side error reaches the client as an MCPError with the handler's message.""" + """A handler that raises an unmapped exception reaches the client as a generic + INTERNAL_ERROR MCPError: the exception's text stays in the server log.""" with pytest.raises(MCPError) as exc_info: await initialized_client_session.read_resource(uri="unknown://test-error") - assert exc_info.value.error.code == 0 - assert "Unknown resource: unknown://test-error" in exc_info.value.error.message + assert exc_info.value.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error") @pytest.mark.anyio From 1e4d87ea61e15faa55c20dd329245eaa707edc87 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:57:58 +0000 Subject: [PATCH 2/2] Remove the Posture setting; always serve both protocol eras Delete the Posture enum, its export, and the posture= constructor parameter on Server and MCPServer. The lowlevel Server is a registry of handlers and carries no serving configuration; a server offers both the 2025 handshake era and the 2026 per-request era on every transport, and each stream connection's era is decided by the client's opening message, as the default already did. _StreamConnection no longer takes a starting era; it starts undecided and pins on the first era-distinctive message. The streamable-HTTP manager's header routing is unchanged: streams it has already routed to the handshake era are entered born-legacy through the transport- internal serve_legacy_stream, so the legacy leg keeps refusing enveloped requests with -32600 exactly as before. The MODERN_ONLY / LEGACY_ONLY tests and doc sections go with it, and the streamable-HTTP orphan test file is renamed now that its posture half is gone. --- docs/advanced/low-level-server.md | 5 +- docs/migration.md | 12 +- docs/run/legacy-clients.md | 31 +---- docs/whats-new.md | 2 +- src/mcp/server/__init__.py | 3 +- src/mcp/server/lowlevel/server.py | 11 +- src/mcp/server/mcpserver/server.py | 3 - src/mcp/server/serving.py | 48 +++----- src/mcp/server/streamable_http_manager.py | 9 +- tests/server/mcpserver/test_resolve.py | 2 +- tests/server/mcpserver/test_server.py | 4 +- ...han_and_posture.py => test_http_orphan.py} | 114 ++---------------- tests/server/test_request_state.py | 2 +- tests/server/test_request_state_boundary.py | 6 +- tests/server/test_serving.py | 62 ++-------- tests/server/test_stdio_ordering.py | 2 +- tests/server/test_streamable_http_modern.py | 2 +- tests/shared/test_inbound.py | 2 +- 18 files changed, 59 insertions(+), 261 deletions(-) rename tests/server/{test_http_orphan_and_posture.py => test_http_orphan.py} (64%) diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index df3d5513f8..015777c9fd 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -226,10 +226,7 @@ async with server.lifespan() as state, anyio.create_task_group() as tg: The lifespan is entered **outside** the task group on purpose: on the way out the task group joins the still-running connection tasks first and only then does the lifespan tear down, so the shared pool outlives every connection using it (the other order tears the pool down while connections are still being served). -Two things about the shared-`Server` shape that the single-connection rungs never surface: - -* Which protocol eras a server offers is `Server(posture=...)` (`Posture.DUAL` by default, `Posture.MODERN_ONLY`, `Posture.LEGACY_ONLY`); it rides along on the server object, so none of these drivers takes a posture argument you could forget. -* The cross-connection listen behaviour called out under `serve_listener` above applies to any shared `Server`, including this rung; the per-connection-`Server` recipe there is how you sidestep it. +One thing about the shared-`Server` shape that the single-connection rungs never surface: the cross-connection listen behaviour called out under `serve_listener` above applies to any shared `Server`, including this rung; the per-connection-`Server` recipe there is how you sidestep it. An `MCPServer` reaches every one of these through `mcp.lowlevel_server`, and `mcp.close_subscriptions()` (or `Server.close_subscriptions()` down here) ends the open listen streams gracefully from the server's side. diff --git a/docs/migration.md b/docs/migration.md index 180c7eb15f..206e6f1ce1 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1371,17 +1371,7 @@ The concrete dispatcher's `inline_methods=` constructor keyword is also gone: th `server/discover` no longer pins the connection to the modern era: it is a probe, answered with modern semantics, and the era is decided by the first non-probe message. A client whose discover probe was answered can therefore still fall back to `initialize` on the same connection, and `subscriptions/listen` is now served over stdio and every other duplex stream (previously refused with `-32601`). -A stray leading notification (for example a client's `notifications/roots/list_changed`) no longer decides the era: only `initialize` (or a bare pre-handshake request) opens the legacy era, only `notifications/initialized` opens it among notifications, and any other early notification is ignored, so a 2026 client is never pinned to the legacy era by a courtesy frame it sent first. A legacy-committed connection also now refuses a request that carries the modern envelope with `-32600` instead of processing it under legacy semantics, and `initialize` is answered `-32022` (naming the modern versions the server serves) whenever it reaches the modern side, on every transport. - -The new `posture` constructor parameter on `Server` and `MCPServer` narrows which eras a server offers, on stream transports and streamable HTTP alike: - -```python -from mcp.server import Posture, Server - -server = Server("app", posture=Posture.MODERN_ONLY) # or Posture.LEGACY_ONLY; Posture.DUAL is the default -``` - -A `MODERN_ONLY` server answers a 2025 handshake with `-32022` and its supported versions (on stdio and streamable HTTP); a `LEGACY_ONLY` server refuses envelope-bearing requests in legacy vocabulary and never enters the modern era. Posture is the one place a server restricts its eras; the walkthrough is in [Serving one era only](run/legacy-clients.md#serving-one-era-only). +A stray leading notification (for example a client's `notifications/roots/list_changed`) no longer decides the era: only `initialize` (or a bare pre-handshake request) opens the legacy era, only `notifications/initialized` opens it among notifications, and any other early notification is ignored, so a 2026 client is never pinned to the legacy era by a courtesy frame it sent first. A legacy-committed connection also now refuses a request that carries the modern envelope with `-32600` instead of processing it under legacy semantics, and `initialize` is answered `-32022` (naming the modern versions the server serves) whenever it reaches the modern side, on every transport. Every server offers both eras on every transport; there is no era switch on `Server` or `MCPServer`. At stdin EOF a stdio server now winds down gracefully: it gives in-flight requests a short bounded window to finish, ends every open `subscriptions/listen` stream with its empty `SubscriptionsListenResult` (the spec's graceful closure), and then writes nothing further onto a stdout nobody reads. In particular, an in-flight request no longer receives a `CONNECTION_CLOSED` error at EOF, since the peer that would read it is gone. diff --git a/docs/run/legacy-clients.md b/docs/run/legacy-clients.md index 0bcc1f6886..abab8a3f7a 100644 --- a/docs/run/legacy-clients.md +++ b/docs/run/legacy-clients.md @@ -9,14 +9,11 @@ The SDK routes every request by its `MCP-Protocol-Version` header. A request nam So a legacy client is not something you build *for*. It is something that connects *to* the server you already wrote. You configure nothing. !!! note - Nothing on the transport, literally. There is no `legacy=` option and no version - allowlist on `streamable_http_app()`, `run()`, or the session manager; by default both - eras are always on, and the version header routes each request to the right one. The one - place a server can restrict eras is its own posture: - `MCPServer(name, posture=Posture.LEGACY_ONLY)` (or `MODERN_ONLY`) is a constructor - property every transport honours (see **[Serving one era only](#serving-one-era-only)**), - and the default `DUAL` is this page's assumption. The nearest thing to a per-request - switch in the HTTP signature is `stateless_http`, and it is most of this page. + Nothing on the transport, and nothing on the server either. There is no `legacy=` option + and no version allowlist on `MCPServer`, `Server`, `streamable_http_app()`, `run()`, or the + session manager; both eras are always on, and the version header routes each request to + the right one. The nearest thing to a per-request switch in the HTTP signature is + `stateless_http`, and it is most of this page. ## One handler, both eras @@ -114,25 +111,9 @@ Over HTTP, neither call reaches the other era's clients. To tell everyone, call Two lines, no `if`, no version check, and you are done. That is the entire list of things a handler does differently because a legacy client exists. -## Serving one era only - -Everything above assumes the default: your server offers both eras. That default is a constructor property called the server's **posture**, and it is the one place you narrow it: - -```python -from mcp.server import Posture -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("Bookshop", posture=Posture.MODERN_ONLY) -``` - -`Posture.DUAL` is the default and the whole rest of this page. `Posture.MODERN_ONLY` retires the handshake era: a pre-2026 client's `initialize` is answered with a `-32022` error naming the modern versions the server does speak, so a well-behaved client learns to go modern rather than being silently dropped. `Posture.LEGACY_ONLY` is the mirror image, a server that speaks only the `initialize` handshake and refuses requests carrying the modern envelope. The low-level `Server` takes the same argument. - -Posture lives on the server object, not on any driver, so `run()`, `streamable_http_app()`, and the stream drivers all honour the same declaration and there is no per-transport switch to keep in step. Reach for it deliberately: `MODERN_ONLY` turns away every deployed pre-2026 client, and `LEGACY_ONLY` turns away 2026 ones, so `DUAL` is what you want unless you know exactly who connects. - ## Recap -* One `streamable_http_app()` serves both protocol eras. The SDK routes each request by its `MCP-Protocol-Version` header; there is nothing to configure on the transport and no per-request era knob to look for. -* The one era switch is the server's posture: `MCPServer(name, posture=Posture.MODERN_ONLY)` or `LEGACY_ONLY`, a constructor property every transport honours. The default `DUAL` is what this whole page assumes. +* One `streamable_http_app()` (and one stdio server) serves both protocol eras, always. The SDK routes each request by its `MCP-Protocol-Version` header, and a stream connection's era is decided by the client's opening message; there is nothing to configure on the transport or the server and no era switch to look for. * A legacy client costs you a session: an in-process `Mcp-Session-Id` record with no distributed store behind it. More than one worker means **sticky routing**, or the wrong worker answers `404 Session not found`. **[Deploy & scale](deploy.md)** has the multi-worker story. * `stateless_http=True` is the one knob, and it is **legacy-leg-only**. It buys free load balancing for legacy clients at the price of both server-to-client channels on that leg: server-initiated requests raise `NoBackChannelError` (a top-level error at the client, not an `is_error` result), and notifications are dropped. * A `2026-07-28` connection is sessionless either way. `stateless_http` never touches it. diff --git a/docs/whats-new.md b/docs/whats-new.md index 6e806b1618..d6dfeb103f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -153,7 +153,7 @@ Each of these is a section in the **[Migration Guide](migration.md)**: ## The protocol: 2025-11-25 to 2026-07-28 -v2 implements the 2026-07-28 revision, and it serves **both** revisions at once: the same `streamable_http_app()` (and the same stdio server) answers a 2025-era client's `initialize` and a 2026-era client's requests with nothing to configure, no flag to flip, and no separate deployment. Serving the new revision does not strand a client on the old one. If you want to serve only one revision, that is a single constructor property, `Server(posture=...)` / `MCPServer(posture=...)`, honoured by every transport; see [Serving one era only](run/legacy-clients.md#serving-one-era-only). What follows is what the new revision itself changes. +v2 implements the 2026-07-28 revision, and it serves **both** revisions at once: the same `streamable_http_app()` (and the same stdio server) answers a 2025-era client's `initialize` and a 2026-era client's requests with nothing to configure, no flag to flip, and no separate deployment. Serving the new revision does not strand a client on the old one. What follows is what the new revision itself changes. ### No handshake, no session diff --git a/src/mcp/server/__init__.py b/src/mcp/server/__init__.py index d95a56c49c..dec27a1f8a 100644 --- a/src/mcp/server/__init__.py +++ b/src/mcp/server/__init__.py @@ -3,7 +3,7 @@ from .lowlevel import NotificationOptions, Server from .mcpserver import MCPServer from .models import InitializationOptions -from .serving import Posture, serve_listener, serve_stream +from .serving import serve_listener, serve_stream __all__ = [ "CacheHint", @@ -12,7 +12,6 @@ "MCPServer", "NotificationOptions", "InitializationOptions", - "Posture", "serve_listener", "serve_stream", ] diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 1e7eeb23c8..8a0c98c621 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -59,7 +59,7 @@ async def main(): from mcp.server.caching import CacheableMethod, CacheHint, validate_cache_hints from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext from mcp.server.models import InitializationOptions -from mcp.server.serving import Posture, serve_stream +from mcp.server.serving import serve_stream from mcp.server.streamable_http import EventStore from mcp.server.streamable_http_manager import ( DEFAULT_MAX_REQUEST_BODY_SIZE, @@ -135,7 +135,6 @@ def __init__( website_url: str | None = None, icons: list[types.Icon] | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, - posture: Posture = Posture.DUAL, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -219,7 +218,6 @@ def __init__( website_url: str | None = None, icons: list[types.Icon] | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, - posture: Posture = Posture.DUAL, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -312,7 +310,6 @@ def __init__( website_url: str | None = None, icons: list[types.Icon] | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, - posture: Posture = Posture.DUAL, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -420,8 +417,6 @@ def __init__( self.instructions = instructions self.website_url = website_url self.icons = icons - # Which protocol eras this server offers, on every transport. - self.posture: Posture = posture # Per-method `ttl_ms`/`cache_scope` fills, applied by `ServerRunner` # after the handler returns; fields the handler set explicitly win. self.cache_hints: dict[str, CacheHint] = validate_cache_hints(cache_hints) @@ -724,8 +719,8 @@ async def run( """Serve one connection over a duplex message stream until the read side closes. Enters the server's lifespan, lets the client's opening message pick - the connection's protocol era (subject to `self.posture`), and serves - that era until EOF; `initialization_options` defaults to + the connection's protocol era, and serves that era until EOF; + `initialization_options` defaults to `create_initialization_options()`. For a socket host use `serve_listener`; for connections sharing a lifespan, `serve_stream(..., lifespan_state=)`. """ diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index adddb75e0b..b9d51bbcbf 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -84,7 +84,6 @@ from mcp.server.mcpserver.utilities.context_injection import find_context_parameter from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity -from mcp.server.serving import Posture from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server from mcp.server.streamable_http import EventStore @@ -185,7 +184,6 @@ def __init__( request_state_security: RequestStateSecurity | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, subscriptions: SubscriptionBus | None = None, - posture: Posture = Posture.DUAL, ): self._resource_security = resource_security self.settings = Settings( @@ -219,7 +217,6 @@ def __init__( icons=icons, version=version, cache_hints=cache_hints, - posture=posture, on_list_tools=self._handle_list_tools, on_call_tool=self._handle_call_tool, on_list_resources=self._handle_list_resources, diff --git a/src/mcp/server/serving.py b/src/mcp/server/serving.py index ba2d58bfcd..60068d964c 100644 --- a/src/mcp/server/serving.py +++ b/src/mcp/server/serving.py @@ -1,16 +1,14 @@ """Serving a `Server` over a duplex message stream (stdio, SSE, custom sockets). -`serve_stream` is the driver for stream transports. The client's opening -messages decide the connection's protocol era, in receive order, among those -`Server(posture=)` offers, and that era serves every later message; `Posture` -names which eras a server offers at all. +`serve_stream` is the driver for stream transports. A server offers both +protocol eras on every connection; the client's opening messages decide the +connection's era, in receive order, and that era serves every later message. """ from __future__ import annotations import logging from collections.abc import Awaitable, Mapping -from enum import Enum from typing import TYPE_CHECKING, Any, Generic, Literal import anyio @@ -34,7 +32,7 @@ if TYPE_CHECKING: from mcp.server.lowlevel.server import Server -__all__ = ["Posture", "serve_listener", "serve_stream"] +__all__ = ["serve_listener", "serve_stream"] logger = logging.getLogger(__name__) @@ -47,19 +45,6 @@ """The one notification that completes a legacy handshake and so opens the legacy era.""" -class Posture(Enum): - """Which protocol eras a server offers on a connection; `DUAL` lets the opening message decide.""" - - DUAL = "dual" - """Both eras: the legacy `initialize` handshake and the modern per-request envelope.""" - - LEGACY_ONLY = "legacy-only" - """Only the legacy `initialize` handshake era.""" - - MODERN_ONLY = "modern-only" - """Only the modern per-request-envelope era (2026-07-28+).""" - - class _Unset: """Sentinel type for an omitted `lifespan_state` (`None` is a valid state).""" @@ -159,8 +144,8 @@ async def _ignore_stray_notification() -> None: class _StreamConnection(Generic[LifespanT]): """One duplex stream connection: admits every message and routes it to the connection's era. - `_era` starts from the server's posture and, under `DUAL`, is decided once, in - receive order, by the first era-distinctive message the dispatcher admits. + `_era` starts undecided and is decided once, in receive order, by the first + era-distinctive message the dispatcher admits. """ def __init__( @@ -169,7 +154,6 @@ def __init__( read_stream: ReadStream[SessionMessage | Exception], write_stream: WriteStream[SessionMessage], *, - posture: Posture, lifespan_state: LifespanT, init_options: InitializationOptions | None, session_id: str | None, @@ -188,13 +172,7 @@ def __init__( ServerRunner(server, loop_connection, lifespan_state, init_options=init_options) ) self._modern: _ModernEra[LifespanT] = _ModernEra(server, lifespan_state, NotifyOnlyOutbound(self._dispatcher)) - # Posture is consumed here, once: which eras exist for this connection. - starting_era: dict[Posture, _LegacyEra[LifespanT] | _ModernEra[LifespanT] | None] = { - Posture.DUAL: None, - Posture.LEGACY_ONLY: self._legacy, - Posture.MODERN_ONLY: self._modern, - } - self._era: _LegacyEra[LifespanT] | _ModernEra[LifespanT] | None = starting_era[posture] + self._era: _LegacyEra[LifespanT] | _ModernEra[LifespanT] | None = None def _transport_context(self, _metadata: MessageMetadata) -> TransportContext: # Admission has already decided this frame's era; only the legacy era @@ -239,6 +217,11 @@ def _admit_notification(self, method: str, params: Mapping[str, Any] | None) -> self._era = self._legacy return False + def open_legacy_era(self) -> None: + """Open the legacy era before any message: the transport routed this stream to + the handshake era ahead of its first frame (streamable HTTP's stateful sessions).""" + self._era = self._legacy + def _on_request( self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> Awaitable[dict[str, Any]]: @@ -274,8 +257,8 @@ async def serve_stream( """Serve `server` over a duplex message stream until the read side closes. The driver for stream transports: the client's opening messages decide the - connection's era among those `server.posture` offers. Enters - `server.lifespan()` unless `lifespan_state` is given. + connection's era. Enters `server.lifespan()` unless `lifespan_state` is + given. Args: initialization_options: The legacy handshake's `InitializeResult` @@ -301,7 +284,6 @@ async def serve_stream( server, read_stream, write_stream, - posture=server.posture, lifespan_state=lifespan_state, init_options=initialization_options, session_id=None, @@ -327,12 +309,12 @@ async def serve_legacy_stream( server, read_stream, write_stream, - posture=Posture.LEGACY_ONLY, lifespan_state=lifespan_state, init_options=None, session_id=session_id, raise_exceptions=False, ) + connection.open_legacy_era() await connection.run() diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index 31dc832498..821726eb99 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -22,7 +22,7 @@ from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context from mcp.server.connection import Connection from mcp.server.runner import serve_connection -from mcp.server.serving import Posture, serve_legacy_stream +from mcp.server.serving import serve_legacy_stream from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, EventStore, StreamableHTTPServerTransport from mcp.server.transport_security import TransportSecuritySettings from mcp.shared._compat import resync_tracer @@ -179,14 +179,9 @@ async def _handle_request(self, scope: Scope, receive: Receive, send: Send) -> N # initialize-handshake versions; anything else (including unknown # values) goes to the modern entry so the classifier can validate it # and return a structured rejection. 2025 paths below remain unchanged. - # `Server.posture` routes: modern-only sends every request to the - # modern entry; legacy-only never does. header = MCP_PROTOCOL_VERSION_HEADER.encode("ascii") pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == header), None) - posture = self.app.posture - if posture is Posture.MODERN_ONLY or ( - posture is Posture.DUAL and pv is not None and pv not in HANDSHAKE_PROTOCOL_VERSIONS - ): + if pv is not None and pv not in HANDSHAKE_PROTOCOL_VERSIONS: await handle_modern_request( self.app, self.security_settings, self.json_response, self._lifespan_state, scope, receive, send ) diff --git a/tests/server/mcpserver/test_resolve.py b/tests/server/mcpserver/test_resolve.py index aa5ced266a..fae1e04b70 100644 --- a/tests/server/mcpserver/test_resolve.py +++ b/tests/server/mcpserver/test_resolve.py @@ -2250,7 +2250,7 @@ async def act( @pytest.mark.anyio async def test_resolver_elicitation_seals_and_completes_on_a_fully_default_server(): - # The headline default-posture invariant: a resolver tool on a bare MCPServer() - + # The headline default invariant: a resolver tool on a bare MCPServer() - # no name, no security configuration - mints sealed state and completes the round. mcp = MCPServer() diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 33c40e5a29..7f354feb55 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -1883,7 +1883,7 @@ def get_user(user_id: str) -> str: async def test_tool_returning_input_required_result_reaches_client_sealed(): - # Default posture: the wire carries an opaque sealed token, never the handler's plaintext. + # By default: the wire carries an opaque sealed token, never the handler's plaintext. mcp = MCPServer() @mcp.tool() @@ -2017,7 +2017,7 @@ async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: async def test_prompt_input_required_result_on_legacy_session_is_a_serialization_error(): """Pins the shared era gate: a pre-2026 session has no input_required vocabulary, so - the runner rejects the frame with -32603 — the same posture the tools path has.""" + the runner rejects the frame with -32603 — the same stance the tools path has.""" mcp = MCPServer() @mcp.prompt() diff --git a/tests/server/test_http_orphan_and_posture.py b/tests/server/test_http_orphan.py similarity index 64% rename from tests/server/test_http_orphan_and_posture.py rename to tests/server/test_http_orphan.py index 6c36a222d3..e0f0fc33ab 100644 --- a/tests/server/test_http_orphan_and_posture.py +++ b/tests/server/test_http_orphan.py @@ -1,6 +1,6 @@ -"""Two streamable-HTTP obligations that the stream-driver work must not break. +"""A streamable-HTTP obligation that the stream-driver work must not break. -(i) The request-coroutine orphan. A cancelled request no longer receives the +The request-coroutine orphan. A cancelled request no longer receives the `{"code":0,"message":"Request cancelled"}` frame the JSON-RPC dispatcher used to write, so the legacy streamable-HTTP JSON-response-mode POST loop must not wait for an answer to the cancelled request that never comes (which would leave the POST coroutine and its @@ -9,16 +9,10 @@ stream entry is released, and the session keeps serving. The transport closes the cancelled request's response stream; the dispatcher writes nothing for the id. -(ii) Posture honoured over streamable HTTP. A `MODERN_ONLY` server refuses a 2025 -`initialize` with -32022 whose data names the versions it serves; a `LEGACY_ONLY` server -refuses an enveloped modern request in its own (legacy) vocabulary instead of serving it. -`Server(posture=)` is one constructor property every transport reads; this file measures -the HTTP half of that claim. - Everything runs in process: `StreamableHTTPSessionManager` mounted in Starlette, an httpx2 client on the suite's streaming ASGI bridge, raw JSON-RPC over HTTP - because the properties -under test (a POST's completion, a stream table, statuses and error bodies) are things the -high-level client cannot observe. +under test (a POST's completion, a stream table, statuses) are things the high-level client +cannot observe. """ from collections.abc import AsyncIterator, Awaitable, Callable @@ -29,20 +23,12 @@ import anyio import httpx2 import pytest -from mcp_types import ( - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - PROTOCOL_VERSION_META_KEY, - UNSUPPORTED_PROTOCOL_VERSION, - CallToolRequestParams, - CallToolResult, - TextContent, -) -from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION +from mcp_types import CallToolRequestParams, CallToolResult, TextContent +from mcp_types.version import LATEST_HANDSHAKE_VERSION from starlette.applications import Starlette from starlette.routing import Mount -from mcp.server import Posture, Server, ServerRequestContext +from mcp.server import Server, ServerRequestContext from mcp.server.streamable_http import MCP_SESSION_ID_HEADER from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings @@ -58,13 +44,6 @@ JSON_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"} -# The modern per-request envelope for a request that claims the 2026 era over HTTP. -MODERN_ENVELOPE: dict[str, Any] = { - PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION, - CLIENT_INFO_META_KEY: {"name": "http-suite", "version": "0"}, - CLIENT_CAPABILITIES_META_KEY: {}, -} - @dataclass class Probe: @@ -78,8 +57,8 @@ class Probe: def tool_handler(probe: Probe) -> Callable[[Ctx, CallToolRequestParams], Awaitable[CallToolResult]]: - """One `tools/call` handler for every server in this file: `slow` parks on the probe, - anything else returns immediately.""" + """The suite's `tools/call` handler: `slow` parks on the probe, anything else returns + immediately.""" async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult: if params.name == "slow": @@ -91,15 +70,10 @@ async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult: def make_server(probe: Probe) -> Server[dict[str, Any]]: - """A dual-era lowlevel server (default posture) exposing the probe-driven tool.""" + """A dual-era lowlevel server exposing the probe-driven tool.""" return Server("http-orphan", version="0.0.0", on_call_tool=tool_handler(probe)) -def make_postured_server(posture: Posture, probe: Probe) -> Server[dict[str, Any]]: - """A lowlevel server declaring `posture`, over the same tool surface as `make_server`.""" - return Server("http-posture", version="0.0.0", posture=posture, on_call_tool=tool_handler(probe)) - - @asynccontextmanager async def running_app( server: Server[dict[str, Any]], *, json_response: bool @@ -162,9 +136,6 @@ def open_request_streams(manager: StreamableHTTPSessionManager) -> set[str]: return open_keys -# --- (i) the request-coroutine orphan ----------------------------------------------------- - - async def test_peer_cancel_in_legacy_json_mode_completes_the_post_and_releases_its_stream() -> None: """Legacy streamable HTTP, JSON-response mode: the peer cancels an in-flight `tools/call` with `notifications/cancelled`. The POST awaiting that call MUST complete, @@ -255,68 +226,3 @@ async def post_the_slow_call() -> None: ) assert followup.status_code == 200, f"the session must keep serving after a cancel: {followup.text}" assert followup.json()["result"]["content"][0]["text"] == "fast done" - - -# --- (ii) posture is honoured over streamable HTTP ----------------------------------------- - - -async def test_modern_only_server_refuses_a_2025_handshake_over_http_with_the_version_error() -> None: - """A server declared MODERN_ONLY refuses a 2025 `initialize` over streamable HTTP with - -32022 whose data lists the versions it does serve - the modern era's own answer to a - handshake attempt (versioning.mdx: a modern-only server SHOULD name its versions), not a - legacy handshake completed by a transport that never read the posture.""" - server = make_postured_server(Posture.MODERN_ONLY, Probe()) - async with running_app(server, json_response=True) as (app, _), http_client(app) as client: - response = await client.post( - "/mcp", - headers=JSON_HEADERS, - json={ - "jsonrpc": "2.0", - "id": "init-1", - "method": "initialize", - "params": { - "protocolVersion": LATEST_HANDSHAKE_VERSION, - "capabilities": {}, - "clientInfo": {"name": "http-suite", "version": "0"}, - }, - }, - ) - body = response.json() - assert "error" in body, ( - f"a modern-only server must refuse the 2025 handshake, but answered {response.status_code}: {body}" - ) - assert body["error"]["code"] == UNSUPPORTED_PROTOCOL_VERSION - assert LATEST_MODERN_VERSION in body["error"]["data"]["supported"] - - -async def test_legacy_only_server_refuses_an_enveloped_modern_request_over_http_in_legacy_vocabulary() -> None: - """A server declared LEGACY_ONLY meets an enveloped 2026 request over streamable HTTP: - it must not serve it under the modern era, and it refuses in legacy vocabulary - an HTTP - 4xx or a legacy-space JSON-RPC error, never a served result and never the modern-only - -32022 that would tell an auto-negotiating client not to fall back to `initialize`.""" - server = make_postured_server(Posture.LEGACY_ONLY, Probe()) - async with running_app(server, json_response=True) as (app, _), http_client(app) as client: - response = await client.post( - "/mcp", - headers={**JSON_HEADERS, MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}, - json={ - "jsonrpc": "2.0", - "id": "call-1", - "method": "tools/call", - "params": {"name": "fast", "arguments": {}, "_meta": dict(MODERN_ENVELOPE)}, - }, - ) - # Every refusal path an HTTP entry has today carries a JSON body; a bodyless 4xx - # would surface here as a decode error, which still fails the test with its reason. - payload: dict[str, Any] = response.json() - assert "result" not in payload, ( - f"a legacy-only server must not serve an enveloped modern request, but did: {payload}" - ) - error_code: Any = payload.get("error", {}).get("code") - assert response.status_code >= 400 or error_code is not None, ( - f"the refusal must be an HTTP 4xx or a JSON-RPC error, got {response.status_code}: {payload}" - ) - assert error_code != UNSUPPORTED_PROTOCOL_VERSION, ( - "a legacy-only server must refuse in legacy vocabulary; -32022 tells an auto-negotiating " - f"client not to fall back to initialize: {payload}" - ) diff --git a/tests/server/test_request_state.py b/tests/server/test_request_state.py index 590c046e94..f1696f4a1a 100644 --- a/tests/server/test_request_state.py +++ b/tests/server/test_request_state.py @@ -340,7 +340,7 @@ def test_keys_and_codec_together_are_rejected_at_policy_construction() -> None: def test_a_policy_with_neither_keys_nor_codec_is_rejected() -> None: - """SDK-defined: a policy must name its codec; an empty policy is a mistake, not a posture.""" + """SDK-defined: a policy must name its codec; an empty policy is a mistake, not a stance.""" with pytest.raises(ValueError) as exc: RequestStateSecurity() assert str(exc.value) == snapshot("RequestStateSecurity takes exactly one of keys= or codec=") diff --git a/tests/server/test_request_state_boundary.py b/tests/server/test_request_state_boundary.py index ed4e5b0662..4038cd3d04 100644 --- a/tests/server/test_request_state_boundary.py +++ b/tests/server/test_request_state_boundary.py @@ -118,7 +118,7 @@ def _manual_server( ) -> tuple[MCPServer, list[str | None]]: """MCPServer with one manual MRTR tool: round 1 asks, the retry records the echoed `ctx.request_state`. - `security=None` exercises the default posture (process-local ephemeral sealing), not plaintext. + `security=None` exercises the default policy (process-local ephemeral sealing), not plaintext. """ seen: list[str | None] = [] mcp = MCPServer(name, request_state_security=security) @@ -620,7 +620,7 @@ async def wizard(ctx: Context) -> str | InputRequiredResult: assert (claims_one["iat"], claims_two["iat"]) == (int(_T0), int(_T0) + 5) -# -- the default posture: every MCPServer seals under an ephemeral policy --------------- +# -- the default policy: every MCPServer seals under an ephemeral policy ---------------- async def test_an_mcpserver_seals_request_state_by_default() -> None: @@ -1290,7 +1290,7 @@ def test_a_shared_key_policy_without_a_real_name_must_set_an_audience(name: str MCPServer(name, request_state_security=RequestStateSecurity(keys=[_KEY])) assert str(excinfo.value) == _MISSING_AUDIENCE - # Every neighboring posture constructs: the default needs no name, and a real + # Every neighboring configuration constructs: the default needs no name, and a real # name or an explicit audience satisfies a shared-key policy. MCPServer(name) MCPServer(name, request_state_security=RequestStateSecurity(keys=[_KEY], audience="svc")) diff --git a/tests/server/test_serving.py b/tests/server/test_serving.py index 22922298c7..55bd3c4a35 100644 --- a/tests/server/test_serving.py +++ b/tests/server/test_serving.py @@ -3,9 +3,9 @@ Each test speaks raw JSON-RPC frames to a `serve_stream` server through an in-memory `JSONRPCDispatcher` client, so the wire behaviour under test is the opening exchange: which era the connection opens in, what `server/discover` -does (answered, never opening), and how each posture answers traffic from the -other era. The ordering suite (`test_stdio_ordering.py`) covers pipelined -ordering on both anyio backends; these are the seam-level companions. +does (answered, never opening), and how each era answers traffic from the +other. The ordering suite (`test_stdio_ordering.py`) covers pipelined ordering +on both anyio backends; these are the seam-level companions. """ from collections.abc import AsyncIterator @@ -22,7 +22,6 @@ INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, - METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, SERVER_INFO_META_KEY, UNSUPPORTED_PROTOCOL_VERSION, @@ -47,7 +46,7 @@ from mcp.server.connection import NotifyOnlyOutbound from mcp.server.context import ServerRequestContext from mcp.server.lowlevel.server import Server -from mcp.server.serving import Posture, _opening_intent, serve_listener, serve_stream +from mcp.server.serving import _opening_intent, serve_listener, serve_stream from mcp.server.stdio import newline_json_transport from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ToolsListChanged from mcp.shared.exceptions import MCPError, NoBackChannelError @@ -99,11 +98,11 @@ def _initialize_params() -> dict[str, Any]: ).model_dump(by_alias=True, exclude_none=True) -def _server(*, posture: Posture = Posture.DUAL, **handlers: Any) -> SrvT: +def _server(**handlers: Any) -> SrvT: async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: return ListToolsResult(tools=[_TOOL]) - return Server(name="serve-stream-test", version="0.0.1", posture=posture, on_list_tools=list_tools, **handlers) + return Server(name="serve-stream-test", version="0.0.1", on_list_tools=list_tools, **handlers) @asynccontextmanager @@ -153,9 +152,8 @@ class _BodyFailed(Exception): def test_opening_intent_is_read_off_the_opening_request_alone(): - """The one place an undecided connection's request intent is read. Posture is - not an input here: it was consumed as the connection's starting era, so a - single-era connection never asks this question.""" + """The one place an undecided connection's request intent is read: the method + and its params decide it; nothing else is an input.""" envelope = {"_meta": _envelope()} assert _opening_intent("server/discover", envelope) == "probe" assert _opening_intent("initialize", envelope) == "legacy" # legacy-distinctive even if stamped @@ -165,7 +163,7 @@ def test_opening_intent_is_read_off_the_opening_request_alone(): assert _opening_intent("server/discover", None) == "legacy" # an envelope-less discover is no probe -# --- dual-era posture -------------------------------------------------------- +# --- the era is decided by the opening message --------------------------------- async def test_modern_request_opens_the_modern_era_and_refuses_the_handshake(): @@ -464,48 +462,6 @@ async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult: assert exc.value.error.message == "Internal server error" -async def test_modern_only_posture_refuses_a_handshake_with_no_parseable_version(): - """When `initialize` carries no parseable `protocolVersion`, the modern era's - refusal still names the versions it serves (there is nothing to echo back).""" - async with _raw_client(_server(posture=Posture.MODERN_ONLY)) as (client, _): - with pytest.raises(MCPError) as exc: - await client.send_raw_request("initialize", {"capabilities": {}}) - assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION - assert exc.value.error.data == {"supported": list(MODERN_PROTOCOL_VERSIONS)} - - -# --- single-era postures ----------------------------------------------------- - - -async def test_modern_only_posture_refuses_the_handshake_naming_its_versions(): - """A modern-only server answers `initialize` with -32022 listing the modern - versions (versioning.mdx: modern-only servers SHOULD name them), and serves - modern requests.""" - async with _raw_client(_server(posture=Posture.MODERN_ONLY)) as (client, _): - with pytest.raises(MCPError) as exc: - await client.send_raw_request("initialize", _initialize_params()) - assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION - assert exc.value.error.data["supported"] == list(MODERN_PROTOCOL_VERSIONS) - result = await client.send_raw_request("tools/list", _modern_params()) - assert result["tools"][0]["name"] == "t" - - -async def test_legacy_only_posture_serves_the_handshake_and_answers_probes_in_legacy_vocabulary(): - """A legacy-only server is born a handshake connection: an enveloped probe is - a client mixing eras and is refused (-32600), a bare probe is answered with - the handshake era's own method-not-found - both trigger an auto client's - fallback, since fallback is not keyed to one code - and the handshake works.""" - async with _raw_client(_server(posture=Posture.LEGACY_ONLY)) as (client, _): - with pytest.raises(MCPError) as enveloped: - await client.send_raw_request("server/discover", _modern_params()) - with pytest.raises(MCPError) as bare: - await client.send_raw_request("server/discover", None) - init = await client.send_raw_request("initialize", _initialize_params()) - assert enveloped.value.error.code == INVALID_REQUEST - assert bare.value.error.code == METHOD_NOT_FOUND - assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION - - # --- subscriptions/listen over a stream -------------------------------------- diff --git a/tests/server/test_stdio_ordering.py b/tests/server/test_stdio_ordering.py index cd8a1c077e..16fc8556e4 100644 --- a/tests/server/test_stdio_ordering.py +++ b/tests/server/test_stdio_ordering.py @@ -127,7 +127,7 @@ class Probe: def make_server(probe: Probe) -> Server[dict[str, Any]]: - """A dual-era lowlevel server (default posture) with a fast `tools/list`, a `slow` + """A dual-era lowlevel server with a fast `tools/list`, a `slow` tool that parks on `probe.release`, and a handler for the suite's custom notification.""" async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 6cd0a84f34..fcade42630 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -791,7 +791,7 @@ async def test_modern_tools_call_rejects_missing_mcp_param_header_for_present_ar async def test_modern_tools_call_rejects_orphan_mcp_param_header() -> None: - """SDK posture: a header for an absent argument is the routing-spoof case the gate exists to stop.""" + """SDK stance: a header for an absent argument is the routing-spoof case the gate exists to stop.""" async with _asgi_client(_x_mcp_server()) as http: response = await http.post( "/mcp", json=_tool_call_body({}), headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"} diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 90e67089d0..9a5640d2b1 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -746,7 +746,7 @@ def test_validate_mcp_param_headers_rejects_missing_header_for_present_argument( def test_validate_mcp_param_headers_rejects_orphan_header_for_absent_or_null_argument( arguments: dict[str, Any], ) -> None: - """SDK-defined posture on a spec gap: an orphan header is the routing-spoof case; go rejects too, ts skips.""" + """SDK-defined stance on a spec gap: an orphan header is the routing-spoof case; go rejects too, ts skips.""" rejection = assert_rejected( validate_mcp_param_headers(REGION_SCHEMA, arguments, {"Mcp-Param-Region": "eu"}), HEADER_MISMATCH )