chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
"""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"})
|
||||
)
|
||||
Reference in New Issue
Block a user