Fail fast on server-to-client requests in JSON-response mode instead of hanging (#3195)
This commit is contained in:
+8
-5
@@ -749,7 +749,7 @@ Transport-specific parameters have been moved off the `MCPServer` constructor an
|
||||
- `host`, `port` - HTTP server binding, on `run()` only. The app factories have no `port` (`streamable_http_app(port=...)` raises `TypeError`; a mounted app binds wherever the outer ASGI server does) but do take `host` (default `"127.0.0.1"`), used only to decide whether DNS rebinding protection auto-enables (see the note below)
|
||||
- `sse_path`, `message_path` - SSE transport paths, on `run(transport="sse", ...)` and `sse_app()`
|
||||
- `streamable_http_path` - StreamableHTTP endpoint path, on `run(transport="streamable-http", ...)` and `streamable_http_app()`
|
||||
- `json_response`, `stateless_http` - StreamableHTTP behavior, same two places
|
||||
- `json_response`, `stateless_http` - StreamableHTTP behavior, same two places; each also removes a server-to-client channel, see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror)
|
||||
- `max_request_body_size` - StreamableHTTP request-body limit, same two places
|
||||
- `event_store`, `retry_interval` - StreamableHTTP event handling, same two places
|
||||
- `transport_security` - DNS rebinding protection, on `run()` for both HTTP transports and on both app methods
|
||||
@@ -963,8 +963,11 @@ enforce the spec's egress rule: an undeclared capability (form-mode `elicitation
|
||||
or `tool_choice`) fails the call with a `-32021`
|
||||
`MISSING_REQUIRED_CLIENT_CAPABILITY` JSON-RPC error instead of sending a
|
||||
request the client cannot handle. This applies on 2025-11-25 sessions with a
|
||||
live back-channel too; a session with no back-channel keeps failing with its
|
||||
no-back-channel error. To migrate, declare the capability: the SDK client
|
||||
live back-channel too; a pre-`2026-07-28` session with no back-channel
|
||||
(stateless HTTP, or streamable HTTP with `json_response=True`) keeps failing
|
||||
with its no-back-channel error. At `2026-07-28` a resolver never uses a
|
||||
back-channel — it answers with an `InputRequiredResult` — so the `-32021`
|
||||
check applies there unconditionally. To migrate, declare the capability: the SDK client
|
||||
declares `elicitation`, `sampling`, and `roots` when the matching callback is
|
||||
set, and `sampling.tools` needs an explicit
|
||||
`Client(sampling_capabilities=SamplingCapability(tools=...))`. Direct
|
||||
@@ -1617,7 +1620,7 @@ Nothing changes for callers of the built-in client: abandoning a call (cancellin
|
||||
|
||||
The `stateless: bool` parameter on the lowlevel `Server.run()` has been removed. Stateless serving is now a property of how the connection is constructed (the streamable-HTTP manager builds a born-ready `Connection` per request), not a flag the loop driver inspects.
|
||||
|
||||
Server-initiated requests that have no channel to travel on — a legacy session against a `stateless_http=True` server, or any connection negotiated at 2026-07-28 — now raise `NoBackChannelError` instead of stalling as they did in v1 (the transport silently dropped the outbound message), so a stateless-HTTP `ctx.elicit()` that used to hang now fails fast; see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror) for the exception and the migration paths.
|
||||
Server-initiated requests that have no channel to travel on — a legacy session against a `stateless_http=True` server, the request-scoped channel of a stateful legacy session against a `json_response=True` server, or any connection negotiated at 2026-07-28 — now raise `NoBackChannelError` instead of stalling as they did in v1 (the transport silently dropped the outbound message), so a stateless-HTTP or JSON-mode `ctx.elicit()` that used to hang now fails fast; see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror) for the exception and the migration paths.
|
||||
|
||||
### Lowlevel `Server`: `request_context` property removed
|
||||
|
||||
@@ -2801,7 +2804,7 @@ rest of this guide stays focused on the v1-to-v2 upgrade itself.
|
||||
|
||||
The 2026-07-28 protocol has no server-initiated requests, so a handler that reaches back to the client mid-request — `ctx.elicit()`, `ctx.elicit_url()`, `ctx.session.create_message()`, `ctx.session.list_roots()`, or any other `ServerSession` request helper — raises `NoBackChannelError` on such a connection instead of sending. An in-process `Client(server)` negotiates 2026-07-28 by default (see [`Client` defaults to `mode='auto'`](#client-defaults-to-modeauto)), so the first smoke test of an unchanged v1 sampling or elicitation tool fails, and setting `sampling_callback=` / `elicitation_callback=` on the client changes nothing because no request ever reaches the client.
|
||||
|
||||
`NoBackChannelError` lives in `mcp.shared.exceptions` and subclasses `MCPError` (code `-32600`, message `Cannot send '<method>': this transport context has no back-channel for server-initiated requests.`). Raised inside an `@mcp.tool()` it reaches the client as a top-level JSON-RPC error, not `CallToolResult(is_error=True)` — see [`MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error](#mcperror-raised-from-an-mcptool-handler-now-surfaces-as-a-json-rpc-error) — and the [Troubleshooting](troubleshooting.md) page walks through the client-side traceback. The same exception is raised on a legacy session against a `stateless_http=True` server, where v1 dropped the message and stalled ([`Server.run()` no longer takes a `stateless` flag](#serverrun-no-longer-takes-a-stateless-flag)). Notifications never raise it: `send_log_message()`, `send_tool_list_changed()`, and the other notification helpers are dropped with a debug log where no channel exists, and `UrlElicitationRequiredError` from a tool is unaffected (it is an error response, not a request).
|
||||
`NoBackChannelError` lives in `mcp.shared.exceptions` and subclasses `MCPError` (code `-32600`, message `Cannot send '<method>': this transport context has no back-channel for server-initiated requests.`). Raised inside an `@mcp.tool()` it reaches the client as a top-level JSON-RPC error, not `CallToolResult(is_error=True)` — see [`MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error](#mcperror-raised-from-an-mcptool-handler-now-surfaces-as-a-json-rpc-error) — and the [Troubleshooting](troubleshooting.md) page walks through the client-side traceback. The same exception is raised on a legacy session against a `stateless_http=True` server, and on the request-scoped channel of a stateful legacy session against a `json_response=True` server (a JSON body carries exactly one response, so a mid-request `ctx.elicit()` cannot ride it; the session's standalone `GET` stream still carries unrelated messages) — both places v1 dropped the message and stalled ([`Server.run()` no longer takes a `stateless` flag](#serverrun-no-longer-takes-a-stateless-flag)). Notifications never raise it: `send_log_message()`, `send_tool_list_changed()`, and the other notification helpers are dropped with a debug log where no channel exists, and `UrlElicitationRequiredError` from a tool is unaffected (it is an error response, not a request).
|
||||
|
||||
Two ways to migrate:
|
||||
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ Each transport has its own keyword arguments, all on `run()`:
|
||||
|
||||
* `host` / `port`: where to listen. Defaults `127.0.0.1` and `8000`.
|
||||
* `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`.
|
||||
* `json_response=True`: answer with plain JSON instead of an SSE stream.
|
||||
* `json_response=True`: answer each POST with a single JSON body instead of an SSE stream. That body has room for the response and nothing else, so a tool that calls back into the client mid-request (`ctx.elicit()`, sampling) raises `NoBackChannelError` on this leg, and notifications tied to the in-flight call (progress from `ctx.report_progress()`, per-call log messages) are dropped; the standalone `GET` stream still carries unrelated ones.
|
||||
* `stateless_http=True`: a fresh transport per request, no session tracking.
|
||||
* `max_request_body_size`: largest accepted POST body in bytes. Defaults to 4 MiB; larger requests
|
||||
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages
|
||||
|
||||
@@ -72,6 +72,13 @@ Two things about it matter more than what it does.
|
||||
|
||||
**It costs both server-to-client channels on that leg.** A session that lives for one `POST` has no stream for the server to push a request down and no standalone stream for it to push notifications down. Every server-initiated request raises `NoBackChannelError`: `ctx.elicit()`, the retired sampling and roots calls (**[Deprecated features](../deprecated.md)**), and, yes, `Resolve` asking a *legacy* client its question. Notifications don't even get an error; they are silently dropped.
|
||||
|
||||
!!! note
|
||||
`json_response=True` is not that knob, but it takes half the same cost on *every* legacy
|
||||
session: a `POST` answered with one JSON body has no stream for the request-scoped channel,
|
||||
so a mid-request `ctx.elicit()` raises the same `NoBackChannelError` and notifications tied to
|
||||
the request are dropped. The session's standalone stream is untouched: unrelated notifications
|
||||
still arrive.
|
||||
|
||||
!!! check
|
||||
Do the wrong thing. `reserve` is the exact tool that just served both clients. Deploy it with
|
||||
`stateless_http=True`, connect the same two clients over HTTP, and call it from each.
|
||||
|
||||
@@ -305,7 +305,7 @@ You see this one from `ctx.elicit()` on a legacy connection, and on any connecti
|
||||
|
||||
## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.`
|
||||
|
||||
Your handler tried to reach the client mid-request, on a connection where nothing can carry a request from the server. There are exactly two ways to be on one.
|
||||
Your handler tried to reach the client mid-request, on a connection whose call has no channel that can carry a request from the server. There are three server configurations that put a call there.
|
||||
|
||||
**A `2026-07-28` connection: any transport, always.** The modern protocol has no server-initiated requests at all, so the server refuses before anything is sent. `ctx.elicit()` inside a tool is the classic way to meet this (on the very first in-memory test, since `Client(server)` negotiates `2026-07-28` without being asked), and passing `elicitation_callback=` changes nothing, because no request ever reaches the client for it to answer:
|
||||
|
||||
@@ -329,20 +329,23 @@ mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport
|
||||
--8<-- "docs_src/troubleshooting/tutorial008.py"
|
||||
```
|
||||
|
||||
**A legacy connection on a `json_response=True` server.** The `POST` is answered with one JSON body, and one body carries only the response, so the request-scoped stream a mid-request `ctx.elicit()` needs does not exist here either. The session, its `Mcp-Session-Id`, and its standalone stream are all still there; only the request-scoped channel is gone.
|
||||
|
||||
The message names the method it could not send. `NoBackChannelError` is the class the server raises, but the wire carries only the base `MCPError`, so the sentence above is your traceback's last line, not the class name.
|
||||
|
||||
The fix is the same for both: don't reach back mid-call. Move the question into a **resolver** (or return an `InputRequiredResult` yourself) and it becomes part of the *response*, which every connection can carry:
|
||||
For a `2026-07-28` client the fix is the same on all three: don't reach back mid-call. Move the question into a **resolver** (or return an `InputRequiredResult` yourself) and it becomes part of the *response*, which every connection can carry:
|
||||
|
||||
```python title="server.py" hl_lines="15-17 21"
|
||||
--8<-- "docs_src/troubleshooting/tutorial007.py"
|
||||
```
|
||||
|
||||
Same question, same `elicitation_callback` on the client. The difference is under the hood: a resolver lets the server *return* the question from the call instead of pushing it, so nothing ever flows server-to-client. **[Elicitation](handlers/elicitation.md)** covers resolvers; **[Multi-round-trip requests](handlers/multi-round-trip.md)** covers what happens on the wire.
|
||||
Same question, same `elicitation_callback` on the client. The difference is under the hood: a resolver lets the server *return* the question from the call instead of pushing it, so nothing ever flows server-to-client. That rescues every `2026-07-28` client, whichever of the three configurations the server is in. A *legacy* client is not rescued by the rewrite alone: `2025-11-25` has no way to return a question, so on a legacy connection the resolver still sends `elicitation/create` down the request-scoped channel, and still needs a server that keeps it — neither `stateless_http=True` nor `json_response=True`. **[Elicitation](handlers/elicitation.md)** covers resolvers; **[Multi-round-trip requests](handlers/multi-round-trip.md)** covers what happens on the wire.
|
||||
|
||||
!!! check
|
||||
The tool with `ctx.elicit()` is not wrong, it is *pre-2026*. Connect with `mode="legacy"`
|
||||
(the classic `initialize` handshake, spec `2025-11-25` and earlier) to a server that is not
|
||||
`stateless_http=True`, and it works, because the server-to-client channel exists there.
|
||||
(the classic `initialize` handshake, spec `2025-11-25` and earlier) to a server that is neither
|
||||
`stateless_http=True` nor `json_response=True`, and it works, because the server-to-client
|
||||
channel exists there.
|
||||
**[Protocol versions](protocol-versions.md)** is the page on what each version has.
|
||||
|
||||
## `MCPError: Invalid or expired requestState`
|
||||
@@ -407,6 +410,6 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key
|
||||
* One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: <host>` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`.
|
||||
* `Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`.
|
||||
* `Session not found` -> the server restarted; reconnect.
|
||||
* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` needs a server-to-client channel: a `2026-07-28` connection never has one, and `stateless_http=True` takes away the legacy one. Use a resolver. Its neighbour `Method not found` is a request for a method the other side's protocol revision doesn't have.
|
||||
* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` needs a server-to-client channel: a `2026-07-28` connection never has one, `stateless_http=True` takes away the legacy one, and `json_response=True` takes away the request-scoped one. Use a resolver (a legacy client also needs a server that keeps the channel). Its neighbour `Method not found` is a request for a method the other side's protocol revision doesn't have.
|
||||
* `Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`.
|
||||
* `Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers.
|
||||
|
||||
@@ -44,7 +44,7 @@ from mcp.server.transport_security import TransportSecurityMiddleware, Transport
|
||||
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.message import ServerMessageMetadata, SessionMessage
|
||||
from mcp.shared.message import CloseSSEStreamCallback, ServerMessageMetadata, SessionMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -173,8 +173,12 @@ class StreamableHTTPServerTransport:
|
||||
Args:
|
||||
mcp_session_id: Optional session identifier for this connection.
|
||||
Must contain only visible ASCII characters (0x21-0x7E).
|
||||
is_json_response_enabled: If True, return JSON responses for requests
|
||||
instead of SSE streams. Default is False.
|
||||
is_json_response_enabled: If True, answer each request POST with a single
|
||||
JSON body instead of an SSE stream, which removes
|
||||
the request-scoped back-channel: a server-initiated
|
||||
request tied to the call raises `NoBackChannelError`
|
||||
and its notifications are dropped (see
|
||||
`TransportContext.can_send_request`). Default is False.
|
||||
event_store: Event store for resumability support. If provided,
|
||||
resumability will be enabled, allowing clients to
|
||||
reconnect and resume messages.
|
||||
@@ -212,6 +216,29 @@ class StreamableHTTPServerTransport:
|
||||
"""Check if this transport has been explicitly terminated."""
|
||||
return self._terminated
|
||||
|
||||
def _message_metadata(
|
||||
self,
|
||||
request: Request,
|
||||
*,
|
||||
close_sse_stream: CloseSSEStreamCallback | None = None,
|
||||
close_standalone_sse_stream: CloseSSEStreamCallback | None = None,
|
||||
on_request_unanswered: Callable[[], Awaitable[None]] | None = None,
|
||||
) -> ServerMessageMetadata:
|
||||
"""The metadata this transport frames every inbound message with.
|
||||
|
||||
The one place `can_send_request` is stamped, so no construction site can
|
||||
forget it: a JSON body carries only the response, so in JSON-response mode
|
||||
the request-scoped channel cannot carry a server-initiated request (see
|
||||
`TransportContext.can_send_request`).
|
||||
"""
|
||||
return ServerMessageMetadata(
|
||||
request_context=request,
|
||||
close_sse_stream=close_sse_stream,
|
||||
close_standalone_sse_stream=close_standalone_sse_stream,
|
||||
on_request_unanswered=on_request_unanswered,
|
||||
can_send_request=not self.is_json_response_enabled,
|
||||
)
|
||||
|
||||
def close_sse_stream(self, request_id: RequestId) -> None:
|
||||
"""Close SSE connection for a specific request without terminating the stream.
|
||||
|
||||
@@ -282,14 +309,14 @@ class StreamableHTTPServerTransport:
|
||||
async def close_standalone_stream_callback() -> None:
|
||||
self.close_standalone_sse_stream()
|
||||
|
||||
metadata = ServerMessageMetadata(
|
||||
request_context=request,
|
||||
metadata = self._message_metadata(
|
||||
request,
|
||||
close_sse_stream=close_stream_callback,
|
||||
close_standalone_sse_stream=close_standalone_stream_callback,
|
||||
on_request_unanswered=end_stream,
|
||||
)
|
||||
else:
|
||||
metadata = ServerMessageMetadata(request_context=request, on_request_unanswered=end_stream)
|
||||
metadata = self._message_metadata(request, on_request_unanswered=end_stream)
|
||||
|
||||
return SessionMessage(message, metadata=metadata)
|
||||
|
||||
@@ -557,8 +584,7 @@ class StreamableHTTPServerTransport:
|
||||
await response(scope, receive, send)
|
||||
|
||||
# Process the message after sending the response
|
||||
metadata = ServerMessageMetadata(request_context=request)
|
||||
session_message = SessionMessage(message, metadata=metadata)
|
||||
session_message = SessionMessage(message, metadata=self._message_metadata(request))
|
||||
await writer.send(session_message)
|
||||
|
||||
return
|
||||
@@ -580,50 +606,30 @@ class StreamableHTTPServerTransport:
|
||||
)
|
||||
request_stream_reader = self._request_streams[request_id][1]
|
||||
# Process the message
|
||||
metadata = ServerMessageMetadata(
|
||||
request_context=request,
|
||||
on_request_unanswered=partial(self._terminate_unanswered_request, message.id),
|
||||
metadata = self._message_metadata(
|
||||
request, on_request_unanswered=partial(self._terminate_unanswered_request, message.id)
|
||||
)
|
||||
session_message = SessionMessage(message, metadata=metadata)
|
||||
await writer.send(session_message)
|
||||
try:
|
||||
# Process messages from the request-specific stream
|
||||
# We need to collect all messages until we get a response
|
||||
response_message = None
|
||||
|
||||
# Use similar approach to SSE writer for consistency
|
||||
async for event_message in request_stream_reader: # pragma: no branch
|
||||
# If it's a response, this is what we're waiting for
|
||||
if isinstance(event_message.message, JSONRPCResponse | JSONRPCError):
|
||||
response_message = event_message.message
|
||||
break
|
||||
# For notifications and requests, keep waiting
|
||||
else: # pragma: no cover
|
||||
logger.debug(f"received: {event_message.message.method}")
|
||||
|
||||
# At this point we should have a response
|
||||
if response_message:
|
||||
# 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,
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Error processing JSON response")
|
||||
# `message_router` deposits only this request's own response
|
||||
# here: anything else scoped to the request has no wire in
|
||||
# JSON-response mode.
|
||||
event_message = await request_stream_reader.receive()
|
||||
except (anyio.EndOfStream, anyio.ClosedResourceError):
|
||||
# The stream closed with no response: the session was
|
||||
# terminated while this request was in flight.
|
||||
logger.debug(f"Session terminated with request {request_id} in flight; no response to send")
|
||||
response = self._create_error_response(
|
||||
"Error processing request",
|
||||
"Session terminated before the request completed",
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
INTERNAL_ERROR,
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
else:
|
||||
response = self._create_json_response(event_message.message)
|
||||
finally:
|
||||
await self._clean_up_memory_streams(request_id)
|
||||
await response(scope, receive, send)
|
||||
else:
|
||||
# Mint the priming event before any per-request state exists:
|
||||
# `EventStore.store_event` is user code and may raise, in which
|
||||
@@ -1024,7 +1030,14 @@ class StreamableHTTPServerTransport:
|
||||
)
|
||||
and session_message.metadata.related_request_id is not None
|
||||
):
|
||||
target_request_id = str(session_message.metadata.related_request_id)
|
||||
related_request_id = session_message.metadata.related_request_id
|
||||
if self.is_json_response_enabled:
|
||||
# A JSON body carries only the response: this message
|
||||
# has no wire form (nor a replay), so drop it before
|
||||
# storing or queueing rather than park it (#1764).
|
||||
logger.debug(f"Dropped message related to request {related_request_id} in JSON mode")
|
||||
continue
|
||||
target_request_id = str(related_request_id)
|
||||
|
||||
request_stream_id = target_request_id if target_request_id is not None else GET_STREAM_KEY
|
||||
|
||||
|
||||
@@ -53,12 +53,12 @@ class MCPError(Exception):
|
||||
|
||||
|
||||
class NoBackChannelError(MCPError):
|
||||
"""Raised when sending a server-initiated request over a transport that cannot deliver it.
|
||||
"""Raised when a server-initiated request has no channel that can deliver it.
|
||||
|
||||
Stateless HTTP and JSON-response-mode HTTP have no channel for the server to
|
||||
push requests (sampling, elicitation, roots/list) to the client. This is
|
||||
raised by `DispatchContext.send_raw_request` when `can_send_request` is
|
||||
`False`, and serializes to an `INVALID_REQUEST` error response.
|
||||
Raised by `DispatchContext.send_raw_request` when its request-scoped channel
|
||||
reports `TransportContext.can_send_request` as `False` (the cases are
|
||||
documented on that field), and by a connection's standalone channel when it
|
||||
has none; serializes to an `INVALID_REQUEST` error response.
|
||||
"""
|
||||
|
||||
def __init__(self, method: str):
|
||||
|
||||
@@ -184,8 +184,17 @@ class _JSONRPCDispatchContext(Generic[TransportT]):
|
||||
self._closed = True
|
||||
|
||||
|
||||
def _default_transport_builder(_meta: MessageMetadata) -> TransportContext:
|
||||
return TransportContext(kind="jsonrpc", can_send_request=True)
|
||||
def _default_transport_builder(metadata: MessageMetadata) -> TransportContext:
|
||||
"""The `TransportContext` for a message, honoring the transport's own verdict when it stamps one.
|
||||
|
||||
A message reads as riding a full duplex pipe (`can_send_request=True`)
|
||||
unless the transport that framed it says otherwise on the metadata it
|
||||
attached, so a transport whose response has no room for a server request
|
||||
(streamable HTTP in JSON-response mode) needs no wiring from whoever drives
|
||||
its streams.
|
||||
"""
|
||||
can_send_request = metadata.can_send_request if isinstance(metadata, ServerMessageMetadata) else True
|
||||
return TransportContext(kind="jsonrpc", can_send_request=can_send_request)
|
||||
|
||||
|
||||
def _shielded_progress(fn: ProgressFnT) -> ProgressFnT:
|
||||
|
||||
@@ -45,6 +45,11 @@ class ServerMessageMetadata:
|
||||
# (e.g. it was cancelled), for a transport whose wire must still end the
|
||||
# request even though no response is written.
|
||||
on_request_unanswered: Callable[[], Awaitable[None]] | None = None
|
||||
# The transport's verdict on whether this message's request-scoped channel
|
||||
# can deliver a server-initiated request (see
|
||||
# `TransportContext.can_send_request`); a transport that says nothing leaves
|
||||
# it True.
|
||||
can_send_request: bool = True
|
||||
|
||||
|
||||
MessageMetadata = ClientMessageMetadata | ServerMessageMetadata | None
|
||||
|
||||
@@ -23,11 +23,18 @@ class TransportContext:
|
||||
"""Short identifier for the transport (e.g. `"stdio"`, `"streamable-http"`)."""
|
||||
|
||||
can_send_request: bool
|
||||
"""Whether the transport can deliver server-initiated requests to the peer.
|
||||
"""Whether this message's request-scoped channel can deliver a server-initiated request.
|
||||
|
||||
`False` for stateless HTTP and HTTP with JSON response mode; `True` for
|
||||
stdio, SSE, and stateful streamable HTTP. When `False`,
|
||||
`DispatchContext.send_raw_request` raises `NoBackChannelError`.
|
||||
`False` for any of three reasons: the response has no room (streamable
|
||||
HTTP in JSON-response mode and the 2026-07-28 single-exchange entry answer
|
||||
with one JSON-RPC reply), the client's reply has nowhere to land (stateless
|
||||
HTTP, no session), or the protocol forbids server-initiated requests (any
|
||||
2026-07-28 connection, whose dispatch masks the flag off). `True` for a
|
||||
plain duplex pipe (stdio, SSE) and stateful streamable HTTP with SSE
|
||||
responses, all pre-2026-07-28. When `False`,
|
||||
`DispatchContext.send_raw_request` raises `NoBackChannelError` instead of
|
||||
parking a waiter no reply can reach. Says nothing about the connection's
|
||||
standalone channel, which refuses separately.
|
||||
"""
|
||||
|
||||
headers: Mapping[str, str] | None = None
|
||||
|
||||
@@ -2608,6 +2608,16 @@ REQUIREMENTS: dict[str, Requirement] = {
|
||||
"overtake anything already queued for the request."
|
||||
),
|
||||
),
|
||||
"transport:streamable-http:json-response-restrictions": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"In JSON-response mode a handler's request-scoped server-initiated request fails fast with an "
|
||||
"INVALID_REQUEST protocol error and request-scoped notifications are not delivered, because the "
|
||||
"single JSON body carries only the response; the connection's standalone stream is unaffected."
|
||||
),
|
||||
transports=("streamable-http",),
|
||||
note="Only observable over streamable HTTP: JSON-response mode is an HTTP framing option.",
|
||||
),
|
||||
"transport:streamable-http:stateless": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/basic/transports#streamable-http",
|
||||
behavior=(
|
||||
|
||||
@@ -64,7 +64,8 @@ def _smoke_server() -> MCPServer:
|
||||
async def ask(ctx: Context) -> str:
|
||||
"""Elicit a confirmation from the client and report the outcome."""
|
||||
answer = await ctx.elicit("Proceed?", Confirmation)
|
||||
# In stateless mode the elicit raises before this point: there is no session to call back through.
|
||||
# In stateless and JSON-response modes the elicit raises before this point: there is no
|
||||
# request-scoped channel to call back through.
|
||||
assert isinstance(answer, AcceptedElicitation)
|
||||
return f"confirmed={answer.data.confirmed}"
|
||||
|
||||
@@ -120,6 +121,48 @@ async def test_stateless_streamable_http_rejects_server_initiated_requests() ->
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
|
||||
|
||||
@requirement("transport:streamable-http:json-response-restrictions")
|
||||
async def test_json_response_streamable_http_rejects_request_scoped_server_requests() -> None:
|
||||
"""A handler that calls back to the client mid-request fails fast when the server answers with
|
||||
JSON: the one response body cannot carry the nested `elicitation/create`, so the request-scoped
|
||||
channel raises `NoBackChannelError` (a top-level `MCPError`) instead of parking a waiter no reply
|
||||
could ever reach. Bounded, because before the fix this call hung until it timed out."""
|
||||
async with connect_over_streamable_http(_smoke_server(), json_response=True) as client:
|
||||
with anyio.fail_after(5), pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("ask", {})
|
||||
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
|
||||
|
||||
@requirement("transport:streamable-http:json-response-restrictions")
|
||||
@requirement("transport:streamable-http:unrelated-messages")
|
||||
@requirement("hosting:http:standalone-sse")
|
||||
async def test_json_response_streamable_http_delivers_only_unrelated_notifications() -> None:
|
||||
"""In JSON-response mode the call's own log notification has no stream to ride and never
|
||||
reaches the client, while the tool result comes back as the JSON body and the unrelated
|
||||
resource-updated notification arrives on the standalone stream. The handler writes both
|
||||
notifications before returning, so once the result and the unrelated message are in, no
|
||||
request-scoped message can still be in flight."""
|
||||
received: list[IncomingMessage] = []
|
||||
server_message_seen = anyio.Event()
|
||||
|
||||
async def collect(message: IncomingMessage) -> None:
|
||||
received.append(message)
|
||||
server_message_seen.set()
|
||||
|
||||
async with connect_over_streamable_http(_smoke_server(), json_response=True, message_handler=collect) as client:
|
||||
with anyio.fail_after(5):
|
||||
result = await client.call_tool("announce", {})
|
||||
await server_message_seen.wait()
|
||||
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(text="announced")], structured_content={"result": "announced"})
|
||||
)
|
||||
assert received == snapshot(
|
||||
[ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri="file:///watched.txt"))]
|
||||
)
|
||||
|
||||
|
||||
@requirement("transport:streamable-http:notifications")
|
||||
@requirement("transport:streamable-http:unrelated-messages")
|
||||
@requirement("hosting:http:standalone-sse")
|
||||
|
||||
@@ -25,6 +25,25 @@ class _PrimingFailingStore(EventStore):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _AsgiPost:
|
||||
"""A one-shot POST driven straight at `handle_request`, capturing what the transport sends."""
|
||||
|
||||
def __init__(self, body: bytes, headers: list[tuple[bytes, bytes]]) -> None:
|
||||
self.scope: Scope = {"type": "http", "method": "POST", "path": "/", "query_string": b"", "headers": headers}
|
||||
self.sent: list[Message] = []
|
||||
self._body = body
|
||||
self._body_sent = False
|
||||
|
||||
async def receive(self) -> Message:
|
||||
if not self._body_sent:
|
||||
self._body_sent = True
|
||||
return {"type": "http.request", "body": self._body, "more_body": False}
|
||||
raise NotImplementedError
|
||||
|
||||
async def send(self, message: Message) -> None:
|
||||
self.sent.append(message)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_router_unconsumed_request_stream_does_not_block_siblings() -> None:
|
||||
"""A response whose `sse_writer` is not yet receiving must not park the router (#1764).
|
||||
@@ -73,35 +92,18 @@ async def test_priming_store_failure_leaves_no_per_request_state() -> None:
|
||||
event_store=_PrimingFailingStore(),
|
||||
)
|
||||
|
||||
body = b'{"jsonrpc":"2.0","id":"req-1","method":"tools/list","params":{}}'
|
||||
scope: Scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"query_string": b"",
|
||||
"headers": [
|
||||
post = _AsgiPost(
|
||||
b'{"jsonrpc":"2.0","id":"req-1","method":"tools/list","params":{}}',
|
||||
[
|
||||
(b"accept", b"application/json, text/event-stream"),
|
||||
(b"content-type", b"application/json"),
|
||||
(b"mcp-protocol-version", b"2025-11-25"),
|
||||
],
|
||||
}
|
||||
body_sent = False
|
||||
|
||||
async def receive() -> Message:
|
||||
nonlocal body_sent
|
||||
if not body_sent:
|
||||
body_sent = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
raise NotImplementedError
|
||||
|
||||
sent: list[Message] = []
|
||||
|
||||
async def asgi_send(message: Message) -> None:
|
||||
sent.append(message)
|
||||
)
|
||||
|
||||
async with transport.connect() as (read_stream, _write_stream):
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(transport.handle_request, scope, receive, asgi_send)
|
||||
tg.start_soon(transport.handle_request, post.scope, post.receive, post.send)
|
||||
with anyio.fail_after(5):
|
||||
forwarded = await read_stream.receive()
|
||||
assert isinstance(forwarded, Exception)
|
||||
@@ -110,7 +112,32 @@ async def test_priming_store_failure_leaves_no_per_request_state() -> None:
|
||||
assert transport._request_streams == {}
|
||||
assert transport._sse_stream_writers == {}
|
||||
|
||||
assert sent[0]["type"] == "http.response.start"
|
||||
assert sent[0]["status"] == 500
|
||||
body = b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body")
|
||||
assert post.sent[0]["type"] == "http.response.start"
|
||||
assert post.sent[0]["status"] == 500
|
||||
body = b"".join(m.get("body", b"") for m in post.sent if m["type"] == "http.response.body")
|
||||
assert b"backend unavailable" not in body
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_json_post_answers_500_when_session_terminates_mid_request() -> None:
|
||||
"""A JSON-mode POST whose session is torn down before the handler answers gets a 500, not a stall."""
|
||||
transport = StreamableHTTPServerTransport(mcp_session_id="sid", is_json_response_enabled=True)
|
||||
post = _AsgiPost(
|
||||
b'{"jsonrpc":"2.0","id":"req-1","method":"tools/list","params":{}}',
|
||||
[
|
||||
(b"accept", b"application/json"),
|
||||
(b"content-type", b"application/json"),
|
||||
(b"mcp-session-id", b"sid"),
|
||||
(b"mcp-protocol-version", b"2025-11-25"),
|
||||
],
|
||||
)
|
||||
|
||||
async with transport.connect() as (read_stream, _write_stream):
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(transport.handle_request, post.scope, post.receive, post.send)
|
||||
with anyio.fail_after(5):
|
||||
await read_stream.receive() # the request reached the session; the POST is parked
|
||||
await transport.terminate()
|
||||
|
||||
assert post.sent[0]["type"] == "http.response.start"
|
||||
assert post.sent[0]["status"] == 500
|
||||
|
||||
@@ -1329,6 +1329,45 @@ async def test_ctx_message_metadata_carries_inbound_request_metadata():
|
||||
assert seen[0] is metadata # the exact object, passed through verbatim
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transport_stamped_can_send_request_makes_the_request_channel_refuse():
|
||||
"""A transport that marks a message `can_send_request=False` on its metadata gets a request-scoped
|
||||
channel that raises `NoBackChannelError` immediately - the default builder reads the transport's
|
||||
verdict off the message, so no driver has to wire it."""
|
||||
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)
|
||||
outcomes: list[bool | str] = []
|
||||
|
||||
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
outcomes.append(ctx.can_send_request)
|
||||
try:
|
||||
await ctx.send_raw_request("elicitation/create", {})
|
||||
except NoBackChannelError as exc:
|
||||
outcomes.append(exc.method)
|
||||
return {}
|
||||
|
||||
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
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="tools/call", params=None),
|
||||
metadata=ServerMessageMetadata(can_send_request=False),
|
||||
)
|
||||
)
|
||||
with anyio.fail_after(5):
|
||||
await s2c_recv.receive() # response sent => the handler has run
|
||||
tg.cancel_scope.cancel()
|
||||
finally:
|
||||
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
|
||||
s.close()
|
||||
assert outcomes == [False, "elicitation/create"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ctx_message_metadata_carries_inbound_notification_metadata():
|
||||
"""Notifications get the same metadata pass-through as requests."""
|
||||
|
||||
Reference in New Issue
Block a user