Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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