fastmcp 2.12.5__py3-none-any.whl → 2.14.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/__init__.py +2 -23
- fastmcp/cli/__init__.py +0 -3
- fastmcp/cli/__main__.py +5 -0
- fastmcp/cli/cli.py +19 -33
- fastmcp/cli/install/claude_code.py +6 -6
- fastmcp/cli/install/claude_desktop.py +3 -3
- fastmcp/cli/install/cursor.py +18 -12
- fastmcp/cli/install/gemini_cli.py +3 -3
- fastmcp/cli/install/mcp_json.py +3 -3
- fastmcp/cli/install/shared.py +0 -15
- fastmcp/cli/run.py +13 -8
- fastmcp/cli/tasks.py +110 -0
- fastmcp/client/__init__.py +9 -9
- fastmcp/client/auth/oauth.py +123 -225
- fastmcp/client/client.py +697 -95
- fastmcp/client/elicitation.py +11 -5
- fastmcp/client/logging.py +18 -14
- fastmcp/client/messages.py +7 -5
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/roots.py +2 -1
- fastmcp/client/sampling.py +1 -1
- fastmcp/client/tasks.py +614 -0
- fastmcp/client/transports.py +117 -30
- fastmcp/contrib/component_manager/__init__.py +1 -1
- fastmcp/contrib/component_manager/component_manager.py +2 -2
- fastmcp/contrib/component_manager/component_service.py +10 -26
- fastmcp/contrib/mcp_mixin/README.md +32 -1
- fastmcp/contrib/mcp_mixin/__init__.py +2 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
- fastmcp/dependencies.py +25 -0
- fastmcp/experimental/sampling/handlers/openai.py +3 -3
- fastmcp/experimental/server/openapi/__init__.py +20 -21
- fastmcp/experimental/utilities/openapi/__init__.py +16 -47
- fastmcp/mcp_config.py +3 -4
- fastmcp/prompts/__init__.py +1 -1
- fastmcp/prompts/prompt.py +54 -51
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/__init__.py +5 -5
- fastmcp/resources/resource.py +43 -21
- fastmcp/resources/resource_manager.py +9 -168
- fastmcp/resources/template.py +161 -61
- fastmcp/resources/types.py +30 -24
- fastmcp/server/__init__.py +1 -1
- fastmcp/server/auth/__init__.py +9 -14
- fastmcp/server/auth/auth.py +197 -46
- fastmcp/server/auth/handlers/authorize.py +326 -0
- fastmcp/server/auth/jwt_issuer.py +236 -0
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +1469 -298
- fastmcp/server/auth/oidc_proxy.py +91 -20
- fastmcp/server/auth/providers/auth0.py +40 -21
- fastmcp/server/auth/providers/aws.py +29 -3
- fastmcp/server/auth/providers/azure.py +312 -131
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +86 -29
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/github.py +29 -8
- fastmcp/server/auth/providers/google.py +48 -9
- fastmcp/server/auth/providers/in_memory.py +29 -5
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +48 -31
- fastmcp/server/auth/providers/oci.py +233 -0
- fastmcp/server/auth/providers/scalekit.py +238 -0
- fastmcp/server/auth/providers/supabase.py +188 -0
- fastmcp/server/auth/providers/workos.py +35 -17
- fastmcp/server/context.py +236 -116
- fastmcp/server/dependencies.py +503 -18
- fastmcp/server/elicitation.py +286 -48
- fastmcp/server/event_store.py +177 -0
- fastmcp/server/http.py +71 -20
- fastmcp/server/low_level.py +165 -2
- fastmcp/server/middleware/__init__.py +1 -1
- fastmcp/server/middleware/caching.py +476 -0
- fastmcp/server/middleware/error_handling.py +14 -10
- fastmcp/server/middleware/logging.py +50 -39
- fastmcp/server/middleware/middleware.py +29 -16
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/openapi/__init__.py +35 -0
- fastmcp/{experimental/server → server}/openapi/components.py +15 -10
- fastmcp/{experimental/server → server}/openapi/routing.py +3 -3
- fastmcp/{experimental/server → server}/openapi/server.py +6 -5
- fastmcp/server/proxy.py +72 -48
- fastmcp/server/server.py +1415 -733
- fastmcp/server/tasks/__init__.py +21 -0
- fastmcp/server/tasks/capabilities.py +22 -0
- fastmcp/server/tasks/config.py +89 -0
- fastmcp/server/tasks/converters.py +205 -0
- fastmcp/server/tasks/handlers.py +356 -0
- fastmcp/server/tasks/keys.py +93 -0
- fastmcp/server/tasks/protocol.py +355 -0
- fastmcp/server/tasks/subscriptions.py +205 -0
- fastmcp/settings.py +125 -113
- fastmcp/tools/__init__.py +1 -1
- fastmcp/tools/tool.py +138 -55
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +12 -21
- fastmcp/utilities/cli.py +67 -28
- fastmcp/utilities/components.py +10 -5
- fastmcp/utilities/inspect.py +79 -23
- fastmcp/utilities/json_schema.py +4 -4
- fastmcp/utilities/json_schema_type.py +8 -8
- fastmcp/utilities/logging.py +118 -8
- fastmcp/utilities/mcp_config.py +1 -2
- fastmcp/utilities/mcp_server_config/__init__.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +5 -5
- fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
- fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
- fastmcp/utilities/openapi/__init__.py +63 -0
- fastmcp/{experimental/utilities → utilities}/openapi/director.py +14 -15
- fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
- fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +7 -3
- fastmcp/{experimental/utilities → utilities}/openapi/parser.py +37 -16
- fastmcp/utilities/tests.py +92 -5
- fastmcp/utilities/types.py +86 -16
- fastmcp/utilities/ui.py +626 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/METADATA +24 -15
- fastmcp-2.14.0.dist-info/RECORD +156 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/WHEEL +1 -1
- fastmcp/cli/claude.py +0 -135
- fastmcp/server/auth/providers/bearer.py +0 -25
- fastmcp/server/openapi.py +0 -1083
- fastmcp/utilities/openapi.py +0 -1568
- fastmcp/utilities/storage.py +0 -204
- fastmcp-2.12.5.dist-info/RECORD +0 -134
- fastmcp/{experimental/server → server}/openapi/README.md +0 -0
- fastmcp/{experimental/utilities → utilities}/openapi/models.py +3 -3
- fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +2 -2
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/utilities/logging.py
CHANGED
|
@@ -6,6 +6,7 @@ from typing import Any, Literal, cast
|
|
|
6
6
|
|
|
7
7
|
from rich.console import Console
|
|
8
8
|
from rich.logging import RichHandler
|
|
9
|
+
from typing_extensions import override
|
|
9
10
|
|
|
10
11
|
import fastmcp
|
|
11
12
|
|
|
@@ -19,7 +20,10 @@ def get_logger(name: str) -> logging.Logger:
|
|
|
19
20
|
Returns:
|
|
20
21
|
a configured logger instance
|
|
21
22
|
"""
|
|
22
|
-
|
|
23
|
+
if name.startswith("fastmcp."):
|
|
24
|
+
return logging.getLogger(name=name)
|
|
25
|
+
|
|
26
|
+
return logging.getLogger(name=f"fastmcp.{name}")
|
|
23
27
|
|
|
24
28
|
|
|
25
29
|
def configure_logging(
|
|
@@ -47,25 +51,52 @@ def configure_logging(
|
|
|
47
51
|
if logger is None:
|
|
48
52
|
logger = logging.getLogger("fastmcp")
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
formatter = logging.Formatter("%(message)s")
|
|
55
|
+
|
|
56
|
+
# Don't propagate to the root logger
|
|
57
|
+
logger.propagate = False
|
|
58
|
+
logger.setLevel(level)
|
|
59
|
+
|
|
60
|
+
# Configure the handler for normal logs
|
|
51
61
|
handler = RichHandler(
|
|
52
62
|
console=Console(stderr=True),
|
|
53
|
-
rich_tracebacks=enable_rich_tracebacks,
|
|
54
63
|
**rich_kwargs,
|
|
55
64
|
)
|
|
56
|
-
formatter = logging.Formatter("%(message)s")
|
|
57
65
|
handler.setFormatter(formatter)
|
|
58
66
|
|
|
59
|
-
|
|
67
|
+
# filter to exclude tracebacks
|
|
68
|
+
handler.addFilter(lambda record: record.exc_info is None)
|
|
69
|
+
|
|
70
|
+
# Configure the handler for tracebacks, for tracebacks we use a compressed format:
|
|
71
|
+
# no path or level name to maximize width available for the traceback
|
|
72
|
+
# suppress framework frames and limit the number of frames to 3
|
|
73
|
+
|
|
74
|
+
import mcp
|
|
75
|
+
import pydantic
|
|
76
|
+
|
|
77
|
+
# Build traceback kwargs with defaults that can be overridden
|
|
78
|
+
traceback_kwargs = {
|
|
79
|
+
"console": Console(stderr=True),
|
|
80
|
+
"show_path": False,
|
|
81
|
+
"show_level": False,
|
|
82
|
+
"rich_tracebacks": enable_rich_tracebacks,
|
|
83
|
+
"tracebacks_max_frames": 3,
|
|
84
|
+
"tracebacks_suppress": [fastmcp, mcp, pydantic],
|
|
85
|
+
}
|
|
86
|
+
# Override defaults with user-provided values
|
|
87
|
+
traceback_kwargs.update(rich_kwargs)
|
|
88
|
+
|
|
89
|
+
traceback_handler = RichHandler(**traceback_kwargs) # type: ignore[arg-type]
|
|
90
|
+
traceback_handler.setFormatter(formatter)
|
|
91
|
+
|
|
92
|
+
traceback_handler.addFilter(lambda record: record.exc_info is not None)
|
|
60
93
|
|
|
61
94
|
# Remove any existing handlers to avoid duplicates on reconfiguration
|
|
62
95
|
for hdlr in logger.handlers[:]:
|
|
63
96
|
logger.removeHandler(hdlr)
|
|
64
97
|
|
|
65
98
|
logger.addHandler(handler)
|
|
66
|
-
|
|
67
|
-
# Don't propagate to the root logger
|
|
68
|
-
logger.propagate = False
|
|
99
|
+
logger.addHandler(traceback_handler)
|
|
69
100
|
|
|
70
101
|
|
|
71
102
|
@contextlib.contextmanager
|
|
@@ -118,3 +149,82 @@ def temporary_log_level(
|
|
|
118
149
|
)
|
|
119
150
|
else:
|
|
120
151
|
yield
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_level_to_no: dict[
|
|
155
|
+
Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None, int | None
|
|
156
|
+
] = {
|
|
157
|
+
"DEBUG": logging.DEBUG,
|
|
158
|
+
"INFO": logging.INFO,
|
|
159
|
+
"WARNING": logging.WARNING,
|
|
160
|
+
"ERROR": logging.ERROR,
|
|
161
|
+
"CRITICAL": logging.CRITICAL,
|
|
162
|
+
None: None,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class _ClampedLogFilter(logging.Filter):
|
|
167
|
+
min_level: tuple[int, str] | None
|
|
168
|
+
max_level: tuple[int, str] | None
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
173
|
+
| None = None,
|
|
174
|
+
max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
175
|
+
| None = None,
|
|
176
|
+
):
|
|
177
|
+
self.min_level = None
|
|
178
|
+
self.max_level = None
|
|
179
|
+
|
|
180
|
+
if min_level_no := _level_to_no.get(min_level):
|
|
181
|
+
self.min_level = (min_level_no, str(min_level))
|
|
182
|
+
if max_level_no := _level_to_no.get(max_level):
|
|
183
|
+
self.max_level = (max_level_no, str(max_level))
|
|
184
|
+
|
|
185
|
+
super().__init__()
|
|
186
|
+
|
|
187
|
+
@override
|
|
188
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
189
|
+
if self.max_level:
|
|
190
|
+
max_level_no, max_level_name = self.max_level
|
|
191
|
+
|
|
192
|
+
if record.levelno > max_level_no:
|
|
193
|
+
record.levelno = max_level_no
|
|
194
|
+
record.levelname = max_level_name
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
if self.min_level:
|
|
198
|
+
min_level_no, min_level_name = self.min_level
|
|
199
|
+
if record.levelno < min_level_no:
|
|
200
|
+
record.levelno = min_level_no
|
|
201
|
+
record.levelname = min_level_name
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _clamp_logger(
|
|
208
|
+
logger: logging.Logger,
|
|
209
|
+
min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
|
|
210
|
+
max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Clamp the logger to a minimum and maximum level.
|
|
213
|
+
|
|
214
|
+
If min_level is provided, messages logged at a lower level than `min_level` will have their level increased to `min_level`.
|
|
215
|
+
If max_level is provided, messages logged at a higher level than `max_level` will have their level decreased to `max_level`.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
min_level: The lower bound of the clamp
|
|
219
|
+
max_level: The upper bound of the clamp
|
|
220
|
+
"""
|
|
221
|
+
_unclamp_logger(logger=logger)
|
|
222
|
+
|
|
223
|
+
logger.addFilter(filter=_ClampedLogFilter(min_level=min_level, max_level=max_level))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _unclamp_logger(logger: logging.Logger) -> None:
|
|
227
|
+
"""Remove all clamped log filters from the logger."""
|
|
228
|
+
for filter in logger.filters[:]:
|
|
229
|
+
if isinstance(filter, _ClampedLogFilter):
|
|
230
|
+
logger.removeFilter(filter)
|
fastmcp/utilities/mcp_config.py
CHANGED
|
@@ -43,8 +43,7 @@ def mcp_server_type_to_servers_and_transports(
|
|
|
43
43
|
|
|
44
44
|
if isinstance(mcp_server, TransformingRemoteMCPServer | TransformingStdioMCPServer):
|
|
45
45
|
server, transport = mcp_server._to_server_and_underlying_transport(
|
|
46
|
-
server_name=server_name,
|
|
47
|
-
client_name=client_name,
|
|
46
|
+
server_name=server_name, client_name=client_name
|
|
48
47
|
)
|
|
49
48
|
else:
|
|
50
49
|
transport = mcp_server.to_transport()
|
|
@@ -15,11 +15,11 @@ from fastmcp.utilities.mcp_server_config.v1.sources.base import Source
|
|
|
15
15
|
from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
|
|
16
16
|
|
|
17
17
|
__all__ = [
|
|
18
|
-
"Source",
|
|
19
18
|
"Deployment",
|
|
20
19
|
"Environment",
|
|
21
|
-
"UVEnvironment",
|
|
22
|
-
"MCPServerConfig",
|
|
23
20
|
"FileSystemSource",
|
|
21
|
+
"MCPServerConfig",
|
|
22
|
+
"Source",
|
|
23
|
+
"UVEnvironment",
|
|
24
24
|
"generate_schema",
|
|
25
25
|
]
|
|
@@ -19,7 +19,6 @@ class Environment(BaseModel, ABC):
|
|
|
19
19
|
Returns:
|
|
20
20
|
Full command ready for subprocess execution
|
|
21
21
|
"""
|
|
22
|
-
pass
|
|
23
22
|
|
|
24
23
|
async def prepare(self, output_dir: Path | None = None) -> None:
|
|
25
24
|
"""Prepare the environment (optional, can be no-op).
|
|
@@ -27,4 +26,4 @@ class Environment(BaseModel, ABC):
|
|
|
27
26
|
Args:
|
|
28
27
|
output_dir: Directory for persistent environment setup
|
|
29
28
|
"""
|
|
30
|
-
|
|
29
|
+
# Default no-op implementation
|
|
@@ -28,19 +28,19 @@ class UVEnvironment(Environment):
|
|
|
28
28
|
examples=[["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]],
|
|
29
29
|
)
|
|
30
30
|
|
|
31
|
-
requirements:
|
|
31
|
+
requirements: Path | None = Field(
|
|
32
32
|
default=None,
|
|
33
33
|
description="Path to requirements.txt file",
|
|
34
34
|
examples=["requirements.txt", "../requirements/prod.txt"],
|
|
35
35
|
)
|
|
36
36
|
|
|
37
|
-
project:
|
|
37
|
+
project: Path | None = Field(
|
|
38
38
|
default=None,
|
|
39
39
|
description="Path to project directory containing pyproject.toml",
|
|
40
40
|
examples=[".", "../my-project"],
|
|
41
41
|
)
|
|
42
42
|
|
|
43
|
-
editable: list[
|
|
43
|
+
editable: list[Path] | None = Field(
|
|
44
44
|
default=None,
|
|
45
45
|
description="Directories to install in editable mode",
|
|
46
46
|
examples=[[".", "../my-package"], ["/path/to/package"]],
|
|
@@ -64,7 +64,7 @@ class UVEnvironment(Environment):
|
|
|
64
64
|
|
|
65
65
|
# Add project if specified
|
|
66
66
|
if self.project:
|
|
67
|
-
args.extend(["--project", str(self.project)])
|
|
67
|
+
args.extend(["--project", str(self.project.resolve())])
|
|
68
68
|
|
|
69
69
|
# Add Python version if specified (only if no project, as project has its own Python)
|
|
70
70
|
if self.python and not self.project:
|
|
@@ -78,12 +78,12 @@ class UVEnvironment(Environment):
|
|
|
78
78
|
|
|
79
79
|
# Add requirements file
|
|
80
80
|
if self.requirements:
|
|
81
|
-
args.extend(["--with-requirements", str(self.requirements)])
|
|
81
|
+
args.extend(["--with-requirements", str(self.requirements.resolve())])
|
|
82
82
|
|
|
83
83
|
# Add editable packages
|
|
84
84
|
if self.editable:
|
|
85
85
|
for editable_path in self.editable:
|
|
86
|
-
args.extend(["--with-editable", str(editable_path)])
|
|
86
|
+
args.extend(["--with-editable", str(editable_path.resolve())])
|
|
87
87
|
|
|
88
88
|
# Add the command
|
|
89
89
|
args.extend(command)
|
|
@@ -192,7 +192,7 @@ class MCPServerConfig(BaseModel):
|
|
|
192
192
|
"""
|
|
193
193
|
if isinstance(v, dict):
|
|
194
194
|
return FileSystemSource(**v)
|
|
195
|
-
return v
|
|
195
|
+
return v # type: ignore[return-value]
|
|
196
196
|
|
|
197
197
|
@field_validator("environment", mode="before")
|
|
198
198
|
@classmethod
|
|
@@ -217,7 +217,7 @@ class MCPServerConfig(BaseModel):
|
|
|
217
217
|
"""
|
|
218
218
|
if isinstance(v, dict):
|
|
219
219
|
return Deployment(**v) # type: ignore[arg-type]
|
|
220
|
-
return cast(Deployment, v)
|
|
220
|
+
return cast(Deployment, v) # type: ignore[return-value]
|
|
221
221
|
|
|
222
222
|
@classmethod
|
|
223
223
|
def from_file(cls, file_path: Path) -> MCPServerConfig:
|
|
@@ -291,9 +291,9 @@ class MCPServerConfig(BaseModel):
|
|
|
291
291
|
environment = UVEnvironment(
|
|
292
292
|
python=python,
|
|
293
293
|
dependencies=dependencies,
|
|
294
|
-
requirements=requirements,
|
|
295
|
-
project=project,
|
|
296
|
-
editable=[editable] if editable else None,
|
|
294
|
+
requirements=Path(requirements) if requirements else None,
|
|
295
|
+
project=Path(project) if project else None,
|
|
296
|
+
editable=[Path(editable)] if editable else None,
|
|
297
297
|
)
|
|
298
298
|
|
|
299
299
|
# Build deployment config if any deployment args provided
|
|
@@ -250,6 +250,7 @@
|
|
|
250
250
|
"requirements": {
|
|
251
251
|
"anyOf": [
|
|
252
252
|
{
|
|
253
|
+
"format": "path",
|
|
253
254
|
"type": "string"
|
|
254
255
|
},
|
|
255
256
|
{
|
|
@@ -267,6 +268,7 @@
|
|
|
267
268
|
"project": {
|
|
268
269
|
"anyOf": [
|
|
269
270
|
{
|
|
271
|
+
"format": "path",
|
|
270
272
|
"type": "string"
|
|
271
273
|
},
|
|
272
274
|
{
|
|
@@ -285,6 +287,7 @@
|
|
|
285
287
|
"anyOf": [
|
|
286
288
|
{
|
|
287
289
|
"items": {
|
|
290
|
+
"format": "path",
|
|
288
291
|
"type": "string"
|
|
289
292
|
},
|
|
290
293
|
"type": "array"
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# OpenAPI Utilities
|
|
1
|
+
# OpenAPI Utilities
|
|
2
2
|
|
|
3
|
-
This directory contains the
|
|
3
|
+
This directory contains the OpenAPI integration utilities for FastMCP.
|
|
4
4
|
|
|
5
5
|
## Architecture Overview
|
|
6
6
|
|
|
7
|
-
The
|
|
7
|
+
The implementation follows a **stateless request building strategy** using `openapi-core` for high-performance, per-request HTTP request construction, eliminating startup latency while maintaining robust OpenAPI compliance.
|
|
8
8
|
|
|
9
9
|
### Core Components
|
|
10
10
|
|
|
@@ -83,7 +83,7 @@ MCP Tool Call → RequestDirector.build() → httpx.Request → HTTP Response
|
|
|
83
83
|
|
|
84
84
|
## Component Integration
|
|
85
85
|
|
|
86
|
-
### Server Components (`/server/
|
|
86
|
+
### Server Components (`/server/openapi/`)
|
|
87
87
|
|
|
88
88
|
1. **`OpenAPITool`** - Simplified tool implementation using RequestDirector
|
|
89
89
|
2. **`OpenAPIResource`** - Resource implementation with RequestDirector
|
|
@@ -104,7 +104,7 @@ All components use the same RequestDirector approach:
|
|
|
104
104
|
|
|
105
105
|
```python
|
|
106
106
|
import httpx
|
|
107
|
-
from fastmcp.server.
|
|
107
|
+
from fastmcp.server.openapi import FastMCPOpenAPI
|
|
108
108
|
|
|
109
109
|
# OpenAPI spec (can be loaded from file/URL)
|
|
110
110
|
openapi_spec = {...}
|
|
@@ -124,7 +124,7 @@ async with httpx.AsyncClient() as client:
|
|
|
124
124
|
### Direct RequestDirector Usage
|
|
125
125
|
|
|
126
126
|
```python
|
|
127
|
-
from fastmcp.
|
|
127
|
+
from fastmcp.utilities.openapi.director import RequestDirector
|
|
128
128
|
from jsonschema_path import SchemaPath
|
|
129
129
|
|
|
130
130
|
# Create RequestDirector manually
|
|
@@ -141,7 +141,7 @@ async with httpx.AsyncClient() as client:
|
|
|
141
141
|
|
|
142
142
|
## Testing Strategy
|
|
143
143
|
|
|
144
|
-
Tests are located in `/tests/server/
|
|
144
|
+
Tests are located in `/tests/server/openapi/`:
|
|
145
145
|
|
|
146
146
|
### Test Categories
|
|
147
147
|
|
|
@@ -160,34 +160,6 @@ Tests are located in `/tests/server/openapi_new/`:
|
|
|
160
160
|
- **Performance Focus**: Test that initialization is fast and stateless
|
|
161
161
|
- **Behavioral Testing**: Verify OpenAPI compliance without implementation details
|
|
162
162
|
|
|
163
|
-
## Migration Guide
|
|
164
|
-
|
|
165
|
-
### From Legacy Implementation
|
|
166
|
-
|
|
167
|
-
1. **Import Changes**:
|
|
168
|
-
```python
|
|
169
|
-
# Old
|
|
170
|
-
from fastmcp.server.openapi import FastMCPOpenAPI
|
|
171
|
-
|
|
172
|
-
# New
|
|
173
|
-
from fastmcp.server.openapi_new import FastMCPOpenAPI
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
2. **Constructor**: Same interface, no changes needed
|
|
177
|
-
|
|
178
|
-
3. **Automatic Benefits**:
|
|
179
|
-
- Eliminates startup latency (100-200ms improvement)
|
|
180
|
-
- Better OpenAPI compliance via openapi-core
|
|
181
|
-
- Serverless-friendly performance characteristics
|
|
182
|
-
- Simplified architecture without fallback complexity
|
|
183
|
-
|
|
184
|
-
### Performance Improvements
|
|
185
|
-
|
|
186
|
-
- **Cold Start**: Zero latency penalty for serverless deployments
|
|
187
|
-
- **Memory Usage**: Lower memory footprint without generated client code
|
|
188
|
-
- **Reliability**: No dynamic code generation failures
|
|
189
|
-
- **Maintainability**: Simpler architecture with fewer moving parts
|
|
190
|
-
|
|
191
163
|
## Future Enhancements
|
|
192
164
|
|
|
193
165
|
### Planned Features
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""OpenAPI utilities for FastMCP - refactored for better maintainability."""
|
|
2
|
+
|
|
3
|
+
# Import from models
|
|
4
|
+
from .models import (
|
|
5
|
+
HTTPRoute,
|
|
6
|
+
HttpMethod,
|
|
7
|
+
JsonSchema,
|
|
8
|
+
ParameterInfo,
|
|
9
|
+
ParameterLocation,
|
|
10
|
+
RequestBodyInfo,
|
|
11
|
+
ResponseInfo,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Import from parser
|
|
15
|
+
from .parser import parse_openapi_to_http_routes
|
|
16
|
+
|
|
17
|
+
# Import from formatters
|
|
18
|
+
from .formatters import (
|
|
19
|
+
format_array_parameter,
|
|
20
|
+
format_deep_object_parameter,
|
|
21
|
+
format_description_with_responses,
|
|
22
|
+
format_json_for_description,
|
|
23
|
+
format_simple_description,
|
|
24
|
+
generate_example_from_schema,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Import from schemas
|
|
28
|
+
from .schemas import (
|
|
29
|
+
_combine_schemas,
|
|
30
|
+
extract_output_schema_from_responses,
|
|
31
|
+
clean_schema_for_display,
|
|
32
|
+
_make_optional_parameter_nullable,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Import from json_schema_converter
|
|
36
|
+
from .json_schema_converter import (
|
|
37
|
+
convert_openapi_schema_to_json_schema,
|
|
38
|
+
convert_schema_definitions,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Export public symbols - maintaining backward compatibility
|
|
42
|
+
__all__ = [
|
|
43
|
+
"HTTPRoute",
|
|
44
|
+
"HttpMethod",
|
|
45
|
+
"JsonSchema",
|
|
46
|
+
"ParameterInfo",
|
|
47
|
+
"ParameterLocation",
|
|
48
|
+
"RequestBodyInfo",
|
|
49
|
+
"ResponseInfo",
|
|
50
|
+
"_combine_schemas",
|
|
51
|
+
"_make_optional_parameter_nullable",
|
|
52
|
+
"clean_schema_for_display",
|
|
53
|
+
"convert_openapi_schema_to_json_schema",
|
|
54
|
+
"convert_schema_definitions",
|
|
55
|
+
"extract_output_schema_from_responses",
|
|
56
|
+
"format_array_parameter",
|
|
57
|
+
"format_deep_object_parameter",
|
|
58
|
+
"format_description_with_responses",
|
|
59
|
+
"format_json_for_description",
|
|
60
|
+
"format_simple_description",
|
|
61
|
+
"generate_example_from_schema",
|
|
62
|
+
"parse_openapi_to_http_routes",
|
|
63
|
+
]
|
|
@@ -54,28 +54,27 @@ class RequestDirector:
|
|
|
54
54
|
url = self._build_url(route.path, path_params, base_url)
|
|
55
55
|
|
|
56
56
|
# Step 3: Prepare request data
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
57
|
+
method: str = route.method.upper()
|
|
58
|
+
params = query_params if query_params else None
|
|
59
|
+
headers = header_params if header_params else None
|
|
60
|
+
json_body: dict[str, Any] | list[Any] | None = None
|
|
61
|
+
content: str | bytes | None = None
|
|
63
62
|
|
|
64
63
|
# Step 4: Handle request body
|
|
65
64
|
if body is not None:
|
|
66
|
-
if isinstance(body, dict
|
|
67
|
-
|
|
65
|
+
if isinstance(body, dict | list):
|
|
66
|
+
json_body = body
|
|
68
67
|
else:
|
|
69
|
-
|
|
68
|
+
content = body
|
|
70
69
|
|
|
71
70
|
# Step 5: Create httpx.Request
|
|
72
71
|
return httpx.Request(
|
|
73
|
-
method=
|
|
74
|
-
url=
|
|
75
|
-
params=
|
|
76
|
-
headers=
|
|
77
|
-
json=
|
|
78
|
-
content=
|
|
72
|
+
method=method,
|
|
73
|
+
url=url,
|
|
74
|
+
params=params,
|
|
75
|
+
headers=headers,
|
|
76
|
+
json=json_body,
|
|
77
|
+
content=content,
|
|
79
78
|
)
|
|
80
79
|
|
|
81
80
|
def _unflatten_arguments(
|
|
@@ -67,13 +67,13 @@ def format_deep_object_parameter(
|
|
|
67
67
|
param_value: dict, parameter_name: str
|
|
68
68
|
) -> dict[str, str]:
|
|
69
69
|
"""
|
|
70
|
-
Format a dictionary parameter for
|
|
70
|
+
Format a dictionary parameter for deep-object style serialization.
|
|
71
71
|
|
|
72
72
|
According to OpenAPI 3.0 spec, deepObject style with explode=true serializes
|
|
73
73
|
object properties as separate query parameters with bracket notation.
|
|
74
74
|
|
|
75
|
-
For example
|
|
76
|
-
param[id]=123¶m[type]=user
|
|
75
|
+
For example, `{"id": "123", "type": "user"}` becomes
|
|
76
|
+
`param[id]=123¶m[type]=user`.
|
|
77
77
|
|
|
78
78
|
Args:
|
|
79
79
|
param_value: Dictionary value to format
|
|
@@ -84,7 +84,7 @@ def format_deep_object_parameter(
|
|
|
84
84
|
"""
|
|
85
85
|
if not isinstance(param_value, dict):
|
|
86
86
|
logger.warning(
|
|
87
|
-
f"
|
|
87
|
+
f"Deep-object style parameter '{parameter_name}' expected dict, got {type(param_value)}"
|
|
88
88
|
)
|
|
89
89
|
return {}
|
|
90
90
|
|
|
@@ -181,7 +181,7 @@ def generate_example_from_schema(schema: JsonSchema | None) -> Any:
|
|
|
181
181
|
|
|
182
182
|
|
|
183
183
|
def format_json_for_description(data: Any, indent: int = 2) -> str:
|
|
184
|
-
"""Formats Python data as a JSON string block for
|
|
184
|
+
"""Formats Python data as a JSON string block for Markdown."""
|
|
185
185
|
try:
|
|
186
186
|
json_str = json.dumps(data, indent=indent)
|
|
187
187
|
return f"```json\n{json_str}\n```"
|
|
@@ -60,7 +60,7 @@ def convert_openapi_schema_to_json_schema(
|
|
|
60
60
|
convert_one_of_to_any_of: Whether to convert oneOf to anyOf
|
|
61
61
|
|
|
62
62
|
Returns:
|
|
63
|
-
JSON Schema
|
|
63
|
+
JSON Schema-compatible dictionary
|
|
64
64
|
"""
|
|
65
65
|
if not isinstance(schema, dict):
|
|
66
66
|
return schema
|
|
@@ -164,10 +164,10 @@ def _convert_nullable_field(schema: dict[str, Any]) -> dict[str, Any]:
|
|
|
164
164
|
if isinstance(current_type, str):
|
|
165
165
|
result["type"] = [current_type, "null"]
|
|
166
166
|
elif isinstance(current_type, list) and "null" not in current_type:
|
|
167
|
-
result["type"] = current_type
|
|
167
|
+
result["type"] = [*current_type, "null"]
|
|
168
168
|
elif "oneOf" in result:
|
|
169
169
|
# Convert oneOf to anyOf with null
|
|
170
|
-
result["anyOf"] = result.pop("oneOf")
|
|
170
|
+
result["anyOf"] = [*result.pop("oneOf"), {"type": "null"}]
|
|
171
171
|
elif "anyOf" in result:
|
|
172
172
|
# Add null to anyOf if not present
|
|
173
173
|
if not any(item.get("type") == "null" for item in result["anyOf"]):
|
|
@@ -176,6 +176,10 @@ def _convert_nullable_field(schema: dict[str, Any]) -> dict[str, Any]:
|
|
|
176
176
|
# Wrap allOf in anyOf with null option
|
|
177
177
|
result["anyOf"] = [{"allOf": result.pop("allOf")}, {"type": "null"}]
|
|
178
178
|
|
|
179
|
+
# Handle enum fields - add null to enum values if present
|
|
180
|
+
if "enum" in result and None not in result["enum"]:
|
|
181
|
+
result["enum"] = result["enum"] + [None]
|
|
182
|
+
|
|
179
183
|
return result
|
|
180
184
|
|
|
181
185
|
|
|
@@ -178,7 +178,7 @@ class OpenAPIParser(
|
|
|
178
178
|
else:
|
|
179
179
|
# Special handling for components
|
|
180
180
|
if part == "components" and hasattr(target, "components"):
|
|
181
|
-
target =
|
|
181
|
+
target = target.components
|
|
182
182
|
elif hasattr(target, part): # Fallback check
|
|
183
183
|
target = getattr(target, part, None)
|
|
184
184
|
else:
|
|
@@ -474,9 +474,22 @@ class OpenAPIParser(
|
|
|
474
474
|
and media_type_obj.media_type_schema
|
|
475
475
|
):
|
|
476
476
|
try:
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
477
|
+
# Track if this is a top-level $ref before resolution
|
|
478
|
+
top_level_schema_name = None
|
|
479
|
+
media_schema = media_type_obj.media_type_schema
|
|
480
|
+
if isinstance(media_schema, self.reference_cls):
|
|
481
|
+
ref_str = media_schema.ref
|
|
482
|
+
if isinstance(ref_str, str) and ref_str.startswith(
|
|
483
|
+
"#/components/schemas/"
|
|
484
|
+
):
|
|
485
|
+
top_level_schema_name = ref_str.split("/")[-1]
|
|
486
|
+
|
|
487
|
+
schema_dict = self._extract_schema_as_dict(media_schema)
|
|
488
|
+
# Add marker for top-level schema if it was a ref
|
|
489
|
+
if top_level_schema_name:
|
|
490
|
+
schema_dict["x-fastmcp-top-level-schema"] = (
|
|
491
|
+
top_level_schema_name
|
|
492
|
+
)
|
|
480
493
|
resp_info.content_schema[media_type_str] = schema_dict
|
|
481
494
|
except ValueError as e:
|
|
482
495
|
# Re-raise ValueError for external reference errors
|
|
@@ -541,9 +554,7 @@ class OpenAPIParser(
|
|
|
541
554
|
if "$ref" in obj and isinstance(obj["$ref"], str):
|
|
542
555
|
ref = obj["$ref"]
|
|
543
556
|
# Handle both converted and unconverted refs
|
|
544
|
-
if ref.startswith("#/$defs/"):
|
|
545
|
-
schema_name = ref.split("/")[-1]
|
|
546
|
-
elif ref.startswith("#/components/schemas/"):
|
|
557
|
+
if ref.startswith(("#/$defs/", "#/components/schemas/")):
|
|
547
558
|
schema_name = ref.split("/")[-1]
|
|
548
559
|
else:
|
|
549
560
|
return
|
|
@@ -619,18 +630,28 @@ class OpenAPIParser(
|
|
|
619
630
|
Returns:
|
|
620
631
|
Dictionary containing only the schemas needed for outputs
|
|
621
632
|
"""
|
|
622
|
-
|
|
633
|
+
if not responses or not all_schemas:
|
|
634
|
+
return {}
|
|
635
|
+
|
|
636
|
+
needed_schemas: set[str] = set()
|
|
623
637
|
|
|
624
|
-
# Check responses for schema references
|
|
625
638
|
for response in responses.values():
|
|
626
|
-
if response.content_schema:
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
639
|
+
if not response.content_schema:
|
|
640
|
+
continue
|
|
641
|
+
|
|
642
|
+
for content_schema in response.content_schema.values():
|
|
643
|
+
deps = self._extract_schema_dependencies(content_schema, all_schemas)
|
|
644
|
+
needed_schemas.update(deps)
|
|
645
|
+
|
|
646
|
+
schema_name = content_schema.get("x-fastmcp-top-level-schema")
|
|
647
|
+
if isinstance(schema_name, str) and schema_name in all_schemas:
|
|
648
|
+
needed_schemas.add(schema_name)
|
|
649
|
+
self._extract_schema_dependencies(
|
|
650
|
+
all_schemas[schema_name],
|
|
651
|
+
all_schemas,
|
|
652
|
+
collected=needed_schemas,
|
|
630
653
|
)
|
|
631
|
-
needed_schemas.update(deps)
|
|
632
654
|
|
|
633
|
-
# Return only the needed output schemas
|
|
634
655
|
return {
|
|
635
656
|
name: all_schemas[name] for name in needed_schemas if name in all_schemas
|
|
636
657
|
}
|
|
@@ -795,6 +816,6 @@ class OpenAPIParser(
|
|
|
795
816
|
|
|
796
817
|
# Export public symbols
|
|
797
818
|
__all__ = [
|
|
798
|
-
"parse_openapi_to_http_routes",
|
|
799
819
|
"OpenAPIParser",
|
|
820
|
+
"parse_openapi_to_http_routes",
|
|
800
821
|
]
|