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
2 changed files with 259 additions and 93 deletions

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=(

View file

@ -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")