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

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