Skip to content

Commit c59a57d

Browse files
committed
backport: phase-5 fixup — port roots:list-changed (audit-1), scope warning filter, ASGIApp note
1 parent 6c7cbcc commit c59a57d

5 files changed

Lines changed: 62 additions & 10 deletions

File tree

tests/interaction/_connect.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from starlette.requests import Request
2222
from starlette.responses import Response
2323
from starlette.routing import Mount, Route
24+
from starlette.types import Receive, Scope, Send
2425

2526
from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
2627
from mcp.client.sse import sse_client
@@ -32,7 +33,6 @@
3233
from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes
3334
from mcp.server.auth.settings import AuthSettings
3435
from mcp.server.fastmcp import FastMCP
35-
from mcp.server.fastmcp.server import StreamableHTTPASGIApp
3636
from mcp.server.sse import SseServerTransport
3737
from mcp.server.streamable_http import EventStore
3838
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
@@ -59,6 +59,23 @@
5959
NO_DNS_REBINDING_PROTECTION = TransportSecuritySettings(enable_dns_rebinding_protection=False)
6060

6161

62+
class StreamableHTTPASGIApp:
63+
"""Thin ASGI wrapper around `StreamableHTTPSessionManager.handle_request`.
64+
65+
Starlette's `Route(path, endpoint=...)` treats a *class instance* as a raw ASGI callable
66+
(matching all HTTP verbs), whereas a coroutine function is wrapped via `request_response`
67+
and defaults to GET/HEAD only. v1's `FastMCP.streamable_http_app()` relies on this same
68+
distinction; we inline the wrapper here rather than deep-importing the (non-`__all__`)
69+
`mcp.server.fastmcp.server.StreamableHTTPASGIApp`.
70+
"""
71+
72+
def __init__(self, session_manager: StreamableHTTPSessionManager) -> None:
73+
self.session_manager = session_manager
74+
75+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
76+
await self.session_manager.handle_request(scope, receive, send)
77+
78+
6279
def _lowlevel(server: Server[Any] | FastMCP) -> Server[Any]:
6380
"""Return the lowlevel `Server` for either flavour.
6481

tests/interaction/_requirements.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,10 +1450,6 @@ def __post_init__(self) -> None:
14501450
"roots:list-changed": Requirement(
14511451
source=f"{SPEC_BASE_URL}/client/roots#root-list-changes",
14521452
behavior="A roots/list_changed notification sent by the client is delivered to the server's handler.",
1453-
deferred=(
1454-
"Not expressible via the v1 public API: the low-level Server exposes no decorator for "
1455-
"notifications/roots/list_changed, so a server handler cannot be registered to observe delivery."
1456-
),
14571453
),
14581454
"roots:list-changed:client-emits": Requirement(
14591455
source=f"{SPEC_BASE_URL}/client/roots#root-list-changes",

tests/interaction/auth/_harness.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,9 @@
3434
from mcp.server.auth.provider import AccessToken, ProviderTokenVerifier
3535
from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes
3636
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
37-
from mcp.server.fastmcp.server import StreamableHTTPASGIApp
3837
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
3938
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
40-
from tests.interaction._connect import BASE_URL, NO_DNS_REBINDING_PROTECTION
39+
from tests.interaction._connect import BASE_URL, NO_DNS_REBINDING_PROTECTION, StreamableHTTPASGIApp
4140
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
4241
from tests.interaction.transports._bridge import StreamingASGITransport
4342

tests/interaction/conftest.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@ def pytest_configure(config: pytest.Config) -> None:
1414
# session manager's per-session task-group cancel can race the per-request cleanup). v1's own
1515
# tests run the transport in a separate process and so never observe these `__del__`-time
1616
# 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")
17+
# on `main` and are out of scope for this tests-only backport — tracked in
18+
# `notes/backport/issues.md`. The filters below are scoped to anyio's `MemoryObject*Stream`
19+
# leak signature so an unrelated leak still fails the suite.
20+
config.addinivalue_line(
21+
"filterwarnings", "ignore:.*MemoryObject(Send|Receive)Stream:pytest.PytestUnraisableExceptionWarning"
22+
)
23+
config.addinivalue_line("filterwarnings", "ignore:.*MemoryObject(Send|Receive)Stream:ResourceWarning")
2024

2125

2226
_FACTORIES: dict[str, Connect] = {

tests/interaction/lowlevel/test_roots.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from typing import Any
44

5+
import anyio
56
import pytest
67
from inline_snapshot import snapshot
78
from pydantic import FileUrl
@@ -140,3 +141,38 @@ async def list_roots(context: RequestContext[ClientSession, Any]) -> ListRootsRe
140141
result = await client.call_tool("show_roots", {})
141142

142143
assert result == snapshot(CallToolResult(content=[TextContent(type="text", text="-32603: roots provider crashed")]))
144+
145+
146+
@requirement("roots:list-changed")
147+
async def test_roots_list_changed_reaches_server_handler(connect: Connect) -> None:
148+
"""A roots/list_changed notification from the client is delivered to the server's handler.
149+
150+
v1's low-level `Server` exposes no decorator for this notification; the public path is direct
151+
assignment into `Server.notification_handlers` (a public, typed dict that the server's dispatch
152+
loop consults for every incoming client notification). The handler receives the notification
153+
object itself.
154+
155+
Unlike a request, a notification has no response to await: the handler sets an event and the
156+
test waits on it, which is the only synchronisation point proving delivery.
157+
"""
158+
delivered = anyio.Event()
159+
received: list[types.RootsListChangedNotification] = []
160+
161+
async def on_roots_changed(notify: types.RootsListChangedNotification) -> None:
162+
received.append(notify)
163+
delivered.set()
164+
165+
server = Server("rooted")
166+
server.notification_handlers[types.RootsListChangedNotification] = on_roots_changed
167+
168+
async def list_roots(context: RequestContext[ClientSession, Any]) -> ListRootsResult | ErrorData:
169+
"""Registered so the client declares the roots capability; the server never asks for roots."""
170+
raise NotImplementedError
171+
172+
async with connect(server, list_roots_callback=list_roots) as client:
173+
await client.send_roots_list_changed()
174+
with anyio.fail_after(5):
175+
await delivered.wait()
176+
177+
assert len(received) == 1
178+
assert received[0].method == "notifications/roots/list_changed"

0 commit comments

Comments
 (0)