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) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
+402
View File
@@ -0,0 +1,402 @@
"""Transport-parametrized connection factories for the interaction suite.
The `connect` fixture (see conftest.py) hands tests one of these factories so the same test body
runs over each transport without naming any of them: the factory is a drop-in replacement for
constructing `Client(server, ...)` and yields the connected client. The HTTP factories drive the
server's real Starlette app through the in-process streaming bridge, so the full transport layer
(session ids, SSE encoding, session management) runs with no sockets, threads, or subprocesses.
"""
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from functools import partial
from typing import Any, Protocol
import httpx
from httpx_sse import ServerSentEvent, aconnect_sse
from mcp_types import (
ClientCapabilities,
Implementation,
InitializeRequestParams,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
jsonrpc_message_adapter,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Mount, Route
from mcp.client.client import Client
from mcp.client.extension import ClientExtension
from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver import MCPServer
from mcp.server.sse import SseServerTransport
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from tests.interaction.transports._bridge import StreamingASGITransport
# The in-process app is mounted at this origin purely so URLs are well-formed; nothing listens here.
BASE_URL = "http://127.0.0.1:8000"
# DNS-rebinding protection validates Host/Origin headers against a real network attack that cannot
# exist for an in-process ASGI app, so the in-process factories disable it; tests that exercise the
# protection itself pass explicit settings (or transport_security=None to get the localhost
# auto-enable behaviour).
NO_DNS_REBINDING_PROTECTION = TransportSecuritySettings(enable_dns_rebinding_protection=False)
class Connect(Protocol):
"""Connect a Client to a server over the transport selected by the `connect` fixture.
Accepts the same keyword arguments as `Client` and yields the connected client.
"""
def __call__(
self,
server: Server | MCPServer,
*,
read_timeout_seconds: float | None = None,
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AbstractAsyncContextManager[Client]: ...
@asynccontextmanager
async def connect_in_memory(
server: Server | MCPServer,
*,
read_timeout_seconds: float | None = None,
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server over the in-memory transport.
When `spec_version` is a modern (2026-07-28+) revision the Client is opened with
`mode=<version>`, which drives the server through the DirectDispatcher peer-pair
(per-request `serve_one`, no initialize handshake) instead of the legacy stream pair.
"""
async with Client(
server,
mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy",
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
extensions=extensions,
) as client:
yield client
@asynccontextmanager
async def connect_over_streamable_http(
server: Server | MCPServer,
*,
stateless_http: bool = False,
json_response: bool = False,
event_store: EventStore | None = None,
retry_interval: int | None = None,
read_timeout_seconds: float | None = None,
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server's streamable HTTP app, entirely in process.
With the defaults this is the matrix leg (stateful sessions, SSE responses); the stateless
matrix arm binds `stateless_http=True` (see `connect_over_streamable_http_stateless`);
transport-specific tests pass `json_response` to select the other server mode, and the
resumability tests pass an `event_store` (with `retry_interval=0` so the client's
reconnection wait is a no-op).
When `spec_version` is a modern (2026-07-28+) revision the Client is opened with
`mode=<version>`, which adopts a synthesized DiscoverResult instead of running the legacy
initialize handshake.
"""
app = server.streamable_http_app(
stateless_http=stateless_http,
json_response=json_response,
event_store=event_store,
retry_interval=retry_interval,
transport_security=NO_DNS_REBINDING_PROTECTION,
)
async with (
server.session_manager.run(),
httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client),
mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy",
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
extensions=extensions,
) as client,
):
yield client
connect_over_streamable_http_stateless: Connect = partial(connect_over_streamable_http, stateless_http=True)
"""The streamable-http matrix arm with the server in stateless mode (fresh transport per request,
no session id, no standalone GET stream). The same shared Server instance backs every request --
stateless mode does not require a server factory."""
@asynccontextmanager
async def mounted_app(
server: Server | MCPServer,
*,
stateless_http: bool = False,
json_response: bool = False,
event_store: EventStore | None = None,
retry_interval: int | None = None,
transport_security: TransportSecuritySettings | None = NO_DNS_REBINDING_PROTECTION,
on_request: Callable[[httpx.Request], Awaitable[None]] | None = None,
on_response: Callable[[httpx.Response], Awaitable[None]] | None = None,
headers: dict[str, str] | None = None,
auth: AuthSettings | None = None,
token_verifier: TokenVerifier | None = None,
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
) -> AsyncIterator[tuple[httpx.AsyncClient, StreamableHTTPSessionManager]]:
"""Mount the server's streamable HTTP app on the in-process bridge and yield an httpx client.
Yields the httpx client (rooted at the in-process origin) and the live session manager. Tests
use this in two ways: for raw-httpx assertions (status codes, headers, SSE bytes) the test
speaks HTTP through the yielded client directly; for client-driven assertions the test wraps
that client in `client_via_http(http)`, which lets several `Client`s share the one mounted
session manager. `on_request` observes every outgoing HTTP request before it leaves the
yielded client; `on_response` observes every HTTP response as its headers arrive (response
bodies of SSE streams are not yet read at that point).
DNS-rebinding protection is disabled by default; pass explicit settings (or `None` for the
localhost auto-enable behaviour) to test the protection itself.
"""
lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server
app = lowlevel.streamable_http_app(
stateless_http=stateless_http,
json_response=json_response,
event_store=event_store,
retry_interval=retry_interval,
transport_security=transport_security,
auth=auth,
token_verifier=token_verifier,
auth_server_provider=auth_server_provider,
)
event_hooks: dict[str, list[Callable[..., Awaitable[None]]]] = {}
if on_request is not None:
event_hooks["request"] = [on_request]
if on_response is not None:
event_hooks["response"] = [on_response]
async with (
server.session_manager.run(),
httpx.AsyncClient(
transport=StreamingASGITransport(app), base_url=BASE_URL, event_hooks=event_hooks, headers=headers
) as http_client,
):
yield http_client, server.session_manager
@asynccontextmanager
async def client_via_http(
http_client: httpx.AsyncClient,
*,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
elicitation_callback: ElicitationFnT | None = None,
) -> AsyncIterator[Client]:
"""Connect a `Client` over an already-mounted streamable HTTP app.
Use with `mounted_app(...)` so several `Client`s share the one session manager, or so a
client-driven assertion can sit alongside raw-httpx assertions in the same test. The
underlying `httpx.AsyncClient` is left open when the `Client` exits.
"""
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
async with Client(
transport,
# Callers assert the legacy HTTP wire shape (session-id header, standalone GET stream,
# closing DELETE); the modern flow is sessionless and would silently change the subject.
mode="legacy",
logging_callback=logging_callback,
message_handler=message_handler,
elicitation_callback=elicitation_callback,
) as client:
yield client
def parse_sse_messages(events: Iterable[ServerSentEvent]) -> list[JSONRPCMessage]:
"""Decode SSE events into JSON-RPC messages, skipping priming events that carry no data."""
return [jsonrpc_message_adapter.validate_json(event.data) for event in events if event.data]
async def post_jsonrpc(
http: httpx.AsyncClient, body: dict[str, object], *, session_id: str | None = None
) -> tuple[httpx.Response, list[JSONRPCMessage]]:
"""POST a JSON-RPC body and read its SSE response stream to completion.
Returns the HTTP response (for header/status assertions) and the parsed JSON-RPC messages
that arrived on the response's SSE stream. Only meaningful for requests the server answers
with `text/event-stream`; for error responses or 202 notification acknowledgements, use
`httpx.AsyncClient.post` directly and assert on the response.
"""
async with aconnect_sse(http, "POST", "/mcp", json=body, headers=base_headers(session_id=session_id)) as source:
events = [event async for event in source.aiter_sse()]
return source.response, parse_sse_messages(events)
def base_headers(*, session_id: str | None = None) -> dict[str, str]:
"""Standard request headers for raw-httpx streamable-HTTP tests.
Every well-formed request carries these (Accept covering both response representations,
Content-Type for POST bodies, MCP-Protocol-Version at the newest handshake revision, and the session
ID once one exists), so a test that wants to assert a specific rejection only varies the one
header under test.
"""
headers = {
"accept": "application/json, text/event-stream",
"content-type": "application/json",
"mcp-protocol-version": LATEST_HANDSHAKE_VERSION,
}
if session_id is not None:
headers["mcp-session-id"] = session_id
return headers
def initialize_body(request_id: int = 1) -> dict[str, object]:
"""A wire-level initialize JSON-RPC request body, exactly as an SDK client would send it."""
params = InitializeRequestParams(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ClientCapabilities(),
client_info=Implementation(name="raw", version="0.0.0"),
)
return JSONRPCRequest(
jsonrpc="2.0", id=request_id, method="initialize", params=params.model_dump(by_alias=True, exclude_none=True)
).model_dump(by_alias=True, exclude_none=True)
async def initialize_via_http(http: httpx.AsyncClient) -> str:
"""Perform the initialize handshake over a raw `httpx.AsyncClient` and return the session ID.
Validates the SSE response and sends the `notifications/initialized` follow-up, so the server
is fully ready for subsequent feature requests when this returns.
"""
async with aconnect_sse(http, "POST", "/mcp", json=initialize_body(), headers=base_headers()) as source:
assert source.response.status_code == 200
# An event-store-backed server opens the stream with a priming event (empty data); skip it.
events = [event async for event in source.aiter_sse() if event.data]
assert len(events) == 1
assert JSONRPCResponse.model_validate_json(events[0].data).id == 1
session_id = source.response.headers["mcp-session-id"]
initialized = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
headers=base_headers(session_id=session_id),
)
assert initialized.status_code == 202
return session_id
def build_sse_app(server: Server | MCPServer) -> tuple[Starlette, SseServerTransport]:
"""Mount a server on a Starlette app exposing the legacy SSE transport at /sse and /messages/.
`MCPServer.sse_app()` exists but does not expose the underlying `SseServerTransport`, which
the SSE-specific tests need; building the app explicitly here gives both server flavours the
same routing while keeping that handle.
"""
sse = SseServerTransport(
"/messages/", security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False)
)
lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server
async def handle_sse(request: Request) -> Response:
async with sse.connect_sse(request.scope, request.receive, request._send) as (read, write):
await lowlevel.run(read, write, lowlevel.create_initialization_options())
return Response()
app = Starlette(
routes=[
Route("/sse", endpoint=handle_sse, methods=["GET"]),
Mount("/messages/", app=sse.handle_post_message),
],
)
return app, sse
@asynccontextmanager
async def connect_over_sse(
server: Server | MCPServer,
*,
read_timeout_seconds: float | None = None,
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server's legacy SSE transport, entirely in process."""
app, _ = build_sse_app(server)
def httpx_client_factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
# The SSE server transport's connect_sse runs the entire MCP session inside the GET
# request and only releases its streams after that request observes a disconnect, so the
# bridge must let the application drain rather than cancelling at close.
return httpx.AsyncClient(
transport=StreamingASGITransport(app, cancel_on_close=False),
base_url=BASE_URL,
headers=headers,
timeout=timeout,
auth=auth,
)
transport = sse_client(f"{BASE_URL}/sse", httpx_client_factory=httpx_client_factory)
async with Client(
transport,
# SSE is a legacy-only transport; the modern path has no SSE story.
mode="legacy",
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
extensions=extensions,
) as client:
yield client