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/cli/cli.py +30 -138
- fastmcp/cli/run.py +179 -0
- fastmcp/client/client.py +80 -20
- fastmcp/client/logging.py +20 -6
- fastmcp/client/progress.py +38 -0
- fastmcp/client/transports.py +153 -67
- fastmcp/server/context.py +6 -3
- fastmcp/server/http.py +70 -15
- fastmcp/server/openapi.py +113 -10
- fastmcp/server/server.py +414 -138
- fastmcp/settings.py +16 -0
- fastmcp/utilities/mcp_config.py +76 -0
- fastmcp/utilities/openapi.py +233 -602
- fastmcp/utilities/tests.py +8 -4
- {fastmcp-2.3.4.dist-info → fastmcp-2.4.0.dist-info}/METADATA +26 -3
- {fastmcp-2.3.4.dist-info → fastmcp-2.4.0.dist-info}/RECORD +19 -17
- fastmcp/low_level/sse_server_transport.py +0 -104
- {fastmcp-2.3.4.dist-info → fastmcp-2.4.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.3.4.dist-info → fastmcp-2.4.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.3.4.dist-info → fastmcp-2.4.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
|
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
|
-
) ->
|
|
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
|
|
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
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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 = {}
|