Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/servers/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@ Schema generation works for most common types including basic types, collections

### Full Control with ToolResult

For complete control over both traditional content and structured output, return a `ToolResult` object:
For complete control over traditional content, structured output, and metadata, return a `ToolResult` object:

```python
from fastmcp.tools.tool import ToolResult
Expand All @@ -626,14 +626,15 @@ def advanced_tool() -> ToolResult:
"""Tool with full control over output."""
return ToolResult(
content=[TextContent(type="text", text="Human-readable summary")],
structured_content={"data": "value", "count": 42}
structured_content={"data": "value", "count": 42},
meta={"some": "metadata"}
)
```

When returning `ToolResult`:
- You control exactly what content and structured data is sent
- You control exactly what content, structured data, and metadata is sent
- Output schemas are optional - structured content can be provided without a schema
- Clients receive both traditional content blocks and structured data
- Clients receive traditional content blocks, structured data, and metadata

<Note>
If your return type annotation cannot be converted to a JSON schema (e.g., complex custom classes without Pydantic support), the output schema will be omitted but the tool will still function normally with traditional content.
Expand Down
22 changes: 22 additions & 0 deletions examples/tool_result_echo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""
FastMCP Echo Server
"""

from dataclasses import dataclass

from fastmcp import FastMCP
from fastmcp.tools.tool import ToolResult

mcp = FastMCP("Echo Server")


@dataclass
class EchoData:
data: str


@mcp.tool
def echo(text: str) -> ToolResult:
return ToolResult(
content=text, structured_content=EchoData(data=text), meta={"some": "metadata"}
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ dependencies = [
"python-dotenv>=1.1.0",
"exceptiongroup>=1.2.2",
"httpx>=0.28.1",
"mcp>=1.17.0,<2.0.0",
"mcp>=1.19.0,<2.0.0",
"openapi-pydantic>=0.5.1",
"platformdirs>=4.0.0",
"rich>=13.9.4",
Expand Down
2 changes: 2 additions & 0 deletions src/fastmcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,7 @@ async def call_tool(
return CallToolResult(
content=result.content,
structured_content=result.structuredContent,
meta=result.meta,
data=data,
is_error=result.isError,
)
Expand All @@ -945,5 +946,6 @@ def generate_name(cls, name: str | None = None) -> str:
class CallToolResult:
content: list[mcp.types.ContentBlock]
structured_content: dict[str, Any] | None
meta: dict[str, Any] | None
Copy link
Contributor

@coderabbitai coderabbitai bot Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Dataclass extended with meta — update downstream extenders.

Classes extending/deriving from CallToolResult should propagate structured_content and meta. Example fix for bulk caller:

 class CallToolRequestResult(CallToolResult):
@@
     def from_call_tool_result(
         cls, result: CallToolResult, tool: str, arguments: dict[str, Any]
     ) -> "CallToolRequestResult":
         return cls(
             tool=tool,
             arguments=arguments,
-            isError=result.isError,
-            content=result.content,
+            isError=result.is_error,
+            content=result.content,
+            structured_content=result.structured_content,
+            meta=result.meta,
         )

Search for similar helpers to update:


🏁 Script executed:

#!/bin/bash
rg -nP 'from_call_tool_result|class\s+CallToolResult|result\.(structuredContent|meta)\b' -C2

Length of output: 5210


🏁 Script executed:

#!/bin/bash
# View the implementation of from_call_tool_result
cat src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py | head -60

Length of output: 1693


🏁 Script executed:

#!/bin/bash
# View the CallToolResult dataclass definition
sed -n '945,960p' src/fastmcp/client/client.py

Length of output: 200


Update CallToolRequestResult.from_call_tool_result() to propagate all fields.

The method currently attempts to access result.isError (which doesn't exist) and fails to propagate structured_content and meta. Fix:

 @classmethod
 def from_call_tool_result(
     cls, result: CallToolResult, tool: str, arguments: dict[str, Any]
 ) -> "CallToolRequestResult":
     """
     Create a CallToolRequestResult from a CallToolResult.
     """
     return cls(
         tool=tool,
         arguments=arguments,
-        isError=result.isError,
+        is_error=result.is_error,
         content=result.content,
+        structured_content=result.structured_content,
+        meta=result.meta,
     )

Location: src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py lines 37–48

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/fastmcp/client/client.py around line 949, update
CallToolRequestResult.from_call_tool_result to stop referencing the non-existent
result.isError and instead propagate the actual fields from the incoming result:
use result.error (or set is_error = bool(result.error)) for error state, and
copy result.structured_content and result.meta into the returned
CallToolRequestResult object so all fields are preserved.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wasn't changed in this PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BrandonShar You're absolutely right! The bulk_tool_caller.py file wasn't modified in this PR. I was flagging a downstream compatibility issue that exists in that separate module—since CallToolResult now includes the meta field, the CallToolRequestResult.from_call_tool_result() method in that file should ideally be updated to propagate it.

Since this is outside the scope of your current changes, would you like me to open a follow-up issue to track updating the bulk tool caller to support the new meta field?

data: Any = None
is_error: bool = False
11 changes: 9 additions & 2 deletions src/fastmcp/server/middleware/caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,21 @@ def unwrap(cls, values: Sequence[Self]) -> list[ReadResourceContents]:
class CachableToolResult(BaseModel):
content: list[mcp.types.ContentBlock]
structured_content: dict[str, Any] | None
meta: dict[str, Any] | None

@classmethod
def wrap(cls, value: ToolResult) -> Self:
return cls(content=value.content, structured_content=value.structured_content)
return cls(
content=value.content,
structured_content=value.structured_content,
meta=value.meta,
)

def unwrap(self) -> ToolResult:
return ToolResult(
content=self.content, structured_content=self.structured_content
content=self.content,
structured_content=self.structured_content,
meta=self.meta,
)


Expand Down
14 changes: 12 additions & 2 deletions src/fastmcp/tools/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import mcp.types
import pydantic_core
from mcp.types import ContentBlock, Icon, TextContent, ToolAnnotations
from mcp.types import CallToolResult, ContentBlock, Icon, TextContent, ToolAnnotations
from mcp.types import Tool as MCPTool
from pydantic import Field, PydanticSchemaGenerationError
from typing_extensions import TypeVar
Expand Down Expand Up @@ -68,13 +68,15 @@ def __init__(
self,
content: list[ContentBlock] | Any | None = None,
structured_content: dict[str, Any] | Any | None = None,
meta: dict[str, Any] | None = None,
):
if content is None and structured_content is None:
raise ValueError("Either content or structured_content must be provided")
elif content is None:
content = structured_content

self.content: list[ContentBlock] = _convert_to_content(result=content)
self.meta: dict[str, Any] | None = meta

if structured_content is not None:
try:
Expand All @@ -96,7 +98,15 @@ def __init__(

def to_mcp_result(
self,
) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]:
) -> (
list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult
):
if self.meta is not None:
return CallToolResult(
structuredContent=self.structured_content,
content=self.content,
_meta=self.meta,
)
if self.structured_content is None:
return self.content
return self.content, self.structured_content
Expand Down
16 changes: 16 additions & 0 deletions tests/server/middleware/test_caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from fastmcp.prompts.prompt import FunctionPrompt, Prompt
from fastmcp.resources.resource import Resource
from fastmcp.server.middleware.caching import (
CachableToolResult,
CallToolSettings,
ResponseCachingMiddleware,
ResponseCachingStatistics,
Expand Down Expand Up @@ -505,3 +506,18 @@ async def test_statistics(
),
)
)


class TestCachableToolResult:
def test_wrap_and_unwrap(self):
tool_result = ToolResult(
"unstructured content",
structured_content={"structured": "content"},
meta={"meta": "data"},
)

cached_tool_result = CachableToolResult.wrap(tool_result).unwrap()

assert cached_tool_result.content == tool_result.content
assert cached_tool_result.structured_content == tool_result.structured_content
assert cached_tool_result.meta == tool_result.meta
1 change: 1 addition & 0 deletions tests/server/test_server_interactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1179,6 +1179,7 @@ def mixed_output() -> list[Any]:
"_meta": None,
},
],
meta=None,
)
)

Expand Down
64 changes: 64 additions & 0 deletions tests/tools/test_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1405,6 +1405,70 @@ def get_profile(user_id: str) -> UserProfile:
assert result.data.verified is True


class TestToolResultCasting:
@pytest.fixture
async def client(self):
from fastmcp import FastMCP
from fastmcp.client import Client

mcp = FastMCP()

@mcp.tool
def test_tool(
unstructured: str | None = None,
structured: dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
):
return ToolResult(
content=unstructured,
structured_content=structured,
meta=meta,
)

async with Client(mcp) as client:
yield client

async def test_only_unstructured_content(self, client):
result = await client.call_tool("test_tool", {"unstructured": "test data"})

assert result.content[0].type == "text"
assert result.content[0].text == "test data"
assert result.structured_content is None
assert result.meta is None

async def test_neither_unstructured_or_structured_content(self, client):
from fastmcp.exceptions import ToolError

with pytest.raises(ToolError):
await client.call_tool("test_tool", {})

async def test_structured_and_unstructured_content(self, client):
result = await client.call_tool(
"test_tool",
{"unstructured": "test data", "structured": {"data_type": "test"}},
)

assert result.content[0].type == "text"
assert result.content[0].text == "test data"
assert result.structured_content == {"data_type": "test"}
assert result.meta is None

async def test_structured_unstructured_and_meta_content(self, client):
result = await client.call_tool(
"test_tool",
{
"unstructured": "test data",
"structured": {"data_type": "test"},
"meta": {"some": "metadata"},
},
)

assert result.content[0].type == "text"
assert result.content[0].text == "test data"
assert result.structured_content == {"data_type": "test"}
assert result.meta == {"some": "metadata"}


class TestUnionReturnTypes:
"""Tests for tools with union return types."""

Expand Down
10 changes: 5 additions & 5 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading