Fail fast on server-to-client requests in JSON-response mode instead of hanging (#3195)
This commit is contained in:
parent
27f5cc7a46
commit
528e366558
2 changed files with 259 additions and 93 deletions
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue