fastmcp 2.12.5__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.
Files changed (108) hide show
  1. fastmcp/__init__.py +2 -2
  2. fastmcp/cli/cli.py +11 -11
  3. fastmcp/cli/install/claude_code.py +6 -6
  4. fastmcp/cli/install/claude_desktop.py +3 -3
  5. fastmcp/cli/install/cursor.py +18 -12
  6. fastmcp/cli/install/gemini_cli.py +3 -3
  7. fastmcp/cli/install/mcp_json.py +3 -3
  8. fastmcp/cli/run.py +13 -8
  9. fastmcp/client/__init__.py +9 -9
  10. fastmcp/client/auth/oauth.py +115 -217
  11. fastmcp/client/client.py +105 -39
  12. fastmcp/client/logging.py +18 -14
  13. fastmcp/client/oauth_callback.py +85 -171
  14. fastmcp/client/sampling.py +1 -1
  15. fastmcp/client/transports.py +80 -25
  16. fastmcp/contrib/component_manager/__init__.py +1 -1
  17. fastmcp/contrib/component_manager/component_manager.py +2 -2
  18. fastmcp/contrib/component_manager/component_service.py +6 -6
  19. fastmcp/contrib/mcp_mixin/README.md +32 -1
  20. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  21. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
  22. fastmcp/experimental/sampling/handlers/openai.py +2 -2
  23. fastmcp/experimental/server/openapi/__init__.py +5 -8
  24. fastmcp/experimental/server/openapi/components.py +11 -7
  25. fastmcp/experimental/server/openapi/routing.py +2 -2
  26. fastmcp/experimental/utilities/openapi/__init__.py +10 -15
  27. fastmcp/experimental/utilities/openapi/director.py +14 -15
  28. fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
  29. fastmcp/experimental/utilities/openapi/models.py +3 -3
  30. fastmcp/experimental/utilities/openapi/parser.py +37 -16
  31. fastmcp/experimental/utilities/openapi/schemas.py +2 -2
  32. fastmcp/mcp_config.py +3 -4
  33. fastmcp/prompts/__init__.py +1 -1
  34. fastmcp/prompts/prompt.py +22 -19
  35. fastmcp/prompts/prompt_manager.py +16 -101
  36. fastmcp/resources/__init__.py +5 -5
  37. fastmcp/resources/resource.py +14 -9
  38. fastmcp/resources/resource_manager.py +9 -168
  39. fastmcp/resources/template.py +107 -17
  40. fastmcp/resources/types.py +30 -24
  41. fastmcp/server/__init__.py +1 -1
  42. fastmcp/server/auth/__init__.py +9 -5
  43. fastmcp/server/auth/auth.py +70 -43
  44. fastmcp/server/auth/handlers/authorize.py +326 -0
  45. fastmcp/server/auth/jwt_issuer.py +236 -0
  46. fastmcp/server/auth/middleware.py +96 -0
  47. fastmcp/server/auth/oauth_proxy.py +1510 -289
  48. fastmcp/server/auth/oidc_proxy.py +84 -20
  49. fastmcp/server/auth/providers/auth0.py +40 -21
  50. fastmcp/server/auth/providers/aws.py +29 -3
  51. fastmcp/server/auth/providers/azure.py +312 -131
  52. fastmcp/server/auth/providers/bearer.py +1 -1
  53. fastmcp/server/auth/providers/debug.py +114 -0
  54. fastmcp/server/auth/providers/descope.py +86 -29
  55. fastmcp/server/auth/providers/discord.py +308 -0
  56. fastmcp/server/auth/providers/github.py +29 -8
  57. fastmcp/server/auth/providers/google.py +48 -9
  58. fastmcp/server/auth/providers/in_memory.py +27 -3
  59. fastmcp/server/auth/providers/introspection.py +281 -0
  60. fastmcp/server/auth/providers/jwt.py +48 -31
  61. fastmcp/server/auth/providers/oci.py +233 -0
  62. fastmcp/server/auth/providers/scalekit.py +238 -0
  63. fastmcp/server/auth/providers/supabase.py +188 -0
  64. fastmcp/server/auth/providers/workos.py +35 -17
  65. fastmcp/server/context.py +177 -51
  66. fastmcp/server/dependencies.py +39 -12
  67. fastmcp/server/elicitation.py +1 -1
  68. fastmcp/server/http.py +56 -17
  69. fastmcp/server/low_level.py +121 -2
  70. fastmcp/server/middleware/__init__.py +1 -1
  71. fastmcp/server/middleware/caching.py +476 -0
  72. fastmcp/server/middleware/error_handling.py +14 -10
  73. fastmcp/server/middleware/logging.py +50 -39
  74. fastmcp/server/middleware/middleware.py +29 -16
  75. fastmcp/server/middleware/rate_limiting.py +3 -3
  76. fastmcp/server/middleware/tool_injection.py +116 -0
  77. fastmcp/server/openapi.py +10 -6
  78. fastmcp/server/proxy.py +22 -11
  79. fastmcp/server/server.py +725 -242
  80. fastmcp/settings.py +24 -10
  81. fastmcp/tools/__init__.py +1 -1
  82. fastmcp/tools/tool.py +70 -23
  83. fastmcp/tools/tool_manager.py +30 -112
  84. fastmcp/tools/tool_transform.py +12 -10
  85. fastmcp/utilities/cli.py +67 -28
  86. fastmcp/utilities/components.py +7 -2
  87. fastmcp/utilities/inspect.py +79 -23
  88. fastmcp/utilities/json_schema.py +4 -4
  89. fastmcp/utilities/json_schema_type.py +4 -4
  90. fastmcp/utilities/logging.py +118 -8
  91. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  92. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  93. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  94. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +4 -4
  95. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  96. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  97. fastmcp/utilities/openapi.py +11 -11
  98. fastmcp/utilities/tests.py +85 -4
  99. fastmcp/utilities/types.py +78 -16
  100. fastmcp/utilities/ui.py +626 -0
  101. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/METADATA +22 -14
  102. fastmcp-2.13.2.dist-info/RECORD +144 -0
  103. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
  104. fastmcp/cli/claude.py +0 -135
  105. fastmcp/utilities/storage.py +0 -204
  106. fastmcp-2.12.5.dist-info/RECORD +0 -134
  107. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
  108. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/licenses/LICENSE +0 -0
