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) Waiting to run
130 lines
5.4 KiB
Python
130 lines
5.4 KiB
Python
"""Transport-level composed flows: multi-client isolation, reconnection, and dual-transport hosting.
|
|
|
|
These scenarios are about how the transport layer holds together across more than one connection
|
|
or more than one transport, so they connect real `Client`s against one mounted server rather than
|
|
running over the matrix.
|
|
"""
|
|
|
|
import anyio
|
|
import httpx
|
|
import pytest
|
|
from inline_snapshot import snapshot
|
|
from mcp_types import CallToolResult, LoggingMessageNotificationParams, TextContent
|
|
|
|
from mcp.client.session import LoggingFnT
|
|
from mcp.server.mcpserver import Context, MCPServer
|
|
from tests.interaction._connect import client_via_http, connect_over_sse, mounted_app
|
|
from tests.interaction._requirements import requirement
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
@requirement("flow:multi-client:stateful-isolation")
|
|
async def test_concurrent_clients_on_one_stateful_server_receive_only_their_own_notifications() -> None:
|
|
"""Two clients on one stateful manager each receive only the notifications their own request produced.
|
|
|
|
Complements `test_terminating_one_session_leaves_others_working` (which proves session
|
|
independence under termination) with the notification-isolation dimension: a notification
|
|
emitted by one session's handler does not leak to another session's client.
|
|
"""
|
|
mcp = MCPServer("multi")
|
|
|
|
@mcp.tool()
|
|
async def announce(label: str, ctx: Context) -> str:
|
|
"""Emit one info-level log carrying the caller's label, then return it."""
|
|
await ctx.info(label) # pyright: ignore[reportDeprecated]
|
|
return label
|
|
|
|
received_a: list[object] = []
|
|
received_b: list[object] = []
|
|
|
|
async def collect_a(params: LoggingMessageNotificationParams) -> None:
|
|
received_a.append(params.data)
|
|
|
|
async def collect_b(params: LoggingMessageNotificationParams) -> None:
|
|
received_b.append(params.data)
|
|
|
|
async with mounted_app(mcp) as (http, _):
|
|
with anyio.fail_after(5):
|
|
async with anyio.create_task_group() as tg: # pragma: no branch
|
|
|
|
async def call(label: str, collect: LoggingFnT) -> None:
|
|
async with client_via_http(http, logging_callback=collect) as client:
|
|
await client.call_tool("announce", {"label": label})
|
|
|
|
tg.start_soon(call, "a", collect_a)
|
|
tg.start_soon(call, "b", collect_b)
|
|
|
|
assert received_a == ["a"]
|
|
assert received_b == ["b"]
|
|
|
|
|
|
@requirement("flow:session:terminate-then-reconnect")
|
|
async def test_a_fresh_connection_after_termination_obtains_a_new_session_and_operates() -> None:
|
|
"""After a client terminates, a fresh connection to the same manager gets a distinct session.
|
|
|
|
Steps: (1) connect a client and call list_tools, (2) the client exits (its DELETE fires),
|
|
(3) connect a second client to the same mounted app, (4) the second client's call_tool
|
|
succeeds and the recorded session ids show two distinct sessions were issued.
|
|
"""
|
|
mcp = MCPServer("reconnectable")
|
|
|
|
@mcp.tool()
|
|
def echo(text: str) -> str:
|
|
"""Return the input unchanged."""
|
|
return text
|
|
|
|
session_ids: list[str] = []
|
|
|
|
async def record(request: httpx.Request) -> None:
|
|
session_id = request.headers.get("mcp-session-id")
|
|
if session_id is not None:
|
|
session_ids.append(session_id)
|
|
|
|
async with mounted_app(mcp, on_request=record) as (http, _):
|
|
async with client_via_http(http) as first:
|
|
first_result = await first.list_tools()
|
|
async with client_via_http(http) as second:
|
|
second_result = await second.call_tool("echo", {"text": "again"})
|
|
|
|
assert {tool.name for tool in first_result.tools} == {"echo"}
|
|
assert second_result == snapshot(
|
|
CallToolResult(content=[TextContent(text="again")], structured_content={"result": "again"})
|
|
)
|
|
distinct = set(session_ids)
|
|
assert len(distinct) == 2, f"expected two distinct session ids across the two connections, saw {distinct}"
|
|
|
|
|
|
@requirement("flow:compat:dual-transport-server")
|
|
async def test_one_server_serves_streamable_http_and_sse_clients_concurrently() -> None:
|
|
"""One MCPServer instance serves a streamable-HTTP client and a legacy-SSE client at the same time.
|
|
|
|
The two transports have independent connection management (the streamable-HTTP session manager
|
|
versus a per-connection SSE handler), but both dispatch into the same server's request
|
|
handlers. The test connects one client over each transport against the same instance and
|
|
proves both reach the same tool. Uses MCPServer because the low-level Server has no SSE
|
|
convenience; the entry is about hosting composition, not the low-level API.
|
|
"""
|
|
mcp = MCPServer("dual")
|
|
|
|
@mcp.tool()
|
|
def echo(text: str) -> str:
|
|
"""Return the input unchanged."""
|
|
return text
|
|
|
|
async with (
|
|
mounted_app(mcp) as (http, _),
|
|
connect_over_sse(mcp) as sse_client,
|
|
client_via_http(http) as shttp_client,
|
|
):
|
|
with anyio.fail_after(5):
|
|
shttp_result = await shttp_client.call_tool("echo", {"text": "via http"})
|
|
sse_result = await sse_client.call_tool("echo", {"text": "via sse"})
|
|
|
|
assert shttp_result == snapshot(
|
|
CallToolResult(content=[TextContent(text="via http")], structured_content={"result": "via http"})
|
|
)
|
|
assert sse_result == snapshot(
|
|
CallToolResult(content=[TextContent(text="via sse")], structured_content={"result": "via sse"})
|
|
)
|