Jeremiah Lowin Claude commited on
Commit
7974ade
·
1 Parent(s): 03a2687

Update tool transformation for ToolResult compatibility

Browse files

- TransformedTool.run() now returns ToolResult instead of list[ContentBlock]
- forward() and forward_raw() return ToolResult from parent tools
- Transform functions can return ToolResult for full control or any value for auto-wrapping
- Maintains backward compatibility with existing transform functions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

src/fastmcp/client/client.py CHANGED
@@ -676,7 +676,7 @@ class Client(Generic[ClientTransportT]):
676
  arguments: dict[str, Any] | None = None,
677
  timeout: datetime.timedelta | float | int | None = None,
678
  progress_handler: ProgressHandler | None = None,
679
- ) -> list[ContentBlock] | dict[str, Any] | type:
680
  """Call a tool on the server.
681
 
682
  Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
@@ -688,10 +688,11 @@ class Client(Generic[ClientTransportT]):
688
  progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
689
 
690
  Returns:
691
- list[ContentBlock] | dict[str, Any]:
692
  The content returned by the tool. If the tool returns structured
693
- outputs, they are returned as a dictionary; otherwise, a list of
694
- content blocks is returned. Note: to receive both structured and
 
695
  unstructured outputs, use call_tool_mcp instead and access the
696
  raw result object.
697
 
 
676
  arguments: dict[str, Any] | None = None,
677
  timeout: datetime.timedelta | float | int | None = None,
678
  progress_handler: ProgressHandler | None = None,
679
+ ) -> list[ContentBlock] | dict[str, Any] | Any:
680
  """Call a tool on the server.
681
 
682
  Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
 
688
  progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
689
 
690
  Returns:
691
+ list[ContentBlock] | dict[str, Any] | Any:
692
  The content returned by the tool. If the tool returns structured
693
+ outputs, they are returned as a dataclass (if an output schema
694
+ is available) or a dictionary; otherwise, a list of content
695
+ blocks is returned. Note: to receive both structured and
696
  unstructured outputs, use call_tool_mcp instead and access the
697
  raw result object.
698
 
src/fastmcp/server/proxy.py CHANGED
@@ -23,7 +23,7 @@ from fastmcp.resources import Resource, ResourceTemplate
23
  from fastmcp.resources.resource_manager import ResourceManager
24
  from fastmcp.server.context import Context
25
  from fastmcp.server.server import FastMCP
26
- from fastmcp.tools.tool import Tool
27
  from fastmcp.tools.tool_manager import ToolManager
28
  from fastmcp.utilities.logging import get_logger
29
 
@@ -232,7 +232,7 @@ class ProxyTool(Tool):
232
  self,
233
  arguments: dict[str, Any],
234
  context: Context | None = None,
235
- ) -> list[ContentBlock]:
236
  """Executes the tool by making a call through the client."""
237
  # This is where the remote execution logic lives.
238
  async with self._client:
@@ -242,7 +242,10 @@ class ProxyTool(Tool):
242
  )
243
  if result.isError:
244
  raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
245
- return result.content
 
 
 
246
 
247
 
248
  class ProxyResource(Resource):
 
23
  from fastmcp.resources.resource_manager import ResourceManager
24
  from fastmcp.server.context import Context
25
  from fastmcp.server.server import FastMCP
26
+ from fastmcp.tools.tool import Tool, ToolResult
27
  from fastmcp.tools.tool_manager import ToolManager
28
  from fastmcp.utilities.logging import get_logger
29
 
 
232
  self,
233
  arguments: dict[str, Any],
234
  context: Context | None = None,
235
+ ) -> ToolResult:
236
  """Executes the tool by making a call through the client."""
237
  # This is where the remote execution logic lives.
238
  async with self._client:
 
242
  )
243
  if result.isError:
244
  raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
245
+ return ToolResult(
246
+ content=result.content,
247
+ structured_output=result.structuredContent,
248
+ )
249
 
250
 
251
  class ProxyResource(Resource):
src/fastmcp/server/server.py CHANGED
@@ -58,7 +58,7 @@ from fastmcp.server.low_level import LowLevelServer
58
  from fastmcp.server.middleware import Middleware, MiddlewareContext
