fastmcp 2.12.1__py3-none-any.whl → 2.13.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. fastmcp/__init__.py +2 -2
  2. fastmcp/cli/cli.py +56 -36
  3. fastmcp/cli/install/__init__.py +2 -0
  4. fastmcp/cli/install/claude_code.py +7 -16
  5. fastmcp/cli/install/claude_desktop.py +4 -12
  6. fastmcp/cli/install/cursor.py +20 -30
  7. fastmcp/cli/install/gemini_cli.py +241 -0
  8. fastmcp/cli/install/mcp_json.py +4 -12
  9. fastmcp/cli/run.py +15 -94
  10. fastmcp/client/__init__.py +9 -9
  11. fastmcp/client/auth/oauth.py +117 -206
  12. fastmcp/client/client.py +123 -47
  13. fastmcp/client/elicitation.py +6 -1
  14. fastmcp/client/logging.py +18 -14
  15. fastmcp/client/oauth_callback.py +85 -171
  16. fastmcp/client/sampling.py +1 -1
  17. fastmcp/client/transports.py +81 -26
  18. fastmcp/contrib/component_manager/__init__.py +1 -1
  19. fastmcp/contrib/component_manager/component_manager.py +2 -2
  20. fastmcp/contrib/component_manager/component_service.py +7 -7
  21. fastmcp/contrib/mcp_mixin/README.md +35 -4
  22. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  23. fastmcp/contrib/mcp_mixin/mcp_mixin.py +54 -7
  24. fastmcp/experimental/sampling/handlers/openai.py +2 -2
  25. fastmcp/experimental/server/openapi/__init__.py +5 -8
  26. fastmcp/experimental/server/openapi/components.py +11 -7
  27. fastmcp/experimental/server/openapi/routing.py +2 -2
  28. fastmcp/experimental/utilities/openapi/__init__.py +10 -15
  29. fastmcp/experimental/utilities/openapi/director.py +16 -10
  30. fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
  31. fastmcp/experimental/utilities/openapi/models.py +3 -3
  32. fastmcp/experimental/utilities/openapi/parser.py +37 -16
  33. fastmcp/experimental/utilities/openapi/schemas.py +33 -7
  34. fastmcp/mcp_config.py +3 -4
  35. fastmcp/prompts/__init__.py +1 -1
  36. fastmcp/prompts/prompt.py +32 -27
  37. fastmcp/prompts/prompt_manager.py +16 -101
  38. fastmcp/resources/__init__.py +5 -5
  39. fastmcp/resources/resource.py +28 -20
  40. fastmcp/resources/resource_manager.py +9 -168
  41. fastmcp/resources/template.py +119 -27
  42. fastmcp/resources/types.py +30 -24
  43. fastmcp/server/__init__.py +1 -1
  44. fastmcp/server/auth/__init__.py +9 -5
  45. fastmcp/server/auth/auth.py +80 -47
  46. fastmcp/server/auth/handlers/authorize.py +326 -0
  47. fastmcp/server/auth/jwt_issuer.py +236 -0
  48. fastmcp/server/auth/middleware.py +96 -0
  49. fastmcp/server/auth/oauth_proxy.py +1556 -265
  50. fastmcp/server/auth/oidc_proxy.py +412 -0
  51. fastmcp/server/auth/providers/auth0.py +193 -0
  52. fastmcp/server/auth/providers/aws.py +263 -0
  53. fastmcp/server/auth/providers/azure.py +314 -129
  54. fastmcp/server/auth/providers/bearer.py +1 -1
  55. fastmcp/server/auth/providers/debug.py +114 -0
  56. fastmcp/server/auth/providers/descope.py +229 -0
  57. fastmcp/server/auth/providers/discord.py +308 -0
  58. fastmcp/server/auth/providers/github.py +31 -6
  59. fastmcp/server/auth/providers/google.py +50 -7
  60. fastmcp/server/auth/providers/in_memory.py +27 -3
  61. fastmcp/server/auth/providers/introspection.py +281 -0
  62. fastmcp/server/auth/providers/jwt.py +48 -31
  63. fastmcp/server/auth/providers/oci.py +233 -0
  64. fastmcp/server/auth/providers/scalekit.py +238 -0
  65. fastmcp/server/auth/providers/supabase.py +188 -0
  66. fastmcp/server/auth/providers/workos.py +37 -15
  67. fastmcp/server/context.py +194 -67
  68. fastmcp/server/dependencies.py +56 -16
  69. fastmcp/server/elicitation.py +1 -1
  70. fastmcp/server/http.py +57 -18
  71. fastmcp/server/low_level.py +121 -2
  72. fastmcp/server/middleware/__init__.py +1 -1
  73. fastmcp/server/middleware/caching.py +476 -0
  74. fastmcp/server/middleware/error_handling.py +14 -10
  75. fastmcp/server/middleware/logging.py +158 -116
  76. fastmcp/server/middleware/middleware.py +30 -16
  77. fastmcp/server/middleware/rate_limiting.py +3 -3
  78. fastmcp/server/middleware/tool_injection.py +116 -0
  79. fastmcp/server/openapi.py +15 -7
  80. fastmcp/server/proxy.py +22 -11
  81. fastmcp/server/server.py +744 -254
  82. fastmcp/settings.py +65 -15
  83. fastmcp/tools/__init__.py +1 -1
  84. fastmcp/tools/tool.py +173 -108
  85. fastmcp/tools/tool_manager.py +30 -112
  86. fastmcp/tools/tool_transform.py +13 -11
  87. fastmcp/utilities/cli.py +67 -28
  88. fastmcp/utilities/components.py +7 -2
  89. fastmcp/utilities/inspect.py +79 -23
  90. fastmcp/utilities/json_schema.py +21 -4
  91. fastmcp/utilities/json_schema_type.py +4 -4
  92. fastmcp/utilities/logging.py +182 -10
  93. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  94. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  95. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +10 -45
  96. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +8 -7
  97. fastmcp/utilities/mcp_server_config/v1/schema.json +5 -1
  98. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  99. fastmcp/utilities/openapi.py +11 -11
  100. fastmcp/utilities/tests.py +93 -10
  101. fastmcp/utilities/types.py +87 -21
  102. fastmcp/utilities/ui.py +626 -0
  103. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/METADATA +141 -60
  104. fastmcp-2.13.2.dist-info/RECORD +144 -0
  105. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
  106. fastmcp/cli/claude.py +0 -144
  107. fastmcp-2.12.1.dist-info/RECORD +0 -128
  108. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
  109. {fastmcp-2.12.1.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
- from typing import Annotated, Any, Literal
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,17 @@ 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
+
32
+ if TYPE_CHECKING:
33
+ from fastmcp.server.auth.auth import AuthProvider
34
+
26
35
 
27
36
  class ExtendedEnvSettingsSource(EnvSettingsSource):
28
37
  """
@@ -79,7 +88,7 @@ class Settings(BaseSettings):
79
88
 
80
89
  model_config = ExtendedSettingsConfigDict(
81
90
  env_prefixes=["FASTMCP_", "FASTMCP_SERVER_"],
82
- env_file=".env",
91
+ env_file=ENV_FILE,
83
92
  extra="ignore",
84
93
  env_nested_delimiter="__",
85
94
  nested_model_default_partial_update=True,
@@ -142,7 +151,7 @@ class Settings(BaseSettings):
142
151
  )
143
152
  return self
144
153
 
145
- home: Path = Path.home() / ".fastmcp"
154
+ home: Path = Path(user_data_dir("fastmcp", appauthor=False))
146
155
 
147
156
  test_mode: bool = False
148
157
 
@@ -186,7 +195,6 @@ class Settings(BaseSettings):
186
195
  client_raise_first_exceptiongroup_error: Annotated[
187
196
  bool,
188
197
  Field(
189
- default=True,
190
198
  description=inspect.cleandoc(
191
199
  """
192
200
  Many MCP components operate in anyio taskgroups, and raise
@@ -202,7 +210,6 @@ class Settings(BaseSettings):
202
210
  resource_prefix_format: Annotated[
203
211
  Literal["protocol", "path"],
204
212
  Field(
205
- default="path",
206
213
  description=inspect.cleandoc(
207
214
  """
208
215
  When perfixing a resource URI, either use path formatting (resource://prefix/path)
@@ -232,7 +239,6 @@ class Settings(BaseSettings):
232
239
  mask_error_details: Annotated[
233
240
  bool,
234
241
  Field(
235
- default=False,
236
242
  description=inspect.cleandoc(
237
243
  """
238
244
  If True, error details from user-supplied functions (tool, resource, prompt)
@@ -245,6 +251,22 @@ class Settings(BaseSettings):
245
251
  ),
246
252
  ] = False
247
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
+
248
270
  server_dependencies: list[str] = Field(
249
271
  default_factory=list,
250
272
  description="List of dependencies to install in the server environment",
@@ -258,7 +280,7 @@ class Settings(BaseSettings):
258
280
 
259
281
  # Auth settings
260
282
  server_auth: Annotated[
261
- ImportString | None,
283
+ str | None,
262
284
  Field(
263
285
  description=inspect.cleandoc(
264
286
  """
@@ -266,13 +288,13 @@ class Settings(BaseSettings):
266
288
  the full module path to an AuthProvider class (e.g.,
267
289
  'fastmcp.server.auth.providers.google.GoogleProvider').
268
290
 
269
- The specified class will be imported and instantiated automatically.
270
- Any class that inherits from AuthProvider can be used, including
271
- custom implementations.
291
+ The specified class will be imported and instantiated automatically
292
+ during FastMCP server creation. Any class that inherits from AuthProvider
293
+ can be used, including custom implementations.
272
294
 
273
295
  If None, no automatic configuration will take place.
274
296
 
275
- This setting is *always* overriden by any auth provider passed to the
297
+ This setting is *always* overridden by any auth provider passed to the
276
298
  FastMCP constructor.
277
299
 
278
300
  Note that most auth providers require additional configuration
@@ -290,7 +312,6 @@ class Settings(BaseSettings):
290
312
  include_tags: Annotated[
291
313
  set[str] | None,
292
314
  Field(
293
- default=None,
294
315
  description=inspect.cleandoc(
295
316
  """
296
317
  If provided, only components that match these tags will be
@@ -303,7 +324,6 @@ class Settings(BaseSettings):
303
324
  exclude_tags: Annotated[
304
325
  set[str] | None,
305
326
  Field(
306
- default=None,
307
327
  description=inspect.cleandoc(
308
328
  """
309
329
  If provided, components that match these tags will be excluded
@@ -317,7 +337,6 @@ class Settings(BaseSettings):
317
337
  include_fastmcp_meta: Annotated[
318
338
  bool,
319
339
  Field(
320
- default=True,
321
340
  description=inspect.cleandoc(
322
341
  """
323
342
  Whether to include FastMCP meta in the server's MCP responses.
@@ -332,7 +351,6 @@ class Settings(BaseSettings):
332
351
  mounted_components_raise_on_load_error: Annotated[
333
352
  bool,
334
353
  Field(
335
- default=False,
336
354
  description=inspect.cleandoc(
337
355
  """
338
356
  If True, errors encountered when loading mounted components (tools, resources, prompts)
@@ -343,6 +361,38 @@ class Settings(BaseSettings):
343
361
  ),
344
362
  ] = False
345
363
 
364
+ show_cli_banner: Annotated[
365
+ bool,
366
+ Field(
367
+ description=inspect.cleandoc(
368
+ """
369
+ If True, the server banner will be displayed when running the server via CLI.
370
+ This setting can be overridden by the --no-banner CLI flag.
371
+ Set to False via FASTMCP_SHOW_CLI_BANNER=false to suppress the banner.
372
+ """
373
+ ),
374
+ ),
375
+ ] = True
376
+
377
+ @property
378
+ def server_auth_class(self) -> AuthProvider | None:
379
+ from fastmcp.utilities.types import get_cached_typeadapter
380
+
381
+ if not self.server_auth:
382
+ return None
383
+
384
+ # https://github.com/jlowin/fastmcp/issues/1749
385
+ # Pydantic imports the module in an ImportString during model validation, but we don't want the server
386
+ # auth module imported during settings creation as it imports dependencies we aren't ready for yet.
387
+ # To fix this while limiting breaking changes, we delay the import by only creating the ImportString
388
+ # when the class is actually needed
389
+
390
+ type_adapter = get_cached_typeadapter(ImportString)
391
+
392
+ auth_class = type_adapter.validate_python(self.server_auth)
393
+
394
+ return auth_class
395
+
346
396
 
347
397
  def __getattr__(name: str):
348
398
  """
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
@@ -10,12 +10,13 @@ from typing import (
10
10
  Any,
11
11
  Generic,
12
12
  Literal,
13
+ TypeAlias,
13
14
  get_type_hints,
14
15
  )
15
16
 
16
17
  import mcp.types
17
18
  import pydantic_core
18
- from mcp.types import ContentBlock, TextContent, ToolAnnotations
19
+ from mcp.types import CallToolResult, ContentBlock, Icon, TextContent, ToolAnnotations
19
20
  from mcp.types import Tool as MCPTool
20
21
  from pydantic import Field, PydanticSchemaGenerationError
21
22
  from typing_extensions import TypeVar
@@ -31,6 +32,7 @@ from fastmcp.utilities.types import (
31
32
  Image,
32
33
  NotSet,
33
34
  NotSetT,
35
+ create_function_without_params,
34
36
  find_kwarg_by_type,
35
37
  get_cached_typeadapter,
36
38
  replace_type,
@@ -55,6 +57,9 @@ class _UnserializableType:
55
57
  pass
56
58
 
57
59
 
60
+ ToolResultSerializerType: TypeAlias = Callable[[Any], str]
61
+
62
+
58
63
  def default_serializer(data: Any) -> str:
59
64
  return pydantic_core.to_json(data, fallback=str).decode()
60
65
 
@@ -64,18 +69,20 @@ class ToolResult:
64
69
  self,
65
70
  content: list[ContentBlock] | Any | None = None,
66
71
  structured_content: dict[str, Any] | Any | None = None,
72
+ meta: dict[str, Any] | None = None,
67
73
  ):
68
74
  if content is None and structured_content is None:
69
75
  raise ValueError("Either content or structured_content must be provided")
70
76
  elif content is None:
71
77
  content = structured_content
72
78
 
73
- self.content = _convert_to_content(content)
79
+ self.content: list[ContentBlock] = _convert_to_content(result=content)
80
+ self.meta: dict[str, Any] | None = meta
74
81
 
75
82
  if structured_content is not None:
76
83
  try:
77
84
  structured_content = pydantic_core.to_jsonable_python(
78
- structured_content
85
+ value=structured_content
79
86
  )
80
87
  except pydantic_core.PydanticSerializationError as e:
81
88
  logger.error(
@@ -92,7 +99,15 @@ class ToolResult:
92
99
 
93
100
  def to_mcp_result(
94
101
  self,
95
- ) -> 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
+ )
96
111
  if self.structured_content is None:
97
112
  return self.content
98
113
  return self.content, self.structured_content
@@ -112,7 +127,7 @@ class Tool(FastMCPComponent):
112
127
  Field(description="Additional annotations about the tool"),
113
128
  ] = None
114
129
  serializer: Annotated[
115
- Callable[[Any], str] | None,
130
+ ToolResultSerializerType | None,
116
131
  Field(description="Optional custom serializer for tool results"),
117
132
  ] = None
118
133
 
@@ -138,23 +153,26 @@ class Tool(FastMCPComponent):
138
153
  include_fastmcp_meta: bool | None = None,
139
154
  **overrides: Any,
140
155
  ) -> MCPTool:
156
+ """Convert the FastMCP tool to an MCP tool."""
157
+ title = None
158
+
141
159
  if self.title:
142
160
  title = self.title
143
161
  elif self.annotations and self.annotations.title:
144
162
  title = self.annotations.title
145
- else:
146
- title = None
147
-
148
- kwargs = {
149
- "name": self.name,
150
- "description": self.description,
151
- "inputSchema": self.parameters,
152
- "outputSchema": self.output_schema,
153
- "annotations": self.annotations,
154
- "title": title,
155
- "_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta),
156
- }
157
- return MCPTool(**kwargs | overrides)
163
+
164
+ return MCPTool(
165
+ name=overrides.get("name", self.name),
166
+ title=overrides.get("title", title),
167
+ description=overrides.get("description", self.description),
168
+ inputSchema=overrides.get("inputSchema", self.parameters),
169
+ outputSchema=overrides.get("outputSchema", self.output_schema),
170
+ icons=overrides.get("icons", self.icons),
171
+ annotations=overrides.get("annotations", self.annotations),
172
+ _meta=overrides.get(
173
+ "_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta)
174
+ ),
175
+ )
158
176
 
159
177
  @staticmethod
160
178
  def from_function(
@@ -162,11 +180,12 @@ class Tool(FastMCPComponent):
162
180
  name: str | None = None,
163
181
  title: str | None = None,
164
182
  description: str | None = None,
183
+ icons: list[Icon] | None = None,
165
184
  tags: set[str] | None = None,
166
185
  annotations: ToolAnnotations | None = None,
167
186
  exclude_args: list[str] | None = None,
168
- output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
169
- serializer: Callable[[Any], str] | None = None,
187
+ output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet,
188
+ serializer: ToolResultSerializerType | None = None,
170
189
  meta: dict[str, Any] | None = None,
171
190
  enabled: bool | None = None,
172
191
  ) -> FunctionTool:
@@ -176,6 +195,7 @@ class Tool(FastMCPComponent):
176
195
  name=name,
177
196
  title=title,
178
197
  description=description,
198
+ icons=icons,
179
199
  tags=tags,
180
200
  annotations=annotations,
181
201
  exclude_args=exclude_args,
@@ -203,13 +223,13 @@ class Tool(FastMCPComponent):
203
223
  tool: Tool,
204
224
  *,
205
225
  name: str | None = None,
206
- title: str | None | NotSetT = NotSet,
207
- description: str | None | NotSetT = NotSet,
226
+ title: str | NotSetT | None = NotSet,
227
+ description: str | NotSetT | None = NotSet,
208
228
  tags: set[str] | None = None,
209
- annotations: ToolAnnotations | None | NotSetT = NotSet,
210
- output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
211
- serializer: Callable[[Any], str] | None = None,
212
- meta: dict[str, Any] | None | NotSetT = NotSet,
229
+ annotations: ToolAnnotations | NotSetT | None = NotSet,
230
+ output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet,
231
+ serializer: ToolResultSerializerType | None = None,
232
+ meta: dict[str, Any] | NotSetT | None = NotSet,
213
233
  transform_args: dict[str, ArgTransform] | None = None,
214
234
  enabled: bool | None = None,
215
235
  transform_fn: Callable[..., Any] | None = None,
@@ -242,15 +262,26 @@ class FunctionTool(Tool):
242
262
  name: str | None = None,
243
263
  title: str | None = None,
244
264
  description: str | None = None,
265
+ icons: list[Icon] | None = None,
245
266
  tags: set[str] | None = None,
246
267
  annotations: ToolAnnotations | None = None,
247
268
  exclude_args: list[str] | None = None,
248
- output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
249
- serializer: Callable[[Any], str] | None = None,
269
+ output_schema: dict[str, Any] | Literal[False] | NotSetT | None = NotSet,
270
+ serializer: ToolResultSerializerType | None = None,
250
271
  meta: dict[str, Any] | None = None,
251
272
  enabled: bool | None = None,
252
273
  ) -> FunctionTool:
253
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
+ )
254
285
 
255
286
  parsed_fn = ParsedFunction.from_function(fn, exclude_args=exclude_args)
256
287
 
@@ -274,10 +305,11 @@ class FunctionTool(Tool):
274
305
  # Note: explicit schemas (dict) are used as-is without auto-wrapping
275
306
 
276
307
  # Validate that explicit schemas are object type for structured content
308
+ # (resolving $ref references for self-referencing types)
277
309
  if final_output_schema is not None and isinstance(final_output_schema, dict):
278
- if final_output_schema.get("type") != "object":
310
+ if not _is_object_schema(final_output_schema):
279
311
  raise ValueError(
280
- 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}"
281
313
  )
282
314
 
283
315
  return cls(
@@ -285,6 +317,7 @@ class FunctionTool(Tool):
285
317
  name=name or parsed_fn.name,
286
318
  title=title,
287
319
  description=description or parsed_fn.description,
320
+ icons=icons,
288
321
  parameters=parsed_fn.input_schema,
289
322
  output_schema=final_output_schema,
290
323
  annotations=annotations,
@@ -315,30 +348,51 @@ class FunctionTool(Tool):
315
348
 
316
349
  unstructured_result = _convert_to_content(result, serializer=self.serializer)
317
350
 
318
- structured_output = None
319
- # First handle structured content based on output schema, if any
320
- if self.output_schema is not None:
321
- if self.output_schema.get("x-fastmcp-wrap-result"):
322
- # Schema says wrap - always wrap in result key
323
- structured_output = {"result": result}
324
- else:
325
- structured_output = result
326
- # If no output schema, try to serialize the result. If it is a dict, use
327
- # it as structured content. If it is not a dict, ignore it.
328
- if structured_output is None:
351
+ if self.output_schema is None:
352
+ # Do not produce a structured output for MCP Content Types
353
+ if isinstance(result, ContentBlock | Audio | Image | File) or (
354
+ isinstance(result, list | tuple)
355
+ and any(isinstance(item, ContentBlock) for item in result)
356
+ ):
357
+ return ToolResult(content=unstructured_result)
358
+
359
+ # Otherwise, try to serialize the result as a dict
329
360
  try:
330
- structured_output = pydantic_core.to_jsonable_python(result)
331
- if not isinstance(structured_output, dict):
332
- structured_output = None
333
- except Exception:
361
+ structured_content = pydantic_core.to_jsonable_python(result)
362
+ if isinstance(structured_content, dict):
363
+ return ToolResult(
364
+ content=unstructured_result,
365
+ structured_content=structured_content,
366
+ )
367
+
368
+ except pydantic_core.PydanticSerializationError:
334
369
  pass
335
370
 
371
+ return ToolResult(content=unstructured_result)
372
+
373
+ wrap_result = self.output_schema.get("x-fastmcp-wrap-result")
374
+
336
375
  return ToolResult(
337
376
  content=unstructured_result,
338
- structured_content=structured_output,
377
+ structured_content={"result": result} if wrap_result else result,
339
378
  )
340
379
 
341
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
+
342
396
  @dataclass
343
397
  class ParsedFunction:
344
398
  fn: Callable[..., Any]
@@ -399,9 +453,18 @@ class ParsedFunction:
399
453
  if exclude_args:
400
454
  prune_params.extend(exclude_args)
401
455
 
402
- 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)
403
464
  input_schema = input_type_adapter.json_schema()
404
- input_schema = compress_schema(input_schema, prune_params=prune_params)
465
+ input_schema = compress_schema(
466
+ input_schema, prune_params=prune_params, prune_titles=True
467
+ )
405
468
 
406
469
  output_schema = None
407
470
  # Get the return annotation from the signature
@@ -427,9 +490,8 @@ class ParsedFunction:
427
490
  # we ensure that no output schema is automatically generated.
428
491
  clean_output_type = replace_type(
429
492
  output_type,
430
- {
431
- t: _UnserializableType
432
- for t in (
493
+ dict.fromkeys( # type: ignore[arg-type]
494
+ (
433
495
  Image,
434
496
  Audio,
435
497
  File,
@@ -439,8 +501,9 @@ class ParsedFunction:
439
501
  mcp.types.AudioContent,
440
502
  mcp.types.ResourceLink,
441
503
  mcp.types.EmbeddedResource,
442
- )
443
- },
504
+ ),
505
+ _UnserializableType,
506
+ ),
444
507
  )
445
508
 
446
509
  try:
@@ -449,10 +512,9 @@ class ParsedFunction:
449
512
 
450
513
  # Generate schema for wrapped type if it's non-object
451
514
  # because MCP requires that output schemas are objects
452
- if (
453
- wrap_non_object_output_schema
454
- and base_schema.get("type") != "object"
455
- ):
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):
456
518
  # Use the wrapped result schema directly
457
519
  wrapped_type = _WrappedResult[clean_output_type]
458
520
  wrapped_adapter = get_cached_typeadapter(wrapped_type)
@@ -461,7 +523,7 @@ class ParsedFunction:
461
523
  else:
462
524
  output_schema = base_schema
463
525
 
464
- output_schema = compress_schema(output_schema)
526
+ output_schema = compress_schema(output_schema, prune_titles=True)
465
527
 
466
528
  except PydanticSchemaGenerationError as e:
467
529
  if "_UnserializableType" not in str(e):
@@ -476,65 +538,68 @@ class ParsedFunction:
476
538
  )
477
539
 
478
540
 
479
- def _convert_to_content(
480
- result: Any,
481
- serializer: Callable[[Any], str] | None = None,
482
- _process_as_single_item: bool = False,
483
- ) -> list[ContentBlock]:
484
- """Convert a result to a sequence of content objects."""
541
+ def _serialize_with_fallback(
542
+ result: Any, serializer: ToolResultSerializerType | None = None
543
+ ) -> str:
544
+ if serializer is not None:
545
+ try:
546
+ return serializer(result)
547
+ except Exception as e:
548
+ logger.warning(
549
+ "Error serializing tool result: %s",
550
+ e,
551
+ exc_info=True,
552
+ )
485
553
 
486
- if result is None:
487
- return []
554
+ return default_serializer(result)
488
555
 
489
- if isinstance(result, ContentBlock):
490
- return [result]
491
556
 
492
- if isinstance(result, Image):
493
- return [result.to_image_content()]
557
+ def _convert_to_single_content_block(
558
+ item: Any,
559
+ serializer: ToolResultSerializerType | None = None,
560
+ ) -> ContentBlock:
561
+ if isinstance(item, ContentBlock):
562
+ return item
494
563
 
495
- elif isinstance(result, Audio):
496
- return [result.to_audio_content()]
564
+ if isinstance(item, Image):
565
+ return item.to_image_content()
497
566
 
498
- elif isinstance(result, File):
499
- return [result.to_resource_content()]
567
+ if isinstance(item, Audio):
568
+ return item.to_audio_content()
500
569
 
501
- if isinstance(result, list | tuple) and not _process_as_single_item:
502
- # if the result is a list, then it could either be a list of MCP types,
503
- # or a "regular" list that the tool is returning, or a mix of both.
504
- #
505
- # so we extract all the MCP types / images and convert them as individual content elements,
506
- # and aggregate the rest as a single content element
570
+ if isinstance(item, File):
571
+ return item.to_resource_content()
507
572
 
508
- mcp_types = []
509
- other_content = []
573
+ if isinstance(item, str):
574
+ return TextContent(type="text", text=item)
510
575
 
511
- for item in result:
512
- if isinstance(item, ContentBlock | Image | Audio | File):
513
- mcp_types.append(_convert_to_content(item)[0])
514
- else:
515
- other_content.append(item)
576
+ return TextContent(type="text", text=_serialize_with_fallback(item, serializer))
516
577
 
517
- if other_content:
518
- other_content = _convert_to_content(
519
- other_content,
520
- serializer=serializer,
521
- _process_as_single_item=True,
522
- )
523
578
 
524
- return other_content + mcp_types
579
+ def _convert_to_content(
580
+ result: Any,
581
+ serializer: ToolResultSerializerType | None = None,
582
+ ) -> list[ContentBlock]:
583
+ """Convert a result to a sequence of content objects."""
525
584
 
526
- if not isinstance(result, str):
527
- if serializer is None:
528
- result = default_serializer(result)
529
- else:
530
- try:
531
- result = serializer(result)
532
- except Exception as e:
533
- logger.warning(
534
- "Error serializing tool result: %s",
535
- e,
536
- exc_info=True,
537
- )
538
- result = default_serializer(result)
585
+ if result is None:
586
+ return []
539
587
 
540
- return [TextContent(type="text", text=result)]
588
+ if not isinstance(result, (list | tuple)):
589
+ return [_convert_to_single_content_block(result, serializer)]
590
+
591
+ # If all items are ContentBlocks, return them as is
592
+ if all(isinstance(item, ContentBlock) for item in result):
593
+ return result
594
+
595
+ # If any item is a ContentBlock, convert non-ContentBlock items to TextContent
596
+ # without aggregating them
597
+ if any(isinstance(item, ContentBlock | Image | Audio | File) for item in result):
598
+ return [
599
+ _convert_to_single_content_block(item, serializer)
600
+ if not isinstance(item, ContentBlock)
601
+ else item
602
+ for item in result
603
+ ]
604
+ # If none of the items are ContentBlocks, aggregate all items into a single TextContent
605
+ return [TextContent(type="text", text=_serialize_with_fallback(result, serializer))]