-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy path_connect.py
More file actions
414 lines (366 loc) · 18.2 KB
/
Copy path_connect.py
File metadata and controls
414 lines (366 loc) · 18.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
"""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 httpx2
from httpx2 import ServerSentEvent
from mcp_types import (
ClientCapabilities,
Implementation,
InitializeRequestParams,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
LoggingLevel,
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,
log_level: LoggingLevel | 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,
log_level: LoggingLevel | 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,
log_level=log_level,
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,
log_level: LoggingLevel | 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(),
httpx2.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,
log_level=log_level,
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[[httpx2.Request], Awaitable[None]] | None = None,
on_response: Callable[[httpx2.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[httpx2.AsyncClient, StreamableHTTPSessionManager]]:
"""Mount the server's streamable HTTP app on the in-process bridge and yield an httpx2 client.
Yields the httpx2 client (rooted at the in-process origin) and the live session manager. Tests
use this in two ways: for raw-httpx2 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(),
httpx2.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: httpx2.AsyncClient,
*,
logging_callback: LoggingFnT | None = None,
log_level: LoggingLevel | 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-httpx2 assertions in the same test. The
underlying `httpx2.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,
log_level=log_level,
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: httpx2.AsyncClient, body: dict[str, object], *, session_id: str | None = None
) -> tuple[httpx2.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
`httpx2.AsyncClient.post` directly and assert on the response.
"""
async with http.sse("/mcp", method="POST", json=body, headers=base_headers(session_id=session_id)) as source:
events = [event async for event in source]
return source.response, parse_sse_messages(events)
def base_headers(*, session_id: str | None = None) -> dict[str, str]:
"""Standard request headers for raw-httpx2 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: httpx2.AsyncClient) -> str:
"""Perform the initialize handshake over a raw `httpx2.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 http.sse("/mcp", method="POST", 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 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,
log_level: LoggingLevel | 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: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.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 httpx2.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,
# A policy lock, not a capability one: the dual-era server loop behind build_sse_app
# would negotiate 2026 if probed, but SSE is the deprecated legacy transport and its
# clients run the handshake era by design.
mode="legacy",
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
log_level=log_level,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
extensions=extensions,
) as client:
yield client