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
commit 528e366558
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 259 additions and 93 deletions

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

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):

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:

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

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