Jeremiah Lowin commited on
Commit
329333a
·
1 Parent(s): b9a7171

Vendor streamable http manager

Browse files
src/fastmcp/server/http.py CHANGED
@@ -22,17 +22,10 @@ from starlette.responses import Response
22
  from starlette.routing import Mount, Route
23
  from starlette.types import Receive, Scope, Send
24
 
 
 
25
  from fastmcp.utilities.logging import get_logger
26
 
27
- # Import these conditionally to handle case where they might not be available
28
- try:
29
- from mcp.server.streamable_http import EventStore
30
- from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
31
-
32
- STREAMABLE_HTTP_AVAILABLE = True
33
- except ImportError:
34
- STREAMABLE_HTTP_AVAILABLE = False
35
-
36
  if TYPE_CHECKING:
37
  from fastmcp.server.server import FastMCP
38
 
@@ -238,7 +231,7 @@ def create_sse_app(
238
  def create_streamable_http_app(
239
  server: FastMCP,
240
  streamable_http_path: str,
241
- event_store: EventStore | None = None,
242
  auth_server_provider: OAuthAuthorizationServerProvider | None = None,
243
  auth_settings: AuthSettings | None = None,
244
  json_response: bool = False,
@@ -262,11 +255,6 @@ def create_streamable_http_app(
262
  Returns:
263
  A Starlette application with StreamableHTTP support
264
  """
265
- if not STREAMABLE_HTTP_AVAILABLE:
266
- raise ImportError(
267
- "StreamableHTTP transport is not available. Make sure your version of `mcp` is up-to-date."
268
- )
269
-
270
  # Create session manager using the provided event store
271
  session_manager = StreamableHTTPSessionManager(
272
  app=server._mcp_server,
 
22
  from starlette.routing import Mount, Route
23
  from starlette.types import Receive, Scope, Send
24
 
25
+ # This import is vendored until it is finalized in the upstream SDK
26
+ from fastmcp.server.streamable_http_manager import StreamableHTTPSessionManager
27
  from fastmcp.utilities.logging import get_logger
28
 
 
 
 
 
 
 
 
 
 
29
  if TYPE_CHECKING:
30
  from fastmcp.server.server import FastMCP
31
 
 
231
  def create_streamable_http_app(
232
  server: FastMCP,
233
  streamable_http_path: str,
234
+ event_store: None = None,
235
  auth_server_provider: OAuthAuthorizationServerProvider | None = None,
236
  auth_settings: AuthSettings | None = None,
237
  json_response: bool = False,
 
255
  Returns:
256
  A Starlette application with StreamableHTTP support
257
  """
 
 
 
 
 
258
  # Create session manager using the provided event store
259
  session_manager = StreamableHTTPSessionManager(
260
  app=server._mcp_server,
src/fastmcp/server/server.py CHANGED
@@ -752,25 +752,19 @@ class FastMCP(Generic[LifespanResultT]):
752
 
753
  def streamable_http_app(self) -> Starlette:
754
  """Return an instance of the StreamableHTTP server app."""
755
- try:
756
- from fastmcp.server.http import create_streamable_http_app
757
-
758
- return create_streamable_http_app(
759
- server=self,
760
- streamable_http_path=self.settings.streamable_http_path,
761
- event_store=None,
762
- auth_server_provider=self._auth_server_provider,
763
- auth_settings=self.settings.auth,
764
- json_response=self.settings.json_response,
765
- stateless_http=self.settings.stateless_http,
766
- debug=self.settings.debug,
767
- additional_routes=self._additional_http_routes,
768
- )
769
- except ImportError as e:
770
- logger.error(f"Failed to create StreamableHTTP app: {e}")
771
- raise ImportError(
772
- "StreamableHTTP transport is not available. Make sure your version of `mcp` is up-to-date."
773
- ) from e
774
 
775
  async def run_streamable_http_async(
776
  self,
 
752
 
753
  def streamable_http_app(self) -> Starlette:
754
  """Return an instance of the StreamableHTTP server app."""
755
+ from fastmcp.server.http import create_streamable_http_app
756
+
757
+ return create_streamable_http_app(
758
+ server=self,
759
+ streamable_http_path=self.settings.streamable_http_path,
760
+ event_store=None,
761
+ auth_server_provider=self._auth_server_provider,
762
+ auth_settings=self.settings.auth,
763
+ json_response=self.settings.json_response,
764
+ stateless_http=self.settings.stateless_http,
765
+ debug=self.settings.debug,
766
+ additional_routes=self._additional_http_routes,
767
+ )
 
 
 
 
 
 
768
 
769
  async def run_streamable_http_async(
770
  self,
src/fastmcp/server/streamable_http_manager.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """StreamableHTTP Session Manager for MCP servers."""
2
+
3
+ # follows https://github.com/modelcontextprotocol/python-sdk/blob/ihrpr/shttp/src/mcp/server/streamable_http_manager.py
4
+ # and can be removed once that spec is finalized
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import logging
10
+ from collections.abc import AsyncIterator
11
+ from http import HTTPStatus
12
+ from typing import Any
13
+ from uuid import uuid4
14
+
15
+ import anyio
16
+ from anyio.abc import TaskStatus
17
+ from mcp.server.lowlevel.server import Server as MCPServer
18
+ from mcp.server.streamable_http import (
19
+ MCP_SESSION_ID_HEADER,
20
+ EventStore,
21
+ StreamableHTTPServerTransport,
22
+ )
23
+ from starlette.requests import Request
24
+ from starlette.responses import Response
25
+ from starlette.types import Receive, Scope, Send
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class StreamableHTTPSessionManager:
31
+ """
32
+ Manages StreamableHTTP sessions with optional resumability via event store.
33
+
34
+ This class abstracts away the complexity of session management, event storage,
35
+ and request handling for StreamableHTTP transports. It handles:
36
+
37
+ 1. Session tracking for clients
38
+ 2. Resumability via an optional event store
39
+ 3. Connection management and lifecycle
40
+ 4. Request handling and transport setup
41
+
42
+ Args:
43
+ app: The MCP server instance
44
+ event_store: Optional event store for resumability support.
45
+ If provided, enables resumable connections where clients
46
+ can reconnect and receive missed events.
47
+ If None, sessions are still tracked but not resumable.
48
+ json_response: Whether to use JSON responses instead of SSE streams
49
+ stateless: If True, creates a completely fresh transport for each request
50
+ with no session tracking or state persistence between requests.
51
+
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ app: MCPServer[Any],
57
+ event_store: EventStore | None = None,
58
+ json_response: bool = False,
59
+ stateless: bool = False,
60
+ ):
61
+ self.app = app
62
+ self.event_store = event_store
63
+ self.json_response = json_response
64
+ self.stateless = stateless
65
+
66
+ # Session tracking (only used if not stateless)
67
+ self._session_creation_lock = anyio.Lock()
68
+ self._server_instances: dict[str, StreamableHTTPServerTransport] = {}
69
+
70
+ # The task group will be set during lifespan
71
+ self._task_group = None
72
+
73
+ @contextlib.asynccontextmanager
74
+ async def run(self) -> AsyncIterator[None]:
75
+ """
76
+ Run the session manager with proper lifecycle management.
77
+
78
+ This creates and manages the task group for all session operations.
79
+
80
+ Use this in the lifespan context manager of your Starlette app:
81
+
82
+ @contextlib.asynccontextmanager
83
+ async def lifespan(app: Starlette) -> AsyncIterator[None]:
84
+ async with session_manager.run():
85
+ yield
86
+ """
87
+ async with anyio.create_task_group() as tg:
88
+ # Store the task group for later use
89
+ self._task_group = tg
90
+ logger.info("StreamableHTTP session manager started")
91
+ try:
92
+ yield # Let the application run
93
+ finally:
94
+ logger.info("StreamableHTTP session manager shutting down")
95
+ # Cancel task group to stop all spawned tasks
96
+ tg.cancel_scope.cancel()
97
+ self._task_group = None
98
+ # Clear any remaining server instances
99
+ self._server_instances.clear()
100
+
101
+ async def handle_request(
102
+ self,
103
+ scope: Scope,
104
+ receive: Receive,
105
+ send: Send,
106
+ ) -> None:
107
+ """
108
+ Process ASGI request with proper session handling and transport setup.
109
+
110
+ Dispatches to the appropriate handler based on stateless mode.
111
+
112
+ Args:
113
+ scope: ASGI scope
114
+ receive: ASGI receive function
115
+ send: ASGI send function
116
+ """
117
+ if self._task_group is None:
118
+ raise RuntimeError(
119
+ "Task group is not initialized. Make sure to use the run()."
120
+ )
121
+
122
+ # Dispatch to the appropriate handler
123
+ if self.stateless:
124
+ await self._handle_stateless_request(scope, receive, send)
125
+ else:
126
+ await self._handle_stateful_request(scope, receive, send)
127
+
128
+ async def _handle_stateless_request(
129
+ self,
130
+ scope: Scope,
131
+ receive: Receive,
132
+ send: Send,
133
+ ) -> None:
134
+ """
135
+ Process request in stateless mode - creating a new transport for each request.
136
+
137
+ Args:
138
+ scope: ASGI scope
139
+ receive: ASGI receive function
140
+ send: ASGI send function
141
+ """
142
+ logger.debug("Stateless mode: Creating new transport for this request")
143
+ # No session ID needed in stateless mode
144
+ http_transport = StreamableHTTPServerTransport(
145
+ mcp_session_id=None, # No session tracking in stateless mode
146
+ is_json_response_enabled=self.json_response,
147
+ event_store=None, # No event store in stateless mode
148
+ )
149
+
150
+ # Start server in a new task
151
+ async def run_stateless_server(
152
+ *, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED
153
+ ):
154
+ async with http_transport.connect() as streams:
155
+ read_stream, write_stream = streams
156
+ task_status.started()
157
+ await self.app.run(
158
+ read_stream,
159
+ write_stream,
160
+ self.app.create_initialization_options(),
161
+ stateless=True,
162
+ )
163
+
164
+ # Assert task group is not None for type checking
165
+ assert self._task_group is not None
166
+ # Start the server task
167
+ await self._task_group.start(run_stateless_server)
168
+
169
+ # Handle the HTTP request and return the response
170
+ await http_transport.handle_request(scope, receive, send)
171
+
172
+ async def _handle_stateful_request(
173
+ self,
174
+ scope: Scope,
175
+ receive: Receive,
176
+ send: Send,
177
+ ) -> None:
178
+ """
179
+ Process request in stateful mode - maintaining session state between requests.
180
+
181
+ Args:
182
+ scope: ASGI scope
183
+ receive: ASGI receive function
184
+ send: ASGI send function
185
+ """
186
+ request = Request(scope, receive)
187
+ request_mcp_session_id = request.headers.get(MCP_SESSION_ID_HEADER)
188
+
189
+ # Existing session case
190
+ if (
191
+ request_mcp_session_id is not None
192
+ and request_mcp_session_id in self._server_instances
193
+ ):
194
+ transport = self._server_instances[request_mcp_session_id]
195
+ logger.debug("Session already exists, handling request directly")
196
+ await transport.handle_request(scope, receive, send)
197
+ return
198
+
199
+ if request_mcp_session_id is None:
200
+ # New session case
201
+ logger.debug("Creating new transport")
202
+ async with self._session_creation_lock:
203
+ new_session_id = uuid4().hex
204
+ http_transport = StreamableHTTPServerTransport(
205
+ mcp_session_id=new_session_id,
206
+ is_json_response_enabled=self.json_response,
207
+ event_store=self.event_store, # May be None (no resumability)
208
+ )
209
+
210
+ assert http_transport.mcp_session_id is not None
211
+ self._server_instances[http_transport.mcp_session_id] = http_transport
212
+ logger.info(f"Created new transport with session ID: {new_session_id}")
213
+
214
+ # Define the server runner
215
+ async def run_server(
216
+ *, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED
217
+ ) -> None:
218
+ async with http_transport.connect() as streams:
219
+ read_stream, write_stream = streams
220
+ task_status.started()
221
+ await self.app.run(
222
+ read_stream,
223
+ write_stream,
224
+ self.app.create_initialization_options(),
225
+ stateless=False, # Stateful mode
226
+ )
227
+
228
+ # Assert task group is not None for type checking
229
+ assert self._task_group is not None
230
+ # Start the server task
231
+ await self._task_group.start(run_server)
232
+
233
+ # Handle the HTTP request and return the response
234
+ await http_transport.handle_request(scope, receive, send)
235
+ else:
236
+ # Invalid session ID
237
+ response = Response(
238
+ "Bad Request: No valid session ID provided",
239
+ status_code=HTTPStatus.BAD_REQUEST,
240
+ )
241
+ await response(scope, receive, send)