Add meta support to ToolResult (#2283)

* Add meta to ToolResult

* add this at the client level and test the full integration

* add example

* slipped through linting somehow

---------

Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
This commit is contained in:
Brandon Shar 2025-11-02 16:46:52 -05:00 committed by GitHub
commit c18782f02b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 137 additions and 14 deletions

View file

@ -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
@ -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.

View file

@ -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"}
)

View file

@ -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",

View file

@ -928,6 +928,7 @@ class Client(Generic[ClientTransportT]):
return CallToolResult(
content=result.content,
structured_content=result.structuredContent,
meta=result.meta,
data=data,
is_error=result.isError,
)
@ -945,5 +946,6 @@ class Client(Generic[ClientTransportT]):
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

View file

@ -63,14 +63,21 @@ class CachableReadResourceContents(BaseModel):
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,
)

View file

@ -16,7 +16,7 @@ from typing import (
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
@ -68,6 +68,7 @@ class ToolResult:
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")
@ -75,6 +76,7 @@ class ToolResult:
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:
@ -96,7 +98,15 @@ class ToolResult:
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

View file

@ -23,6 +23,7 @@ from fastmcp.client.transports import FastMCPTransport
from fastmcp.prompts.prompt import FunctionPrompt, Prompt
from fastmcp.resources.resource import Resource
from fastmcp.server.middleware.caching import (
CachableToolResult,
CallToolSettings,
ResponseCachingMiddleware,
ResponseCachingStatistics,
@ -505,3 +506,18 @@ class TestResponseCachingMiddlewareIntegration:
),
)
)
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

View file

@ -1179,6 +1179,7 @@ class TestToolOutputSchema:
"_meta": None,
},
],
meta=None,
)
)

View file

@ -1405,6 +1405,70 @@ class TestAutomaticStructuredContent:
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."""

10
uv.lock generated
View file

@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.11'",
@ -636,7 +636,7 @@ requires-dist = [
{ name = "exceptiongroup", specifier = ">=1.2.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "jsonschema-path", specifier = ">=0.3.4" },
{ name = "mcp", specifier = ">=1.17.0,<2.0.0" },
{ name = "mcp", specifier = ">=1.19.0,<2.0.0" },
{ name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" },
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
{ name = "platformdirs", specifier = ">=4.0.0" },
@ -1068,7 +1068,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.18.0"
version = "1.19.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -1083,9 +1083,9 @@ dependencies = [
{ name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/e0/fe34ce16ea2bacce489ab859abd1b47ae28b438c3ef60b9c5eee6c02592f/mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6", size = 482926, upload-time = "2025-10-16T19:19:55.125Z" }
sdist = { url = "https://files.pythonhosted.org/packages/69/2b/916852a5668f45d8787378461eaa1244876d77575ffef024483c94c0649c/mcp-1.19.0.tar.gz", hash = "sha256:213de0d3cd63f71bc08ffe9cc8d4409cc87acffd383f6195d2ce0457c021b5c1", size = 444163, upload-time = "2025-10-24T01:11:15.839Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/44/f5970e3e899803823826283a70b6003afd46f28e082544407e24575eccd3/mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a", size = 168762, upload-time = "2025-10-16T19:19:53.2Z" },
{ url = "https://files.pythonhosted.org/packages/ce/a3/3e71a875a08b6a830b88c40bc413bff01f1650f1efe8a054b5e90a9d4f56/mcp-1.19.0-py3-none-any.whl", hash = "sha256:f5907fe1c0167255f916718f376d05f09a830a215327a3ccdd5ec8a519f2e572", size = 170105, upload-time = "2025-10-24T01:11:14.151Z" },
]
[[package]]