fastmcp 2.3.4__py3-none-any.whl → 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fastmcp/server/http.py CHANGED
@@ -10,9 +10,16 @@ from mcp.server.auth.middleware.bearer_auth import (
10
10
  BearerAuthBackend,
11
11
  RequireAuthMiddleware,
12
12
  )
13
- from mcp.server.auth.provider import OAuthAuthorizationServerProvider
13
+ from mcp.server.auth.provider import (
14
+ AccessTokenT,
15
+ AuthorizationCodeT,
16
+ OAuthAuthorizationServerProvider,
17
+ RefreshTokenT,
18
+ )
14
19
  from mcp.server.auth.routes import create_auth_routes
15
20
  from mcp.server.auth.settings import AuthSettings
21
+ from mcp.server.lowlevel.server import LifespanResultT
22
+ from mcp.server.sse import SseServerTransport
16
23
  from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
17
24
  from starlette.applications import Starlette
18
25
  from starlette.middleware import Middleware
@@ -20,9 +27,8 @@ from starlette.middleware.authentication import AuthenticationMiddleware
20
27
  from starlette.requests import Request
21
28
  from starlette.responses import Response
22
29
  from starlette.routing import BaseRoute, Mount, Route
23
- from starlette.types import Receive, Scope, Send
30
+ from starlette.types import Lifespan, Receive, Scope, Send
24
31
 
25
- from fastmcp.low_level.sse_server_transport import SseServerTransport
26
32
  from fastmcp.utilities.logging import get_logger
27
33
 
28
34
  if TYPE_CHECKING:
@@ -30,12 +36,19 @@ if TYPE_CHECKING:
30
36
 
31
37
  logger = get_logger(__name__)
32
38
 
39
+
33
40
  _current_http_request: ContextVar[Request | None] = ContextVar(
34
41
  "http_request",
35
42
  default=None,
36
43
  )
37
44
 
38
45
 
46
+ class StarletteWithLifespan(Starlette):
47
+ @property
48
+ def lifespan(self) -> Lifespan:
49
+ return self.router.lifespan_context
50
+
51
+
39
52
  @contextmanager
40
53
  def set_http_request(request: Request) -> Generator[Request, None, None]:
41
54
  token = _current_http_request.set(request)
@@ -62,7 +75,10 @@ class RequestContextMiddleware:
62
75
 
63
76
 
64
77
  def setup_auth_middleware_and_routes(
65
- auth_server_provider: OAuthAuthorizationServerProvider | None,
78
+ auth_server_provider: OAuthAuthorizationServerProvider[
79
+ AuthorizationCodeT, RefreshTokenT, AccessTokenT
80
+ ]
81
+ | None,
66
82
  auth_settings: AuthSettings | None,
67
83
  ) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
68
84
  """Set up authentication middleware and routes if auth is enabled.
@@ -112,7 +128,7 @@ def create_base_app(
112
128
  middleware: list[Middleware],
113
129
  debug: bool = False,
114
130
  lifespan: Callable | None = None,
115
- ) -> Starlette:
131
+ ) -> StarletteWithLifespan:
116
132
  """Create a base Starlette app with common middleware and routes.
117
133
 
118
134
  Args:
