Files
modelcontextprotocol--pytho…/tests/interaction/transports/test_streamable_http.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

171 lines
7.7 KiB
Python

"""Behaviour specific to the streamable HTTP transport, exercised entirely in process.
Transport-agnostic behaviour is covered by the `connect`-fixture matrix, which runs the rest of
the suite over this transport as well; this file only pins what cannot be observed in memory: the
server's stateless and JSON-response modes, the standalone GET stream, and the full-duplex
server-initiated exchange on a still-open call. Every test drives the server's real Starlette app
through the suite's streaming ASGI bridge — no sockets, threads, or subprocesses.
"""
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_REQUEST,
CallToolResult,
ElicitRequestParams,
ElicitResult,
LoggingMessageNotification,
LoggingMessageNotificationParams,
ResourceUpdatedNotification,
ResourceUpdatedNotificationParams,
TextContent,
)
from pydantic import BaseModel
from mcp.client import ClientRequestContext
from mcp.server.elicitation import AcceptedElicitation
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.exceptions import MCPError
from tests.interaction._connect import connect_over_streamable_http
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _smoke_server() -> MCPServer:
"""A server exercising each message shape the transport-specific tests need."""
mcp = MCPServer("smoke", instructions="Talk to the smoke server.")
@mcp.tool()
def echo(text: str) -> str:
"""Echo the text back."""
return text
class Confirmation(BaseModel):
confirmed: bool
@mcp.tool()
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.
assert isinstance(answer, AcceptedElicitation)
return f"confirmed={answer.data.confirmed}"
@mcp.tool()
async def announce(ctx: Context) -> str:
"""Send one notification related to this request and one that is not."""
await ctx.info("about to announce") # pyright: ignore[reportDeprecated]
await ctx.session.send_resource_updated("file:///watched.txt")
return "announced"
return mcp
@requirement("transport:streamable-http:json-response")
@requirement("client-transport:http:json-response-parsed")
async def test_tool_call_over_streamable_http_with_json_responses() -> None:
"""The round trip works when the server answers with a single JSON body instead of an SSE stream."""
async with connect_over_streamable_http(_smoke_server(), json_response=True) as client:
assert client.server_info.name == "smoke"
result = await client.call_tool("echo", {"text": "as json"})
assert result == snapshot(
CallToolResult(content=[TextContent(text="as json")], structured_content={"result": "as json"})
)
@requirement("transport:streamable-http:stateless")
async def test_tool_calls_over_stateless_streamable_http() -> None:
"""Consecutive requests each succeed against a stateless server with no session to share."""
async with connect_over_streamable_http(_smoke_server(), stateless_http=True) as client:
first = await client.call_tool("echo", {"text": "first"})
second = await client.call_tool("echo", {"text": "second"})
assert first == snapshot(
CallToolResult(content=[TextContent(text="first")], structured_content={"result": "first"})
)
assert second == snapshot(
CallToolResult(content=[TextContent(text="second")], structured_content={"result": "second"})
)
@requirement("transport:streamable-http:stateless-restrictions")
async def test_stateless_streamable_http_rejects_server_initiated_requests() -> None:
"""A handler that tries to call back to the client in stateless mode fails: there is no
back-channel for server-initiated requests. The resulting ``NoBackChannelError`` is an
``MCPError``, so it surfaces as a top-level JSON-RPC error rather than an
``isError`` result."""
async with connect_over_streamable_http(_smoke_server(), stateless_http=True) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("ask", {})
assert exc_info.value.error.code == INVALID_REQUEST
@requirement("transport:streamable-http:notifications")
@requirement("transport:streamable-http:unrelated-messages")
@requirement("hosting:http:standalone-sse")
async def test_unrelated_server_messages_arrive_on_the_standalone_stream() -> None:
"""A server message with no related request reaches the client through the standalone GET stream.
The log notification is related to the tool call and travels on that call's own SSE stream;
the resource-updated notification is not related to any request, so the only way it can reach
the client is the standalone stream the client opens after initialization. Delivery order
across the two streams is not guaranteed, so the unrelated message is awaited rather than
assumed to beat the tool result.
"""
received: list[IncomingMessage] = []
resource_update_seen = anyio.Event()
async def collect(message: IncomingMessage) -> None:
received.append(message)
if isinstance(message, ResourceUpdatedNotification):
resource_update_seen.set()
async with connect_over_streamable_http(_smoke_server(), message_handler=collect) as client:
result = await client.call_tool("announce", {})
with anyio.fail_after(5):
await resource_update_seen.wait()
assert result == snapshot(
CallToolResult(content=[TextContent(text="announced")], structured_content={"result": "announced"})
)
# The related log notification rides the call's stream; the unrelated resource-updated
# notification rides the standalone stream. Both arrive, nothing else does.
assert [message for message in received if isinstance(message, LoggingMessageNotification)] == snapshot(
[LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="about to announce"))]
)
assert [message for message in received if isinstance(message, ResourceUpdatedNotification)] == snapshot(
[ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri="file:///watched.txt"))]
)
assert len(received) == 2
@requirement("transport:streamable-http:stateful")
@requirement("transport:streamable-http:server-to-client")
async def test_server_initiated_elicitation_round_trips_during_a_tool_call() -> None:
"""An elicitation issued mid-call reaches the client and its answer reaches the handler over stateful HTTP.
The elicitation request travels on the still-open SSE response of the tool call that triggered
it, and the client's answer arrives as a separate POST -- the full-duplex exchange the
streamable HTTP transport exists to provide.
"""
asked: list[ElicitRequestParams] = []
async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
asked.append(params)
return ElicitResult(action="accept", content={"confirmed": True})
async with connect_over_streamable_http(_smoke_server(), elicitation_callback=answer) as client:
# Bounded because a harness regression here historically meant deadlock, not failure.
with anyio.fail_after(5):
result = await client.call_tool("ask", {})
assert result == snapshot(
CallToolResult(content=[TextContent(text="confirmed=True")], structured_content={"result": "confirmed=True"})
)
assert [params.message for params in asked] == snapshot(["Proceed?"])