59
  from fastmcp.settings import Settings
60
  from fastmcp.tools import ToolManager
61
- from fastmcp.tools.tool import FunctionTool, Tool
62
  from fastmcp.utilities.cache import TimedCache
63
  from fastmcp.utilities.components import FastMCPComponent
64
  from fastmcp.utilities.logging import get_logger
@@ -593,7 +593,7 @@ class FastMCP(Generic[LifespanResultT]):
593
 
594
  async def _mcp_call_tool(
595
  self, key: str, arguments: dict[str, Any]
596
- ) -> list[ContentBlock]:
597
  """
598
  Handle MCP 'callTool' requests.
599
 
@@ -610,22 +610,21 @@ class FastMCP(Generic[LifespanResultT]):
610
 
611
  async with fastmcp.server.context.Context(fastmcp=self):
612
  try:
613
- return await self._call_tool(key, arguments)
 
614
  except DisabledError:
615
  raise NotFoundError(f"Unknown tool: {key}")
616
  except NotFoundError:
617
  raise NotFoundError(f"Unknown tool: {key}")
618
 
619
- async def _call_tool(
620
- self, key: str, arguments: dict[str, Any]
621
- ) -> list[ContentBlock]:
622
  """
623
  Applies this server's middleware and delegates the filtered call to the manager.
624
  """
625
 
626
  async def _handler(
627
  context: MiddlewareContext[mcp.types.CallToolRequestParams],
628
- ) -> list[ContentBlock]:
629
  tool = await self._tool_manager.get_tool(context.message.name)
630
  if not self._should_enable_component(tool):
631
  raise NotFoundError(f"Unknown tool: {context.message.name!r}")
 
58
  from fastmcp.server.middleware import Middleware, MiddlewareContext
59
  from fastmcp.settings import Settings
60
  from fastmcp.tools import ToolManager
61
+ from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
62
  from fastmcp.utilities.cache import TimedCache
63
  from fastmcp.utilities.components import FastMCPComponent
64
  from fastmcp.utilities.logging import get_logger
 
593
 
594
  async def _mcp_call_tool(
595
  self, key: str, arguments: dict[str, Any]
596
+ ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
597
  """
598
  Handle MCP 'callTool' requests.
599
 
 
610
 
611
  async with fastmcp.server.context.Context(fastmcp=self):
612
  try:
613
+ result = await self._call_tool(key, arguments)
614
+ return result.to_mcp_result()
615
  except DisabledError:
616
  raise NotFoundError(f"Unknown tool: {key}")
617
  except NotFoundError:
618
  raise NotFoundError(f"Unknown tool: {key}")
619
 
620
+ async def _call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
 
 
621
  """
622
  Applies this server's middleware and delegates the filtered call to the manager.
623
  """
624
 
625
  async def _handler(
626
  context: MiddlewareContext[mcp.types.CallToolRequestParams],
627
+ ) -> ToolResult:
628
  tool = await self._tool_manager.get_tool(context.message.name)
629
  if not self._should_enable_component(tool):
630
  raise NotFoundError(f"Unknown tool: {context.message.name!r}")
src/fastmcp/tools/tool.py CHANGED
@@ -5,7 +5,6 @@ from collections.abc import Callable
5
  from dataclasses import dataclass
6
  from typing import TYPE_CHECKING, Annotated, Any
7
 
8
- import mcp.types
9
  import pydantic_core
10
  from mcp.types import ContentBlock, TextContent, ToolAnnotations
11
  from mcp.types import Tool as MCPTool
@@ -24,7 +23,6 @@ from fastmcp.utilities.types import (
24
  StructuredOutput,
25
  find_kwarg_by_type,
26
  get_cached_typeadapter,
27
- replace_type,
28
  )
29
 
30
  if TYPE_CHECKING:
@@ -37,6 +35,19 @@ def default_serializer(data: Any) -> str:
37
  return pydantic_core.to_json(data, fallback=str, indent=2).decode()
38
 
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  class Tool(FastMCPComponent):
41
  """Internal tool registration info."""
42
 
@@ -106,9 +117,7 @@ class Tool(FastMCPComponent):
106
  enabled=enabled,
