fastmcp 2.8.1__py3-none-any.whl → 2.9.1__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 +99 -1
- fastmcp/cli/run.py +1 -3
- fastmcp/client/auth/oauth.py +1 -2
- fastmcp/client/client.py +23 -7
- fastmcp/client/logging.py +1 -2
- fastmcp/client/messages.py +126 -0
- fastmcp/client/transports.py +17 -2
- fastmcp/contrib/mcp_mixin/README.md +79 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -0
- fastmcp/prompts/prompt.py +109 -13
- fastmcp/prompts/prompt_manager.py +119 -43
- fastmcp/resources/resource.py +27 -1
- fastmcp/resources/resource_manager.py +249 -76
- fastmcp/resources/template.py +44 -2
- fastmcp/server/auth/providers/bearer.py +62 -13
- fastmcp/server/context.py +113 -10
- fastmcp/server/http.py +8 -0
- fastmcp/server/low_level.py +35 -0
- fastmcp/server/middleware/__init__.py +6 -0
- fastmcp/server/middleware/error_handling.py +206 -0
- fastmcp/server/middleware/logging.py +165 -0
- fastmcp/server/middleware/middleware.py +236 -0
- fastmcp/server/middleware/rate_limiting.py +231 -0
- fastmcp/server/middleware/timing.py +156 -0
- fastmcp/server/proxy.py +250 -140
- fastmcp/server/server.py +446 -280
- fastmcp/settings.py +2 -2
- fastmcp/tools/tool.py +22 -2
- fastmcp/tools/tool_manager.py +114 -45
- fastmcp/tools/tool_transform.py +42 -16
- fastmcp/utilities/components.py +22 -2
- fastmcp/utilities/inspect.py +326 -0
- fastmcp/utilities/json_schema.py +67 -23
- fastmcp/utilities/mcp_config.py +13 -7
- fastmcp/utilities/openapi.py +75 -5
- fastmcp/utilities/tests.py +1 -1
- fastmcp/utilities/types.py +90 -1
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/METADATA +2 -2
- fastmcp-2.9.1.dist-info/RECORD +78 -0
- fastmcp-2.8.1.dist-info/RECORD +0 -69
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/licenses/LICENSE +0 -0
fastmcp/settings.py
CHANGED
|
@@ -192,9 +192,9 @@ class Settings(BaseSettings):
|
|
|
192
192
|
# HTTP settings
|
|
193
193
|
host: str = "127.0.0.1"
|
|
194
194
|
port: int = 8000
|
|
195
|
-
sse_path: str = "/sse"
|
|
195
|
+
sse_path: str = "/sse/"
|
|
196
196
|
message_path: str = "/messages/"
|
|
197
|
-
streamable_http_path: str = "/mcp"
|
|
197
|
+
streamable_http_path: str = "/mcp/"
|
|
198
198
|
debug: bool = False
|
|
199
199
|
|
|
200
200
|
# error handling
|
fastmcp/tools/tool.py
CHANGED
|
@@ -18,6 +18,7 @@ from fastmcp.utilities.json_schema import compress_schema
|
|
|
18
18
|
from fastmcp.utilities.logging import get_logger
|
|
19
19
|
from fastmcp.utilities.types import (
|
|
20
20
|
Audio,
|
|
21
|
+
File,
|
|
21
22
|
Image,
|
|
22
23
|
MCPContent,
|
|
23
24
|
find_kwarg_by_type,
|
|
@@ -45,6 +46,22 @@ class Tool(FastMCPComponent):
|
|
|
45
46
|
default=None, description="Optional custom serializer for tool results"
|
|
46
47
|
)
|
|
47
48
|
|
|
49
|
+
def enable(self) -> None:
|
|
50
|
+
super().enable()
|
|
51
|
+
try:
|
|
52
|
+
context = get_context()
|
|
53
|
+
context._queue_tool_list_changed() # type: ignore[private-use]
|
|
54
|
+
except RuntimeError:
|
|
55
|
+
pass # No context available
|
|
56
|
+
|
|
57
|
+
def disable(self) -> None:
|
|
58
|
+
super().disable()
|
|
59
|
+
try:
|
|
60
|
+
context = get_context()
|
|
61
|
+
context._queue_tool_list_changed() # type: ignore[private-use]
|
|
62
|
+
except RuntimeError:
|
|
63
|
+
pass # No context available
|
|
64
|
+
|
|
48
65
|
def to_mcp_tool(self, **overrides: Any) -> MCPTool:
|
|
49
66
|
kwargs = {
|
|
50
67
|
"name": self.name,
|
|
@@ -231,7 +248,7 @@ class ParsedFunction:
|
|
|
231
248
|
|
|
232
249
|
# collect name and doc before we potentially modify the function
|
|
233
250
|
fn_name = getattr(fn, "__name__", None) or fn.__class__.__name__
|
|
234
|
-
fn_doc = fn
|
|
251
|
+
fn_doc = inspect.getdoc(fn)
|
|
235
252
|
|
|
236
253
|
# if the fn is a callable class, we need to get the __call__ method from here out
|
|
237
254
|
if not inspect.isroutine(fn):
|
|
@@ -277,6 +294,9 @@ def _convert_to_content(
|
|
|
277
294
|
elif isinstance(result, Audio):
|
|
278
295
|
return [result.to_audio_content()]
|
|
279
296
|
|
|
297
|
+
elif isinstance(result, File):
|
|
298
|
+
return [result.to_resource_content()]
|
|
299
|
+
|
|
280
300
|
if isinstance(result, list | tuple) and not _process_as_single_item:
|
|
281
301
|
# if the result is a list, then it could either be a list of MCP types,
|
|
282
302
|
# or a "regular" list that the tool is returning, or a mix of both.
|
|
@@ -288,7 +308,7 @@ def _convert_to_content(
|
|
|
288
308
|
other_content = []
|
|
289
309
|
|
|
290
310
|
for item in result:
|
|
291
|
-
if isinstance(item, MCPContent | Image | Audio):
|
|
311
|
+
if isinstance(item, MCPContent | Image | Audio | File):
|
|
292
312
|
mcp_types.append(_convert_to_content(item)[0])
|
|
293
313
|
else:
|
|
294
314
|
other_content.append(item)
|
fastmcp/tools/tool_manager.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
1
|
+
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import warnings
|
|
4
4
|
from collections.abc import Callable
|
|
@@ -14,7 +14,7 @@ from fastmcp.utilities.logging import get_logger
|
|
|
14
14
|
from fastmcp.utilities.types import MCPContent
|
|
15
15
|
|
|
16
16
|
if TYPE_CHECKING:
|
|
17
|
-
|
|
17
|
+
from fastmcp.server.server import MountedServer
|
|
18
18
|
|
|
19
19
|
logger = get_logger(__name__)
|
|
20
20
|
|
|
@@ -28,6 +28,7 @@ class ToolManager:
|
|
|
28
28
|
mask_error_details: bool | None = None,
|
|
29
29
|
):
|
|
30
30
|
self._tools: dict[str, Tool] = {}
|
|
31
|
+
self._mounted_servers: list[MountedServer] = []
|
|
31
32
|
self.mask_error_details = mask_error_details or settings.mask_error_details
|
|
32
33
|
|
|
33
34
|
# Default to "warn" if None is provided
|
|
@@ -42,23 +43,72 @@ class ToolManager:
|
|
|
42
43
|
|
|
43
44
|
self.duplicate_behavior = duplicate_behavior
|
|
44
45
|
|
|
45
|
-
def
|
|
46
|
+
def mount(self, server: MountedServer) -> None:
|
|
47
|
+
"""Adds a mounted server as a source for tools."""
|
|
48
|
+
self._mounted_servers.append(server)
|
|
49
|
+
|
|
50
|
+
async def _load_tools(self, *, via_server: bool = False) -> dict[str, Tool]:
|
|
51
|
+
"""
|
|
52
|
+
The single, consolidated recursive method for fetching tools. The 'via_server'
|
|
53
|
+
parameter determines the communication path.
|
|
54
|
+
|
|
55
|
+
- via_server=False: Manager-to-manager path for complete, unfiltered inventory
|
|
56
|
+
- via_server=True: Server-to-server path for filtered MCP requests
|
|
57
|
+
"""
|
|
58
|
+
all_tools: dict[str, Tool] = {}
|
|
59
|
+
|
|
60
|
+
for mounted in self._mounted_servers:
|
|
61
|
+
try:
|
|
62
|
+
if via_server:
|
|
63
|
+
# Use the server-to-server filtered path
|
|
64
|
+
child_results = await mounted.server._list_tools()
|
|
65
|
+
else:
|
|
66
|
+
# Use the manager-to-manager unfiltered path
|
|
67
|
+
child_results = await mounted.server._tool_manager.list_tools()
|
|
68
|
+
|
|
69
|
+
# The combination logic is the same for both paths
|
|
70
|
+
child_dict = {t.key: t for t in child_results}
|
|
71
|
+
if mounted.prefix:
|
|
72
|
+
for tool in child_dict.values():
|
|
73
|
+
prefixed_tool = tool.with_key(f"{mounted.prefix}_{tool.key}")
|
|
74
|
+
all_tools[prefixed_tool.key] = prefixed_tool
|
|
75
|
+
else:
|
|
76
|
+
all_tools.update(child_dict)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
# Skip failed mounts silently, matches existing behavior
|
|
79
|
+
logger.warning(
|
|
80
|
+
f"Failed to get tools from mounted server '{mounted.prefix}': {e}"
|
|
81
|
+
)
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Finally, add local tools, which always take precedence
|
|
85
|
+
all_tools.update(self._tools)
|
|
86
|
+
return all_tools
|
|
87
|
+
|
|
88
|
+
async def has_tool(self, key: str) -> bool:
|
|
46
89
|
"""Check if a tool exists."""
|
|
47
|
-
|
|
90
|
+
tools = await self.get_tools()
|
|
91
|
+
return key in tools
|
|
48
92
|
|
|
49
|
-
def get_tool(self, key: str) -> Tool:
|
|
93
|
+
async def get_tool(self, key: str) -> Tool:
|
|
50
94
|
"""Get tool by key."""
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
95
|
+
tools = await self.get_tools()
|
|
96
|
+
if key in tools:
|
|
97
|
+
return tools[key]
|
|
98
|
+
raise NotFoundError(f"Tool {key!r} not found")
|
|
54
99
|
|
|
55
|
-
def get_tools(self) -> dict[str, Tool]:
|
|
56
|
-
"""
|
|
57
|
-
|
|
100
|
+
async def get_tools(self) -> dict[str, Tool]:
|
|
101
|
+
"""
|
|
102
|
+
Gets the complete, unfiltered inventory of all tools.
|
|
103
|
+
"""
|
|
104
|
+
return await self._load_tools(via_server=False)
|
|
58
105
|
|
|
59
|
-
def list_tools(self) -> list[Tool]:
|
|
60
|
-
"""
|
|
61
|
-
|
|
106
|
+
async def list_tools(self) -> list[Tool]:
|
|
107
|
+
"""
|
|
108
|
+
Lists all tools, applying protocol filtering.
|
|
109
|
+
"""
|
|
110
|
+
tools_dict = await self._load_tools(via_server=True)
|
|
111
|
+
return list(tools_dict.values())
|
|
62
112
|
|
|
63
113
|
def add_tool_from_fn(
|
|
64
114
|
self,
|
|
@@ -89,22 +139,21 @@ class ToolManager:
|
|
|
89
139
|
)
|
|
90
140
|
return self.add_tool(tool)
|
|
91
141
|
|
|
92
|
-
def add_tool(self, tool: Tool
|
|
142
|
+
def add_tool(self, tool: Tool) -> Tool:
|
|
93
143
|
"""Register a tool with the server."""
|
|
94
|
-
|
|
95
|
-
existing = self._tools.get(key)
|
|
144
|
+
existing = self._tools.get(tool.key)
|
|
96
145
|
if existing:
|
|
97
146
|
if self.duplicate_behavior == "warn":
|
|
98
|
-
logger.warning(f"Tool already exists: {key}")
|
|
99
|
-
self._tools[key] = tool
|
|
147
|
+
logger.warning(f"Tool already exists: {tool.key}")
|
|
148
|
+
self._tools[tool.key] = tool
|
|
100
149
|
elif self.duplicate_behavior == "replace":
|
|
101
|
-
self._tools[key] = tool
|
|
150
|
+
self._tools[tool.key] = tool
|
|
102
151
|
elif self.duplicate_behavior == "error":
|
|
103
|
-
raise ValueError(f"Tool already exists: {key}")
|
|
152
|
+
raise ValueError(f"Tool already exists: {tool.key}")
|
|
104
153
|
elif self.duplicate_behavior == "ignore":
|
|
105
154
|
return existing
|
|
106
155
|
else:
|
|
107
|
-
self._tools[key] = tool
|
|
156
|
+
self._tools[tool.key] = tool
|
|
108
157
|
return tool
|
|
109
158
|
|
|
110
159
|
def remove_tool(self, key: str) -> None:
|
|
@@ -119,28 +168,48 @@ class ToolManager:
|
|
|
119
168
|
if key in self._tools:
|
|
120
169
|
del self._tools[key]
|
|
121
170
|
else:
|
|
122
|
-
raise NotFoundError(f"
|
|
171
|
+
raise NotFoundError(f"Tool {key!r} not found")
|
|
123
172
|
|
|
124
173
|
async def call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
|
|
125
|
-
"""
|
|
126
|
-
tool
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
174
|
+
"""
|
|
175
|
+
Internal API for servers: Finds and calls a tool, respecting the
|
|
176
|
+
filtered protocol path.
|
|
177
|
+
"""
|
|
178
|
+
# 1. Check local tools first. The server will have already applied its filter.
|
|
179
|
+
if key in self._tools:
|
|
180
|
+
tool = await self.get_tool(key)
|
|
181
|
+
if not tool:
|
|
182
|
+
raise NotFoundError(f"Tool {key!r} not found")
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
return await tool.run(arguments)
|
|
186
|
+
|
|
187
|
+
# raise ToolErrors as-is
|
|
188
|
+
except ToolError as e:
|
|
189
|
+
logger.exception(f"Error calling tool {key!r}")
|
|
190
|
+
raise e
|
|
191
|
+
|
|
192
|
+
# Handle other exceptions
|
|
193
|
+
except Exception as e:
|
|
194
|
+
logger.exception(f"Error calling tool {key!r}")
|
|
195
|
+
if self.mask_error_details:
|
|
196
|
+
# Mask internal details
|
|
197
|
+
raise ToolError(f"Error calling tool {key!r}") from e
|
|
198
|
+
else:
|
|
199
|
+
# Include original error details
|
|
200
|
+
raise ToolError(f"Error calling tool {key!r}: {e}") from e
|
|
201
|
+
|
|
202
|
+
# 2. Check mounted servers using the filtered protocol path.
|
|
203
|
+
for mounted in reversed(self._mounted_servers):
|
|
204
|
+
tool_key = key
|
|
205
|
+
if mounted.prefix:
|
|
206
|
+
if key.startswith(f"{mounted.prefix}_"):
|
|
207
|
+
tool_key = key.removeprefix(f"{mounted.prefix}_")
|
|
208
|
+
else:
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
return await mounted.server._call_tool(tool_key, arguments)
|
|
212
|
+
except NotFoundError:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
raise NotFoundError(f"Tool {key!r} not found.")
|
fastmcp/tools/tool_transform.py
CHANGED
|
@@ -100,35 +100,55 @@ class ArgTransform:
|
|
|
100
100
|
examples: Examples for the argument. Use ... for no change.
|
|
101
101
|
|
|
102
102
|
Examples:
|
|
103
|
-
|
|
103
|
+
Rename argument 'old_name' to 'new_name'
|
|
104
|
+
```python
|
|
104
105
|
ArgTransform(name="new_name")
|
|
106
|
+
```
|
|
105
107
|
|
|
106
|
-
|
|
108
|
+
Change description only
|
|
109
|
+
```python
|
|
107
110
|
ArgTransform(description="Updated description")
|
|
111
|
+
```
|
|
108
112
|
|
|
109
|
-
|
|
113
|
+
Add a default value (makes argument optional)
|
|
114
|
+
```python
|
|
110
115
|
ArgTransform(default=42)
|
|
116
|
+
```
|
|
111
117
|
|
|
112
|
-
|
|
118
|
+
Add a default factory (makes argument optional)
|
|
119
|
+
```python
|
|
113
120
|
ArgTransform(default_factory=lambda: time.time())
|
|
121
|
+
```
|
|
114
122
|
|
|
115
|
-
|
|
123
|
+
Change the type
|
|
124
|
+
```python
|
|
116
125
|
ArgTransform(type=str)
|
|
126
|
+
```
|
|
117
127
|
|
|
118
|
-
|
|
128
|
+
Hide the argument entirely from clients
|
|
129
|
+
```python
|
|
119
130
|
ArgTransform(hide=True)
|
|
131
|
+
```
|
|
120
132
|
|
|
121
|
-
|
|
133
|
+
Hide argument but pass a constant value to parent
|
|
134
|
+
```python
|
|
122
135
|
ArgTransform(hide=True, default="constant_value")
|
|
136
|
+
```
|
|
123
137
|
|
|
124
|
-
|
|
138
|
+
Hide argument but pass a factory-generated value to parent
|
|
139
|
+
```python
|
|
125
140
|
ArgTransform(hide=True, default_factory=lambda: uuid.uuid4().hex)
|
|
141
|
+
```
|
|
126
142
|
|
|
127
|
-
|
|
143
|
+
Make an optional parameter required (removes any default)
|
|
144
|
+
```python
|
|
128
145
|
ArgTransform(required=True)
|
|
146
|
+
```
|
|
129
147
|
|
|
130
|
-
|
|
148
|
+
Combine multiple transformations
|
|
149
|
+
```python
|
|
131
150
|
ArgTransform(name="new_name", description="New desc", default=None, type=int)
|
|
151
|
+
```
|
|
132
152
|
"""
|
|
133
153
|
|
|
134
154
|
name: str | EllipsisType = NotSet
|
|
@@ -279,9 +299,9 @@ class TransformedTool(Tool):
|
|
|
279
299
|
name: New name for the tool. Defaults to parent tool's name.
|
|
280
300
|
transform_args: Optional transformations for parent tool arguments.
|
|
281
301
|
Only specified arguments are transformed, others pass through unchanged:
|
|
282
|
-
-
|
|
283
|
-
-
|
|
284
|
-
-
|
|
302
|
+
- Simple rename (str)
|
|
303
|
+
- Complex transformation (rename/description/default/drop) (ArgTransform)
|
|
304
|
+
- Drop the argument (None)
|
|
285
305
|
description: New description. Defaults to parent's description.
|
|
286
306
|
tags: New tags. Defaults to parent's tags.
|
|
287
307
|
annotations: New annotations. Defaults to parent's annotations.
|
|
@@ -290,23 +310,29 @@ class TransformedTool(Tool):
|
|
|
290
310
|
Returns:
|
|
291
311
|
TransformedTool with the specified transformations.
|
|
292
312
|
|
|
293
|
-
|
|
313
|
+
Examples:
|
|
294
314
|
# Transform specific arguments only
|
|
315
|
+
```python
|
|
295
316
|
Tool.from_tool(parent, transform_args={"old": "new"}) # Others unchanged
|
|
317
|
+
```
|
|
296
318
|
|
|
297
319
|
# Custom function with partial transforms
|
|
320
|
+
```python
|
|
298
321
|
async def custom(x: int, y: int) -> str:
|
|
299
322
|
result = await forward(x=x, y=y)
|
|
300
323
|
return f"Custom: {result}"
|
|
301
324
|
|
|
302
325
|
Tool.from_tool(parent, transform_fn=custom, transform_args={"a": "x", "b": "y"})
|
|
326
|
+
```
|
|
303
327
|
|
|
304
328
|
# Using **kwargs (gets all args, transformed and untransformed)
|
|
329
|
+
```python
|
|
305
330
|
async def flexible(**kwargs) -> str:
|
|
306
331
|
result = await forward(**kwargs)
|
|
307
332
|
return f"Got: {kwargs}"
|
|
308
333
|
|
|
309
334
|
Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"})
|
|
335
|
+
```
|
|
310
336
|
"""
|
|
311
337
|
transform_args = transform_args or {}
|
|
312
338
|
|
|
@@ -423,8 +449,8 @@ class TransformedTool(Tool):
|
|
|
423
449
|
|
|
424
450
|
Returns:
|
|
425
451
|
A tuple containing:
|
|
426
|
-
-
|
|
427
|
-
-
|
|
452
|
+
- The new JSON schema for the transformed tool as a dictionary
|
|
453
|
+
- Async function that validates and forwards calls to the parent tool
|
|
428
454
|
"""
|
|
429
455
|
|
|
430
456
|
# Build transformed schema and mapping
|
fastmcp/utilities/components.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from collections.abc import Sequence
|
|
2
|
-
from typing import Annotated, TypeVar
|
|
2
|
+
from typing import Annotated, Any, TypeVar
|
|
3
3
|
|
|
4
|
-
from pydantic import BeforeValidator, Field
|
|
4
|
+
from pydantic import BeforeValidator, Field, PrivateAttr
|
|
5
|
+
from typing_extensions import Self
|
|
5
6
|
|
|
6
7
|
from fastmcp.utilities.types import FastMCPBaseModel
|
|
7
8
|
|
|
@@ -37,6 +38,25 @@ class FastMCPComponent(FastMCPBaseModel):
|
|
|
37
38
|
description="Whether the component is enabled.",
|
|
38
39
|
)
|
|
39
40
|
|
|
41
|
+
_key: str | None = PrivateAttr()
|
|
42
|
+
|
|
43
|
+
def __init__(self, *, key: str | None = None, **kwargs: Any) -> None:
|
|
44
|
+
super().__init__(**kwargs)
|
|
45
|
+
self._key = key
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def key(self) -> str:
|
|
49
|
+
"""
|
|
50
|
+
The key of the component. This is used for internal bookkeeping
|
|
51
|
+
and may reflect e.g. prefixes or other identifiers. You should not depend on
|
|
52
|
+
keys having a certain value, as the same tool loaded from different
|
|
53
|
+
hierarchies of servers may have different keys.
|
|
54
|
+
"""
|
|
55
|
+
return self._key or self.name
|
|
56
|
+
|
|
57
|
+
def with_key(self, key: str) -> Self:
|
|
58
|
+
return self.model_copy(update={"_key": key})
|
|
59
|
+
|
|
40
60
|
def __eq__(self, other: object) -> bool:
|
|
41
61
|
if type(self) is not type(other):
|
|
42
62
|
return False
|