@@ -127,7 +143,7 @@ def create_base_app(
127
143
  # Always add RequestContextMiddleware as the outermost middleware
128
144
  middleware.append(Middleware(RequestContextMiddleware))
129
145
 
130
- return Starlette(
146
+ return StarletteWithLifespan(
131
147
  routes=routes,
132
148
  middleware=middleware,
133
149
  debug=debug,
@@ -136,15 +152,18 @@ def create_base_app(
136
152
 
137
153
 
138
154
  def create_sse_app(
139
- server: FastMCP,
155
+ server: FastMCP[LifespanResultT],
140
156
  message_path: str,
141
157
  sse_path: str,
142
- auth_server_provider: OAuthAuthorizationServerProvider | None = None,
158
+ auth_server_provider: OAuthAuthorizationServerProvider[
159
+ AuthorizationCodeT, RefreshTokenT, AccessTokenT
160
+ ]
161
+ | None = None,
143
162
  auth_settings: AuthSettings | None = None,
144
163
  debug: bool = False,
145
164
  routes: list[BaseRoute] | None = None,
146
165
  middleware: list[Middleware] | None = None,
147
- ) -> Starlette:
166
+ ) -> StarletteWithLifespan:
148
167
  """Return an instance of the SSE server app.
149
168
 
150
169
  Args:
@@ -228,25 +247,33 @@ def create_sse_app(
228
247
  server_middleware.extend(middleware)
229
248
 
230
249
  # Create and return the app
231
- return create_base_app(
250
+ app = create_base_app(
232
251
  routes=server_routes,
233
252
  middleware=server_middleware,
234
253
  debug=debug,
235
254
  )
255
+ # Store the FastMCP server instance on the Starlette app state
256
+ app.state.fastmcp_server = server
257
+ app.state.path = sse_path
258
+
259
+ return app
236
260
 
237
261
 
238
262
  def create_streamable_http_app(
239
- server: FastMCP,
263
+ server: FastMCP[LifespanResultT],
240
264
  streamable_http_path: str,
241
265
  event_store: None = None,
242
- auth_server_provider: OAuthAuthorizationServerProvider | None = None,
266
+ auth_server_provider: OAuthAuthorizationServerProvider[
267
+ AuthorizationCodeT, RefreshTokenT, AccessTokenT
268
+ ]
269
+ | None = None,
243
270
  auth_settings: AuthSettings | None = None,
244
271
  json_response: bool = False,
245
272
  stateless_http: bool = False,
246
273
  debug: bool = False,
247
274
  routes: list[BaseRoute] | None = None,
248
275
  middleware: list[Middleware] | None = None,
249
- ) -> Starlette:
276
+ ) -> StarletteWithLifespan:
250
277
  """Return an instance of the StreamableHTTP server app.
251
278
 
252
279
  Args:
@@ -279,7 +306,29 @@ def create_streamable_http_app(
279
306
  async def handle_streamable_http(
280
307
  scope: Scope, receive: Receive, send: Send
281
308
  ) -> None:
282
- await session_manager.handle_request(scope, receive, send)
309
+ try:
310
+ await session_manager.handle_request(scope, receive, send)
311
+ except RuntimeError as e:
312
+ if str(e) == "Task group is not initialized. Make sure to use run().":
313
+ logger.error(
314
+ f"Original RuntimeError from mcp library: {e}", exc_info=True
315
+ )
316
+ new_error_message = (
317
+ "FastMCP's StreamableHTTPSessionManager task group was not initialized. "
318
+ "This commonly occurs when the FastMCP application's lifespan is not "
319
+ "passed to the parent ASGI application (e.g., FastAPI or Starlette). "
320
+ "Please ensure you are setting `lifespan=mcp_app.lifespan` in your "
321
+ "parent app's constructor, where `mcp_app` is the application instance "
322
+ "returned by `fastmcp_instance.http_app()`. \\n"
323
+ "For more details, see the FastMCP ASGI integration documentation: "
324
+ "https://gofastmcp.com/deployment/asgi"
325
+ )
326
+ # Raise a new RuntimeError that includes the original error's message
327
+ # for full context, but leads with the more helpful guidance.
328
+ raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e
329
+ else:
330
+ # Re-raise other RuntimeErrors if they don't match the specific message
331
+ raise
283
332
 
284
333
  # Get auth middleware and routes
285
334
  auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
@@ -322,9 +371,15 @@ def create_streamable_http_app(
322
371
  yield
323
372
 
324
373
  # Create and return the app with lifespan
325
- return create_base_app(
374
+ app = create_base_app(
326
375
  routes=server_routes,
327
376
  middleware=server_middleware,
328
377
  debug=debug,
329
378
  lifespan=lifespan,
330
379
  )
380
+ # Store the FastMCP server instance on the Starlette app state
381
+ app.state.fastmcp_server = server
382
+
383
+ app.state.path = streamable_http_path
384
+
385
+ return app
fastmcp/server/openapi.py CHANGED
@@ -47,7 +47,7 @@ class RouteType(enum.Enum):
47
47
  class RouteMap:
48
48
  """Mapping configuration for HTTP routes to FastMCP component types."""
49
49
 
50
- methods: list[HttpMethod]
50
+ methods: list[HttpMethod] | Literal["*"]
51
51
  pattern: Pattern[str] | str
52
52
  route_type: RouteType
53
53
 
@@ -86,7 +86,7 @@ def _determine_route_type(
86
86
  # Check mappings in priority order (first match wins)
87
87
  for route_map in mappings:
88
88
  # Check if the HTTP method matches
89
- if route.method in route_map.methods:
89
+ if route_map.methods == "*" or route.method in route_map.methods:
90
90
  # Handle both string patterns and compiled Pattern objects
91
91
  if isinstance(route_map.pattern, Pattern):
92
92
  pattern_matches = route_map.pattern.search(route.path)
@@ -171,17 +171,120 @@ class OpenAPITool(Tool):
171
171
  raise ToolError(f"Missing required path parameters: {missing_params}")
172
172
 
173
173
  for param_name, param_value in path_params.items():
174
+ # Handle array path parameters with style 'simple' (comma-separated)
175
+ # In OpenAPI, 'simple' is the default style for path parameters
176
+ param_info = next(
177
+ (p for p in self._route.parameters if p.name == param_name), None
178
+ )
179
+
180
+ if param_info and isinstance(param_value, list):
181
+ # Check if schema indicates an array type
182
+ schema = param_info.schema_
183
+ is_array = schema.get("type") == "array"
184
+
185
+ if is_array:
186
+ # Format array values as comma-separated string
187
+ # This follows the OpenAPI 'simple' style (default for path)
188
+ if all(
189
+ isinstance(item, str | int | float | bool)
190
+ for item in param_value
191
+ ):
192
+ # Handle simple array types
193
+ path = path.replace(
194
+ f"{{{param_name}}}", ",".join(str(v) for v in param_value)
195
+ )
196
+ else:
197
+ # Handle complex array types (containing objects/dicts)
198
+ try:
199
+ # Try to create a simple representation without Python syntax artifacts
200
+ formatted_parts = []
201
+ for item in param_value:
202
+ if isinstance(item, dict):
203
+ # For objects, serialize key-value pairs
204
+ item_parts = []
205
+ for k, v in item.items():
206
+ item_parts.append(f"{k}:{v}")
207
+ formatted_parts.append(".".join(item_parts))
208
+ else:
209
+ # Fallback for other complex types
210
+ formatted_parts.append(str(item))
211
+
212
+ # Join parts with commas
213
+ formatted_value = ",".join(formatted_parts)
214
+ path = path.replace(f"{{{param_name}}}", formatted_value)
215
+ except Exception as e:
216
+ logger.warning(
217
+ f"Failed to format complex array path parameter '{param_name}': {e}"
218
+ )
219
+ # Fallback to string representation, but remove Python syntax artifacts
220
+ str_value = (
221
+ str(param_value)
222
+ .replace("[", "")
223
+ .replace("]", "")
224
+ .replace("'", "")
225
+ .replace('"', "")
226
+ )
227
+ path = path.replace(f"{{{param_name}}}", str_value)
228
+ continue
229
+
230
+ # Default handling for non-array parameters or non-array schemas
174
231
  path = path.replace(f"{{{param_name}}}", str(param_value))
175
232
 
176
233
  # Prepare query parameters - filter out None and empty strings
177
- query_params = {
178
- p.name: kwargs.get(p.name)
179
- for p in self._route.parameters
180
- if p.location == "query"
181
- and p.name in kwargs
182
- and kwargs.get(p.name) is not None
183
- and kwargs.get(p.name) != ""
184
- }
234
+ query_params = {}
235
+ for p in self._route.parameters:
236
+ if (
237
+ p.location == "query"
238
+ and p.name in kwargs
239
+ and kwargs.get(p.name) is not None
240
+ and kwargs.get(p.name) != ""
241
+ ):
242
+ param_value = kwargs.get(p.name)
243
+
244
+ # Format array query parameters as comma-separated strings
245
+ # following OpenAPI form style (default for query parameters)
246
+ if isinstance(param_value, list) and p.schema_.get("type") == "array":
247
+ # Get explode parameter from schema, default is True for query parameters
248
+ # If explode is True, the array is serialized as separate parameters
249
+ # If explode is False, the array is serialized as a comma-separated string
250
+ explode = p.schema_.get("explode", True)
251
+
252
+ if explode:
253
+ # When explode=True, we pass the array directly, which HTTPX will serialize
254
+ # as multiple parameters with the same name
255
+ query_params[p.name] = param_value
256
+ else:
257
+ # For arrays of simple types (strings, numbers, etc.), join with commas
258
+ if all(
259
+ isinstance(item, str | int | float | bool)
260
+ for item in param_value
261
+ ):
262
+ query_params[p.name] = ",".join(str(v) for v in param_value)
263
+ else:
264
+ # For complex types, try to create a simpler representation
265
+ try:
266
+ # Try to create a simple string representation
267
+ formatted_parts = []
268
+ for item in param_value:
269
+ if isinstance(item, dict):
270
+ # For objects, serialize key-value pairs
271
+ item_parts = []
272
+ for k, v in item.items():
273
+ item_parts.append(f"{k}:{v}")
274
+ formatted_parts.append(".".join(item_parts))
275
+ else:
276
+ formatted_parts.append(str(item))
277
+
278
+ query_params[p.name] = ",".join(formatted_parts)
279
+ except Exception as e:
280
+ logger.warning(
281
+ f"Failed to format complex array query parameter '{p.name}': {e}"
282
+ )
283
+ # Fallback to string representation
284
+ query_params[p.name] = param_value
285
+ else:
286
+ # Non-array parameters are passed as is
287
+ query_params[p.name] = param_value
185
288
 
186
289
  # Prepare headers - fix typing by ensuring all values are strings
187
290
  headers = {}