Fail fast on server-to-client requests in JSON-response mode instead of hanging (#3195)

This commit is contained in:
Max
2026-07-28 11:04:51 +01:00
committed by GitHub
parent 27f5cc7a46
commit 528e366558
13 changed files with 258 additions and 92 deletions
+8 -5
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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.
+9 -6
View File
@@ -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.
+56 -43
View File
@@ -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
+5 -5
View File
@@ -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):
+11 -2
View File
@@ -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:
+5
View File
@@ -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
+11 -4
View File
@@ -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
+10
View File
@@ -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")
+52 -25
View File
@@ -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
+39
View File
@@ -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."""