diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 8b2279ccf6..c7adc16d6b 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -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 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. diff --git a/examples/tool_result_echo.py b/examples/tool_result_echo.py new file mode 100644 index 0000000000..5e542b4e24 --- /dev/null +++ b/examples/tool_result_echo.py @@ -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"} + ) diff --git a/pyproject.toml b/pyproject.toml index ac89b01a44..d461186fb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index d24350d386..aa6fb30c2e 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -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, ) @@ -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 diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index 52540248ed..1ae232ec44 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -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, ) diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index b586455794..4517f94861 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -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 @@ -68,6 +68,7 @@ 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") @@ -75,6 +76,7 @@ def __init__( 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 @@ 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 diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index 696933530d..01dadafde1 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -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, @@ -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 diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 76f1dffb2d..238893151b 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -1179,6 +1179,7 @@ def mixed_output() -> list[Any]: "_meta": None, }, ], + meta=None, ) ) diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index dc815b5bf5..c779d09423 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -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.""" diff --git a/uv.lock b/uv.lock index 2d6c95d231..b06bb819b8 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]