Skip to content

Commit 7a936a2

Browse files
committed
backport: harness H4 — build_streamable_http_app (no-auth), connect_over_streamable_http, client_via_http; gate: 3 smoke legs pass
Also: - pyproject: add [tool.inline-snapshot] default-flags=["disable"] (matches main; without it -n0 runs use inline-snapshot active mode whose pydantic comparison mishandles extra="allow" models) - conftest: suppress PytestUnraisableExceptionWarning/ResourceWarning — v1 streamable-HTTP server transport leaks memory streams on teardown (e.g. _handle_get_request only closes sse_stream_reader on the exception path); fixes are src/-side on main, out of scope here - test_ping.py: convert to v1 decorator pattern using session-access pattern B (request_ctx contextvar) per the file→pattern assignment Gate: tests/interaction/lowlevel/test_ping.py 6/6 pass (both tests × 3 transport legs).
1 parent f7daf85 commit 7a936a2

4 files changed

Lines changed: 105 additions & 32 deletions

File tree

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@ members = ["examples/clients/*", "examples/servers/*", "examples/snippets"]
155155
[tool.uv.sources]
156156
mcp = { workspace = true }
157157

158+
[tool.inline-snapshot]
159+
# `snapshot(x)` becomes the identity (plain `==`); regenerate with `--inline-snapshot=fix`.
160+
default-flags = ["disable"]
161+
format-command = "ruff format --stdin-filename {filename}"
162+
158163
[tool.pytest.ini_options]
159164
log_cli = true
160165
xfail_strict = true

