mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
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:
parent
c8ddbff488
commit
c18782f02b
10 changed files with 137 additions and 14 deletions
|
|
@ -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.
|
||||
|
|
|
|||
22
examples/tool_result_echo.py
Normal file
22
examples/tool_result_echo.py
Normal 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"}
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1179,6 +1179,7 @@ class TestToolOutputSchema:
|
|||
"_meta": None,
|
||||
},
|
||||
],
|
||||
meta=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
10
uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue