fastmcp 2.12.1__py3-none-any.whl → 2.13.2__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 -2
- fastmcp/cli/cli.py +56 -36
- fastmcp/cli/install/__init__.py +2 -0
- fastmcp/cli/install/claude_code.py +7 -16
- fastmcp/cli/install/claude_desktop.py +4 -12
- fastmcp/cli/install/cursor.py +20 -30
- fastmcp/cli/install/gemini_cli.py +241 -0
- fastmcp/cli/install/mcp_json.py +4 -12
- fastmcp/cli/run.py +15 -94
- fastmcp/client/__init__.py +9 -9
- fastmcp/client/auth/oauth.py +117 -206
- fastmcp/client/client.py +123 -47
- fastmcp/client/elicitation.py +6 -1
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/sampling.py +1 -1
- fastmcp/client/transports.py +81 -26
- fastmcp/contrib/component_manager/__init__.py +1 -1
- fastmcp/contrib/component_manager/component_manager.py +2 -2
- fastmcp/contrib/component_manager/component_service.py +7 -7
- fastmcp/contrib/mcp_mixin/README.md +35 -4
- fastmcp/contrib/mcp_mixin/__init__.py +2 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +54 -7
- fastmcp/experimental/sampling/handlers/openai.py +2 -2
- fastmcp/experimental/server/openapi/__init__.py +5 -8
- fastmcp/experimental/server/openapi/components.py +11 -7
- fastmcp/experimental/server/openapi/routing.py +2 -2
- fastmcp/experimental/utilities/openapi/__init__.py +10 -15
- fastmcp/experimental/utilities/openapi/director.py +16 -10
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
- fastmcp/experimental/utilities/openapi/models.py +3 -3
- fastmcp/experimental/utilities/openapi/parser.py +37 -16
- fastmcp/experimental/utilities/openapi/schemas.py +33 -7
- fastmcp/mcp_config.py +3 -4
- fastmcp/prompts/__init__.py +1 -1
- fastmcp/prompts/prompt.py +32 -27
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/__init__.py +5 -5
- fastmcp/resources/resource.py +28 -20
- fastmcp/resources/resource_manager.py +9 -168
- fastmcp/resources/template.py +119 -27
- fastmcp/resources/types.py +30 -24
- fastmcp/server/__init__.py +1 -1
- fastmcp/server/auth/__init__.py +9 -5
- fastmcp/server/auth/auth.py +80 -47
- 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 +1556 -265
- fastmcp/server/auth/oidc_proxy.py +412 -0
- fastmcp/server/auth/providers/auth0.py +193 -0
- fastmcp/server/auth/providers/aws.py +263 -0
- fastmcp/server/auth/providers/azure.py +314 -129
- fastmcp/server/auth/providers/bearer.py +1 -1
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +229 -0
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/github.py +31 -6
- fastmcp/server/auth/providers/google.py +50 -7
- fastmcp/server/auth/providers/in_memory.py +27 -3
- 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 +37 -15
- fastmcp/server/context.py +194 -67
- fastmcp/server/dependencies.py +56 -16
- fastmcp/server/elicitation.py +1 -1
- fastmcp/server/http.py +57 -18
- fastmcp/server/low_level.py +121 -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 +158 -116
- fastmcp/server/middleware/middleware.py +30 -16
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/openapi.py +15 -7
- fastmcp/server/proxy.py +22 -11
- fastmcp/server/server.py +744 -254
- fastmcp/settings.py +65 -15
- fastmcp/tools/__init__.py +1 -1
- fastmcp/tools/tool.py +173 -108
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +13 -11
- fastmcp/utilities/cli.py +67 -28
- fastmcp/utilities/components.py +7 -2
- fastmcp/utilities/inspect.py +79 -23
- fastmcp/utilities/json_schema.py +21 -4
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/logging.py +182 -10
- 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 +10 -45
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +8 -7
- fastmcp/utilities/mcp_server_config/v1/schema.json +5 -1
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
- fastmcp/utilities/openapi.py +11 -11
- fastmcp/utilities/tests.py +93 -10
- fastmcp/utilities/types.py +87 -21
- fastmcp/utilities/ui.py +626 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/METADATA +141 -60
- fastmcp-2.13.2.dist-info/RECORD +144 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
- fastmcp/cli/claude.py +0 -144
- fastmcp-2.12.1.dist-info/RECORD +0 -128
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import shutil
|
|
3
2
|
import subprocess
|
|
4
|
-
import sys
|
|
5
3
|
from pathlib import Path
|
|
6
4
|
from typing import Literal
|
|
7
5
|
|
|
@@ -30,19 +28,19 @@ class UVEnvironment(Environment):
|
|
|
30
28
|
examples=[["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]],
|
|
31
29
|
)
|
|
32
30
|
|
|
33
|
-
requirements:
|
|
31
|
+
requirements: Path | None = Field(
|
|
34
32
|
default=None,
|
|
35
33
|
description="Path to requirements.txt file",
|
|
36
34
|
examples=["requirements.txt", "../requirements/prod.txt"],
|
|
37
35
|
)
|
|
38
36
|
|
|
39
|
-
project:
|
|
37
|
+
project: Path | None = Field(
|
|
40
38
|
default=None,
|
|
41
39
|
description="Path to project directory containing pyproject.toml",
|
|
42
40
|
examples=[".", "../my-project"],
|
|
43
41
|
)
|
|
44
42
|
|
|
45
|
-
editable: list[
|
|
43
|
+
editable: list[Path] | None = Field(
|
|
46
44
|
default=None,
|
|
47
45
|
description="Directories to install in editable mode",
|
|
48
46
|
examples=[[".", "../my-package"], ["/path/to/package"]],
|
|
@@ -59,14 +57,14 @@ class UVEnvironment(Environment):
|
|
|
59
57
|
If no environment configuration is set, returns the command unchanged.
|
|
60
58
|
"""
|
|
61
59
|
# If no environment setup is needed, return command as-is
|
|
62
|
-
if not self.
|
|
60
|
+
if not self._must_run_with_uv():
|
|
63
61
|
return command
|
|
64
62
|
|
|
65
63
|
args = ["uv", "run"]
|
|
66
64
|
|
|
67
65
|
# Add project if specified
|
|
68
66
|
if self.project:
|
|
69
|
-
args.extend(["--project", str(self.project)])
|
|
67
|
+
args.extend(["--project", str(self.project.resolve())])
|
|
70
68
|
|
|
71
69
|
# Add Python version if specified (only if no project, as project has its own Python)
|
|
72
70
|
if self.python and not self.project:
|
|
@@ -75,48 +73,24 @@ class UVEnvironment(Environment):
|
|
|
75
73
|
# Always add dependencies, requirements, and editable packages
|
|
76
74
|
# These work with --project to add additional packages on top of the project env
|
|
77
75
|
if self.dependencies:
|
|
78
|
-
for dep in self.dependencies:
|
|
76
|
+
for dep in sorted(set(self.dependencies)):
|
|
79
77
|
args.extend(["--with", dep])
|
|
80
78
|
|
|
81
79
|
# Add requirements file
|
|
82
80
|
if self.requirements:
|
|
83
|
-
args.extend(["--with-requirements", str(self.requirements)])
|
|
81
|
+
args.extend(["--with-requirements", str(self.requirements.resolve())])
|
|
84
82
|
|
|
85
83
|
# Add editable packages
|
|
86
84
|
if self.editable:
|
|
87
85
|
for editable_path in self.editable:
|
|
88
|
-
args.extend(["--with-editable", str(editable_path)])
|
|
86
|
+
args.extend(["--with-editable", str(editable_path.resolve())])
|
|
89
87
|
|
|
90
88
|
# Add the command
|
|
91
89
|
args.extend(command)
|
|
92
90
|
|
|
93
91
|
return args
|
|
94
92
|
|
|
95
|
-
def
|
|
96
|
-
"""Execute a command using uv run with this environment configuration.
|
|
97
|
-
|
|
98
|
-
Args:
|
|
99
|
-
command: Command and arguments to execute (e.g., ["fastmcp", "run", "server.py"])
|
|
100
|
-
"""
|
|
101
|
-
import subprocess
|
|
102
|
-
|
|
103
|
-
# Build the full uv command
|
|
104
|
-
cmd = self.build_command(command)
|
|
105
|
-
|
|
106
|
-
# Set marker to prevent infinite loops when subprocess calls FastMCP again
|
|
107
|
-
env = os.environ | {"FASTMCP_UV_SPAWNED": "1"}
|
|
108
|
-
|
|
109
|
-
logger.debug(f"Running command: {' '.join(cmd)}")
|
|
110
|
-
|
|
111
|
-
try:
|
|
112
|
-
# Run without capturing output so it flows through naturally
|
|
113
|
-
process = subprocess.run(cmd, check=True, env=env)
|
|
114
|
-
sys.exit(process.returncode)
|
|
115
|
-
except subprocess.CalledProcessError as e:
|
|
116
|
-
logger.error(f"Command failed: {e}")
|
|
117
|
-
sys.exit(e.returncode)
|
|
118
|
-
|
|
119
|
-
def _needs_setup(self) -> bool:
|
|
93
|
+
def _must_run_with_uv(self) -> bool:
|
|
120
94
|
"""Check if this environment config requires uv to set up.
|
|
121
95
|
|
|
122
96
|
Returns:
|
|
@@ -132,15 +106,6 @@ class UVEnvironment(Environment):
|
|
|
132
106
|
]
|
|
133
107
|
)
|
|
134
108
|
|
|
135
|
-
# Backward compatibility aliases
|
|
136
|
-
def needs_uv(self) -> bool:
|
|
137
|
-
"""Deprecated: Use _needs_setup() internally or check if build_command modifies the command."""
|
|
138
|
-
return self._needs_setup()
|
|
139
|
-
|
|
140
|
-
def build_uv_run_command(self, command: list[str]) -> list[str]:
|
|
141
|
-
"""Deprecated: Use build_command() instead."""
|
|
142
|
-
return self.build_command(command)
|
|
143
|
-
|
|
144
109
|
async def prepare(self, output_dir: Path | None = None) -> None:
|
|
145
110
|
"""Prepare the Python environment using uv.
|
|
146
111
|
|
|
@@ -157,7 +122,7 @@ class UVEnvironment(Environment):
|
|
|
157
122
|
)
|
|
158
123
|
|
|
159
124
|
# Only prepare environment if there are actual settings to apply
|
|
160
|
-
if not self.
|
|
125
|
+
if not self._must_run_with_uv():
|
|
161
126
|
logger.debug("No environment settings configured, skipping preparation")
|
|
162
127
|
return
|
|
163
128
|
|
|
@@ -26,7 +26,7 @@ logger = get_logger("cli.config")
|
|
|
26
26
|
FASTMCP_JSON_SCHEMA = "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json"
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
# Type alias for source union (will expand with GitSource, etc in future)
|
|
29
|
+
# Type alias for source union (will expand with GitSource, etc. in future)
|
|
30
30
|
SourceType: TypeAlias = FileSystemSource
|
|
31
31
|
|
|
32
32
|
# Type alias for environment union (will expand with other environments in future)
|
|
@@ -36,7 +36,7 @@ EnvironmentType: TypeAlias = UVEnvironment
|
|
|
36
36
|
class Deployment(BaseModel):
|
|
37
37
|
"""Configuration for server deployment and runtime settings."""
|
|
38
38
|
|
|
39
|
-
transport: Literal["stdio", "http", "sse"] | None = Field(
|
|
39
|
+
transport: Literal["stdio", "http", "sse", "streamable-http"] | None = Field(
|
|
40
40
|
default=None,
|
|
41
41
|
description="Transport protocol to use",
|
|
42
42
|
)
|
|
@@ -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
|
|
@@ -403,7 +403,8 @@ class MCPServerConfig(BaseModel):
|
|
|
403
403
|
run_args["port"] = self.deployment.port
|
|
404
404
|
if self.deployment.path:
|
|
405
405
|
run_args["path"] = self.deployment.path
|
|
406
|
-
|
|
406
|
+
if self.deployment.log_level:
|
|
407
|
+
run_args["log_level"] = self.deployment.log_level
|
|
407
408
|
|
|
408
409
|
# Override with any provided kwargs
|
|
409
410
|
run_args.update(kwargs)
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"enum": [
|
|
10
10
|
"stdio",
|
|
11
11
|
"http",
|
|
12
|
-
"sse"
|
|
12
|
+
"sse",
|
|
13
|
+
"streamable-http"
|
|
13
14
|
],
|
|
14
15
|
"type": "string"
|
|
15
16
|
},
|
|
@@ -249,6 +250,7 @@
|
|
|
249
250
|
"requirements": {
|
|
250
251
|
"anyOf": [
|
|
251
252
|
{
|
|
253
|
+
"format": "path",
|
|
252
254
|
"type": "string"
|
|
253
255
|
},
|
|
254
256
|
{
|
|
@@ -266,6 +268,7 @@
|
|
|
266
268
|
"project": {
|
|
267
269
|
"anyOf": [
|
|
268
270
|
{
|
|
271
|
+
"format": "path",
|
|
269
272
|
"type": "string"
|
|
270
273
|
},
|
|
271
274
|
{
|
|
@@ -284,6 +287,7 @@
|
|
|
284
287
|
"anyOf": [
|
|
285
288
|
{
|
|
286
289
|
"items": {
|
|
290
|
+
"format": "path",
|
|
287
291
|
"type": "string"
|
|
288
292
|
},
|
|
289
293
|
"type": "array"
|
fastmcp/utilities/openapi.py
CHANGED
|
@@ -175,16 +175,16 @@ class HTTPRoute(FastMCPBaseModel):
|
|
|
175
175
|
# Export public symbols
|
|
176
176
|
__all__ = [
|
|
177
177
|
"HTTPRoute",
|
|
178
|
+
"HttpMethod",
|
|
179
|
+
"JsonSchema",
|
|
178
180
|
"ParameterInfo",
|
|
181
|
+
"ParameterLocation",
|
|
179
182
|
"RequestBodyInfo",
|
|
180
183
|
"ResponseInfo",
|
|
181
|
-
"
|
|
182
|
-
"ParameterLocation",
|
|
183
|
-
"JsonSchema",
|
|
184
|
-
"parse_openapi_to_http_routes",
|
|
184
|
+
"_handle_nullable_fields",
|
|
185
185
|
"extract_output_schema_from_responses",
|
|
186
186
|
"format_deep_object_parameter",
|
|
187
|
-
"
|
|
187
|
+
"parse_openapi_to_http_routes",
|
|
188
188
|
]
|
|
189
189
|
|
|
190
190
|
# Type variables for generic parser
|
|
@@ -321,7 +321,7 @@ class OpenAPIParser(
|
|
|
321
321
|
else:
|
|
322
322
|
# Special handling for components
|
|
323
323
|
if part == "components" and hasattr(target, "components"):
|
|
324
|
-
target =
|
|
324
|
+
target = target.components
|
|
325
325
|
elif hasattr(target, part): # Fallback check
|
|
326
326
|
target = getattr(target, part, None)
|
|
327
327
|
else:
|
|
@@ -1178,10 +1178,10 @@ def _add_null_to_type(schema: dict[str, Any]) -> None:
|
|
|
1178
1178
|
elif isinstance(current_type, list):
|
|
1179
1179
|
# Add null to array if not already present
|
|
1180
1180
|
if "null" not in current_type:
|
|
1181
|
-
schema["type"] = current_type
|
|
1181
|
+
schema["type"] = [*current_type, "null"]
|
|
1182
1182
|
elif "oneOf" in schema:
|
|
1183
1183
|
# Convert oneOf to anyOf with null type
|
|
1184
|
-
schema["anyOf"] = schema.pop("oneOf")
|
|
1184
|
+
schema["anyOf"] = [*schema.pop("oneOf"), {"type": "null"}]
|
|
1185
1185
|
elif "anyOf" in schema:
|
|
1186
1186
|
# Add null type to anyOf if not already present
|
|
1187
1187
|
if not any(item.get("type") == "null" for item in schema["anyOf"]):
|
|
@@ -1233,7 +1233,7 @@ def _handle_nullable_fields(schema: dict[str, Any] | Any) -> dict[str, Any] | An
|
|
|
1233
1233
|
|
|
1234
1234
|
# Handle properties nullable fields
|
|
1235
1235
|
if has_property_nullable_field and "properties" in result:
|
|
1236
|
-
for
|
|
1236
|
+
for _prop_name, prop_schema in result["properties"].items():
|
|
1237
1237
|
if isinstance(prop_schema, dict) and "nullable" in prop_schema:
|
|
1238
1238
|
nullable_value = prop_schema.pop("nullable")
|
|
1239
1239
|
if nullable_value and (
|
|
@@ -1371,7 +1371,7 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
|
1371
1371
|
if used_refs:
|
|
1372
1372
|
result["$defs"] = {
|
|
1373
1373
|
name: def_schema
|
|
1374
|
-
for name, def_schema in result["$defs"].items()
|
|
1374
|
+
for name, def_schema in result["$defs"].items() # type: ignore[index]
|
|
1375
1375
|
if name in used_refs
|
|
1376
1376
|
}
|
|
1377
1377
|
else:
|
|
@@ -1556,7 +1556,7 @@ def extract_output_schema_from_responses(
|
|
|
1556
1556
|
if used_refs:
|
|
1557
1557
|
output_schema["$defs"] = {
|
|
1558
1558
|
name: def_schema
|
|
1559
|
-
for name, def_schema in output_schema["$defs"].items()
|
|
1559
|
+
for name, def_schema in output_schema["$defs"].items() # type: ignore[index]
|
|
1560
1560
|
if name in used_refs
|
|
1561
1561
|
}
|
|
1562
1562
|
else:
|
fastmcp/utilities/tests.py
CHANGED
|
@@ -5,8 +5,8 @@ import logging
|
|
|
5
5
|
import multiprocessing
|
|
6
6
|
import socket
|
|
7
7
|
import time
|
|
8
|
-
from collections.abc import Callable, Generator
|
|
9
|
-
from contextlib import contextmanager
|
|
8
|
+
from collections.abc import AsyncGenerator, Callable, Generator
|
|
9
|
+
from contextlib import asynccontextmanager, contextmanager, suppress
|
|
10
10
|
from typing import TYPE_CHECKING, Any, Literal
|
|
11
11
|
from urllib.parse import parse_qs, urlparse
|
|
12
12
|
|
|
@@ -66,6 +66,7 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No
|
|
|
66
66
|
host="127.0.0.1",
|
|
67
67
|
port=port,
|
|
68
68
|
log_level="error",
|
|
69
|
+
ws="websockets-sansio",
|
|
69
70
|
)
|
|
70
71
|
)
|
|
71
72
|
uvicorn_server.run()
|
|
@@ -74,11 +75,11 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No
|
|
|
74
75
|
@contextmanager
|
|
75
76
|
def run_server_in_process(
|
|
76
77
|
server_fn: Callable[..., None],
|
|
77
|
-
*args,
|
|
78
|
+
*args: Any,
|
|
78
79
|
provide_host_and_port: bool = True,
|
|
79
80
|
host: str = "127.0.0.1",
|
|
80
81
|
port: int | None = None,
|
|
81
|
-
**kwargs,
|
|
82
|
+
**kwargs: Any,
|
|
82
83
|
) -> Generator[str, None, None]:
|
|
83
84
|
"""
|
|
84
85
|
Context manager that runs a FastMCP server in a separate process and
|
|
@@ -109,7 +110,7 @@ def run_server_in_process(
|
|
|
109
110
|
proc.start()
|
|
110
111
|
|
|
111
112
|
# Wait for server to be running
|
|
112
|
-
max_attempts =
|
|
113
|
+
max_attempts = 30
|
|
113
114
|
attempt = 0
|
|
114
115
|
while attempt < max_attempts and proc.is_alive():
|
|
115
116
|
try:
|
|
@@ -117,10 +118,12 @@ def run_server_in_process(
|
|
|
117
118
|
s.connect((host, port))
|
|
118
119
|
break
|
|
119
120
|
except ConnectionRefusedError:
|
|
120
|
-
if attempt <
|
|
121
|
-
time.sleep(0.
|
|
122
|
-
|
|
121
|
+
if attempt < 5:
|
|
122
|
+
time.sleep(0.05)
|
|
123
|
+
elif attempt < 15:
|
|
123
124
|
time.sleep(0.1)
|
|
125
|
+
else:
|
|
126
|
+
time.sleep(0.2)
|
|
124
127
|
attempt += 1
|
|
125
128
|
else:
|
|
126
129
|
raise RuntimeError(f"Server failed to start after {max_attempts} attempts")
|
|
@@ -137,14 +140,94 @@ def run_server_in_process(
|
|
|
137
140
|
raise RuntimeError("Server process failed to terminate even after kill")
|
|
138
141
|
|
|
139
142
|
|
|
143
|
+
@asynccontextmanager
|
|
144
|
+
async def run_server_async(
|
|
145
|
+
server: FastMCP,
|
|
146
|
+
port: int | None = None,
|
|
147
|
+
transport: Literal["http", "streamable-http", "sse"] = "http",
|
|
148
|
+
path: str = "/mcp",
|
|
149
|
+
host: str = "127.0.0.1",
|
|
150
|
+
) -> AsyncGenerator[str, None]:
|
|
151
|
+
"""
|
|
152
|
+
Start a FastMCP server as an asyncio task for in-process async testing.
|
|
153
|
+
|
|
154
|
+
This is the recommended way to test FastMCP servers. It runs the server
|
|
155
|
+
as an async task in the same process, eliminating subprocess coordination,
|
|
156
|
+
sleeps, and cleanup issues.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
server: FastMCP server instance
|
|
160
|
+
port: Port to bind to (default: find available port)
|
|
161
|
+
transport: Transport type ("http", "streamable-http", or "sse")
|
|
162
|
+
path: URL path for the server (default: "/mcp")
|
|
163
|
+
host: Host to bind to (default: "127.0.0.1")
|
|
164
|
+
|
|
165
|
+
Yields:
|
|
166
|
+
Server URL string
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
```python
|
|
170
|
+
import pytest
|
|
171
|
+
from fastmcp import FastMCP, Client
|
|
172
|
+
from fastmcp.client.transports import StreamableHttpTransport
|
|
173
|
+
from fastmcp.utilities.tests import run_server_async
|
|
174
|
+
|
|
175
|
+
@pytest.fixture
|
|
176
|
+
async def server():
|
|
177
|
+
mcp = FastMCP("test")
|
|
178
|
+
|
|
179
|
+
@mcp.tool()
|
|
180
|
+
def greet(name: str) -> str:
|
|
181
|
+
return f"Hello, {name}!"
|
|
182
|
+
|
|
183
|
+
async with run_server_async(mcp) as url:
|
|
184
|
+
yield url
|
|
185
|
+
|
|
186
|
+
async def test_greet(server: str):
|
|
187
|
+
async with Client(StreamableHttpTransport(server)) as client:
|
|
188
|
+
result = await client.call_tool("greet", {"name": "World"})
|
|
189
|
+
assert result.content[0].text == "Hello, World!"
|
|
190
|
+
```
|
|
191
|
+
"""
|
|
192
|
+
import asyncio
|
|
193
|
+
|
|
194
|
+
if port is None:
|
|
195
|
+
port = find_available_port()
|
|
196
|
+
|
|
197
|
+
# Wait a tiny bit for the port to be released if it was just used
|
|
198
|
+
await asyncio.sleep(0.01)
|
|
199
|
+
|
|
200
|
+
# Start server as a background task
|
|
201
|
+
server_task = asyncio.create_task(
|
|
202
|
+
server.run_http_async(
|
|
203
|
+
host=host,
|
|
204
|
+
port=port,
|
|
205
|
+
transport=transport,
|
|
206
|
+
path=path,
|
|
207
|
+
show_banner=False,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Give the server a moment to start
|
|
212
|
+
await asyncio.sleep(0.1)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
yield f"http://{host}:{port}{path}"
|
|
216
|
+
finally:
|
|
217
|
+
# Cleanup: cancel the task
|
|
218
|
+
server_task.cancel()
|
|
219
|
+
with suppress(asyncio.CancelledError):
|
|
220
|
+
await server_task
|
|
221
|
+
|
|
222
|
+
|
|
140
223
|
@contextmanager
|
|
141
224
|
def caplog_for_fastmcp(caplog):
|
|
142
225
|
"""Context manager to capture logs from FastMCP loggers even when propagation is disabled."""
|
|
143
226
|
caplog.clear()
|
|
144
|
-
logger = logging.getLogger("
|
|
227
|
+
logger = logging.getLogger("fastmcp")
|
|
145
228
|
logger.addHandler(caplog.handler)
|
|
146
229
|
try:
|
|
147
|
-
yield
|
|
230
|
+
yield caplog
|
|
148
231
|
finally:
|
|
149
232
|
logger.removeHandler(caplog.handler)
|
|
150
233
|
|
fastmcp/utilities/types.py
CHANGED
|
@@ -31,6 +31,10 @@ NotSet = ...
|
|
|
31
31
|
NotSetT: TypeAlias = EllipsisType
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
def get_fn_name(fn: Callable[..., Any]) -> str:
|
|
35
|
+
return fn.__name__ # ty: ignore[unresolved-attribute]
|
|
36
|
+
|
|
37
|
+
|
|
34
38
|
class FastMCPBaseModel(BaseModel):
|
|
35
39
|
"""Base model for FastMCP models."""
|
|
36
40
|
|
|
@@ -80,11 +84,11 @@ def get_cached_typeadapter(cls: T) -> TypeAdapter[T]:
|
|
|
80
84
|
# Handle both functions and methods
|
|
81
85
|
if inspect.ismethod(cls):
|
|
82
86
|
actual_func = cls.__func__
|
|
83
|
-
code = actual_func.__code__
|
|
84
|
-
globals_dict = actual_func.__globals__
|
|
85
|
-
name = actual_func.__name__
|
|
86
|
-
defaults = actual_func.__defaults__
|
|
87
|
-
closure = actual_func.__closure__
|
|
87
|
+
code = actual_func.__code__ # ty: ignore[unresolved-attribute]
|
|
88
|
+
globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute]
|
|
89
|
+
name = actual_func.__name__ # ty: ignore[unresolved-attribute]
|
|
90
|
+
defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute]
|
|
91
|
+
closure = actual_func.__closure__ # ty: ignore[unresolved-attribute]
|
|
88
92
|
else:
|
|
89
93
|
code = cls.__code__
|
|
90
94
|
globals_dict = cls.__globals__
|
|
@@ -171,6 +175,55 @@ def find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None:
|
|
|
171
175
|
return None
|
|
172
176
|
|
|
173
177
|
|
|
178
|
+
def create_function_without_params(
|
|
179
|
+
fn: Callable[..., Any], exclude_params: list[str]
|
|
180
|
+
) -> Callable[..., Any]:
|
|
181
|
+
"""
|
|
182
|
+
Create a new function with the same code but without the specified parameters in annotations.
|
|
183
|
+
|
|
184
|
+
This is used to exclude parameters from type adapter processing when they can't be serialized.
|
|
185
|
+
The excluded parameters are removed from the function's __annotations__ dictionary.
|
|
186
|
+
"""
|
|
187
|
+
import types
|
|
188
|
+
|
|
189
|
+
if inspect.ismethod(fn):
|
|
190
|
+
actual_func = fn.__func__
|
|
191
|
+
code = actual_func.__code__ # ty: ignore[unresolved-attribute]
|
|
192
|
+
globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute]
|
|
193
|
+
name = actual_func.__name__ # ty: ignore[unresolved-attribute]
|
|
194
|
+
defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute]
|
|
195
|
+
closure = actual_func.__closure__ # ty: ignore[unresolved-attribute]
|
|
196
|
+
else:
|
|
197
|
+
code = fn.__code__ # ty: ignore[unresolved-attribute]
|
|
198
|
+
globals_dict = fn.__globals__ # ty: ignore[unresolved-attribute]
|
|
199
|
+
name = fn.__name__ # ty: ignore[unresolved-attribute]
|
|
200
|
+
defaults = fn.__defaults__ # ty: ignore[unresolved-attribute]
|
|
201
|
+
closure = fn.__closure__ # ty: ignore[unresolved-attribute]
|
|
202
|
+
|
|
203
|
+
# Create a copy of annotations without the excluded parameters
|
|
204
|
+
original_annotations = getattr(fn, "__annotations__", {})
|
|
205
|
+
new_annotations = {
|
|
206
|
+
k: v for k, v in original_annotations.items() if k not in exclude_params
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
new_func = types.FunctionType(
|
|
210
|
+
code,
|
|
211
|
+
globals_dict,
|
|
212
|
+
name,
|
|
213
|
+
defaults,
|
|
214
|
+
closure,
|
|
215
|
+
)
|
|
216
|
+
new_func.__dict__.update(fn.__dict__)
|
|
217
|
+
new_func.__module__ = fn.__module__
|
|
218
|
+
new_func.__qualname__ = getattr(fn, "__qualname__", fn.__name__) # ty: ignore[unresolved-attribute]
|
|
219
|
+
new_func.__annotations__ = new_annotations
|
|
220
|
+
|
|
221
|
+
if inspect.ismethod(fn):
|
|
222
|
+
return types.MethodType(new_func, fn.__self__)
|
|
223
|
+
else:
|
|
224
|
+
return new_func
|
|
225
|
+
|
|
226
|
+
|
|
174
227
|
class Image:
|
|
175
228
|
"""Helper class for returning images from tools."""
|
|
176
229
|
|
|
@@ -186,34 +239,33 @@ class Image:
|
|
|
186
239
|
if path is not None and data is not None:
|
|
187
240
|
raise ValueError("Only one of path or data can be provided")
|
|
188
241
|
|
|
189
|
-
self.path =
|
|
242
|
+
self.path = self._get_expanded_path(path)
|
|
190
243
|
self.data = data
|
|
191
244
|
self._format = format
|
|
192
245
|
self._mime_type = self._get_mime_type()
|
|
193
246
|
self.annotations = annotations
|
|
194
247
|
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _get_expanded_path(path: str | Path | None) -> Path | None:
|
|
250
|
+
"""Expand environment variables and user home in path."""
|
|
251
|
+
return Path(os.path.expandvars(str(path))).expanduser() if path else None
|
|
252
|
+
|
|
195
253
|
def _get_mime_type(self) -> str:
|
|
196
254
|
"""Get MIME type from format or guess from file extension."""
|
|
197
255
|
if self._format:
|
|
198
256
|
return f"image/{self._format.lower()}"
|
|
199
257
|
|
|
200
258
|
if self.path:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
".webp": "image/webp",
|
|
208
|
-
}.get(suffix, "application/octet-stream")
|
|
259
|
+
# Workaround for WEBP in Py3.10
|
|
260
|
+
mimetypes.add_type("image/webp", ".webp")
|
|
261
|
+
resp = mimetypes.guess_type(self.path, strict=False)
|
|
262
|
+
if resp and resp[0] is not None:
|
|
263
|
+
return resp[0]
|
|
264
|
+
return "application/octet-stream"
|
|
209
265
|
return "image/png" # default for raw binary data
|
|
210
266
|
|
|
211
|
-
def
|
|
212
|
-
|
|
213
|
-
mime_type: str | None = None,
|
|
214
|
-
annotations: Annotations | None = None,
|
|
215
|
-
) -> mcp.types.ImageContent:
|
|
216
|
-
"""Convert to MCP ImageContent."""
|
|
267
|
+
def _get_data(self) -> str:
|
|
268
|
+
"""Get raw image data as base64-encoded string."""
|
|
217
269
|
if self.path:
|
|
218
270
|
with open(self.path, "rb") as f:
|
|
219
271
|
data = base64.b64encode(f.read()).decode()
|
|
@@ -221,6 +273,15 @@ class Image:
|
|
|
221
273
|
data = base64.b64encode(self.data).decode()
|
|
222
274
|
else:
|
|
223
275
|
raise ValueError("No image data available")
|
|
276
|
+
return data
|
|
277
|
+
|
|
278
|
+
def to_image_content(
|
|
279
|
+
self,
|
|
280
|
+
mime_type: str | None = None,
|
|
281
|
+
annotations: Annotations | None = None,
|
|
282
|
+
) -> mcp.types.ImageContent:
|
|
283
|
+
"""Convert to MCP ImageContent."""
|
|
284
|
+
data = self._get_data()
|
|
224
285
|
|
|
225
286
|
return mcp.types.ImageContent(
|
|
226
287
|
type="image",
|
|
@@ -229,6 +290,11 @@ class Image:
|
|
|
229
290
|
annotations=annotations or self.annotations,
|
|
230
291
|
)
|
|
231
292
|
|
|
293
|
+
def to_data_uri(self, mime_type: str | None = None) -> str:
|
|
294
|
+
"""Get image as a data URI."""
|
|
295
|
+
data = self._get_data()
|
|
296
|
+
return f"data:{mime_type or self._mime_type};base64,{data}"
|
|
297
|
+
|
|
232
298
|
|
|
233
299
|
class Audio:
|
|
234
300
|
"""Helper class for returning audio from tools."""
|
|
@@ -289,7 +355,7 @@ class Audio:
|
|
|
289
355
|
|
|
290
356
|
|
|
291
357
|
class File:
|
|
292
|
-
"""Helper class for returning
|
|
358
|
+
"""Helper class for returning file data from tools."""
|
|
293
359
|
|
|
294
360
|
def __init__(
|
|
295
361
|
self,
|