tests/interaction/_connect.py

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
2828
from mcp.server.auth.settings import AuthSettings
2929
from mcp.server.fastmcp import FastMCP
30+
from mcp.server.fastmcp.server import StreamableHTTPASGIApp
3031
from mcp.server.sse import SseServerTransport
3132
from mcp.server.streamable_http import EventStore
3233
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
@@ -114,6 +115,51 @@ async def connect_in_memory(
114115
yield session
115116

116117

118+
def build_streamable_http_app(
119+
server: Server[Any] | FastMCP,
120+
*,
121+
stateless_http: bool = False,
122+
json_response: bool = False,
123+
event_store: EventStore | None = None,
124+
retry_interval: int | None = None,
125+
transport_security: TransportSecuritySettings | None = NO_DNS_REBINDING_PROTECTION,
126+
auth: AuthSettings | None = None,
127+
token_verifier: TokenVerifier | None = None,
128+
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
129+
) -> tuple[Starlette, StreamableHTTPSessionManager]:
130+
"""Assemble a streamable-HTTP Starlette app for either server flavour.
131+
132+
v1's lowlevel `Server` has no `streamable_http_app()`; this follows
133+
`FastMCP.streamable_http_app()` (`mcp/server/fastmcp/server.py`) so behaviour matches what a
134+
v1 user would get from `FastMCP(..., **knobs).streamable_http_app()`. Returns the live
135+
`StreamableHTTPSessionManager` alongside the app so the caller can enter `manager.run()`
136+
(the in-process bridge does not drive Starlette lifespan) and so tests can reach
137+
`manager._server_instances`.
138+
139+
`/mcp` is mounted via `Route(path, endpoint=<class instance>)` with no `methods=`, exactly
140+
as FastMCP does — Starlette treats a class-instance endpoint as raw ASGI and matches all
141+
verbs, which is what the transport requires.
142+
"""
143+
manager = StreamableHTTPSessionManager(
144+
app=_lowlevel(server),
145+
event_store=event_store,
146+
json_response=json_response,
147+
stateless=stateless_http,
148+
security_settings=transport_security,
149+
retry_interval=retry_interval,
150+
)
151+
asgi = StreamableHTTPASGIApp(manager)
152+
153+
routes: list[Route] = []
154+
# Auth routing (middleware, AS routes, RequireAuthMiddleware wrap, PRM routes) is added in
155+
# H5; until then `auth` / `token_verifier` / `auth_server_provider` are accepted but ignored
156+
# so callers that don't pass them work today.
157+
assert auth is None and token_verifier is None and auth_server_provider is None, "auth branch lands in H5"
158+
routes.append(Route("/mcp", endpoint=asgi))
159+
160+
return Starlette(routes=routes), manager
161+
162+
117163
@asynccontextmanager
118164
async def connect_over_streamable_http(
119165
server: Server[Any] | FastMCP,
@@ -137,28 +183,31 @@ async def connect_over_streamable_http(
137183
server modes, and the resumability tests pass an `event_store` (with `retry_interval=0` so
138184
the client's reconnection wait is a no-op).
139185
"""
140-
app = server.streamable_http_app(
186+
app, manager = build_streamable_http_app(
187+
server,
141188
stateless_http=stateless_http,
142189
json_response=json_response,
143190
event_store=event_store,
144191
retry_interval=retry_interval,
145-
transport_security=NO_DNS_REBINDING_PROTECTION,
146192
)
147193
async with (
148-
server.session_manager.run(),
194+
manager.run(),
149195
httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
150-
Client( # noqa: F821 -- body rewritten in H4
151-
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client),
196+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) as (read, write, _get_session_id),
197+
ClientSession(
198+
read,
199+
write,
152200
read_timeout_seconds=read_timeout_seconds,
153201
sampling_callback=sampling_callback,
154202
list_roots_callback=list_roots_callback,
155203
logging_callback=logging_callback,
156204
message_handler=message_handler,
157205
client_info=client_info,
158206
elicitation_callback=elicitation_callback,
159-
) as client,
207+
) as session,
160208
):
161-
yield client
209+
await session.initialize()
210+
yield session
162211

163212

164213
@asynccontextmanager
@@ -219,18 +268,22 @@ async def client_via_http(
219268
) -> AsyncIterator[ClientSession]:
220269
"""Connect a `ClientSession` over an already-mounted streamable HTTP app.
221270
222-
Use with `mounted_app(...)` so several `Client`s share the one session manager, or so a
223-
client-driven assertion can sit alongside raw-httpx assertions in the same test. The
224-
underlying `httpx.AsyncClient` is left open when the `Client` exits.
271+
Use with `mounted_app(...)` so several `ClientSession`s share the one session manager, or
272+
so a client-driven assertion can sit alongside raw-httpx assertions in the same test. The
273+
underlying `httpx.AsyncClient` is left open when the session exits.
225274
"""
226-
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
227-
async with Client( # noqa: F821 -- body rewritten in H4
228-
transport,
229-
logging_callback=logging_callback,
230-
message_handler=message_handler,
231-
elicitation_callback=elicitation_callback,
232-
) as client:
233-
yield client
275+
async with (
276+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) as (read, write, _get_session_id),
277+
ClientSession(
278+
read,
279+
write,
280+
logging_callback=logging_callback,
281+
message_handler=message_handler,
282+
elicitation_callback=elicitation_callback,
283+
) as session,
284+
):
285+
await session.initialize()
286+
yield session
234287

235288

236289
def parse_sse_messages(events: Iterable[ServerSentEvent]) -> list[JSONRPCMessage]:

tests/interaction/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ def pytest_configure(config: pytest.Config) -> None:
99
config.addinivalue_line(
1010
"markers", "requirement(id): tag a test as covering an entry in tests/interaction/_requirements.py"
1111
)
12+
# v1's streamable-HTTP server transport leaks a handful of anyio memory streams on teardown
13+
# (e.g. `_handle_get_request` only closes `sse_stream_reader` on the exception path; the
14+
# session manager's per-session task-group cancel can race the per-request cleanup). v1's own
15+
# tests run the transport in a separate process and so never observe these `__del__`-time
16+
# ResourceWarnings; running in-process via the streaming bridge does. The fixes live in `src/`
17+
# on `main` and are out of scope for this tests-only backport, so suppress here.
18+
config.addinivalue_line("filterwarnings", "ignore::pytest.PytestUnraisableExceptionWarning")
19+
config.addinivalue_line("filterwarnings", "ignore::ResourceWarning")
1220

1321

1422
_FACTORIES: dict[str, Connect] = {

tests/interaction/lowlevel/test_ping.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
1-
"""Ping interactions against the low-level Server, driven through the public Client API."""
1+
"""Ping interactions against the low-level Server, driven through the public ClientSession API.
2+
3+
This file reaches the server session via the module-level `request_ctx` contextvar (pattern B
4+
from the v1 backport's session-access spread). That contextvar is the mechanism behind
5+
`Server.request_context`; reading it directly is a public-module-level name a v1 user can
6+
import, and exercising it here covers the contextvar path the eventual v2 compatibility shims
7+
must preserve.
8+
"""
9+
10+
from typing import Any
211

312
import pytest
413
from inline_snapshot import snapshot
514

615
from mcp import types
7-
from mcp.server import Server, ServerRequestContext
16+
from mcp.server import Server
17+
from mcp.server.lowlevel.server import request_ctx
818
from mcp.types import CallToolResult, EmptyResult, TextContent
919
from tests.interaction._connect import Connect
1020
from tests.interaction._requirements import requirement
@@ -16,7 +26,7 @@
1626
@requirement("ping:client-to-server")
1727
async def test_client_ping_returns_empty_result(connect: Connect) -> None:
1828
"""A client ping is answered with an empty result, even by a server with no handlers."""
19-
server = Server("silent")
29+
server: Server[None] = Server("silent")
2030

2131
async with connect(server) as client:
2232
result = await client.send_ping()
@@ -32,21 +42,18 @@ async def test_server_ping_returns_empty_result(connect: Connect) -> None:
3242
The tool returns the type of the ping response, proving the round trip completed inside
3343
the handler before the tool result was produced.
3444
"""
45+
server: Server[None] = Server("pinger")
3546

36-
async def list_tools(
37-
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
38-
) -> types.ListToolsResult:
39-
return types.ListToolsResult(
40-
tools=[types.Tool(name="ping_back", description="Ping the client.", inputSchema={"type": "object"})]
41-
)
47+
@server.list_tools()
48+
async def list_tools() -> list[types.Tool]:
49+
return [types.Tool(name="ping_back", description="Ping the client.", inputSchema={"type": "object"})]
4250

43-
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
44-
assert params.name == "ping_back"
45-
pong = await ctx.session.send_ping()
51+
@server.call_tool()
52+
async def call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult:
53+
assert name == "ping_back"
54+
pong = await request_ctx.get().session.send_ping()
4655
return CallToolResult(content=[TextContent(type="text", text=type(pong).__name__)])
4756

48-
server = Server("pinger", on_list_tools=list_tools, on_call_tool=call_tool)
49-
5057
async with connect(server) as client:
5158
result = await client.call_tool("ping_back", {})
5259

0 commit comments

Comments
 (0)