Skip to content

Commit e55b40e

Browse files
committed
backport: harness H5 — build_streamable_http_app auth branch + mounted_app body
Mirrors FastMCP.streamable_http_app()'s auth gating exactly (verifier derivation, middleware, AS routes, RequireAuthMiddleware wrap, PRM routes); mounted_app now calls the public builder. _connect.py is feature-complete; gate still 6/6.
1 parent 7a936a2 commit e55b40e

1 file changed

Lines changed: 62 additions & 13 deletions

File tree

tests/interaction/_connect.py

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import httpx
1717
from httpx_sse import ServerSentEvent, aconnect_sse
1818
from starlette.applications import Starlette
19+
from starlette.middleware import Middleware
20+
from starlette.middleware.authentication import AuthenticationMiddleware
1921
from starlette.requests import Request
2022
from starlette.responses import Response
2123
from starlette.routing import Mount, Route
@@ -24,7 +26,10 @@
2426
from mcp.client.sse import sse_client
2527
from mcp.client.streamable_http import streamable_http_client
2628
from mcp.server import Server
27-
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
29+
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
30+
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
31+
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
32+
from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes
2833
from mcp.server.auth.settings import AuthSettings
2934
from mcp.server.fastmcp import FastMCP
3035
from mcp.server.fastmcp.server import StreamableHTTPASGIApp
@@ -139,6 +144,9 @@ def build_streamable_http_app(
139144
`/mcp` is mounted via `Route(path, endpoint=<class instance>)` with no `methods=`, exactly
140145
as FastMCP does — Starlette treats a class-instance endpoint as raw ASGI and matches all
141146
verbs, which is what the transport requires.
147+
148+
Unlike `FastMCP.__init__`, this does not enforce `auth_server_provider` XOR
149+
`token_verifier`; the AS-handler tests pass both.
142150
"""
143151
manager = StreamableHTTPSessionManager(
144152
app=_lowlevel(server),
@@ -150,14 +158,55 @@ def build_streamable_http_app(
150158
)
151159
asgi = StreamableHTTPASGIApp(manager)
152160

161+
# FastMCP derives a verifier from the provider at construction time when no explicit verifier
162+
# is given (mcp/server/fastmcp/server.py:230); the harness has no construction step, so the
163+
# same derivation runs here so the gating below sees the same verifier FastMCP would.
164+
verifier = token_verifier
165+
if auth_server_provider is not None and token_verifier is None:
166+
verifier = ProviderTokenVerifier(auth_server_provider)
167+
153168
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))
169+
middleware: list[Middleware] = []
170+
required_scopes: list[str] = []
171+
172+
if auth is not None:
173+
required_scopes = auth.required_scopes or []
174+
if verifier is not None:
175+
middleware = [
176+
Middleware(AuthenticationMiddleware, backend=BearerAuthBackend(verifier)),
177+
Middleware(AuthContextMiddleware),
178+
]
179+
if auth_server_provider is not None:
180+
routes.extend(
181+
create_auth_routes(
182+
provider=auth_server_provider,
183+
issuer_url=auth.issuer_url,
184+
service_documentation_url=auth.service_documentation_url,
185+
client_registration_options=auth.client_registration_options,
186+
revocation_options=auth.revocation_options,
187+
)
188+
)
189+
190+
if verifier is not None:
191+
resource_metadata_url = (
192+
build_resource_metadata_url(auth.resource_server_url)
193+
if auth is not None and auth.resource_server_url
194+
else None
195+
)
196+
routes.append(Route("/mcp", endpoint=RequireAuthMiddleware(asgi, required_scopes, resource_metadata_url)))
197+
else:
198+
routes.append(Route("/mcp", endpoint=asgi))
199+
200+
if auth is not None and auth.resource_server_url:
201+
routes.extend(
202+
create_protected_resource_routes(
203+
resource_url=auth.resource_server_url,
204+
authorization_servers=[auth.issuer_url],
205+
scopes_supported=auth.required_scopes,
206+
)
207+
)
159208

160-
return Starlette(routes=routes), manager
209+
return Starlette(routes=routes, middleware=middleware), manager
161210

162211

163212
@asynccontextmanager
@@ -230,15 +279,15 @@ async def mounted_app(
230279
Yields the httpx client (rooted at the in-process origin) and the live session manager. Tests
231280
use this in two ways: for raw-httpx assertions (status codes, headers, SSE bytes) the test
232281
speaks HTTP through the yielded client directly; for client-driven assertions the test wraps
233-
that client in `client_via_http(http)`, which lets several `Client`s share the one mounted
234-
session manager. `on_request` records every outgoing HTTP request before it leaves the
282+
that client in `client_via_http(http)`, which lets several `ClientSession`s share the one
283+
mounted session manager. `on_request` records every outgoing HTTP request before it leaves the
235284
yielded client.
236285
237286
DNS-rebinding protection is disabled by default; pass explicit settings (or `None` for the
238287
localhost auto-enable behaviour) to test the protection itself.
239288
"""
240-
lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server # noqa: F821 -- body rewritten in H5
241-
app = lowlevel.streamable_http_app(
289+
app, manager = build_streamable_http_app(
290+
server,
242291
stateless_http=stateless_http,
243292
json_response=json_response,
244293
event_store=event_store,
@@ -250,12 +299,12 @@ async def mounted_app(
250299
)
251300
event_hooks = {"request": [on_request]} if on_request is not None else None
252301
async with (
253-
server.session_manager.run(),
302+
manager.run(),
254303
httpx.AsyncClient(
255304
transport=StreamingASGITransport(app), base_url=BASE_URL, event_hooks=event_hooks, headers=headers
256305
) as http_client,
257306
):
258-
yield http_client, server.session_manager
307+
yield http_client, manager
259308

260309

261310
@asynccontextmanager

0 commit comments

Comments
 (0)