Allow CallToolResult to be returned directly to support _meta field for OpenAI Apps (#1459)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
parent
40acbc596c
commit
1200ba0082
3 changed files with 445 additions and 5 deletions
|
|
@ -13,6 +13,7 @@ from dirty_equals import IsPartialDict
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
|
||||
from mcp.types import CallToolResult
|
||||
|
||||
|
||||
class SomeInputModelA(BaseModel):
|
||||
|
|
@ -834,6 +835,93 @@ def test_unstructured_output_unannotated_class():
|
|||
assert meta.output_schema is None
|
||||
|
||||
|
||||
def test_tool_call_result_is_unstructured_and_not_converted():
|
||||
def func_returning_call_tool_result() -> CallToolResult:
|
||||
return CallToolResult(content=[])
|
||||
|
||||
meta = func_metadata(func_returning_call_tool_result)
|
||||
|
||||
assert meta.output_schema is None
|
||||
assert isinstance(meta.convert_result(func_returning_call_tool_result()), CallToolResult)
|
||||
|
||||
|
||||
def test_tool_call_result_annotated_is_structured_and_converted():
|
||||
class PersonClass(BaseModel):
|
||||
name: str
|
||||
|
||||
def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, PersonClass]:
|
||||
return CallToolResult(content=[], structuredContent={"name": "Brandon"})
|
||||
|
||||
meta = func_metadata(func_returning_annotated_tool_call_result)
|
||||
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
},
|
||||
"required": ["name"],
|
||||
"title": "PersonClass",
|
||||
}
|
||||
assert isinstance(meta.convert_result(func_returning_annotated_tool_call_result()), CallToolResult)
|
||||
|
||||
|
||||
def test_tool_call_result_annotated_is_structured_and_invalid():
|
||||
class PersonClass(BaseModel):
|
||||
name: str
|
||||
|
||||
def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, PersonClass]:
|
||||
return CallToolResult(content=[], structuredContent={"person": "Brandon"})
|
||||
|
||||
meta = func_metadata(func_returning_annotated_tool_call_result)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
meta.convert_result(func_returning_annotated_tool_call_result())
|
||||
|
||||
|
||||
def test_tool_call_result_in_optional_is_rejected():
|
||||
"""Test that Optional[CallToolResult] raises InvalidSignature"""
|
||||
|
||||
from mcp.server.fastmcp.exceptions import InvalidSignature
|
||||
|
||||
def func_optional_call_tool_result() -> CallToolResult | None:
|
||||
return CallToolResult(content=[])
|
||||
|
||||
with pytest.raises(InvalidSignature) as exc_info:
|
||||
func_metadata(func_optional_call_tool_result)
|
||||
|
||||
assert "Union or Optional" in str(exc_info.value)
|
||||
assert "CallToolResult" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_tool_call_result_in_union_is_rejected():
|
||||
"""Test that Union[str, CallToolResult] raises InvalidSignature"""
|
||||
|
||||
from mcp.server.fastmcp.exceptions import InvalidSignature
|
||||
|
||||
def func_union_call_tool_result() -> str | CallToolResult:
|
||||
return CallToolResult(content=[])
|
||||
|
||||
with pytest.raises(InvalidSignature) as exc_info:
|
||||
func_metadata(func_union_call_tool_result)
|
||||
|
||||
assert "Union or Optional" in str(exc_info.value)
|
||||
assert "CallToolResult" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_tool_call_result_in_pipe_union_is_rejected():
|
||||
"""Test that str | CallToolResult raises InvalidSignature"""
|
||||
from mcp.server.fastmcp.exceptions import InvalidSignature
|
||||
|
||||
def func_pipe_union_call_tool_result() -> str | CallToolResult:
|
||||
return CallToolResult(content=[])
|
||||
|
||||
with pytest.raises(InvalidSignature) as exc_info:
|
||||
func_metadata(func_pipe_union_call_tool_result)
|
||||
|
||||
assert "Union or Optional" in str(exc_info.value)
|
||||
assert "CallToolResult" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_structured_output_with_field_descriptions():
|
||||
"""Test that Field descriptions are preserved in structured output"""
|
||||
|
||||
|
|
|
|||
|
|
@ -391,6 +391,47 @@ async def test_both_content_and_valid_dict_with_output_schema():
|
|||
assert result.structuredContent == {"sentiment": "positive", "confidence": 0.95}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_call_result():
|
||||
"""Test returning ToolCallResult when no outputSchema is defined."""
|
||||
tools = [
|
||||
Tool(
|
||||
name="get_info",
|
||||
description="Get structured information",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
# No outputSchema for direct return of tool call result
|
||||
)
|
||||
]
|
||||
|
||||
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> CallToolResult:
|
||||
if name == "get_info":
|
||||
return CallToolResult(
|
||||
content=[TextContent(type="text", text="Results calculated")],
|
||||
structuredContent={"status": "ok", "data": {"value": 42}},
|
||||
_meta={"some": "metadata"},
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
async def test_callback(client_session: ClientSession) -> CallToolResult:
|
||||
return await client_session.call_tool("get_info", {})
|
||||
|
||||
result = await run_tool_test(tools, call_tool_handler, test_callback)
|
||||
|
||||
# Verify results
|
||||
assert result is not None
|
||||
assert not result.isError
|
||||
assert len(result.content) == 1
|
||||
assert result.content[0].type == "text"
|
||||
assert result.content[0].text == "Results calculated"
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.structuredContent == {"status": "ok", "data": {"value": 42}}
|
||||
assert result.meta == {"some": "metadata"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_output_schema_type_validation():
|
||||
"""Test outputSchema validates types correctly."""
|
||||
|
|
|
|||
|
|
@ -44,6 +44,23 @@ async def test_complex_inputs():
|
|||
assert result.content[2].text == "charlie"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_call_tool_result_return():
|
||||
"""Test the CallToolResult echo server"""
|
||||
from examples.fastmcp.direct_call_tool_result_return import mcp
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
result = await client.call_tool("echo", {"text": "hello"})
|
||||
assert len(result.content) == 1
|
||||
content = result.content[0]
|
||||
assert isinstance(content, TextContent)
|
||||
assert content.text == "hello"
|
||||
assert result.structuredContent
|
||||
assert result.structuredContent["text"] == "hello"
|
||||
assert isinstance(result.meta, dict)
|
||||
assert result.meta["some"] == "metadata"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_desktop(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Test the desktop server"""
|
||||
|
|
|
|||
Loading…
Reference in a new issue