fastmcp 2.9.2__py3-none-any.whl → 2.10.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 (39) hide show
  1. fastmcp/client/auth/oauth.py +5 -82
  2. fastmcp/client/client.py +114 -24
  3. fastmcp/client/elicitation.py +63 -0
  4. fastmcp/client/transports.py +50 -36
  5. fastmcp/contrib/component_manager/README.md +170 -0
  6. fastmcp/contrib/component_manager/__init__.py +4 -0
  7. fastmcp/contrib/component_manager/component_manager.py +186 -0
  8. fastmcp/contrib/component_manager/component_service.py +225 -0
  9. fastmcp/contrib/component_manager/example.py +59 -0
  10. fastmcp/prompts/prompt.py +12 -4
  11. fastmcp/resources/resource.py +8 -3
  12. fastmcp/resources/template.py +5 -0
  13. fastmcp/server/auth/auth.py +15 -0
  14. fastmcp/server/auth/providers/bearer.py +41 -3
  15. fastmcp/server/auth/providers/bearer_env.py +4 -0
  16. fastmcp/server/auth/providers/in_memory.py +15 -0
  17. fastmcp/server/context.py +144 -4
  18. fastmcp/server/elicitation.py +160 -0
  19. fastmcp/server/http.py +1 -9
  20. fastmcp/server/low_level.py +4 -2
  21. fastmcp/server/middleware/__init__.py +14 -1
  22. fastmcp/server/middleware/logging.py +11 -0
  23. fastmcp/server/middleware/middleware.py +10 -6
  24. fastmcp/server/openapi.py +19 -77
  25. fastmcp/server/proxy.py +13 -6
  26. fastmcp/server/server.py +27 -7
  27. fastmcp/settings.py +0 -17
  28. fastmcp/tools/tool.py +209 -57
  29. fastmcp/tools/tool_manager.py +2 -3
  30. fastmcp/tools/tool_transform.py +125 -26
  31. fastmcp/utilities/components.py +5 -1
  32. fastmcp/utilities/json_schema_type.py +648 -0
  33. fastmcp/utilities/openapi.py +69 -0
  34. fastmcp/utilities/types.py +50 -19
  35. {fastmcp-2.9.2.dist-info → fastmcp-2.10.1.dist-info}/METADATA +3 -2
  36. {fastmcp-2.9.2.dist-info → fastmcp-2.10.1.dist-info}/RECORD +39 -31
  37. {fastmcp-2.9.2.dist-info → fastmcp-2.10.1.dist-info}/WHEEL +0 -0
  38. {fastmcp-2.9.2.dist-info → fastmcp-2.10.1.dist-info}/entry_points.txt +0 -0
  39. {fastmcp-2.9.2.dist-info → fastmcp-2.10.1.dist-info}/licenses/LICENSE +0 -0
fastmcp/server/server.py CHANGED
@@ -26,6 +26,7 @@ from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
26
26
  from mcp.server.stdio import stdio_server
27
27
  from mcp.types import (
28
28
  AnyFunction,
29
+ ContentBlock,
29
30
  GetPromptResult,
30
31
  ToolAnnotations,
31
32
  )
@@ -57,12 +58,12 @@ from fastmcp.server.low_level import LowLevelServer
57
58
  from fastmcp.server.middleware import Middleware, MiddlewareContext
58
59
  from fastmcp.settings import Settings
59
60
  from fastmcp.tools import ToolManager
60
- from fastmcp.tools.tool import FunctionTool, Tool
61
+ from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
61
62
  from fastmcp.utilities.cache import TimedCache
62
63
  from fastmcp.utilities.components import FastMCPComponent
63
64
  from fastmcp.utilities.logging import get_logger
64
65
  from fastmcp.utilities.mcp_config import MCPConfig
65
- from fastmcp.utilities.types import MCPContent
66
+ from fastmcp.utilities.types import NotSet, NotSetT
66
67
 
67
68
  if TYPE_CHECKING:
68
69
  from fastmcp.client import Client
@@ -441,7 +442,6 @@ class FastMCP(Generic[LifespanResultT]):
441
442
  """
442
443
  List all available tools, in the format expected by the low-level MCP
443
444
  server.
444
-
445
445
  """
446
446
 
