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.
Files changed (43) hide show
  1. fastmcp/cli/cli.py +99 -1
  2. fastmcp/cli/run.py +1 -3
  3. fastmcp/client/auth/oauth.py +1 -2
  4. fastmcp/client/client.py +23 -7
  5. fastmcp/client/logging.py +1 -2
  6. fastmcp/client/messages.py +126 -0
  7. fastmcp/client/transports.py +17 -2
  8. fastmcp/contrib/mcp_mixin/README.md +79 -2
  9. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -0
  10. fastmcp/prompts/prompt.py +109 -13
  11. fastmcp/prompts/prompt_manager.py +119 -43
  12. fastmcp/resources/resource.py +27 -1
  13. fastmcp/resources/resource_manager.py +249 -76
  14. fastmcp/resources/template.py +44 -2
  15. fastmcp/server/auth/providers/bearer.py +62 -13
  16. fastmcp/server/context.py +113 -10
  17. fastmcp/server/http.py +8 -0
  18. fastmcp/server/low_level.py +35 -0
  19. fastmcp/server/middleware/__init__.py +6 -0
  20. fastmcp/server/middleware/error_handling.py +206 -0
  21. fastmcp/server/middleware/logging.py +165 -0
  22. fastmcp/server/middleware/middleware.py +236 -0
  23. fastmcp/server/middleware/rate_limiting.py +231 -0
  24. fastmcp/server/middleware/timing.py +156 -0
  25. fastmcp/server/proxy.py +250 -140
  26. fastmcp/server/server.py +446 -280
  27. fastmcp/settings.py +2 -2
  28. fastmcp/tools/tool.py +22 -2
  29. fastmcp/tools/tool_manager.py +114 -45
  30. fastmcp/tools/tool_transform.py +42 -16
  31. fastmcp/utilities/components.py +22 -2
  32. fastmcp/utilities/inspect.py +326 -0
  33. fastmcp/utilities/json_schema.py +67 -23
  34. fastmcp/utilities/mcp_config.py +13 -7
  35. fastmcp/utilities/openapi.py +75 -5
  36. fastmcp/utilities/tests.py +1 -1
  37. fastmcp/utilities/types.py +90 -1
  38. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/METADATA +2 -2
  39. fastmcp-2.9.1.dist-info/RECORD +78 -0
  40. fastmcp-2.8.1.dist-info/RECORD +0 -69
  41. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/WHEEL +0 -0
  42. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/entry_points.txt +0 -0
  43. {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.__doc__
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)
@@ -1,4 +1,4 @@
1
- from __future__ import annotations as _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
- pass
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 has_tool(self, key: str) -> bool:
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
- return key in self._tools
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
- if key in self._tools:
52
- return self._tools[key]
53
- raise NotFoundError(f"Unknown tool: {key}")
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
- """Get all registered tools, indexed by registered key."""
57
- return self._tools
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
- """List all registered tools."""
61
- return list(self.get_tools().values())
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, key: str | None = None) -> Tool:
142
+ def add_tool(self, tool: Tool) -> Tool:
93
143
  """Register a tool with the server."""
94
- key = key or tool.name
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"Unknown tool: {key}")
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
- """Call a tool by name with arguments."""
126
- tool = self.get_tool(key)
127
- if not tool:
128
- raise NotFoundError(f"Unknown tool: {key}")
129
-
130
- try:
131
- return await tool.run(arguments)
132
-
133
- # raise ToolErrors as-is
134
- except ToolError as e:
135
- logger.exception(f"Error calling tool {key!r}: {e}")
136
- raise e
137
-
138
- # Handle other exceptions
139
- except Exception as e:
140
- logger.exception(f"Error calling tool {key!r}: {e}")
141
- if self.mask_error_details:
142
- # Mask internal details
143
- raise ToolError(f"Error calling tool {key!r}") from e
144
- else:
145
- # Include original error details
146
- raise ToolError(f"Error calling tool {key!r}: {e}") from e
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.")
@@ -100,35 +100,55 @@ class ArgTransform:
100
100
  examples: Examples for the argument. Use ... for no change.
101
101
 
102
102
  Examples:
103
- # Rename argument 'old_name' to 'new_name'
103
+ Rename argument 'old_name' to 'new_name'
104
+ ```python
104
105
  ArgTransform(name="new_name")
106
+ ```
105
107
 
106
- # Change description only
108
+ Change description only
109
+ ```python
107
110
  ArgTransform(description="Updated description")
111
+ ```
108
112
 
109
- # Add a default value (makes argument optional)
113
+ Add a default value (makes argument optional)
114
+ ```python
110
115
  ArgTransform(default=42)
116
+ ```
111
117
 
112
- # Add a default factory (makes argument optional)
118
+ Add a default factory (makes argument optional)
119
+ ```python
113
120
  ArgTransform(default_factory=lambda: time.time())
121
+ ```
114
122
 
115
- # Change the type
123
+ Change the type
124
+ ```python
116
125
  ArgTransform(type=str)
126
+ ```
117
127
 
118
- # Hide the argument entirely from clients
128
+ Hide the argument entirely from clients
129
+ ```python
119
130
  ArgTransform(hide=True)
131
+ ```
120
132
 
121
- # Hide argument but pass a constant value to parent
133
+ Hide argument but pass a constant value to parent
134
+ ```python
122
135
  ArgTransform(hide=True, default="constant_value")
136
+ ```
123
137
 
124
- # Hide argument but pass a factory-generated value to parent
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
- # Make an optional parameter required (removes any default)
143
+ Make an optional parameter required (removes any default)
144
+ ```python
128
145
  ArgTransform(required=True)
146
+ ```
129
147
 
130
- # Combine multiple transformations
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
- - str: Simple rename
283
- - ArgTransform: Complex transformation (rename/description/default/drop)
284
- - None: Drop the argument
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
- Examples:
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
- - dict: The new JSON schema for the transformed tool
427
- - Callable: Async function that validates and forwards calls to the parent tool
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
@@ -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