fastmcp 2.3.5__py3-none-any.whl → 2.5.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/client/client.py +44 -6
- fastmcp/client/logging.py +14 -8
- fastmcp/client/transports.py +202 -57
- fastmcp/prompts/prompt.py +11 -4
- fastmcp/prompts/prompt_manager.py +25 -5
- fastmcp/resources/resource_manager.py +31 -5
- fastmcp/resources/template.py +10 -5
- fastmcp/server/context.py +46 -0
- fastmcp/server/http.py +25 -1
- fastmcp/server/openapi.py +436 -73
- fastmcp/server/server.py +412 -127
- fastmcp/settings.py +46 -1
- fastmcp/tools/tool.py +5 -1
- fastmcp/tools/tool_manager.py +9 -2
- fastmcp/utilities/logging.py +6 -1
- fastmcp/utilities/mcp_config.py +77 -0
- fastmcp/utilities/openapi.py +233 -602
- fastmcp/utilities/tests.py +8 -4
- {fastmcp-2.3.5.dist-info → fastmcp-2.5.0.dist-info}/METADATA +27 -4
- {fastmcp-2.3.5.dist-info → fastmcp-2.5.0.dist-info}/RECORD +23 -22
- {fastmcp-2.3.5.dist-info → fastmcp-2.5.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.3.5.dist-info → fastmcp-2.5.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.3.5.dist-info → fastmcp-2.5.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/server/openapi.py
CHANGED
|
@@ -5,8 +5,10 @@ from __future__ import annotations
|
|
|
5
5
|
import enum
|
|
6
6
|
import json
|
|
7
7
|
import re
|
|
8
|
+
import warnings
|
|
9
|
+
from collections import Counter
|
|
8
10
|
from collections.abc import Callable
|
|
9
|
-
from dataclasses import dataclass
|
|
11
|
+
from dataclasses import dataclass, field
|
|
10
12
|
from re import Pattern
|
|
11
13
|
from typing import TYPE_CHECKING, Any, Literal
|
|
12
14
|
|
|
@@ -16,11 +18,13 @@ from pydantic.networks import AnyUrl
|
|
|
16
18
|
|
|
17
19
|
from fastmcp.exceptions import ToolError
|
|
18
20
|
from fastmcp.resources import Resource, ResourceTemplate
|
|
21
|
+
from fastmcp.server.dependencies import get_http_request
|
|
19
22
|
from fastmcp.server.server import FastMCP
|
|
20
23
|
from fastmcp.tools.tool import Tool, _convert_to_content
|
|
21
24
|
from fastmcp.utilities import openapi
|
|
22
25
|
from fastmcp.utilities.logging import get_logger
|
|
23
26
|
from fastmcp.utilities.openapi import (
|
|
27
|
+
HTTPRoute,
|
|
24
28
|
_combine_schemas,
|
|
25
29
|
format_description_with_responses,
|
|
26
30
|
)
|
|
@@ -33,13 +37,88 @@ logger = get_logger(__name__)
|
|
|
33
37
|
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
|
|
34
38
|
|
|
35
39
|
|
|
40
|
+
def _slugify(text: str) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Convert text to a URL-friendly slug format that only contains lowercase
|
|
43
|
+
letters, uppercase letters, numbers, and underscores.
|
|
44
|
+
"""
|
|
45
|
+
if not text:
|
|
46
|
+
return ""
|
|
47
|
+
|
|
48
|
+
# Replace spaces and common separators with underscores
|
|
49
|
+
slug = re.sub(r"[\s\-\.]+", "_", text)
|
|
50
|
+
|
|
51
|
+
# Remove non-alphanumeric characters except underscores
|
|
52
|
+
slug = re.sub(r"[^a-zA-Z0-9_]", "", slug)
|
|
53
|
+
|
|
54
|
+
# Remove multiple consecutive underscores
|
|
55
|
+
slug = re.sub(r"_+", "_", slug)
|
|
56
|
+
|
|
57
|
+
# Remove leading/trailing underscores
|
|
58
|
+
slug = slug.strip("_")
|
|
59
|
+
|
|
60
|
+
return slug
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _get_mcp_client_headers() -> dict[str, str]:
|
|
64
|
+
"""
|
|
65
|
+
Extract headers from the current MCP client HTTP request if available.
|
|
66
|
+
|
|
67
|
+
These headers will take precedence over OpenAPI-defined headers when both are present.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Dictionary of header name-value pairs (lowercased names), or empty dict if no HTTP request is active.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
http_request = get_http_request()
|
|
74
|
+
return {
|
|
75
|
+
name.lower(): str(value) for name, value in http_request.headers.items()
|
|
76
|
+
}
|
|
77
|
+
except RuntimeError:
|
|
78
|
+
# No active HTTP request (e.g., STDIO transport), return empty dict
|
|
79
|
+
return {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Type definitions for the mapping functions
|
|
83
|
+
RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"]
|
|
84
|
+
ComponentFn = Callable[
|
|
85
|
+
[
|
|
86
|
+
HTTPRoute,
|
|
87
|
+
"OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate",
|
|
88
|
+
],
|
|
89
|
+
None,
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class MCPType(enum.Enum):
|
|
94
|
+
"""Type of FastMCP component to create from a route.
|
|
95
|
+
|
|
96
|
+
Enum values:
|
|
97
|
+
TOOL: Convert the route to a callable Tool
|
|
98
|
+
RESOURCE: Convert the route to a Resource (typically GET endpoints)
|
|
99
|
+
RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params)
|
|
100
|
+
EXCLUDE: Exclude the route from being converted to any MCP component
|
|
101
|
+
IGNORE: Deprecated, use EXCLUDE instead
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
TOOL = "TOOL"
|
|
105
|
+
RESOURCE = "RESOURCE"
|
|
106
|
+
RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
|
|
107
|
+
# PROMPT = "PROMPT"
|
|
108
|
+
EXCLUDE = "EXCLUDE"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Keep RouteType as an alias to MCPType for backward compatibility
|
|
36
112
|
class RouteType(enum.Enum):
|
|
37
|
-
"""
|
|
113
|
+
"""
|
|
114
|
+
Deprecated: Use MCPType instead.
|
|
115
|
+
|
|
116
|
+
This enum is kept for backward compatibility and will be removed in a future version.
|
|
117
|
+
"""
|
|
38
118
|
|
|
39
119
|
TOOL = "TOOL"
|
|
40
120
|
RESOURCE = "RESOURCE"
|
|
41
121
|
RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
|
|
42
|
-
PROMPT = "PROMPT"
|
|
43
122
|
IGNORE = "IGNORE"
|
|
44
123
|
|
|
45
124
|
|
|
@@ -47,32 +126,71 @@ class RouteType(enum.Enum):
|
|
|
47
126
|
class RouteMap:
|
|
48
127
|
"""Mapping configuration for HTTP routes to FastMCP component types."""
|
|
49
128
|
|
|
50
|
-
methods: list[HttpMethod]
|
|
51
|
-
pattern: Pattern[str] | str
|
|
52
|
-
|
|
129
|
+
methods: list[HttpMethod] | Literal["*"] = field(default="*")
|
|
130
|
+
pattern: Pattern[str] | str = field(default=r".*")
|
|
131
|
+
mcp_type: MCPType | None = field(default=None)
|
|
132
|
+
route_type: RouteType | MCPType | None = field(default=None)
|
|
133
|
+
tags: set[str] = field(default_factory=set)
|
|
134
|
+
|
|
135
|
+
def __post_init__(self):
|
|
136
|
+
"""Validate and process the route map after initialization."""
|
|
137
|
+
# Handle backward compatibility for route_type, deprecated in 2.5.0
|
|
138
|
+
if self.mcp_type is None and self.route_type is not None:
|
|
139
|
+
warnings.warn(
|
|
140
|
+
"The 'route_type' parameter is deprecated and will be removed in a future version. "
|
|
141
|
+
"Use 'mcp_type' instead with the appropriate MCPType value.",
|
|
142
|
+
DeprecationWarning,
|
|
143
|
+
stacklevel=2,
|
|
144
|
+
)
|
|
145
|
+
if isinstance(self.route_type, RouteType):
|
|
146
|
+
warnings.warn(
|
|
147
|
+
"The RouteType class is deprecated and will be removed in a future version. "
|
|
148
|
+
"Use MCPType instead.",
|
|
149
|
+
DeprecationWarning,
|
|
150
|
+
stacklevel=2,
|
|
151
|
+
)
|
|
152
|
+
# Check for the deprecated IGNORE value
|
|
153
|
+
if self.route_type == RouteType.IGNORE:
|
|
154
|
+
warnings.warn(
|
|
155
|
+
"RouteType.IGNORE is deprecated and will be removed in a future version. "
|
|
156
|
+
"Use MCPType.EXCLUDE instead.",
|
|
157
|
+
DeprecationWarning,
|
|
158
|
+
stacklevel=2,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Convert from RouteType to MCPType if needed
|
|
162
|
+
if isinstance(self.route_type, RouteType):
|
|
163
|
+
route_type_name = self.route_type.name
|
|
164
|
+
if route_type_name == "IGNORE":
|
|
165
|
+
route_type_name = "EXCLUDE"
|
|
166
|
+
self.mcp_type = getattr(MCPType, route_type_name)
|
|
167
|
+
else:
|
|
168
|
+
self.mcp_type = self.route_type
|
|
169
|
+
elif self.mcp_type is None:
|
|
170
|
+
raise ValueError("`mcp_type` must be provided")
|
|
171
|
+
|
|
172
|
+
# Set route_type to match mcp_type for backward compatibility
|
|
173
|
+
if self.route_type is None:
|
|
174
|
+
self.route_type = self.mcp_type
|
|
53
175
|
|
|
54
176
|
|
|
55
177
|
# Default route mappings as a list, where order determines priority
|
|
56
178
|
DEFAULT_ROUTE_MAPPINGS = [
|
|
57
179
|
# GET requests with path parameters go to ResourceTemplate
|
|
58
180
|
RouteMap(
|
|
59
|
-
methods=["GET"], pattern=r".*\{.*\}.*",
|
|
181
|
+
methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE
|
|
60
182
|
),
|
|
61
183
|
# GET requests without path parameters go to Resource
|
|
62
|
-
RouteMap(methods=["GET"], pattern=r".*",
|
|
184
|
+
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
|
63
185
|
# All other HTTP methods go to Tool
|
|
64
|
-
RouteMap(
|
|
65
|
-
methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
|
66
|
-
pattern=r".*",
|
|
67
|
-
route_type=RouteType.TOOL,
|
|
68
|
-
),
|
|
186
|
+
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL),
|
|
69
187
|
]
|
|
70
188
|
|
|
71
189
|
|
|
72
190
|
def _determine_route_type(
|
|
73
191
|
route: openapi.HTTPRoute,
|
|
74
192
|
mappings: list[RouteMap],
|
|
75
|
-
) ->
|
|
193
|
+
) -> MCPType:
|
|
76
194
|
"""
|
|
77
195
|
Determines the FastMCP component type based on the route and mappings.
|
|
78
196
|
|
|
@@ -81,12 +199,12 @@ def _determine_route_type(
|
|
|
81
199
|
mappings: List of RouteMap objects in priority order
|
|
82
200
|
|
|
83
201
|
Returns:
|
|
84
|
-
|
|
202
|
+
MCPType for this route
|
|
85
203
|
"""
|
|
86
204
|
# Check mappings in priority order (first match wins)
|
|
87
205
|
for route_map in mappings:
|
|
88
206
|
# Check if the HTTP method matches
|
|
89
|
-
if route.method in route_map.methods:
|
|
207
|
+
if route_map.methods == "*" or route.method in route_map.methods:
|
|
90
208
|
# Handle both string patterns and compiled Pattern objects
|
|
91
209
|
if isinstance(route_map.pattern, Pattern):
|
|
92
210
|
pattern_matches = route_map.pattern.search(route.path)
|
|
@@ -94,20 +212,24 @@ def _determine_route_type(
|
|
|
94
212
|
pattern_matches = re.search(route_map.pattern, route.path)
|
|
95
213
|
|
|
96
214
|
if pattern_matches:
|
|
215
|
+
# Check if tags match (if specified)
|
|
216
|
+
# If route_map.tags is empty, tags are not matched
|
|
217
|
+
# If route_map.tags is non-empty, all tags must be present in route.tags (AND condition)
|
|
218
|
+
if route_map.tags:
|
|
219
|
+
route_tags_set = set(route.tags or [])
|
|
220
|
+
if not route_map.tags.issubset(route_tags_set):
|
|
221
|
+
# Tags don't match, continue to next mapping
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
# We know mcp_type is not None here due to post_init validation
|
|
225
|
+
assert route_map.mcp_type is not None
|
|
97
226
|
logger.debug(
|
|
98
|
-
f"Route {route.method} {route.path} matched mapping to {route_map.
|
|
227
|
+
f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}"
|
|
99
228
|
)
|
|
100
|
-
return route_map.
|
|
229
|
+
return route_map.mcp_type
|
|
101
230
|
|
|
102
231
|
# Default fallback
|
|
103
|
-
return
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
# Placeholder function to provide function metadata
|
|
107
|
-
async def _openapi_passthrough(*args, **kwargs):
|
|
108
|
-
"""Placeholder function for OpenAPI endpoints."""
|
|
109
|
-
# This is kept for metadata generation purposes
|
|
110
|
-
pass
|
|
232
|
+
return MCPType.TOOL
|
|
111
233
|
|
|
112
234
|
|
|
113
235
|
class OpenAPITool(Tool):
|
|
@@ -171,27 +293,138 @@ class OpenAPITool(Tool):
|
|
|
171
293
|
raise ToolError(f"Missing required path parameters: {missing_params}")
|
|
172
294
|
|
|
173
295
|
for param_name, param_value in path_params.items():
|
|
296
|
+
# Handle array path parameters with style 'simple' (comma-separated)
|
|
297
|
+
# In OpenAPI, 'simple' is the default style for path parameters
|
|
298
|
+
param_info = next(
|
|
299
|
+
(p for p in self._route.parameters if p.name == param_name), None
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if param_info and isinstance(param_value, list):
|
|
303
|
+
# Check if schema indicates an array type
|
|
304
|
+
schema = param_info.schema_
|
|
305
|
+
is_array = schema.get("type") == "array"
|
|
306
|
+
|
|
307
|
+
if is_array:
|
|
308
|
+
# Format array values as comma-separated string
|
|
309
|
+
# This follows the OpenAPI 'simple' style (default for path)
|
|
310
|
+
if all(
|
|
311
|
+
isinstance(item, str | int | float | bool)
|
|
312
|
+
for item in param_value
|
|
313
|
+
):
|
|
314
|
+
# Handle simple array types
|
|
315
|
+
path = path.replace(
|
|
316
|
+
f"{{{param_name}}}", ",".join(str(v) for v in param_value)
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
# Handle complex array types (containing objects/dicts)
|
|
320
|
+
try:
|
|
321
|
+
# Try to create a simple representation without Python syntax artifacts
|
|
322
|
+
formatted_parts = []
|
|
323
|
+
for item in param_value:
|
|
324
|
+
if isinstance(item, dict):
|
|
325
|
+
# For objects, serialize key-value pairs
|
|
326
|
+
item_parts = []
|
|
327
|
+
for k, v in item.items():
|
|
328
|
+
item_parts.append(f"{k}:{v}")
|
|
329
|
+
formatted_parts.append(".".join(item_parts))
|
|
330
|
+
else:
|
|
331
|
+
# Fallback for other complex types
|
|
332
|
+
formatted_parts.append(str(item))
|
|
333
|
+
|
|
334
|
+
# Join parts with commas
|
|
335
|
+
formatted_value = ",".join(formatted_parts)
|
|
336
|
+
path = path.replace(f"{{{param_name}}}", formatted_value)
|
|
337
|
+
except Exception as e:
|
|
338
|
+
logger.warning(
|
|
339
|
+
f"Failed to format complex array path parameter '{param_name}': {e}"
|
|
340
|
+
)
|
|
341
|
+
# Fallback to string representation, but remove Python syntax artifacts
|
|
342
|
+
str_value = (
|
|
343
|
+
str(param_value)
|
|
344
|
+
.replace("[", "")
|
|
345
|
+
.replace("]", "")
|
|
346
|
+
.replace("'", "")
|
|
347
|
+
.replace('"', "")
|
|
348
|
+
)
|
|
349
|
+
path = path.replace(f"{{{param_name}}}", str_value)
|
|
350
|
+
continue
|
|
351
|
+
|
|
352
|
+
# Default handling for non-array parameters or non-array schemas
|
|
174
353
|
path = path.replace(f"{{{param_name}}}", str(param_value))
|
|
175
354
|
|
|
176
355
|
# Prepare query parameters - filter out None and empty strings
|
|
177
|
-
query_params = {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
356
|
+
query_params = {}
|
|
357
|
+
for p in self._route.parameters:
|
|
358
|
+
if (
|
|
359
|
+
p.location == "query"
|
|
360
|
+
and p.name in kwargs
|
|
361
|
+
and kwargs.get(p.name) is not None
|
|
362
|
+
and kwargs.get(p.name) != ""
|
|
363
|
+
):
|
|
364
|
+
param_value = kwargs.get(p.name)
|
|
365
|
+
|
|
366
|
+
# Format array query parameters as comma-separated strings
|
|
367
|
+
# following OpenAPI form style (default for query parameters)
|
|
368
|
+
if isinstance(param_value, list) and p.schema_.get("type") == "array":
|
|
369
|
+
# Get explode parameter from schema, default is True for query parameters
|
|
370
|
+
# If explode is True, the array is serialized as separate parameters
|
|
371
|
+
# If explode is False, the array is serialized as a comma-separated string
|
|
372
|
+
explode = p.schema_.get("explode", True)
|
|
373
|
+
|
|
374
|
+
if explode:
|
|
375
|
+
# When explode=True, we pass the array directly, which HTTPX will serialize
|
|
376
|
+
# as multiple parameters with the same name
|
|
377
|
+
query_params[p.name] = param_value
|
|
378
|
+
else:
|
|
379
|
+
# For arrays of simple types (strings, numbers, etc.), join with commas
|
|
380
|
+
if all(
|
|
381
|
+
isinstance(item, str | int | float | bool)
|
|
382
|
+
for item in param_value
|
|
383
|
+
):
|
|
384
|
+
query_params[p.name] = ",".join(str(v) for v in param_value)
|
|
385
|
+
else:
|
|
386
|
+
# For complex types, try to create a simpler representation
|
|
387
|
+
try:
|
|
388
|
+
# Try to create a simple string representation
|
|
389
|
+
formatted_parts = []
|
|
390
|
+
for item in param_value:
|
|
391
|
+
if isinstance(item, dict):
|
|
392
|
+
# For objects, serialize key-value pairs
|
|
393
|
+
item_parts = []
|
|
394
|
+
for k, v in item.items():
|
|
395
|
+
item_parts.append(f"{k}:{v}")
|
|
396
|
+
formatted_parts.append(".".join(item_parts))
|
|
397
|
+
else:
|
|
398
|
+
formatted_parts.append(str(item))
|
|
399
|
+
|
|
400
|
+
query_params[p.name] = ",".join(formatted_parts)
|
|
401
|
+
except Exception as e:
|
|
402
|
+
logger.warning(
|
|
403
|
+
f"Failed to format complex array query parameter '{p.name}': {e}"
|
|
404
|
+
)
|
|
405
|
+
# Fallback to string representation
|
|
406
|
+
query_params[p.name] = param_value
|
|
407
|
+
else:
|
|
408
|
+
# Non-array parameters are passed as is
|
|
409
|
+
query_params[p.name] = param_value
|
|
185
410
|
|
|
186
411
|
# Prepare headers - fix typing by ensuring all values are strings
|
|
187
412
|
headers = {}
|
|
413
|
+
|
|
414
|
+
# Start with OpenAPI-defined header parameters
|
|
415
|
+
openapi_headers = {}
|
|
188
416
|
for p in self._route.parameters:
|
|
189
417
|
if (
|
|
190
418
|
p.location == "header"
|
|
191
419
|
and p.name in kwargs
|
|
192
420
|
and kwargs[p.name] is not None
|
|
193
421
|
):
|
|
194
|
-
|
|
422
|
+
openapi_headers[p.name.lower()] = str(kwargs[p.name])
|
|
423
|
+
headers.update(openapi_headers)
|
|
424
|
+
|
|
425
|
+
# Add headers from the current MCP client HTTP request (these take precedence)
|
|
426
|
+
mcp_headers = _get_mcp_client_headers()
|
|
427
|
+
headers.update(mcp_headers)
|
|
195
428
|
|
|
196
429
|
# Prepare request body
|
|
197
430
|
json_data = None
|
|
@@ -339,10 +572,16 @@ class OpenAPIResource(Resource):
|
|
|
339
572
|
if value is not None and value != "":
|
|
340
573
|
query_params[param.name] = value
|
|
341
574
|
|
|
575
|
+
# Prepare headers from MCP client request if available
|
|
576
|
+
headers = {}
|
|
577
|
+
mcp_headers = _get_mcp_client_headers()
|
|
578
|
+
headers.update(mcp_headers)
|
|
579
|
+
|
|
342
580
|
response = await self._client.request(
|
|
343
581
|
method=self._route.method,
|
|
344
582
|
url=path,
|
|
345
583
|
params=query_params,
|
|
584
|
+
headers=headers,
|
|
346
585
|
timeout=self._timeout,
|
|
347
586
|
)
|
|
348
587
|
|
|
@@ -443,7 +682,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
443
682
|
|
|
444
683
|
Example:
|
|
445
684
|
```python
|
|
446
|
-
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap,
|
|
685
|
+
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, MCPType
|
|
447
686
|
import httpx
|
|
448
687
|
|
|
449
688
|
# Define custom route mappings
|
|
@@ -452,17 +691,17 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
452
691
|
RouteMap(
|
|
453
692
|
methods=["GET", "POST", "PATCH"],
|
|
454
693
|
pattern=r".*/users/.*",
|
|
455
|
-
|
|
694
|
+
mcp_type=MCPType.RESOURCE_TEMPLATE
|
|
456
695
|
),
|
|
457
696
|
# Map all analytics endpoints to Tool
|
|
458
697
|
RouteMap(
|
|
459
698
|
methods=["GET"],
|
|
460
699
|
pattern=r".*/analytics/.*",
|
|
461
|
-
|
|
700
|
+
mcp_type=MCPType.TOOL
|
|
462
701
|
),
|
|
463
702
|
]
|
|
464
703
|
|
|
465
|
-
# Create server with custom mappings
|
|
704
|
+
# Create server with custom mappings and route mapper
|
|
466
705
|
server = FastMCPOpenAPI(
|
|
467
706
|
openapi_spec=spec,
|
|
468
707
|
client=httpx.AsyncClient(),
|
|
@@ -478,6 +717,9 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
478
717
|
client: httpx.AsyncClient,
|
|
479
718
|
name: str | None = None,
|
|
480
719
|
route_maps: list[RouteMap] | None = None,
|
|
720
|
+
route_map_fn: RouteMapFn | None = None,
|
|
721
|
+
mcp_component_fn: ComponentFn | None = None,
|
|
722
|
+
mcp_names: dict[str, str] | None = None,
|
|
481
723
|
timeout: float | None = None,
|
|
482
724
|
**settings: Any,
|
|
483
725
|
):
|
|
@@ -489,6 +731,17 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
489
731
|
client: httpx AsyncClient for making HTTP requests
|
|
490
732
|
name: Optional name for the server
|
|
491
733
|
route_maps: Optional list of RouteMap objects defining route mappings
|
|
734
|
+
route_map_fn: Optional callable for advanced route type mapping.
|
|
735
|
+
Receives (route, mcp_type) and returns MCPType or None.
|
|
736
|
+
Called on every route, including excluded ones.
|
|
737
|
+
mcp_component_fn: Optional callable for component customization.
|
|
738
|
+
Receives (route, component) and can modify the component in-place.
|
|
739
|
+
Called on every created component.
|
|
740
|
+
mcp_names: Optional dictionary mapping operationId to desired component names.
|
|
741
|
+
If an operationId is not in the dictionary, falls back to using the
|
|
742
|
+
operationId up to the first double underscore. If no operationId exists,
|
|
743
|
+
falls back to slugified summary or path-based naming.
|
|
744
|
+
All names are truncated to 56 characters maximum.
|
|
492
745
|
timeout: Optional timeout (in seconds) for all requests
|
|
493
746
|
**settings: Additional settings for FastMCP
|
|
494
747
|
"""
|
|
@@ -496,6 +749,18 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
496
749
|
|
|
497
750
|
self._client = client
|
|
498
751
|
self._timeout = timeout
|
|
752
|
+
self._route_map_fn = route_map_fn
|
|
753
|
+
self._mcp_component_fn = mcp_component_fn
|
|
754
|
+
self._mcp_names = mcp_names or {}
|
|
755
|
+
|
|
756
|
+
# Keep track of names to detect collisions
|
|
757
|
+
self._used_names = {
|
|
758
|
+
"tool": Counter(),
|
|
759
|
+
"resource": Counter(),
|
|
760
|
+
"resource_template": Counter(),
|
|
761
|
+
"prompt": Counter(),
|
|
762
|
+
}
|
|
763
|
+
|
|
499
764
|
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
|
|
500
765
|
|
|
501
766
|
# Process routes
|
|
@@ -504,34 +769,97 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
504
769
|
# Determine route type based on mappings or default rules
|
|
505
770
|
route_type = _determine_route_type(route, route_maps)
|
|
506
771
|
|
|
507
|
-
#
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
772
|
+
# Call route_map_fn if provided
|
|
773
|
+
if self._route_map_fn is not None:
|
|
774
|
+
try:
|
|
775
|
+
result = self._route_map_fn(route, route_type)
|
|
776
|
+
if result is not None:
|
|
777
|
+
route_type = result
|
|
778
|
+
logger.debug(
|
|
779
|
+
f"Route {route.method} {route.path} mapping customized by route_map_fn: "
|
|
780
|
+
f"type={route_type.name}"
|
|
781
|
+
)
|
|
782
|
+
except Exception as e:
|
|
783
|
+
logger.warning(
|
|
784
|
+
f"Error in route_map_fn for {route.method} {route.path}: {e}. "
|
|
785
|
+
f"Using default values."
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
# Generate a default name from the route
|
|
789
|
+
component_name = self._generate_default_name(route, route_type)
|
|
790
|
+
|
|
791
|
+
if route_type == MCPType.TOOL:
|
|
792
|
+
self._create_openapi_tool(route, component_name)
|
|
793
|
+
elif route_type == MCPType.RESOURCE:
|
|
794
|
+
self._create_openapi_resource(route, component_name)
|
|
795
|
+
elif route_type == MCPType.RESOURCE_TEMPLATE:
|
|
796
|
+
self._create_openapi_template(route, component_name)
|
|
797
|
+
elif route_type == MCPType.EXCLUDE:
|
|
798
|
+
logger.info(f"Excluding route: {route.method} {route.path}")
|
|
528
799
|
|
|
529
800
|
logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes")
|
|
530
801
|
|
|
531
|
-
def
|
|
802
|
+
def _generate_default_name(
|
|
803
|
+
self, route: openapi.HTTPRoute, mcp_type: MCPType
|
|
804
|
+
) -> str:
|
|
805
|
+
"""Generate a default name from the route using the configured strategy."""
|
|
806
|
+
name = ""
|
|
807
|
+
|
|
808
|
+
# First check if there's a custom mapping for this operationId
|
|
809
|
+
if route.operation_id:
|
|
810
|
+
if route.operation_id in self._mcp_names:
|
|
811
|
+
name = self._mcp_names[route.operation_id]
|
|
812
|
+
else:
|
|
813
|
+
# If there's a double underscore in the operationId, use the first part
|
|
814
|
+
name = route.operation_id.split("__")[0]
|
|
815
|
+
else:
|
|
816
|
+
name = route.summary or f"{route.method}_{route.path}"
|
|
817
|
+
|
|
818
|
+
name = _slugify(name)
|
|
819
|
+
|
|
820
|
+
# Truncate to 56 characters maximum
|
|
821
|
+
if len(name) > 56:
|
|
822
|
+
name = name[:56]
|
|
823
|
+
|
|
824
|
+
return name
|
|
825
|
+
|
|
826
|
+
def _get_unique_name(
|
|
827
|
+
self,
|
|
828
|
+
name: str,
|
|
829
|
+
component_type: Literal["tool", "resource", "resource_template", "prompt"],
|
|
830
|
+
) -> str:
|
|
831
|
+
"""
|
|
832
|
+
Ensure the name is unique within its component type by appending numbers if needed.
|
|
833
|
+
|
|
834
|
+
Args:
|
|
835
|
+
name: The proposed name
|
|
836
|
+
component_type: The type of component ("tools", "resources", or "templates")
|
|
837
|
+
|
|
838
|
+
Returns:
|
|
839
|
+
str: A unique name for the component
|
|
840
|
+
"""
|
|
841
|
+
# Check if the name is already used
|
|
842
|
+
self._used_names[component_type][name] += 1
|
|
843
|
+
if self._used_names[component_type][name] == 1:
|
|
844
|
+
return name
|
|
845
|
+
|
|
846
|
+
else:
|
|
847
|
+
# Create the new name
|
|
848
|
+
new_name = f"{name}_{self._used_names[component_type][name]}"
|
|
849
|
+
logger.debug(
|
|
850
|
+
f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. "
|
|
851
|
+
f"Using '{new_name}' instead."
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
return new_name
|
|
855
|
+
|
|
856
|
+
def _create_openapi_tool(self, route: openapi.HTTPRoute, name: str):
|
|
532
857
|
"""Creates and registers an OpenAPITool with enhanced description."""
|
|
533
858
|
combined_schema = _combine_schemas(route)
|
|
534
|
-
|
|
859
|
+
|
|
860
|
+
# Get a unique tool name
|
|
861
|
+
tool_name = self._get_unique_name(name, "tool")
|
|
862
|
+
|
|
535
863
|
base_description = (
|
|
536
864
|
route.description
|
|
537
865
|
or route.summary
|
|
@@ -555,16 +883,30 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
555
883
|
tags=set(route.tags or []),
|
|
556
884
|
timeout=self._timeout,
|
|
557
885
|
)
|
|
886
|
+
|
|
887
|
+
# Call component_fn if provided
|
|
888
|
+
if self._mcp_component_fn is not None:
|
|
889
|
+
try:
|
|
890
|
+
self._mcp_component_fn(route, tool)
|
|
891
|
+
logger.debug(f"Tool {tool_name} customized by component_fn")
|
|
892
|
+
except Exception as e:
|
|
893
|
+
logger.warning(
|
|
894
|
+
f"Error in component_fn for tool {tool_name}: {e}. "
|
|
895
|
+
f"Using component as-is."
|
|
896
|
+
)
|
|
897
|
+
|
|
558
898
|
# Register the tool by directly assigning to the tools dictionary
|
|
559
899
|
self._tool_manager._tools[tool_name] = tool
|
|
560
900
|
logger.debug(
|
|
561
901
|
f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}"
|
|
562
902
|
)
|
|
563
903
|
|
|
564
|
-
def _create_openapi_resource(self, route: openapi.HTTPRoute,
|
|
904
|
+
def _create_openapi_resource(self, route: openapi.HTTPRoute, name: str):
|
|
565
905
|
"""Creates and registers an OpenAPIResource with enhanced description."""
|
|
566
|
-
|
|
567
|
-
|
|
906
|
+
# Get a unique resource name
|
|
907
|
+
resource_name = self._get_unique_name(name, "resource")
|
|
908
|
+
|
|
909
|
+
resource_uri = f"resource://{resource_name}"
|
|
568
910
|
base_description = (
|
|
569
911
|
route.description or route.summary or f"Represents {route.path}"
|
|
570
912
|
)
|
|
@@ -586,19 +928,33 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
586
928
|
tags=set(route.tags or []),
|
|
587
929
|
timeout=self._timeout,
|
|
588
930
|
)
|
|
931
|
+
|
|
932
|
+
# Call component_fn if provided
|
|
933
|
+
if self._mcp_component_fn is not None:
|
|
934
|
+
try:
|
|
935
|
+
self._mcp_component_fn(route, resource)
|
|
936
|
+
logger.debug(f"Resource {resource_uri} customized by component_fn")
|
|
937
|
+
except Exception as e:
|
|
938
|
+
logger.warning(
|
|
939
|
+
f"Error in component_fn for resource {resource_uri}: {e}. "
|
|
940
|
+
f"Using component as-is."
|
|
941
|
+
)
|
|
942
|
+
|
|
589
943
|
# Register the resource by directly assigning to the resources dictionary
|
|
590
944
|
self._resource_manager._resources[str(resource.uri)] = resource
|
|
591
945
|
logger.debug(
|
|
592
946
|
f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
|
|
593
947
|
)
|
|
594
948
|
|
|
595
|
-
def _create_openapi_template(self, route: openapi.HTTPRoute,
|
|
949
|
+
def _create_openapi_template(self, route: openapi.HTTPRoute, name: str):
|
|
596
950
|
"""Creates and registers an OpenAPIResourceTemplate with enhanced description."""
|
|
597
|
-
|
|
951
|
+
# Get a unique template name
|
|
952
|
+
template_name = self._get_unique_name(name, "resource_template")
|
|
953
|
+
|
|
598
954
|
path_params = [p.name for p in route.parameters if p.location == "path"]
|
|
599
955
|
path_params.sort() # Sort for consistent URIs
|
|
600
956
|
|
|
601
|
-
uri_template_str = f"resource://
|
|
957
|
+
uri_template_str = f"resource://{template_name}"
|
|
602
958
|
if path_params:
|
|
603
959
|
uri_template_str += "/" + "/".join(f"{{{p}}}" for p in path_params)
|
|
604
960
|
|
|
@@ -646,13 +1002,20 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
646
1002
|
tags=set(route.tags or []),
|
|
647
1003
|
timeout=self._timeout,
|
|
648
1004
|
)
|
|
1005
|
+
|
|
1006
|
+
# Call component_fn if provided
|
|
1007
|
+
if self._mcp_component_fn is not None:
|
|
1008
|
+
try:
|
|
1009
|
+
self._mcp_component_fn(route, template)
|
|
1010
|
+
logger.debug(f"Template {uri_template_str} customized by component_fn")
|
|
1011
|
+
except Exception as e:
|
|
1012
|
+
logger.warning(
|
|
1013
|
+
f"Error in component_fn for template {uri_template_str}: {e}. "
|
|
1014
|
+
f"Using component as-is."
|
|
1015
|
+
)
|
|
1016
|
+
|
|
649
1017
|
# Register the template by directly assigning to the templates dictionary
|
|
650
1018
|
self._resource_manager._templates[uri_template_str] = template
|
|
651
1019
|
logger.debug(
|
|
652
1020
|
f"Registered TEMPLATE: {uri_template_str} ({route.method} {route.path}) with tags: {route.tags}"
|
|
653
1021
|
)
|
|
654
|
-
|
|
655
|
-
async def _mcp_call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
|
|
656
|
-
"""Override the call_tool method to return the raw result without converting to content."""
|
|
657
|
-
result = await self._tool_manager.call_tool(name, arguments)
|
|
658
|
-
return result
|