fastmcp/settings.py CHANGED
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations as _annotations
2
2
 
3
3
  import inspect
4
+ import os
4
5
  import warnings
5
6
  from pathlib import Path
6
7
  from typing import TYPE_CHECKING, Annotated, Any, Literal
7
8
 
9
+ from platformdirs import user_data_dir
8
10
  from pydantic import Field, ImportString, field_validator
9
11
  from pydantic.fields import FieldInfo
10
12
  from pydantic_settings import (
@@ -19,10 +21,14 @@ from fastmcp.utilities.logging import get_logger
19
21
 
20
22
  logger = get_logger(__name__)
21
23
 
24
+ ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env")
25
+
22
26
  LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
23
27
 
24
28
  DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
25
29
 
30
+ TEN_MB_IN_BYTES = 1024 * 1024 * 10
31
+
26
32
  if TYPE_CHECKING:
27
33
  from fastmcp.server.auth.auth import AuthProvider
28
34
 
@@ -82,7 +88,7 @@ class Settings(BaseSettings):
82
88
 
83
89
  model_config = ExtendedSettingsConfigDict(
84
90
  env_prefixes=["FASTMCP_", "FASTMCP_SERVER_"],
85
- env_file=".env",
91
+ env_file=ENV_FILE,
86
92
  extra="ignore",
87
93
  env_nested_delimiter="__",
88
94
  nested_model_default_partial_update=True,
@@ -145,7 +151,7 @@ class Settings(BaseSettings):
145
151
  )
146
152
  return self
147
153
 
148
- home: Path = Path.home() / ".fastmcp"
154
+ home: Path = Path(user_data_dir("fastmcp", appauthor=False))
149
155
 
150
156
  test_mode: bool = False
151
157
 
@@ -189,7 +195,6 @@ class Settings(BaseSettings):
189
195
  client_raise_first_exceptiongroup_error: Annotated[
190
196
  bool,
191
197
  Field(
192
- default=True,
193
198
  description=inspect.cleandoc(
194
199
  """
195
200
  Many MCP components operate in anyio taskgroups, and raise
@@ -205,7 +210,6 @@ class Settings(BaseSettings):
205
210
  resource_prefix_format: Annotated[
206
211
  Literal["protocol", "path"],
207
212
  Field(
208
- default="path",
209
213
  description=inspect.cleandoc(
210
214
  """
211
215
  When perfixing a resource URI, either use path formatting (resource://prefix/path)
@@ -235,7 +239,6 @@ class Settings(BaseSettings):
235
239
  mask_error_details: Annotated[
236
240
  bool,
237
241
  Field(
238
- default=False,
239
242
  description=inspect.cleandoc(
240
243
  """
241
244
  If True, error details from user-supplied functions (tool, resource, prompt)
@@ -248,6 +251,22 @@ class Settings(BaseSettings):
248
251
  ),
249
252
  ] = False
250
253
 
254
+ strict_input_validation: Annotated[
255
+ bool,
256
+ Field(
257
+ description=inspect.cleandoc(
258
+ """
259
+ If True, tool inputs are strictly validated against the input
260
+ JSON schema. For example, providing the string \"10\" to an
261
+ integer field will raise an error. If False, compatible inputs
262
+ will be coerced to match the schema, which can increase
263
+ compatibility. For example, providing the string \"10\" to an
264
+ integer field will be coerced to 10. Defaults to False.
265
+ """
266
+ ),
267
+ ),
268
+ ] = False
269
+
251
270
  server_dependencies: list[str] = Field(
252
271
  default_factory=list,
253
272
  description="List of dependencies to install in the server environment",
@@ -293,7 +312,6 @@ class Settings(BaseSettings):
293
312
  include_tags: Annotated[
294
313
  set[str] | None,
295
314
  Field(
296
- default=None,
297
315
  description=inspect.cleandoc(
298
316
  """
299
317
  If provided, only components that match these tags will be
@@ -306,7 +324,6 @@ class Settings(BaseSettings):
306
324
  exclude_tags: Annotated[
307
325
  set[str] | None,
308
326
  Field(
309
- default=None,
310
327
  description=inspect.cleandoc(
311
328
  """
312
329
  If provided, components that match these tags will be excluded
@@ -320,7 +337,6 @@ class Settings(BaseSettings):
320
337
  include_fastmcp_meta: Annotated[
321
338
  bool,
322
339
  Field(
323
- default=True,
324
340
  description=inspect.cleandoc(
325
341
  """
326
342
  Whether to include FastMCP meta in the server's MCP responses.
@@ -335,7 +351,6 @@ class Settings(BaseSettings):
335
351
  mounted_components_raise_on_load_error: Annotated[
336
352
  bool,
337
353
  Field(
338
- default=False,
339
354
  description=inspect.cleandoc(
340
355
  """
341
356
  If True, errors encountered when loading mounted components (tools, resources, prompts)
@@ -349,7 +364,6 @@ class Settings(BaseSettings):
349
364
  show_cli_banner: Annotated[
350
365
  bool,
351
366
  Field(
352
- default=True,
353
367
  description=inspect.cleandoc(
354
368
  """
355
369
  If True, the server banner will be displayed when running the server via CLI.
fastmcp/tools/__init__.py CHANGED
@@ -2,4 +2,4 @@ from .tool import Tool, FunctionTool
2
2
  from .tool_manager import ToolManager
3
3
  from .tool_transform import forward, forward_raw
4
4
 
5
- __all__ = ["Tool", "ToolManager", "FunctionTool", "forward", "forward_raw"]
5
+ __all__ = ["FunctionTool", "Tool", "ToolManager", "forward", "forward_raw"]
fastmcp/tools/tool.py CHANGED
@@ -16,7 +16,7 @@ from typing import (
16
16
 
17
17
  import mcp.types
18
18
  import pydantic_core
19
- from mcp.types import ContentBlock, TextContent, ToolAnnotations
19
+ from mcp.types import CallToolResult, ContentBlock, Icon, TextContent, ToolAnnotations
20
20
  from mcp.types import Tool as MCPTool
21
21
  from pydantic import Field, PydanticSchemaGenerationError
22
22
  from typing_extensions import TypeVar
@@ -32,6 +32,7 @@ from fastmcp.utilities.types import (
32
32
  Image,
33
33
  NotSet,
34
34
  NotSetT,
35
+ create_function_without_params,
35
36
  find_kwarg_by_type,
36
37
  get_cached_typeadapter,
37
38
  replace_type,
@@ -68,6 +69,7 @@ class ToolResult:
68
69
  self,
69
70
  content: list[ContentBlock] | Any | None = None,
70
71
  structured_content: dict[str, Any] | Any | None = None,
72
+ meta: dict[str, Any] | None = None,
71
73
  ):
72
74
  if content is None and structured_content is None:
73
75
  raise ValueError("Either content or structured_content must be provided")
@@ -75,6 +77,7 @@ class ToolResult:
75
77
  content = structured_content
76
78
 
77
79
  self.content: list[ContentBlock] = _convert_to_content(result=content)
80
+ self.meta: dict[str, Any] | None = meta
78
81
 
79
82
  if structured_content is not None:
80
83
  try:
@@ -96,7 +99,15 @@ class ToolResult:
96
99
 
97
100
  def to_mcp_result(
98
101
  self,
99
- ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
102
+ ) -> (
103
+ list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult
104
+ ):
105
+ if self.meta is not None:
106
+ return CallToolResult(
107
+ structuredContent=self.structured_content,
108
+ content=self.content,
109
+ _meta=self.meta,
110
+ )
100
111
  if self.structured_content is None:
101
112
  return self.content
102
113
  return self.content, self.structured_content
@@ -156,6 +167,7 @@ class Tool(FastMCPComponent):
156
167
  description=overrides.get("description", self.description),
157
168
  inputSchema=overrides.get("inputSchema", self.parameters),
158
169
  outputSchema=overrides.get("outputSchema", self.output_schema),
170
+ icons=overrides.get("icons", self.icons),
159
171
  annotations=overrides.get("annotations", self.annotations),
160
172
  _meta=overrides.get(
161
173
  "_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta)
@@ -168,10 +180,11 @@ class Tool(FastMCPComponent):
168
180
  name: str | None = None,
169
181
  title: str | None = None,
170
182
  description: str | None = None,
183
+ icons: list[Icon] | None = None,
171
184
  tags: set[str] | None = None,
172
185
  annotations: ToolAnnotations | None = None,
173
186
  exclude_args: list[str] | None = None,
174
- output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
187
+ output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet,
175
188
  serializer: ToolResultSerializerType | None = None,
176
189
  meta: dict[str, Any] | None = None,
177
190
  enabled: bool | None = None,
@@ -182,6 +195,7 @@ class Tool(FastMCPComponent):
182
195
  name=name,
183
196
  title=title,
184
197
  description=description,
198
+ icons=icons,
185
199
  tags=tags,
186
200
  annotations=annotations,
187
201
  exclude_args=exclude_args,
@@ -209,13 +223,13 @@ class Tool(FastMCPComponent):
209
223
  tool: Tool,
210
224
  *,
211
225
  name: str | None = None,
212
- title: str | None | NotSetT = NotSet,
213
- description: str | None | NotSetT = NotSet,
226
+ title: str | NotSetT | None = NotSet,
227
+ description: str | NotSetT | None = NotSet,
214
228
  tags: set[str] | None = None,
215
- annotations: ToolAnnotations | None | NotSetT = NotSet,
216
- output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
229
+ annotations: ToolAnnotations | NotSetT | None = NotSet,
230
+ output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet,
217
231
  serializer: ToolResultSerializerType | None = None,
218
- meta: dict[str, Any] | None | NotSetT = NotSet,
232
+ meta: dict[str, Any] | NotSetT | None = NotSet,
219
233
  transform_args: dict[str, ArgTransform] | None = None,
220
234
  enabled: bool | None = None,
221
235
  transform_fn: Callable[..., Any] | None = None,
@@ -248,15 +262,26 @@ class FunctionTool(Tool):
248
262
  name: str | None = None,
249
263
  title: str | None = None,
250
264
  description: str | None = None,
265
+ icons: list[Icon] | None = None,
251
266
  tags: set[str] | None = None,
252
267
  annotations: ToolAnnotations | None = None,
253
268
  exclude_args: list[str] | None = None,
254
- output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
269
+ output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet,
255
270
  serializer: ToolResultSerializerType | None = None,
256
271
  meta: dict[str, Any] | None = None,
257
272
  enabled: bool | None = None,
258
273
  ) -> FunctionTool:
259
274
  """Create a Tool from a function."""
275
+ if exclude_args and fastmcp.settings.deprecation_warnings:
276
+ warnings.warn(
277
+ "The `exclude_args` parameter will be deprecated in FastMCP 2.14. "
278
+ "We recommend using dependency injection with `Depends()` instead, which provides "
279
+ "better lifecycle management and is more explicit. "
280
+ "`exclude_args` will continue to work until then. "
281
+ "See https://gofastmcp.com/docs/servers/tools for examples.",
282
+ DeprecationWarning,
283
+ stacklevel=2,
284
+ )
260
285
 
261
286
  parsed_fn = ParsedFunction.from_function(fn, exclude_args=exclude_args)
262
287
 
@@ -280,10 +305,11 @@ class FunctionTool(Tool):
280
305
  # Note: explicit schemas (dict) are used as-is without auto-wrapping
281
306
 
282
307
  # Validate that explicit schemas are object type for structured content
308
+ # (resolving $ref references for self-referencing types)
283
309
  if final_output_schema is not None and isinstance(final_output_schema, dict):
284
- if final_output_schema.get("type") != "object":
310
+ if not _is_object_schema(final_output_schema):
285
311
  raise ValueError(
286
- f'Output schemas must have "type" set to "object" due to MCP spec limitations. Received: {final_output_schema!r}'
312
+ f"Output schemas must represent object types due to MCP spec limitations. Received: {final_output_schema!r}"
287
313
  )
288
314
 
289
315
  return cls(
@@ -291,6 +317,7 @@ class FunctionTool(Tool):
291
317
  name=name or parsed_fn.name,
292
318
  title=title,
293
319
  description=description or parsed_fn.description,
320
+ icons=icons,
294
321
  parameters=parsed_fn.input_schema,
295
322
  output_schema=final_output_schema,
296
323
  annotations=annotations,
@@ -351,6 +378,21 @@ class FunctionTool(Tool):
351
378
  )
352
379
 
353
380
 
381
+ def _is_object_schema(schema: dict[str, Any]) -> bool:
382
+ """Check if a JSON schema represents an object type."""
383
+ # Direct object type
384
+ if schema.get("type") == "object":
385
+ return True
386
+
387
+ # Schema with properties but no explicit type is treated as object
388
+ if "properties" in schema:
389
+ return True
390
+
391
+ # Self-referencing types use $ref pointing to $defs
392
+ # The referenced type is always an object in our use case
393
+ return "$ref" in schema and "$defs" in schema
394
+
395
+
354
396
  @dataclass
355
397
  class ParsedFunction:
356
398
  fn: Callable[..., Any]
@@ -411,7 +453,14 @@ class ParsedFunction:
411
453
  if exclude_args:
412
454
  prune_params.extend(exclude_args)
413
455
 
414
- input_type_adapter = get_cached_typeadapter(fn)
456
+ # Create a function without excluded parameters in annotations
457
+ # This prevents Pydantic from trying to serialize non-serializable types
458
+ # before we can exclude them in compress_schema
459
+ fn_for_typeadapter = fn
460
+ if prune_params:
461
+ fn_for_typeadapter = create_function_without_params(fn, prune_params)
462
+
463
+ input_type_adapter = get_cached_typeadapter(fn_for_typeadapter)
415
464
  input_schema = input_type_adapter.json_schema()
416
465
  input_schema = compress_schema(
417
466
  input_schema, prune_params=prune_params, prune_titles=True
@@ -441,9 +490,8 @@ class ParsedFunction:
441
490
  # we ensure that no output schema is automatically generated.
442
491
  clean_output_type = replace_type(
443
492
  output_type,
444
- {
445
- t: _UnserializableType
446
- for t in (
493
+ dict.fromkeys( # type: ignore[arg-type]
494
+ (
447
495
  Image,
448
496
  Audio,
449
497
  File,
@@ -453,8 +501,9 @@ class ParsedFunction:
453
501
  mcp.types.AudioContent,
454
502
  mcp.types.ResourceLink,
455
503
  mcp.types.EmbeddedResource,
456
- )
457
- },
504
+ ),
505
+ _UnserializableType,
506
+ ),
458
507
  )
459
508
 
460
509
  try:
@@ -463,10 +512,9 @@ class ParsedFunction:
463
512
 
464
513
  # Generate schema for wrapped type if it's non-object
465
514
  # because MCP requires that output schemas are objects
466
- if (
467
- wrap_non_object_output_schema
468
- and base_schema.get("type") != "object"
469
- ):
515
+ # Check if schema is an object type, resolving $ref references
516
+ # (self-referencing types use $ref at root level)
517
+ if wrap_non_object_output_schema and not _is_object_schema(base_schema):
470
518
  # Use the wrapped result schema directly
471
519
  wrapped_type = _WrappedResult[clean_output_type]
472
520
  wrapped_adapter = get_cached_typeadapter(wrapped_type)
@@ -546,13 +594,12 @@ def _convert_to_content(
546
594
 
547
595
  # If any item is a ContentBlock, convert non-ContentBlock items to TextContent
548
596
  # without aggregating them
549
- if any(isinstance(item, ContentBlock) for item in result):
597
+ if any(isinstance(item, ContentBlock | Image | Audio | File) for item in result):
550
598
  return [
551
599
  _convert_to_single_content_block(item, serializer)
552
600
  if not isinstance(item, ContentBlock)
553
601
  else item
554
602
  for item in result
555
603
  ]
556
-
557
604
  # If none of the items are ContentBlocks, aggregate all items into a single TextContent
558
605
  return [TextContent(type="text", text=_serialize_with_fallback(result, serializer))]
@@ -1,10 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import warnings
4
- from collections.abc import Callable
5
- from typing import TYPE_CHECKING, Any
4
+ from collections.abc import Callable, Mapping
5
+ from typing import Any
6
6
 
7
7
  from mcp.types import ToolAnnotations
8
+ from pydantic import ValidationError
8
9
 
9
10
  from fastmcp import settings
10
11
  from fastmcp.exceptions import NotFoundError, ToolError
@@ -16,9 +17,6 @@ from fastmcp.tools.tool_transform import (
16
17
  )
17
18
  from fastmcp.utilities.logging import get_logger
18
19
 
19
- if TYPE_CHECKING:
20
- from fastmcp.server.server import MountedServer
21
-
22
20
  logger = get_logger(__name__)
23
21
 
24
22
 
@@ -29,12 +27,15 @@ class ToolManager:
29
27
  self,
30
28
  duplicate_behavior: DuplicateBehavior | None = None,
31
29
  mask_error_details: bool | None = None,
32
- transformations: dict[str, ToolTransformConfig] | None = None,
30
+ transformations: Mapping[str, ToolTransformConfig] | None = None,
33
31
  ):
34
32
  self._tools: dict[str, Tool] = {}
35
- self._mounted_servers: list[MountedServer] = []
36
- self.mask_error_details = mask_error_details or settings.mask_error_details
37
- self.transformations = transformations or {}
33
+ self.mask_error_details: bool = (
34
+ mask_error_details or settings.mask_error_details
35
+ )
36
+ self.transformations: dict[str, ToolTransformConfig] = dict(
37
+ transformations or {}
38
+ )
38
39
 
39
40
  # Default to "warn" if None is provided
40
41
  if duplicate_behavior is None:
@@ -48,56 +49,12 @@ class ToolManager:
48
49
 
49
50
  self.duplicate_behavior = duplicate_behavior
50
51
 
51
- def mount(self, server: MountedServer) -> None:
52
- """Adds a mounted server as a source for tools."""
53
- self._mounted_servers.append(server)
54
-
55
- async def _load_tools(self, *, via_server: bool = False) -> dict[str, Tool]:
56
- """
57
- The single, consolidated recursive method for fetching tools. The 'via_server'
58
- parameter determines the communication path.
59
-
60
- - via_server=False: Manager-to-manager path for complete, unfiltered inventory
61
- - via_server=True: Server-to-server path for filtered MCP requests
62
- """
63
- all_tools: dict[str, Tool] = {}
64
-
65
- for mounted in self._mounted_servers:
66
- try:
67
- if via_server:
68
- # Use the server-to-server filtered path
69
- child_results = await mounted.server._list_tools()
70
- else:
71
- # Use the manager-to-manager unfiltered path
72
- child_results = await mounted.server._tool_manager.list_tools()
73
-
74
- # The combination logic is the same for both paths
75
- child_dict = {t.key: t for t in child_results}
76
- if mounted.prefix:
77
- for tool in child_dict.values():
78
- prefixed_tool = tool.model_copy(
79
- key=f"{mounted.prefix}_{tool.key}"
80
- )
81
- all_tools[prefixed_tool.key] = prefixed_tool
82
- else:
83
- all_tools.update(child_dict)
84
- except Exception as e:
85
- # Skip failed mounts silently, matches existing behavior
86
- logger.warning(
87
- f"Failed to get tools from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}"
88
- )
89
- if settings.mounted_components_raise_on_load_error:
90
- raise
91
- continue
92
-
93
- # Finally, add local tools, which always take precedence
94
- all_tools.update(self._tools)
95
-
52
+ async def _load_tools(self) -> dict[str, Tool]:
53
+ """Return this manager's local tools with transformations applied."""
96
54
  transformed_tools = apply_transformations_to_tools(
97
- tools=all_tools,
55
+ tools=self._tools,
98
56
  transformations=self.transformations,
99
57
  )
100
-
101
58
  return transformed_tools
102
59
 
103
60
  async def has_tool(self, key: str) -> bool:
@@ -114,25 +71,9 @@ class ToolManager:
114
71
 
115
72
  async def get_tools(self) -> dict[str, Tool]:
116
73
  """
117
- Gets the complete, unfiltered inventory of all tools.
118
- """
119
- return await self._load_tools(via_server=False)
120
-
121
- async def list_tools(self) -> list[Tool]:
74
+ Gets the complete, unfiltered inventory of local tools.
122
75
  """
123
- Lists all tools, applying protocol filtering.
124
- """
125
- tools_dict = await self._load_tools(via_server=True)
126
- return list(tools_dict.values())
127
-
128
- @property
129
- def _tools_transformed(self) -> list[str]:
130
- """Get the local tools."""
131
-
132
- return [
133
- transformation.name or tool_name
134
- for tool_name, transformation in self.transformations.items()
135
- ]
76
+ return await self._load_tools()
136
77
 
137
78
  def add_tool_from_fn(
138
79
  self,
@@ -214,41 +155,18 @@ class ToolManager:
214
155
  Internal API for servers: Finds and calls a tool, respecting the
215
156
  filtered protocol path.
216
157
  """
217
- # 1. Check local tools first. The server will have already applied its filter.
218
- if key in self._tools or key in self._tools_transformed:
219
- tool = await self.get_tool(key)
220
- if not tool:
221
- raise NotFoundError(f"Tool {key!r} not found")
222
-
223
- try:
224
- return await tool.run(arguments)
225
-
226
- # raise ToolErrors as-is
227
- except ToolError as e:
228
- logger.exception(f"Error calling tool {key!r}")
229
- raise e
230
-
231
- # Handle other exceptions
232
- except Exception as e:
233
- logger.exception(f"Error calling tool {key!r}")
234
- if self.mask_error_details:
235
- # Mask internal details
236
- raise ToolError(f"Error calling tool {key!r}") from e
237
- else:
238
- # Include original error details
239
- raise ToolError(f"Error calling tool {key!r}: {e}") from e
240
-
241
- # 2. Check mounted servers using the filtered protocol path.
242
- for mounted in reversed(self._mounted_servers):
243
- tool_key = key
244
- if mounted.prefix:
245
- if key.startswith(f"{mounted.prefix}_"):
246
- tool_key = key.removeprefix(f"{mounted.prefix}_")
247
- else:
248
- continue
249
- try:
250
- return await mounted.server._call_tool(tool_key, arguments)
251
- except NotFoundError:
252
- continue
253
-
254
- raise NotFoundError(f"Tool {key!r} not found.")
158
+ tool = await self.get_tool(key)
159
+ try:
160
+ return await tool.run(arguments)
161
+ except ValidationError as e:
162
+ logger.exception(f"Error validating tool {key!r}: {e}")
163
+ raise e
164
+ except ToolError as e:
165
+ logger.exception(f"Error calling tool {key!r}")
166
+ raise e
167
+ except Exception as e:
168
+ logger.exception(f"Error calling tool {key!r}")
169
+ if self.mask_error_details:
170
+ raise ToolError(f"Error calling tool {key!r}") from e
171
+ else:
172
+ raise ToolError(f"Error calling tool {key!r}: {e}") from e
@@ -4,6 +4,7 @@ import inspect
4
4
  import warnings
5
5
  from collections.abc import Callable
6
6
  from contextvars import ContextVar
7
+ from copy import deepcopy
7
8
  from dataclasses import dataclass
8
9
  from typing import Annotated, Any, Literal, cast
9
10
 
@@ -34,7 +35,7 @@ _current_tool: ContextVar[TransformedTool | None] = ContextVar( # type: ignore[
34
35
  )
35
36
 
36
37
 
37
- async def forward(**kwargs) -> ToolResult:
38
+ async def forward(**kwargs: Any) -> ToolResult:
38
39
  """Forward to parent tool with argument transformation applied.
39
40
 
40
41
  This function can only be called from within a transformed tool's custom
@@ -64,7 +65,7 @@ async def forward(**kwargs) -> ToolResult:
64
65
  return await tool.forwarding_fn(**kwargs)
65
66
 
66
67
 
67
- async def forward_raw(**kwargs) -> ToolResult:
68
+ async def forward_raw(**kwargs: Any) -> ToolResult:
68
69
  """Forward directly to parent tool without transformation.
69
70
 
70
71
  This function bypasses all argument transformation and validation, calling the parent
@@ -365,15 +366,15 @@ class TransformedTool(Tool):
365
366
  cls,
366
367
  tool: Tool,
367
368
  name: str | None = None,
368
- title: str | None | NotSetT = NotSet,
369
- description: str | None | NotSetT = NotSet,
369
+ title: str | NotSetT | None = NotSet,
370
+ description: str | NotSetT | None = NotSet,
370
371
  tags: set[str] | None = None,
371
372
  transform_fn: Callable[..., Any] | None = None,
372
373
  transform_args: dict[str, ArgTransform] | None = None,
373
- annotations: ToolAnnotations | None | NotSetT = NotSet,
374
- output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
375
- serializer: Callable[[Any], str] | None | NotSetT = NotSet,
376
- meta: dict[str, Any] | None | NotSetT = NotSet,
374
+ annotations: ToolAnnotations | NotSetT | None = NotSet,
375
+ output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet,
376
+ serializer: Callable[[Any], str] | NotSetT | None = NotSet,
377
+ meta: dict[str, Any] | NotSetT | None = NotSet,
377
378
  enabled: bool | None = None,
378
379
  ) -> TransformedTool:
379
380
  """Create a transformed tool from a parent tool.
@@ -620,7 +621,8 @@ class TransformedTool(Tool):
620
621
  """
621
622
 
622
623
  # Build transformed schema and mapping
623
- parent_defs = parent_tool.parameters.get("$defs", {})
624
+ # Deep copy to prevent compress_schema from mutating parent tool's $defs
625
+ parent_defs = deepcopy(parent_tool.parameters.get("$defs", {}))
624
626
  parent_props = parent_tool.parameters.get("properties", {}).copy()
625
627
  parent_required = set(parent_tool.parameters.get("required", []))
626
628
 
@@ -681,7 +683,7 @@ class TransformedTool(Tool):
681
683
  schema = compress_schema(schema, prune_defs=True)
682
684
 
683
685
  # Create forwarding function that closes over everything it needs
684
- async def _forward(**kwargs):
686
+ async def _forward(**kwargs: Any):
685
687
  # Validate arguments
686
688
  valid_args = set(new_props.keys())
687
689
  provided_args = set(kwargs.keys())