447
447
  async def _handler(
@@ -593,7 +593,7 @@ class FastMCP(Generic[LifespanResultT]):
593
593
 
594
594
  async def _mcp_call_tool(
595
595
  self, key: str, arguments: dict[str, Any]
596
- ) -> list[MCPContent]:
596
+ ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
597
597
  """
598
598
  Handle MCP 'callTool' requests.
599
599
 
@@ -610,20 +610,21 @@ class FastMCP(Generic[LifespanResultT]):
610
610
 
611
611
  async with fastmcp.server.context.Context(fastmcp=self):
612
612
  try:
613
- return await self._call_tool(key, arguments)
613
+ result = await self._call_tool(key, arguments)
614
+ return result.to_mcp_result()
614
615
  except DisabledError:
615
616
  raise NotFoundError(f"Unknown tool: {key}")
616
617
  except NotFoundError:
617
618
  raise NotFoundError(f"Unknown tool: {key}")
618
619
 
619
- async def _call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
620
+ async def _call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
620
621
  """
621
622
  Applies this server's middleware and delegates the filtered call to the manager.
622
623
  """
623
624
 
624
625
  async def _handler(
625
626
  context: MiddlewareContext[mcp.types.CallToolRequestParams],
626
- ) -> list[MCPContent]:
627
+ ) -> ToolResult:
627
628
  tool = await self._tool_manager.get_tool(context.message.name)
628
629
  if not self._should_enable_component(tool):
629
630
  raise NotFoundError(f"Unknown tool: {context.message.name!r}")
@@ -789,8 +790,10 @@ class FastMCP(Generic[LifespanResultT]):
789
790
  name_or_fn: AnyFunction,
790
791
  *,
791
792
  name: str | None = None,
793
+ title: str | None = None,
792
794
  description: str | None = None,
793
795
  tags: set[str] | None = None,
796
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
794
797
  annotations: ToolAnnotations | dict[str, Any] | None = None,
795
798
  exclude_args: list[str] | None = None,
796
799
  enabled: bool | None = None,
@@ -802,8 +805,10 @@ class FastMCP(Generic[LifespanResultT]):
802
805
  name_or_fn: str | None = None,
803
806
  *,
804
807
  name: str | None = None,
808
+ title: str | None = None,
805
809
  description: str | None = None,
806
810
  tags: set[str] | None = None,
811
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
807
812
  annotations: ToolAnnotations | dict[str, Any] | None = None,
808
813
  exclude_args: list[str] | None = None,
809
814
  enabled: bool | None = None,
@@ -814,8 +819,10 @@ class FastMCP(Generic[LifespanResultT]):
814
819
  name_or_fn: str | AnyFunction | None = None,
815
820
  *,
816
821
  name: str | None = None,
822
+ title: str | None = None,
817
823
  description: str | None = None,
818
824
  tags: set[str] | None = None,
825
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
819
826
  annotations: ToolAnnotations | dict[str, Any] | None = None,
820
827
  exclude_args: list[str] | None = None,
821
828
  enabled: bool | None = None,
@@ -838,6 +845,7 @@ class FastMCP(Generic[LifespanResultT]):
838
845
  name: Optional name for the tool (keyword-only, alternative to name_or_fn)
839
846
  description: Optional description of what the tool does
840
847
  tags: Optional set of tags for categorizing the tool
848
+ output_schema: Optional JSON schema for the tool's output
841
849
  annotations: Optional annotations about the tool's behavior
842
850
  exclude_args: Optional list of argument names to exclude from the tool schema
843
851
  enabled: Optional boolean to enable or disable the tool
@@ -892,8 +900,10 @@ class FastMCP(Generic[LifespanResultT]):
892
900
  tool = Tool.from_function(
893
901
  fn,
894
902
  name=tool_name,
903
+ title=title,
895
904
  description=description,
896
905
  tags=tags,
906
+ output_schema=output_schema,
897
907
  annotations=annotations,
898
908
  exclude_args=exclude_args,
899
909
  serializer=self._tool_serializer,
@@ -922,8 +932,10 @@ class FastMCP(Generic[LifespanResultT]):
922
932
  return partial(
923
933
  self.tool,
924
934
  name=tool_name,
935
+ title=title,
925
936
  description=description,
926
937
  tags=tags,
938
+ output_schema=output_schema,
927
939
  annotations=annotations,
928
940
  exclude_args=exclude_args,
929
941
  enabled=enabled,
@@ -1009,6 +1021,7 @@ class FastMCP(Generic[LifespanResultT]):
1009
1021
  uri: str,
1010
1022
  *,
1011
1023
  name: str | None = None,
1024
+ title: str | None = None,
1012
1025
  description: str | None = None,
1013
1026
  mime_type: str | None = None,
1014
1027
  tags: set[str] | None = None,
@@ -1100,6 +1113,7 @@ class FastMCP(Generic[LifespanResultT]):
1100
1113
  fn=fn,
1101
1114
  uri_template=uri,
1102
1115
  name=name,
1116
+ title=title,
1103
1117
  description=description,
1104
1118
  mime_type=mime_type,
1105
1119
  tags=tags,
@@ -1112,6 +1126,7 @@ class FastMCP(Generic[LifespanResultT]):
1112
1126
  fn=fn,
1113
1127
  uri=uri,
1114
1128
  name=name,
1129
+ title=title,
1115
1130
  description=description,
1116
1131
  mime_type=mime_type,
1117
1132
  tags=tags,
@@ -1151,6 +1166,7 @@ class FastMCP(Generic[LifespanResultT]):
1151
1166
  name_or_fn: AnyFunction,
1152
1167
  *,
1153
1168
  name: str | None = None,
1169
+ title: str | None = None,
1154
1170
  description: str | None = None,
1155
1171
  tags: set[str] | None = None,
1156
1172
  enabled: bool | None = None,
@@ -1162,6 +1178,7 @@ class FastMCP(Generic[LifespanResultT]):
1162
1178
  name_or_fn: str | None = None,
1163
1179
  *,
1164
1180
  name: str | None = None,
1181
+ title: str | None = None,
1165
1182
  description: str | None = None,
1166
1183
  tags: set[str] | None = None,
1167
1184
  enabled: bool | None = None,
@@ -1172,6 +1189,7 @@ class FastMCP(Generic[LifespanResultT]):
1172
1189
  name_or_fn: str | AnyFunction | None = None,
1173
1190
  *,
1174
1191
  name: str | None = None,
1192
+ title: str | None = None,
1175
1193
  description: str | None = None,
1176
1194
  tags: set[str] | None = None,
1177
1195
  enabled: bool | None = None,
@@ -1268,6 +1286,7 @@ class FastMCP(Generic[LifespanResultT]):
1268
1286
  prompt = Prompt.from_function(
1269
1287
  fn=fn,
1270
1288
  name=prompt_name,
1289
+ title=title,
1271
1290
  description=description,
1272
1291
  tags=tags,
1273
1292
  enabled=enabled,
@@ -1296,6 +1315,7 @@ class FastMCP(Generic[LifespanResultT]):
1296
1315
  return partial(
1297
1316
  self.prompt,
1298
1317
  name=prompt_name,
1318
+ title=title,
1299
1319
  description=description,
1300
1320
  tags=tags,
1301
1321
  enabled=enabled,
fastmcp/settings.py CHANGED
@@ -154,23 +154,6 @@ class Settings(BaseSettings):
154
154
  ),
155
155
  ] = "path"
156
156
 
157
- tool_attempt_parse_json_args: Annotated[
158
- bool,
159
- Field(
160
- default=False,
161
- description=inspect.cleandoc(
162
- """
163
- Note: this enables a legacy behavior. If True, will attempt to parse
164
- stringified JSON lists and objects strings in tool arguments before
165
- passing them to the tool. This is an old behavior that can create
166
- unexpected type coercion issues, but may be helpful for less powerful
167
- LLMs that stringify JSON instead of passing actual lists and objects.
168
- Defaults to False.
169
- """
170
- ),
171
- ),
172
- ] = False
173
-
174
157
  client_init_timeout: Annotated[
175
158
  float | None,
176
159
  Field(
fastmcp/tools/tool.py CHANGED
@@ -1,17 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
- import json
5
4
  from collections.abc import Callable
6
5
  from dataclasses import dataclass
7
- from typing import TYPE_CHECKING, Any
6
+ from typing import TYPE_CHECKING, Annotated, Any, Generic, Literal, TypeVar
8
7
 
8
+ import mcp.types
9
9
  import pydantic_core
10
- from mcp.types import TextContent, ToolAnnotations
10
+ from mcp.types import ContentBlock, TextContent, ToolAnnotations
11
11
  from mcp.types import Tool as MCPTool
12
- from pydantic import Field
12
+ from pydantic import Field, PydanticSchemaGenerationError
13
13
 
14
- import fastmcp
15
14
  from fastmcp.server.dependencies import get_context
16
15
  from fastmcp.utilities.components import FastMCPComponent
17
16
  from fastmcp.utilities.json_schema import compress_schema
@@ -20,9 +19,11 @@ from fastmcp.utilities.types import (
20
19
  Audio,
21
20
  File,
22
21
  Image,
23
- MCPContent,
22
+ NotSet,
23
+ NotSetT,
24
24
  find_kwarg_by_type,
25
25
  get_cached_typeadapter,
26
+ replace_type,
26
27
  )
27
28
 
28
29
  if TYPE_CHECKING:
@@ -30,21 +31,80 @@ if TYPE_CHECKING:
30
31
 
31
32
  logger = get_logger(__name__)
32
33
 
34
+ T = TypeVar("T")
35
+
36
+
37
+ @dataclass
38
+ class _WrappedResult(Generic[T]):
39
+ """Generic wrapper for non-object return types."""
40
+
41
+ result: T
42
+
43
+
44
+ class _UnserializableType:
45
+ pass
46
+
33
47
 
34
48
  def default_serializer(data: Any) -> str:
35
49
  return pydantic_core.to_json(data, fallback=str, indent=2).decode()
36
50
 
37
51
 
52
+ class ToolResult:
53
+ def __init__(
54
+ self,
55
+ content: list[ContentBlock] | Any | None = None,
56
+ structured_content: dict[str, Any] | Any | None = None,
57
+ ):
58
+ if content is None and structured_content is None:
59
+ raise ValueError("Either content or structured_content must be provided")
60
+ elif content is None:
61
+ content = structured_content
62
+
63
+ self.content = _convert_to_content(content)
64
+
65
+ if structured_content is not None:
66
+ try:
67
+ structured_content = pydantic_core.to_jsonable_python(
68
+ structured_content
69
+ )
70
+ except pydantic_core.PydanticSerializationError as e:
71
+ logger.error(
72
+ f"Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization: {e}"
73
+ )
74
+ raise
75
+ if not isinstance(structured_content, dict):
76
+ raise ValueError(
77
+ "structured_content must be a dict or None. "
78
+ f"Got {type(structured_content).__name__}: {structured_content!r}. "
79
+ "Tools should wrap non-dict values based on their output_schema."
80
+ )
81
+ self.structured_content: dict[str, Any] | None = structured_content
82
+
83
+ def to_mcp_result(
84
+ self,
85
+ ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
86
+ if self.structured_content is None:
87
+ return self.content
88
+ return self.content, self.structured_content
89
+
90
+
38
91
  class Tool(FastMCPComponent):
39
92
  """Internal tool registration info."""
40
93
 
41
- parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
42
- annotations: ToolAnnotations | None = Field(
43
- default=None, description="Additional annotations about the tool"
44
- )
45
- serializer: Callable[[Any], str] | None = Field(
46
- default=None, description="Optional custom serializer for tool results"
47
- )
94
+ parameters: Annotated[
95
+ dict[str, Any], Field(description="JSON schema for tool parameters")
96
+ ]
97
+ output_schema: Annotated[
98
+ dict[str, Any] | None, Field(description="JSON schema for tool output")
99
+ ] = None
100
+ annotations: Annotated[
101
+ ToolAnnotations | None,
102
+ Field(description="Additional annotations about the tool"),
103
+ ] = None
104
+ serializer: Annotated[
105
+ Callable[[Any], str] | None,
106
+ Field(description="Optional custom serializer for tool results"),
107
+ ] = None
48
108
 
49
109
  def enable(self) -> None:
50
110
  super().enable()
@@ -63,11 +123,20 @@ class Tool(FastMCPComponent):
63
123
  pass # No context available
64
124
 
65
125
  def to_mcp_tool(self, **overrides: Any) -> MCPTool:
126
+ if self.title:
127
+ title = self.title
128
+ elif self.annotations and self.annotations.title:
129
+ title = self.annotations.title
130
+ else:
131
+ title = None
132
+
66
133
  kwargs = {
67
134
  "name": self.name,
68
135
  "description": self.description,
69
136
  "inputSchema": self.parameters,
137
+ "outputSchema": self.output_schema,
70
138
  "annotations": self.annotations,
139
+ "title": title,
71
140
  }
72
141
  return MCPTool(**kwargs | overrides)
73
142
 
@@ -75,10 +144,12 @@ class Tool(FastMCPComponent):
75
144
  def from_function(
76
145
  fn: Callable[..., Any],
77
146
  name: str | None = None,
147
+ title: str | None = None,
78
148
  description: str | None = None,
79
149
  tags: set[str] | None = None,
80
150
  annotations: ToolAnnotations | None = None,
81
151
  exclude_args: list[str] | None = None,
152
+ output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
82
153
  serializer: Callable[[Any], str] | None = None,
83
154
  enabled: bool | None = None,
84
155
  ) -> FunctionTool:
@@ -86,16 +157,26 @@ class Tool(FastMCPComponent):
86
157
  return FunctionTool.from_function(
87
158
  fn=fn,
88
159
  name=name,
160
+ title=title,
89
161
  description=description,
90
162
  tags=tags,
91
163
  annotations=annotations,
92
164
  exclude_args=exclude_args,
165
+ output_schema=output_schema,
93
166
  serializer=serializer,
94
167
  enabled=enabled,
95
168
  )
96
169
 
97
- async def run(self, arguments: dict[str, Any]) -> list[MCPContent]:
98
- """Run the tool with arguments."""
170
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
171
+ """
172
+ Run the tool with arguments.
173
+
174
+ This method is not implemented in the base Tool class and must be
175
+ implemented by subclasses.
176
+
177
+ `run()` can EITHER return a list of ContentBlocks, or a tuple of
178
+ (list of ContentBlocks, dict of structured output).
179
+ """
99
180
  raise NotImplementedError("Subclasses must implement run()")
100
181
 
101
182
  @classmethod
@@ -108,6 +189,7 @@ class Tool(FastMCPComponent):
108
189
  description: str | None = None,
109
190
  tags: set[str] | None = None,
110
191
  annotations: ToolAnnotations | None = None,
192
+ output_schema: dict[str, Any] | None | Literal[False] = None,
111
193
  serializer: Callable[[Any], str] | None = None,
112
194
  enabled: bool | None = None,
113
195
  ) -> TransformedTool:
@@ -121,6 +203,7 @@ class Tool(FastMCPComponent):
121
203
  description=description,
122
204
  tags=tags,
123
205
  annotations=annotations,
206
+ output_schema=output_schema,
124
207
  serializer=serializer,
125
208
  enabled=enabled,
126
209
  )
@@ -134,10 +217,12 @@ class FunctionTool(Tool):
134
217
  cls,
135
218
  fn: Callable[..., Any],
136
219
  name: str | None = None,
220
+ title: str | None = None,
137
221
  description: str | None = None,
138
222
  tags: set[str] | None = None,
139
223
  annotations: ToolAnnotations | None = None,
140
224
  exclude_args: list[str] | None = None,
225
+ output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
141
226
  serializer: Callable[[Any], str] | None = None,
142
227
  enabled: bool | None = None,
143
228
  ) -> FunctionTool:
@@ -148,18 +233,33 @@ class FunctionTool(Tool):
148
233
  if name is None and parsed_fn.name == "<lambda>":
149
234
  raise ValueError("You must provide a name for lambda functions")
150
235
 
236
+ if isinstance(output_schema, NotSetT):
237
+ output_schema = parsed_fn.output_schema
238
+ elif output_schema is False:
239
+ output_schema = None
240
+ # Note: explicit schemas (dict) are used as-is without auto-wrapping
241
+
242
+ # Validate that explicit schemas are object type for structured content
243
+ if output_schema is not None and isinstance(output_schema, dict):
244
+ if output_schema.get("type") != "object":
245
+ raise ValueError(
246
+ f'Output schemas must have "type" set to "object" due to MCP spec limitations. Received: {output_schema!r}'
247
+ )
248
+
151
249
  return cls(
152
250
  fn=parsed_fn.fn,
153
251
  name=name or parsed_fn.name,
252
+ title=title,
154
253
  description=description or parsed_fn.description,
155
- parameters=parsed_fn.parameters,
156
- tags=tags or set(),
254
+ parameters=parsed_fn.input_schema,
255
+ output_schema=output_schema,
157
256
  annotations=annotations,
257
+ tags=tags or set(),
158
258
  serializer=serializer,
159
259
  enabled=enabled if enabled is not None else True,
160
260
  )
161
261
 
162
- async def run(self, arguments: dict[str, Any]) -> list[MCPContent]:
262
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
163
263
  """Run the tool with arguments."""
164
264
  from fastmcp.server.context import Context
165
265
 
@@ -169,41 +269,39 @@ class FunctionTool(Tool):
169
269
  if context_kwarg and context_kwarg not in arguments:
170
270
  arguments[context_kwarg] = get_context()
171
271
 
172
- if fastmcp.settings.tool_attempt_parse_json_args:
173
- # Pre-parse data from JSON in order to handle cases like `["a", "b", "c"]`
174
- # being passed in as JSON inside a string rather than an actual list.
175
- #
176
- # Claude desktop is prone to this - in fact it seems incapable of NOT doing
177
- # this. For sub-models, it tends to pass dicts (JSON objects) as JSON strings,
178
- # which can be pre-parsed here.
179
- signature = inspect.signature(self.fn)
180
- for param_name in self.parameters["properties"]:
181
- arg = arguments.get(param_name, None)
182
- # if not in signature, we won't have annotations, so skip logic
183
- if param_name not in signature.parameters:
184
- continue
185
- # if not a string, we won't have a JSON to parse, so skip logic
186
- if not isinstance(arg, str):
187
- continue
188
- # skip if the type is a simple type (int, float, bool)
189
- if signature.parameters[param_name].annotation in (
190
- int,
191
- float,
192
- bool,
193
- ):
194
- continue
195
- try:
196
- arguments[param_name] = json.loads(arg)
197
-
198
- except json.JSONDecodeError:
199
- pass
200
-
201
272
  type_adapter = get_cached_typeadapter(self.fn)
202
273
  result = type_adapter.validate_python(arguments)
274
+
203
275
  if inspect.isawaitable(result):
204
276
  result = await result
205
277
 
206
- return _convert_to_content(result, serializer=self.serializer)
278
+ if isinstance(result, ToolResult):
279
+ return result
280
+
281
+ unstructured_result = _convert_to_content(result, serializer=self.serializer)
282
+
283
+ structured_output = None
284
+ # First handle structured content based on output schema, if any
285
+ if self.output_schema is not None:
286
+ if self.output_schema.get("x-fastmcp-wrap-result"):
287
+ # Schema says wrap - always wrap in result key
288
+ structured_output = {"result": result}
289
+ else:
290
+ structured_output = result
291
+ # If no output schema, try to serialize the result. If it is a dict, use
292
+ # it as structured content. If it is not a dict, ignore it.
293
+ if structured_output is None:
294
+ try:
295
+ structured_output = pydantic_core.to_jsonable_python(result)
296
+ if not isinstance(structured_output, dict):
297
+ structured_output = None
298
+ except Exception:
299
+ pass
300
+
301
+ return ToolResult(
302
+ content=unstructured_result,
303
+ structured_content=structured_output,
304
+ )
207
305
 
208
306
 
209
307
  @dataclass
@@ -211,7 +309,8 @@ class ParsedFunction:
211
309
  fn: Callable[..., Any]
212
310
  name: str
213
311
  description: str | None
214
- parameters: dict[str, Any]
312
+ input_schema: dict[str, Any]
313
+ output_schema: dict[str, Any] | None
215
314
 
216
315
  @classmethod
217
316
  def from_function(
@@ -219,6 +318,7 @@ class ParsedFunction:
219
318
  fn: Callable[..., Any],
220
319
  exclude_args: list[str] | None = None,
221
320
  validate: bool = True,
321
+ wrap_non_object_output_schema: bool = True,
222
322
  ) -> ParsedFunction:
223
323
  from fastmcp.server.context import Context
224
324
 
@@ -257,9 +357,6 @@ class ParsedFunction:
257
357
  if isinstance(fn, staticmethod):
258
358
  fn = fn.__func__
259
359
 
260
- type_adapter = get_cached_typeadapter(fn)
261
- schema = type_adapter.json_schema()
262
-
263
360
  prune_params: list[str] = []
264
361
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
265
362
  if context_kwarg:
@@ -267,12 +364,67 @@ class ParsedFunction:
267
364
  if exclude_args:
268
365
  prune_params.extend(exclude_args)
269
366
 
270
- schema = compress_schema(schema, prune_params=prune_params)
367
+ input_type_adapter = get_cached_typeadapter(fn)
368
+ input_schema = input_type_adapter.json_schema()
369
+ input_schema = compress_schema(input_schema, prune_params=prune_params)
370
+
371
+ output_schema = None
372
+ output_type = inspect.signature(fn).return_annotation
373
+
374
+ if output_type not in (inspect._empty, None, Any, ...):
375
+ # there are a variety of types that we don't want to attempt to
376
+ # serialize because they are either used by FastMCP internally,
377
+ # or are MCP content types that explicitly don't form structured
378
+ # content. By replacing them with an explicitly unserializable type,
379
+ # we ensure that no output schema is automatically generated.
380
+ clean_output_type = replace_type(
381
+ output_type,
382
+ {
383
+ t: _UnserializableType
384
+ for t in (
385
+ Image,
386
+ Audio,
387
+ File,
388
+ ToolResult,
389
+ mcp.types.TextContent,
390
+ mcp.types.ImageContent,
391
+ mcp.types.AudioContent,
392
+ mcp.types.ResourceLink,
393
+ mcp.types.EmbeddedResource,
394
+ )
395
+ },
396
+ )
397
+
398
+ try:
399
+ type_adapter = get_cached_typeadapter(clean_output_type)
400
+ base_schema = type_adapter.json_schema()
401
+
402
+ # Generate schema for wrapped type if it's non-object
403
+ # because MCP requires that output schemas are objects
404
+ if (
405
+ wrap_non_object_output_schema
406
+ and base_schema.get("type") != "object"
407
+ ):
408
+ # Use the wrapped result schema directly
409
+ wrapped_type = _WrappedResult[clean_output_type]
410
+ wrapped_adapter = get_cached_typeadapter(wrapped_type)
411
+ output_schema = wrapped_adapter.json_schema()
412
+ output_schema["x-fastmcp-wrap-result"] = True
413
+ else:
414
+ output_schema = base_schema
415
+
416
+ output_schema = compress_schema(output_schema)
417
+
418
+ except PydanticSchemaGenerationError as e:
419
+ if "_UnserializableType" not in str(e):
420
+ logger.debug(f"Unable to generate schema for type {output_type!r}")
421
+
271
422
  return cls(
272
423
  fn=fn,
273
424
  name=fn_name,
274
425
  description=fn_doc,
275
- parameters=schema,
426
+ input_schema=input_schema,
427
+ output_schema=output_schema or None,
276
428
  )
277
429
 
278
430
 
@@ -280,12 +432,12 @@ def _convert_to_content(
280
432
  result: Any,
281
433
  serializer: Callable[[Any], str] | None = None,
282
434
  _process_as_single_item: bool = False,
283
- ) -> list[MCPContent]:
435
+ ) -> list[ContentBlock]:
284
436
  """Convert a result to a sequence of content objects."""
285
437
  if result is None:
286
438
  return []
287
439
 
288
- if isinstance(result, MCPContent):
440
+ if isinstance(result, ContentBlock):
289
441
  return [result]
290
442
 
291
443
  if isinstance(result, Image):
@@ -308,7 +460,7 @@ def _convert_to_content(
308
460
  other_content = []
309
461
 
310
462
  for item in result:
311
- if isinstance(item, MCPContent | Image | Audio | File):
463
+ if isinstance(item, ContentBlock | Image | Audio | File):
312
464
  mcp_types.append(_convert_to_content(item)[0])
313
465
  else:
314
466
  other_content.append(item)
@@ -9,9 +9,8 @@ from mcp.types import ToolAnnotations
9
9
  from fastmcp import settings
10
10
  from fastmcp.exceptions import NotFoundError, ToolError
11
11
  from fastmcp.settings import DuplicateBehavior
12
- from fastmcp.tools.tool import Tool
12
+ from fastmcp.tools.tool import Tool, ToolResult
13
13
  from fastmcp.utilities.logging import get_logger
14
- from fastmcp.utilities.types import MCPContent
15
14
 
16
15
  if TYPE_CHECKING:
17
16
  from fastmcp.server.server import MountedServer
@@ -170,7 +169,7 @@ class ToolManager:
170
169
  else:
171
170
  raise NotFoundError(f"Tool {key!r} not found")
172
171
 
173
- async def call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
172
+ async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
174
173
  """
175
174
  Internal API for servers: Finds and calls a tool, respecting the
176
175
  filtered protocol path.