fastmcp 2.12.5__py3-none-any.whl → 2.13.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 +7 -6
- fastmcp/cli/install/claude_code.py +6 -6
- fastmcp/cli/install/claude_desktop.py +3 -3
- fastmcp/cli/install/cursor.py +7 -7
- fastmcp/cli/install/gemini_cli.py +3 -3
- fastmcp/cli/install/mcp_json.py +3 -3
- fastmcp/cli/run.py +13 -8
- fastmcp/client/auth/oauth.py +100 -208
- fastmcp/client/client.py +11 -11
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/transports.py +77 -22
- fastmcp/contrib/component_manager/component_service.py +6 -6
- fastmcp/contrib/mcp_mixin/README.md +32 -1
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +4 -0
- fastmcp/experimental/utilities/openapi/parser.py +23 -3
- fastmcp/prompts/prompt.py +13 -6
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/resource.py +13 -6
- fastmcp/resources/resource_manager.py +5 -164
- fastmcp/resources/template.py +107 -17
- fastmcp/resources/types.py +30 -24
- fastmcp/server/auth/auth.py +40 -32
- fastmcp/server/auth/handlers/authorize.py +324 -0
- fastmcp/server/auth/jwt_issuer.py +236 -0
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +1256 -242
- fastmcp/server/auth/oidc_proxy.py +23 -6
- fastmcp/server/auth/providers/auth0.py +40 -21
- fastmcp/server/auth/providers/aws.py +29 -3
- fastmcp/server/auth/providers/azure.py +178 -127
- fastmcp/server/auth/providers/descope.py +4 -6
- fastmcp/server/auth/providers/github.py +29 -8
- fastmcp/server/auth/providers/google.py +30 -9
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +8 -2
- fastmcp/server/auth/providers/scalekit.py +179 -0
- fastmcp/server/auth/providers/supabase.py +172 -0
- fastmcp/server/auth/providers/workos.py +32 -14
- fastmcp/server/context.py +122 -36
- fastmcp/server/http.py +58 -18
- fastmcp/server/low_level.py +121 -2
- fastmcp/server/middleware/caching.py +469 -0
- fastmcp/server/middleware/error_handling.py +6 -2
- fastmcp/server/middleware/logging.py +48 -37
- fastmcp/server/middleware/middleware.py +28 -15
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/proxy.py +6 -6
- fastmcp/server/server.py +683 -207
- fastmcp/settings.py +24 -10
- fastmcp/tools/tool.py +7 -3
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +3 -3
- fastmcp/utilities/cli.py +62 -22
- fastmcp/utilities/components.py +5 -0
- fastmcp/utilities/inspect.py +77 -21
- fastmcp/utilities/logging.py +118 -8
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
- fastmcp/utilities/tests.py +87 -4
- fastmcp/utilities/types.py +1 -1
- fastmcp/utilities/ui.py +617 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/METADATA +10 -6
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/RECORD +70 -63
- fastmcp/cli/claude.py +0 -135
- fastmcp/utilities/storage.py +0 -204
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/utilities/inspect.py
CHANGED
|
@@ -28,6 +28,7 @@ class ToolInfo:
|
|
|
28
28
|
tags: list[str] | None = None
|
|
29
29
|
enabled: bool | None = None
|
|
30
30
|
title: str | None = None
|
|
31
|
+
icons: list[dict[str, Any]] | None = None
|
|
31
32
|
meta: dict[str, Any] | None = None
|
|
32
33
|
|
|
33
34
|
|
|
@@ -42,6 +43,7 @@ class PromptInfo:
|
|
|
42
43
|
tags: list[str] | None = None
|
|
43
44
|
enabled: bool | None = None
|
|
44
45
|
title: str | None = None
|
|
46
|
+
icons: list[dict[str, Any]] | None = None
|
|
45
47
|
meta: dict[str, Any] | None = None
|
|
46
48
|
|
|
47
49
|
|
|
@@ -58,6 +60,7 @@ class ResourceInfo:
|
|
|
58
60
|
tags: list[str] | None = None
|
|
59
61
|
enabled: bool | None = None
|
|
60
62
|
title: str | None = None
|
|
63
|
+
icons: list[dict[str, Any]] | None = None
|
|
61
64
|
meta: dict[str, Any] | None = None
|
|
62
65
|
|
|
63
66
|
|
|
@@ -75,6 +78,7 @@ class TemplateInfo:
|
|
|
75
78
|
tags: list[str] | None = None
|
|
76
79
|
enabled: bool | None = None
|
|
77
80
|
title: str | None = None
|
|
81
|
+
icons: list[dict[str, Any]] | None = None
|
|
78
82
|
meta: dict[str, Any] | None = None
|
|
79
83
|
|
|
80
84
|
|
|
@@ -85,6 +89,8 @@ class FastMCPInfo:
|
|
|
85
89
|
name: str
|
|
86
90
|
instructions: str | None
|
|
87
91
|
version: str | None # The server's own version string (if specified)
|
|
92
|
+
website_url: str | None
|
|
93
|
+
icons: list[dict[str, Any]] | None
|
|
88
94
|
fastmcp_version: str # Version of FastMCP generating this manifest
|
|
89
95
|
mcp_version: str # Version of MCP protocol library
|
|
90
96
|
server_generation: int # Server generation: 1 (mcp package) or 2 (fastmcp)
|
|
@@ -104,21 +110,20 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
|
|
|
104
110
|
Returns:
|
|
105
111
|
FastMCPInfo dataclass containing the extracted information
|
|
106
112
|
"""
|
|
107
|
-
# Get all
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
113
|
+
# Get all components via middleware to respect filtering and preserve metadata
|
|
114
|
+
tools_list = await mcp._list_tools_middleware()
|
|
115
|
+
prompts_list = await mcp._list_prompts_middleware()
|
|
116
|
+
resources_list = await mcp._list_resources_middleware()
|
|
117
|
+
templates_list = await mcp._list_resource_templates_middleware()
|
|
112
118
|
|
|
113
119
|
# Extract detailed tool information
|
|
114
120
|
tool_infos = []
|
|
115
|
-
for
|
|
116
|
-
|
|
117
|
-
mcp_tool = tool.to_mcp_tool(name=key)
|
|
121
|
+
for tool in tools_list:
|
|
122
|
+
mcp_tool = tool.to_mcp_tool(name=tool.key)
|
|
118
123
|
tool_infos.append(
|
|
119
124
|
ToolInfo(
|
|
120
|
-
key=key,
|
|
121
|
-
name=tool.name or key,
|
|
125
|
+
key=tool.key,
|
|
126
|
+
name=tool.name or tool.key,
|
|
122
127
|
description=tool.description,
|
|
123
128
|
input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {},
|
|
124
129
|
output_schema=tool.output_schema,
|
|
@@ -126,17 +131,20 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
|
|
|
126
131
|
tags=list(tool.tags) if tool.tags else None,
|
|
127
132
|
enabled=tool.enabled,
|
|
128
133
|
title=tool.title,
|
|
134
|
+
icons=[icon.model_dump() for icon in tool.icons]
|
|
135
|
+
if tool.icons
|
|
136
|
+
else None,
|
|
129
137
|
meta=tool.meta,
|
|
130
138
|
)
|
|
131
139
|
)
|
|
132
140
|
|
|
133
141
|
# Extract detailed prompt information
|
|
134
142
|
prompt_infos = []
|
|
135
|
-
for
|
|
143
|
+
for prompt in prompts_list:
|
|
136
144
|
prompt_infos.append(
|
|
137
145
|
PromptInfo(
|
|
138
|
-
key=key,
|
|
139
|
-
name=prompt.name or key,
|
|
146
|
+
key=prompt.key,
|
|
147
|
+
name=prompt.name or prompt.key,
|
|
140
148
|
description=prompt.description,
|
|
141
149
|
arguments=[arg.model_dump() for arg in prompt.arguments]
|
|
142
150
|
if prompt.arguments
|
|
@@ -144,17 +152,20 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
|
|
|
144
152
|
tags=list(prompt.tags) if prompt.tags else None,
|
|
145
153
|
enabled=prompt.enabled,
|
|
146
154
|
title=prompt.title,
|
|
155
|
+
icons=[icon.model_dump() for icon in prompt.icons]
|
|
156
|
+
if prompt.icons
|
|
157
|
+
else None,
|
|
147
158
|
meta=prompt.meta,
|
|
148
159
|
)
|
|
149
160
|
)
|
|
150
161
|
|
|
151
162
|
# Extract detailed resource information
|
|
152
163
|
resource_infos = []
|
|
153
|
-
for
|
|
164
|
+
for resource in resources_list:
|
|
154
165
|
resource_infos.append(
|
|
155
166
|
ResourceInfo(
|
|
156
|
-
key=key,
|
|
157
|
-
uri=key,
|
|
167
|
+
key=resource.key,
|
|
168
|
+
uri=resource.key,
|
|
158
169
|
name=resource.name,
|
|
159
170
|
description=resource.description,
|
|
160
171
|
mime_type=resource.mime_type,
|
|
@@ -164,17 +175,20 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
|
|
|
164
175
|
tags=list(resource.tags) if resource.tags else None,
|
|
165
176
|
enabled=resource.enabled,
|
|
166
177
|
title=resource.title,
|
|
178
|
+
icons=[icon.model_dump() for icon in resource.icons]
|
|
179
|
+
if resource.icons
|
|
180
|
+
else None,
|
|
167
181
|
meta=resource.meta,
|
|
168
182
|
)
|
|
169
183
|
)
|
|
170
184
|
|
|
171
185
|
# Extract detailed template information
|
|
172
186
|
template_infos = []
|
|
173
|
-
for
|
|
187
|
+
for template in templates_list:
|
|
174
188
|
template_infos.append(
|
|
175
189
|
TemplateInfo(
|
|
176
|
-
key=key,
|
|
177
|
-
uri_template=key,
|
|
190
|
+
key=template.key,
|
|
191
|
+
uri_template=template.key,
|
|
178
192
|
name=template.name,
|
|
179
193
|
description=template.description,
|
|
180
194
|
mime_type=template.mime_type,
|
|
@@ -185,6 +199,9 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
|
|
|
185
199
|
tags=list(template.tags) if template.tags else None,
|
|
186
200
|
enabled=template.enabled,
|
|
187
201
|
title=template.title,
|
|
202
|
+
icons=[icon.model_dump() for icon in template.icons]
|
|
203
|
+
if template.icons
|
|
204
|
+
else None,
|
|
188
205
|
meta=template.meta,
|
|
189
206
|
)
|
|
190
207
|
)
|
|
@@ -197,13 +214,25 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
|
|
|
197
214
|
"logging": {},
|
|
198
215
|
}
|
|
199
216
|
|
|
217
|
+
# Extract server-level icons and website_url
|
|
218
|
+
server_icons = (
|
|
219
|
+
[icon.model_dump() for icon in mcp._mcp_server.icons]
|
|
220
|
+
if hasattr(mcp._mcp_server, "icons") and mcp._mcp_server.icons
|
|
221
|
+
else None
|
|
222
|
+
)
|
|
223
|
+
server_website_url = (
|
|
224
|
+
mcp._mcp_server.website_url if hasattr(mcp._mcp_server, "website_url") else None
|
|
225
|
+
)
|
|
226
|
+
|
|
200
227
|
return FastMCPInfo(
|
|
201
228
|
name=mcp.name,
|
|
202
229
|
instructions=mcp.instructions,
|
|
230
|
+
version=(mcp.version if hasattr(mcp, "version") else mcp._mcp_server.version),
|
|
231
|
+
website_url=server_website_url,
|
|
232
|
+
icons=server_icons,
|
|
203
233
|
fastmcp_version=fastmcp.__version__,
|
|
204
234
|
mcp_version=importlib.metadata.version("mcp"),
|
|
205
235
|
server_generation=2, # FastMCP v2
|
|
206
|
-
version=(mcp.version if hasattr(mcp, "version") else mcp._mcp_server.version),
|
|
207
236
|
tools=tool_infos,
|
|
208
237
|
prompts=prompt_infos,
|
|
209
238
|
resources=resource_infos,
|
|
@@ -248,6 +277,9 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo:
|
|
|
248
277
|
tags=None, # v1 doesn't have tags
|
|
249
278
|
enabled=None, # v1 doesn't have enabled field
|
|
250
279
|
title=None, # v1 doesn't have title
|
|
280
|
+
icons=[icon.model_dump() for icon in mcp_tool.icons]
|
|
281
|
+
if hasattr(mcp_tool, "icons") and mcp_tool.icons
|
|
282
|
+
else None,
|
|
251
283
|
meta=None, # v1 doesn't have meta field
|
|
252
284
|
)
|
|
253
285
|
)
|
|
@@ -269,6 +301,9 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo:
|
|
|
269
301
|
tags=None, # v1 doesn't have tags
|
|
270
302
|
enabled=None, # v1 doesn't have enabled field
|
|
271
303
|
title=None, # v1 doesn't have title
|
|
304
|
+
icons=[icon.model_dump() for icon in mcp_prompt.icons]
|
|
305
|
+
if hasattr(mcp_prompt, "icons") and mcp_prompt.icons
|
|
306
|
+
else None,
|
|
272
307
|
meta=None, # v1 doesn't have meta field
|
|
273
308
|
)
|
|
274
309
|
)
|
|
@@ -287,6 +322,9 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo:
|
|
|
287
322
|
tags=None, # v1 doesn't have tags
|
|
288
323
|
enabled=None, # v1 doesn't have enabled field
|
|
289
324
|
title=None, # v1 doesn't have title
|
|
325
|
+
icons=[icon.model_dump() for icon in mcp_resource.icons]
|
|
326
|
+
if hasattr(mcp_resource, "icons") and mcp_resource.icons
|
|
327
|
+
else None,
|
|
290
328
|
meta=None, # v1 doesn't have meta field
|
|
291
329
|
)
|
|
292
330
|
)
|
|
@@ -306,6 +344,9 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo:
|
|
|
306
344
|
tags=None, # v1 doesn't have tags
|
|
307
345
|
enabled=None, # v1 doesn't have enabled field
|
|
308
346
|
title=None, # v1 doesn't have title
|
|
347
|
+
icons=[icon.model_dump() for icon in mcp_template.icons]
|
|
348
|
+
if hasattr(mcp_template, "icons") and mcp_template.icons
|
|
349
|
+
else None,
|
|
309
350
|
meta=None, # v1 doesn't have meta field
|
|
310
351
|
)
|
|
311
352
|
)
|
|
@@ -318,13 +359,26 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo:
|
|
|
318
359
|
"logging": {},
|
|
319
360
|
}
|
|
320
361
|
|
|
362
|
+
# Extract server-level icons and website_url from serverInfo
|
|
363
|
+
server_info = client.initialize_result.serverInfo
|
|
364
|
+
server_icons = (
|
|
365
|
+
[icon.model_dump() for icon in server_info.icons]
|
|
366
|
+
if hasattr(server_info, "icons") and server_info.icons
|
|
367
|
+
else None
|
|
368
|
+
)
|
|
369
|
+
server_website_url = (
|
|
370
|
+
server_info.websiteUrl if hasattr(server_info, "websiteUrl") else None
|
|
371
|
+
)
|
|
372
|
+
|
|
321
373
|
return FastMCPInfo(
|
|
322
374
|
name=mcp._mcp_server.name,
|
|
323
375
|
instructions=mcp._mcp_server.instructions,
|
|
376
|
+
version=mcp._mcp_server.version,
|
|
377
|
+
website_url=server_website_url,
|
|
378
|
+
icons=server_icons,
|
|
324
379
|
fastmcp_version=fastmcp.__version__, # Version generating this manifest
|
|
325
380
|
mcp_version=importlib.metadata.version("mcp"),
|
|
326
381
|
server_generation=1, # MCP v1
|
|
327
|
-
version=mcp._mcp_server.version,
|
|
328
382
|
tools=tool_infos,
|
|
329
383
|
prompts=prompt_infos,
|
|
330
384
|
resources=resource_infos,
|
|
@@ -369,6 +423,8 @@ async def format_fastmcp_info(info: FastMCPInfo) -> bytes:
|
|
|
369
423
|
"name": info.name,
|
|
370
424
|
"instructions": info.instructions,
|
|
371
425
|
"version": info.version,
|
|
426
|
+
"website_url": info.website_url,
|
|
427
|
+
"icons": info.icons,
|
|
372
428
|
"generation": info.server_generation,
|
|
373
429
|
"capabilities": info.capabilities,
|
|
374
430
|
},
|
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,48 @@ 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
|
+
traceback_handler = RichHandler(
|
|
78
|
+
console=Console(stderr=True),
|
|
79
|
+
show_path=False,
|
|
80
|
+
show_level=False,
|
|
81
|
+
rich_tracebacks=enable_rich_tracebacks,
|
|
82
|
+
tracebacks_max_frames=3,
|
|
83
|
+
tracebacks_suppress=[fastmcp, mcp, pydantic],
|
|
84
|
+
**rich_kwargs,
|
|
85
|
+
)
|
|
86
|
+
traceback_handler.setFormatter(formatter)
|
|
87
|
+
|
|
88
|
+
traceback_handler.addFilter(lambda record: record.exc_info is not None)
|
|
60
89
|
|
|
61
90
|
# Remove any existing handlers to avoid duplicates on reconfiguration
|
|
62
91
|
for hdlr in logger.handlers[:]:
|
|
63
92
|
logger.removeHandler(hdlr)
|
|
64
93
|
|
|
65
94
|
logger.addHandler(handler)
|
|
66
|
-
|
|
67
|
-
# Don't propagate to the root logger
|
|
68
|
-
logger.propagate = False
|
|
95
|
+
logger.addHandler(traceback_handler)
|
|
69
96
|
|
|
70
97
|
|
|
71
98
|
@contextlib.contextmanager
|
|
@@ -118,3 +145,86 @@ def temporary_log_level(
|
|
|
118
145
|
)
|
|
119
146
|
else:
|
|
120
147
|
yield
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _ClampedLogFilter(logging.Filter):
|
|
151
|
+
min_level: tuple[int, str] | None
|
|
152
|
+
max_level: tuple[int, str] | None
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
157
|
+
| None = None,
|
|
158
|
+
max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
159
|
+
| None = None,
|
|
160
|
+
):
|
|
161
|
+
self.min_level = None
|
|
162
|
+
self.max_level = None
|
|
163
|
+
|
|
164
|
+
if min_level_no := self._level_to_no(level=min_level):
|
|
165
|
+
self.min_level = (min_level_no, str(min_level))
|
|
166
|
+
if max_level_no := self._level_to_no(level=max_level):
|
|
167
|
+
self.max_level = (max_level_no, str(max_level))
|
|
168
|
+
|
|
169
|
+
super().__init__()
|
|
170
|
+
|
|
171
|
+
def _level_to_no(
|
|
172
|
+
self, level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None
|
|
173
|
+
) -> int | None:
|
|
174
|
+
if level == "DEBUG":
|
|
175
|
+
return logging.DEBUG
|
|
176
|
+
elif level == "INFO":
|
|
177
|
+
return logging.INFO
|
|
178
|
+
elif level == "WARNING":
|
|
179
|
+
return logging.WARNING
|
|
180
|
+
elif level == "ERROR":
|
|
181
|
+
return logging.ERROR
|
|
182
|
+
elif level == "CRITICAL":
|
|
183
|
+
return logging.CRITICAL
|
|
184
|
+
else:
|
|
185
|
+
return None
|
|
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)
|
|
@@ -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)
|
|
@@ -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"
|
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
|
|
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
|
|
@@ -139,6 +140,88 @@ def run_server_in_process(
|
|
|
139
140
|
raise RuntimeError("Server process failed to terminate even after kill")
|
|
140
141
|
|
|
141
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
|
+
try:
|
|
220
|
+
await server_task
|
|
221
|
+
except asyncio.CancelledError:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
|
|
142
225
|
@contextmanager
|
|
143
226
|
def caplog_for_fastmcp(caplog):
|
|
144
227
|
"""Context manager to capture logs from FastMCP loggers even when propagation is disabled."""
|