mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 05:54:19 +02:00
Propagate x-fastmcp-wrap-result in tool result _meta (#3490)
* Propagate x-fastmcp-wrap-result flag in tool result _meta 🤖 Generated with Claude Code Co-authored-by: Claude <noreply@anthropic.com> * Skip listTools round-trip when _meta has x-fastmcp-wrap-result 🤖 Generated with Claude Code Co-authored-by: Claude <noreply@anthropic.com> * Use namespaced meta key: {"fastmcp": {"wrap_result": true}} 🤖 Generated with Claude Code Co-authored-by: Claude <noreply@anthropic.com> * Clean up _parse_call_tool_result: hoist cast import, document local CallToolResult import, extract fastmcp_meta 🤖 Generated with Claude Code Co-authored-by: Claude <noreply@anthropic.com> * Merge _meta in tasks result handler instead of overwriting 🤖 Generated with Claude Code * Preserve type validation in meta-based unwrap path 🤖 Generated with Claude Code * Fix type validation for wrapped task results, guard non-dict meta 🤖 Generated with Claude Code --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e2bdc9288b
commit
139d2d8f96
6 changed files with 151 additions and 22 deletions
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import uuid
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING, Any, Literal, overload
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast, overload
|
||||
|
||||
import mcp.types
|
||||
from pydantic import RootModel
|
||||
|
|
@ -379,8 +379,9 @@ async def _parse_call_tool_result(
|
|||
Returns:
|
||||
CallToolResult: Parsed result with structured data
|
||||
"""
|
||||
from typing import cast
|
||||
|
||||
# Local import: CallToolResult is under TYPE_CHECKING at module level to
|
||||
# avoid a circular import (client.client -> mixins.tools -> client.client),
|
||||
# but we need the concrete class here to construct the return value.
|
||||
from fastmcp.client.client import CallToolResult
|
||||
|
||||
data = None
|
||||
|
|
@ -389,23 +390,43 @@ async def _parse_call_tool_result(
|
|||
raise ToolError(msg)
|
||||
elif result.structuredContent:
|
||||
try:
|
||||
raw_fastmcp_meta = (result.meta or {}).get("fastmcp")
|
||||
fastmcp_meta = (
|
||||
raw_fastmcp_meta if isinstance(raw_fastmcp_meta, dict) else {}
|
||||
)
|
||||
wrap_from_meta = fastmcp_meta.get("wrap_result", False)
|
||||
|
||||
# Ensure the schema cache is populated for type validation.
|
||||
# When meta tells us the result is wrapped we can skip the
|
||||
# schema check for *wrap detection*, but we still need the
|
||||
# schema for proper type coercion (e.g. list → set, str → datetime).
|
||||
if name not in tool_output_schemas:
|
||||
await list_tools_fn()
|
||||
if name in tool_output_schemas:
|
||||
|
||||
if wrap_from_meta:
|
||||
# Meta tells us the result is wrapped — unwrap and validate.
|
||||
structured_content = result.structuredContent.get("result")
|
||||
elif name in tool_output_schemas:
|
||||
output_schema = tool_output_schemas.get(name)
|
||||
if output_schema:
|
||||
if output_schema.get("x-fastmcp-wrap-result"):
|
||||
output_schema = output_schema.get("properties", {}).get(
|
||||
"result"
|
||||
)
|
||||
structured_content = result.structuredContent.get("result")
|
||||
else:
|
||||
structured_content = result.structuredContent
|
||||
output_type = json_schema_to_type(output_schema)
|
||||
type_adapter = get_cached_typeadapter(output_type)
|
||||
data = type_adapter.validate_python(structured_content)
|
||||
if output_schema and output_schema.get("x-fastmcp-wrap-result"):
|
||||
structured_content = result.structuredContent.get("result")
|
||||
else:
|
||||
data = result.structuredContent
|
||||
structured_content = result.structuredContent
|
||||
else:
|
||||
structured_content = result.structuredContent
|
||||
|
||||
# Type-validate through the schema if available.
|
||||
output_schema = tool_output_schemas.get(name)
|
||||
if output_schema:
|
||||
if wrap_from_meta or output_schema.get("x-fastmcp-wrap-result"):
|
||||
output_schema = output_schema.get("properties", {}).get(
|
||||
"result", output_schema
|
||||
)
|
||||
output_type = json_schema_to_type(output_schema)
|
||||
type_adapter = get_cached_typeadapter(output_type)
|
||||
data = type_adapter.validate_python(structured_content)
|
||||
else:
|
||||
data = structured_content
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[{client_name or 'client'}] Error parsing structured content: {e}"
|
||||
|
|
|
|||
|
|
@ -347,13 +347,15 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
|
|||
}
|
||||
}
|
||||
|
||||
# Convert based on component type
|
||||
# Convert based on component type.
|
||||
# Each branch merges related_task_meta with any existing _meta
|
||||
# (e.g. fastmcp.wrap_result) rather than overwriting it.
|
||||
if isinstance(component, Tool):
|
||||
fastmcp_result = component.convert_result(raw_value)
|
||||
mcp_result = fastmcp_result.to_mcp_result()
|
||||
# Ensure we have a CallToolResult and add metadata
|
||||
if isinstance(mcp_result, mcp.types.CallToolResult):
|
||||
mcp_result._meta = related_task_meta # type: ignore[attr-defined]
|
||||
merged = {**(mcp_result.meta or {}), **related_task_meta}
|
||||
mcp_result._meta = merged # type: ignore[attr-defined]
|
||||
elif isinstance(mcp_result, tuple):
|
||||
content, structured_content = mcp_result
|
||||
mcp_result = mcp.types.CallToolResult(
|
||||
|
|
@ -371,19 +373,22 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
|
|||
elif isinstance(component, Prompt):
|
||||
fastmcp_result = component.convert_result(raw_value)
|
||||
mcp_result = fastmcp_result.to_mcp_prompt_result()
|
||||
mcp_result._meta = related_task_meta # type: ignore[attr-defined]
|
||||
merged = {**(mcp_result.meta or {}), **related_task_meta}
|
||||
mcp_result._meta = merged # type: ignore[attr-defined]
|
||||
return mcp_result
|
||||
|
||||
elif isinstance(component, ResourceTemplate):
|
||||
fastmcp_result = component.convert_result(raw_value)
|
||||
mcp_result = fastmcp_result.to_mcp_result(component.uri_template)
|
||||
mcp_result._meta = related_task_meta # type: ignore[attr-defined]
|
||||
merged = {**(mcp_result.meta or {}), **related_task_meta}
|
||||
mcp_result._meta = merged # type: ignore[attr-defined]
|
||||
return mcp_result
|
||||
|
||||
elif isinstance(component, Resource):
|
||||
fastmcp_result = component.convert_result(raw_value)
|
||||
mcp_result = fastmcp_result.to_mcp_result(str(component.uri))
|
||||
mcp_result._meta = related_task_meta # type: ignore[attr-defined]
|
||||
merged = {**(mcp_result.meta or {}), **related_task_meta}
|
||||
mcp_result._meta = merged # type: ignore[attr-defined]
|
||||
return mcp_result
|
||||
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -301,6 +301,7 @@ class Tool(FastMCPComponent):
|
|||
return ToolResult(
|
||||
content=content,
|
||||
structured_content={"result": structured} if wrap_result else structured,
|
||||
meta={"fastmcp": {"wrap_result": True}} if wrap_result else None,
|
||||
)
|
||||
|
||||
@overload
|
||||
|
|
|
|||
|
|
@ -717,3 +717,35 @@ async def test_tagged_template_functionality(tagged_resources_server):
|
|||
content_str = str(result[0])
|
||||
assert '"id":"123"' in content_str
|
||||
assert '"type":"template_data"' in content_str
|
||||
|
||||
|
||||
async def test_client_unwraps_result_using_meta():
|
||||
"""Client should unwrap wrapped results using _meta flag."""
|
||||
server = FastMCP()
|
||||
|
||||
@server.tool
|
||||
def list_tool() -> list[int]:
|
||||
return [1, 2, 3]
|
||||
|
||||
client = Client(transport=FastMCPTransport(server))
|
||||
async with client:
|
||||
result = await client.call_tool("list_tool", {})
|
||||
assert result.structured_content == {"result": [1, 2, 3]}
|
||||
assert result.data == [1, 2, 3]
|
||||
assert result.meta == {"fastmcp": {"wrap_result": True}}
|
||||
|
||||
|
||||
async def test_client_does_not_unwrap_dict_result():
|
||||
"""Client should not unwrap dict results that are not wrapped."""
|
||||
server = FastMCP()
|
||||
|
||||
@server.tool
|
||||
def dict_tool() -> dict[str, int]:
|
||||
return {"a": 1}
|
||||
|
||||
client = Client(transport=FastMCPTransport(server))
|
||||
async with client:
|
||||
result = await client.call_tool("dict_tool", {})
|
||||
assert result.structured_content == {"a": 1}
|
||||
assert result.data == {"a": 1}
|
||||
assert result.meta is None
|
||||
|
|
|
|||
|
|
@ -268,6 +268,30 @@ class TestToolOutputSchema:
|
|||
assert isinstance(result.content[2], TextContent)
|
||||
assert result.content[2].text == "direct MCP content"
|
||||
|
||||
async def test_wrapped_result_includes_meta_flag(self):
|
||||
"""Wrapped results include wrap_result in meta."""
|
||||
server = FastMCP()
|
||||
|
||||
@server.tool
|
||||
def list_tool() -> list[dict]:
|
||||
return [{"a": 1}]
|
||||
|
||||
result = await server.call_tool("list_tool", {})
|
||||
assert result.structured_content == {"result": [{"a": 1}]}
|
||||
assert result.meta == {"fastmcp": {"wrap_result": True}}
|
||||
|
||||
async def test_unwrapped_result_has_no_meta_flag(self):
|
||||
"""Unwrapped dict results do not include wrap_result in meta."""
|
||||
server = FastMCP()
|
||||
|
||||
@server.tool
|
||||
def dict_tool() -> dict[str, int]:
|
||||
return {"value": 42}
|
||||
|
||||
result = await server.call_tool("dict_tool", {})
|
||||
assert result.structured_content == {"value": 42}
|
||||
assert result.meta is None
|
||||
|
||||
async def test_output_schema_serialization_edge_cases(self):
|
||||
"""Test edge cases in output schema serialization."""
|
||||
mcp = FastMCP()
|
||||
|
|
|
|||
|
|
@ -532,3 +532,49 @@ class TestToolFromFunctionOutputSchema:
|
|||
ValueError, match="Output schemas must represent object types"
|
||||
):
|
||||
Tool.from_function(func, output_schema=schema)
|
||||
|
||||
|
||||
class TestWrapResultMeta:
|
||||
async def test_list_return_includes_wrap_result_meta(self):
|
||||
"""A tool returning list[dict] should set wrap_result in meta."""
|
||||
|
||||
def func() -> list[dict]:
|
||||
return [{"a": 1}, {"b": 2}]
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
result = await tool.run({})
|
||||
assert result.structured_content == {"result": [{"a": 1}, {"b": 2}]}
|
||||
assert result.meta == {"fastmcp": {"wrap_result": True}}
|
||||
|
||||
async def test_int_return_includes_wrap_result_meta(self):
|
||||
"""A tool returning int should set wrap_result in meta."""
|
||||
|
||||
def func() -> int:
|
||||
return 42
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
result = await tool.run({})
|
||||
assert result.structured_content == {"result": 42}
|
||||
assert result.meta == {"fastmcp": {"wrap_result": True}}
|
||||
|
||||
async def test_dict_return_does_not_include_wrap_result_meta(self):
|
||||
"""A tool returning dict should NOT set wrap_result in meta."""
|
||||
|
||||
def func() -> dict[str, int]:
|
||||
return {"value": 42}
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
result = await tool.run({})
|
||||
assert result.structured_content == {"value": 42}
|
||||
assert result.meta is None
|
||||
|
||||
async def test_no_schema_dict_return_no_meta(self):
|
||||
"""A tool without output schema returning dict should not set meta."""
|
||||
|
||||
def func():
|
||||
return {"key": "val"}
|
||||
|
||||
tool = Tool.from_function(func)
|
||||
result = await tool.run({})
|
||||
assert result.structured_content == {"key": "val"}
|
||||
assert result.meta is None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue