2727from mcp .server .auth .provider import OAuthAuthorizationServerProvider , TokenVerifier
2828from mcp .server .auth .settings import AuthSettings
2929from mcp .server .fastmcp import FastMCP
30+ from mcp .server .fastmcp .server import StreamableHTTPASGIApp
3031from mcp .server .sse import SseServerTransport
3132from mcp .server .streamable_http import EventStore
3233from 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
118164async 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
236289def parse_sse_messages (events : Iterable [ServerSentEvent ]) -> list [JSONRPCMessage ]:
0 commit comments