107
  )
108
 
109
- async def run(
110
- self, arguments: dict[str, Any]
111
- ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
112
  """
113
  Run the tool with arguments.
114
 
@@ -150,6 +159,18 @@ class Tool(FastMCPComponent):
150
 
151
  class FunctionTool(Tool):
152
  fn: Callable[..., Any]
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
  @classmethod
155
  def from_function(
@@ -171,8 +192,18 @@ class FunctionTool(Tool):
171
  if name is None and parsed_fn.name == "<lambda>":
172
  raise ValueError("You must provide a name for lambda functions")
173
 
 
174
  if isinstance(output_schema, NotSetT):
175
  output_schema = parsed_fn.output_schema
 
 
 
 
 
 
 
 
 
176
 
177
  return cls(
178
  fn=parsed_fn.fn,
@@ -184,11 +215,10 @@ class FunctionTool(Tool):
184
  tags=tags or set(),
185
  serializer=serializer,
186
  enabled=enabled if enabled is not None else True,
 
187
  )
188
 
189
- async def run(
190
- self, arguments: dict[str, Any]
191
- ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
192
  """Run the tool with arguments."""
193
  from fastmcp.server.context import Context
194
 
@@ -205,18 +235,20 @@ class FunctionTool(Tool):
205
 
206
  unstructured_result = _convert_to_content(result, serializer=self.serializer)
207
 
208
- structured_result = None
209
  if isinstance(result, StructuredOutput):
210
- structured_result = result.to_structured_output()
211
  elif self.output_schema is not None:
212
- structured_result = pydantic_core.to_jsonable_python(result, fallback=str)
213
-
214
- # return only the unstructured result if there is no structured output
215
- if structured_result is None:
216
- return unstructured_result
217
 
218
- # return both the unstructured and structured results if there is structured output
219
- return (unstructured_result, structured_result)
 
 
220
 
221
 
222
  @dataclass
@@ -284,17 +316,9 @@ class ParsedFunction:
284
 
285
  output_schema = None
286
  output_type = inspect.signature(fn).return_annotation
287
- if output_type is not inspect._empty:
288
  try:
289
- replaced_output_type = replace_type(
290
- output_type,
291
- {
292
- Image: mcp.types.ImageContent,
293
- Audio: mcp.types.AudioContent,
294
- File: mcp.types.EmbeddedResource,
295
- },
296
- )
297
- output_type_adapter = get_cached_typeadapter(replaced_output_type)
298
  output_schema = output_type_adapter.json_schema()
299
  except PydanticSchemaGenerationError:
300
  logger.debug(f"Unable to generate schema for type {output_type!r}")
 
5
  from dataclasses import dataclass
6
  from typing import TYPE_CHECKING, Annotated, Any
7
 
 
8
  import pydantic_core
9
  from mcp.types import ContentBlock, TextContent, ToolAnnotations
10
  from mcp.types import Tool as MCPTool
 
23
  StructuredOutput,
24
  find_kwarg_by_type,
25
  get_cached_typeadapter,
 
26
  )
27
 
28
  if TYPE_CHECKING:
 
35
  return pydantic_core.to_json(data, fallback=str, indent=2).decode()
36
 
37
 
38
+ @dataclass
39
+ class ToolResult:
40
+ content: list[ContentBlock]
41
+ structured_output: dict[str, Any] | None = None
42
+
43
+ def to_mcp_result(
44
+ self,
45
+ ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
46
+ if self.structured_output is None:
47
+ return self.content
48
+ return self.content, self.structured_output
49
+
50
+
51
  class Tool(FastMCPComponent):
52
  """Internal tool registration info."""
53
 
 
117
  enabled=enabled,
118
  )
119
 
120
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
 
 
121
  """
122
  Run the tool with arguments.
123
 
 
159
 
160
  class FunctionTool(Tool):
161
  fn: Callable[..., Any]
162
+ wrap_primitive_output: bool = Field(
163
+ default=False,
164
+ description="""Whether to wrap the function's return value in a {"value": result} object.
165
+
166
+ This is automatically set to True when a function has a primitive return type
167
+ annotation (int, str, bool, etc.) and FastMCP auto-generates an object schema
168
+ with a single "value" property to enable structured output support.
169
+
170
+ When True, the function's raw return value gets wrapped as {"value": raw_result}
171
+ in the structured output, allowing clients to receive properly typed objects
172
+ even for primitive return types.""",
173
+ )
174
 
175
  @classmethod
176
  def from_function(
 
192
  if name is None and parsed_fn.name == "<lambda>":
193
  raise ValueError("You must provide a name for lambda functions")
194
 
195
+ wrap_primitive_output = False
196
  if isinstance(output_schema, NotSetT):
197
  output_schema = parsed_fn.output_schema
198
+ # convert primitive types to object with a single "value" property
199
+ if output_schema and output_schema.get("type") != "object":
200
+ wrap_primitive_output = True
201
+ output_schema = {
202
+ "type": "object",
203
+ "properties": {"value": output_schema | {"title": "Value"}},
204
+ "required": ["value"],
205
+ "title": "Result",
206
+ }
207
 
208
  return cls(
209
  fn=parsed_fn.fn,
 
215
  tags=tags or set(),
216
  serializer=serializer,
217
  enabled=enabled if enabled is not None else True,
218
+ wrap_primitive_output=wrap_primitive_output,
219
  )
220
 
221
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
 
 
222
  """Run the tool with arguments."""
223
  from fastmcp.server.context import Context
224
 
 
235
 
236
  unstructured_result = _convert_to_content(result, serializer=self.serializer)
237
 
238
+ structured_output = None
239
  if isinstance(result, StructuredOutput):
240
+ structured_output = result.to_structured_output()
241
  elif self.output_schema is not None:
242
+ raw_result = pydantic_core.to_jsonable_python(result, fallback=str)
243
+ if self.wrap_primitive_output:
244
+ structured_output = {"value": raw_result}
245
+ else:
246
+ structured_output = raw_result
247
 
248
+ return ToolResult(
249
+ content=unstructured_result,
250
+ structured_output=structured_output,
251
+ )
252
 
253
 
254
  @dataclass
 
316
 
317
  output_schema = None
318
  output_type = inspect.signature(fn).return_annotation
319
+ if output_type not in (inspect._empty, Image, Audio, File, StructuredOutput):
320
  try:
321
+ output_type_adapter = get_cached_typeadapter(output_type)
 
 
 
 
 
 
 
 
322
  output_schema = output_type_adapter.json_schema()
323
  except PydanticSchemaGenerationError:
324
  logger.debug(f"Unable to generate schema for type {output_type!r}")
src/fastmcp/tools/tool_manager.py CHANGED
@@ -4,12 +4,12 @@ import warnings
4
  from collections.abc import Callable
5
  from typing import TYPE_CHECKING, Any
6
 
7
- from mcp.types import ContentBlock, ToolAnnotations
8
 
9
  from fastmcp import settings
10
  from fastmcp.exceptions import NotFoundError, ToolError
11
  from fastmcp.settings import DuplicateBehavior
12
- from fastmcp.tools.tool import Tool
13
  from fastmcp.utilities.logging import get_logger
14
 
15
  if TYPE_CHECKING:
@@ -169,9 +169,7 @@ class ToolManager:
169
  else:
170
  raise NotFoundError(f"Tool {key!r} not found")
171
 
172
- async def call_tool(
173
- self, key: str, arguments: dict[str, Any]
174
- ) -> list[ContentBlock]:
175
  """
176
  Internal API for servers: Finds and calls a tool, respecting the
177
  filtered protocol path.
 
4
  from collections.abc import Callable
5
  from typing import TYPE_CHECKING, Any
6
 
7
+ from mcp.types import ToolAnnotations
8
 
9
  from fastmcp import settings
10
  from fastmcp.exceptions import NotFoundError, ToolError
11
  from fastmcp.settings import DuplicateBehavior
12
+ from fastmcp.tools.tool import Tool, ToolResult
13
  from fastmcp.utilities.logging import get_logger
14
 
15
  if TYPE_CHECKING:
 
169
  else:
170
  raise NotFoundError(f"Tool {key!r} not found")
171
 
172
+ async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
 
 
173
  """
174
  Internal API for servers: Finds and calls a tool, respecting the
175
  filtered protocol path.
src/fastmcp/tools/tool_transform.py CHANGED
@@ -6,10 +6,10 @@ from contextvars import ContextVar
6
  from dataclasses import dataclass
7
  from typing import Any, Literal
8
 
9
- from mcp.types import ContentBlock, ToolAnnotations
10
  from pydantic import ConfigDict
11
 
12
- from fastmcp.tools.tool import ParsedFunction, Tool
13
  from fastmcp.utilities.logging import get_logger
14
  from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter
15
 
@@ -22,7 +22,7 @@ _current_tool: ContextVar[TransformedTool | None] = ContextVar(
22
  )
23
 
24
 
25
- async def forward(**kwargs) -> Any:
26
  """Forward to parent tool with argument transformation applied.
27
 
28
  This function can only be called from within a transformed tool's custom
@@ -38,7 +38,7 @@ async def forward(**kwargs) -> Any:
38
  **kwargs: Arguments to forward to the parent tool (using transformed names).
39
 
40
  Returns:
41
- The result from the parent tool execution.
42
 
43
  Raises:
44
  RuntimeError: If called outside a transformed tool context.
@@ -219,7 +219,7 @@ class TransformedTool(Tool):
219
  forwarding_fn: Callable[..., Any] # Always present, handles arg transformation
220
  transform_args: dict[str, ArgTransform]
221
 
222
- async def run(self, arguments: dict[str, Any]) -> list[ContentBlock]:
223
  """Run the tool with context set for forward() functions.
224
 
225
  This method executes the tool's function while setting up the context
@@ -230,8 +230,7 @@ class TransformedTool(Tool):
230
  arguments: Dictionary of arguments to pass to the tool's function.
231
 
232
  Returns:
233
- List of content objects (text, image, or embedded resources) representing
234
- the tool's output.
235
  """
236
  from fastmcp.tools.tool import _convert_to_content
237
 
@@ -269,7 +268,15 @@ class TransformedTool(Tool):
269
  token = _current_tool.set(self)
270
  try:
271
  result = await self.fn(**arguments)
272
- return _convert_to_content(result, serializer=self.serializer)
 
 
 
 
 
 
 
 
273
  finally:
274
  _current_tool.reset(token)
275
 
 
6
  from dataclasses import dataclass
7
  from typing import Any, Literal
8
 
9
+ from mcp.types import ToolAnnotations
10
  from pydantic import ConfigDict
11
 
12
+ from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult
13
  from fastmcp.utilities.logging import get_logger
14
  from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter
15
 
 
22
  )
23
 
24
 
25
+ async def forward(**kwargs) -> ToolResult:
26
  """Forward to parent tool with argument transformation applied.
27
 
28
  This function can only be called from within a transformed tool's custom
 
38
  **kwargs: Arguments to forward to the parent tool (using transformed names).
39
 
40
  Returns:
41
+ The ToolResult from the parent tool execution.
42
 
43
  Raises:
44
  RuntimeError: If called outside a transformed tool context.
 
219
  forwarding_fn: Callable[..., Any] # Always present, handles arg transformation
220
  transform_args: dict[str, ArgTransform]
221
 
222
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
223
  """Run the tool with context set for forward() functions.
224
 
225
  This method executes the tool's function while setting up the context
 
230
  arguments: Dictionary of arguments to pass to the tool's function.
231
 
232
  Returns:
233
+ ToolResult object containing content and optional structured output.
 
234
  """
235
  from fastmcp.tools.tool import _convert_to_content
236
 
 
268
  token = _current_tool.set(self)
269
  try:
270
  result = await self.fn(**arguments)
271
+
272
+ # If transform function returns ToolResult, use it directly
273
+ if isinstance(result, ToolResult):
274
+ return result
275
+
276
+ # Otherwise convert to content and create basic ToolResult
277
+ from fastmcp.tools.tool import _convert_to_content
278
+ unstructured_result = _convert_to_content(result, serializer=self.serializer)
279
+ return ToolResult(content=unstructured_result)
280
  finally:
281
  _current_tool.reset(token)
282