feat: Add structured output support for tool functions (#993)
This commit is contained in:
parent
c8bbfc034d
commit
43bb24f62b
3 changed files with 1774 additions and 61 deletions
|
|
@ -1,4 +1,5 @@
|
|||
from typing import Annotated
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any, TypedDict
|
||||
|
||||
import annotated_types
|
||||
import pytest
|
||||
|
|
@ -193,6 +194,50 @@ def test_skip_names():
|
|||
assert model.also_keep == 2.5 # type: ignore
|
||||
|
||||
|
||||
def test_structured_output_dict_str_types():
|
||||
"""Test that dict[str, T] types are handled without wrapping."""
|
||||
|
||||
# Test dict[str, Any]
|
||||
def func_dict_any() -> dict[str, Any]:
|
||||
return {"a": 1, "b": "hello", "c": [1, 2, 3]}
|
||||
|
||||
meta = func_metadata(func_dict_any)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"title": "func_dict_anyDictOutput",
|
||||
}
|
||||
|
||||
# Test dict[str, str]
|
||||
def func_dict_str() -> dict[str, str]:
|
||||
return {"name": "John", "city": "NYC"}
|
||||
|
||||
meta = func_metadata(func_dict_str)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
"title": "func_dict_strDictOutput",
|
||||
}
|
||||
|
||||
# Test dict[str, list[int]]
|
||||
def func_dict_list() -> dict[str, list[int]]:
|
||||
return {"nums": [1, 2, 3], "more": [4, 5, 6]}
|
||||
|
||||
meta = func_metadata(func_dict_list)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "array", "items": {"type": "integer"}},
|
||||
"title": "func_dict_listDictOutput",
|
||||
}
|
||||
|
||||
# Test dict[int, str] - should be wrapped since key is not str
|
||||
def func_dict_int_key() -> dict[int, str]:
|
||||
return {1: "a", 2: "b"}
|
||||
|
||||
meta = func_metadata(func_dict_int_key)
|
||||
assert meta.output_schema is not None
|
||||
assert "result" in meta.output_schema["properties"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lambda_function():
|
||||
"""Test lambda function schema and validation"""
|
||||
|
|
@ -408,3 +453,390 @@ def test_str_vs_int():
|
|||
result = meta.pre_parse_json({"a": "123", "b": 123})
|
||||
assert result["a"] == "123"
|
||||
assert result["b"] == 123
|
||||
|
||||
|
||||
# Tests for structured output functionality
|
||||
|
||||
|
||||
def test_structured_output_requires_return_annotation():
|
||||
"""Test that structured_output=True requires a return annotation"""
|
||||
from mcp.server.fastmcp.exceptions import InvalidSignature
|
||||
|
||||
def func_no_annotation():
|
||||
return "hello"
|
||||
|
||||
def func_none_annotation() -> None:
|
||||
return None
|
||||
|
||||
with pytest.raises(InvalidSignature) as exc_info:
|
||||
func_metadata(func_no_annotation, structured_output=True)
|
||||
assert "return annotation required" in str(exc_info.value)
|
||||
|
||||
# None annotation should work
|
||||
meta = func_metadata(func_none_annotation)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {"result": {"title": "Result", "type": "null"}},
|
||||
"required": ["result"],
|
||||
"title": "func_none_annotationOutput",
|
||||
}
|
||||
|
||||
|
||||
def test_structured_output_basemodel():
|
||||
"""Test structured output with BaseModel return types"""
|
||||
|
||||
class PersonModel(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
email: str | None = None
|
||||
|
||||
def func_returning_person() -> PersonModel:
|
||||
return PersonModel(name="Alice", age=30)
|
||||
|
||||
meta = func_metadata(func_returning_person)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"age": {"title": "Age", "type": "integer"},
|
||||
"email": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None, "title": "Email"},
|
||||
},
|
||||
"required": ["name", "age"],
|
||||
"title": "PersonModel",
|
||||
}
|
||||
|
||||
|
||||
def test_structured_output_primitives():
|
||||
"""Test structured output with primitive return types"""
|
||||
|
||||
def func_str() -> str:
|
||||
return "hello"
|
||||
|
||||
def func_int() -> int:
|
||||
return 42
|
||||
|
||||
def func_float() -> float:
|
||||
return 3.14
|
||||
|
||||
def func_bool() -> bool:
|
||||
return True
|
||||
|
||||
def func_bytes() -> bytes:
|
||||
return b"data"
|
||||
|
||||
# Test string
|
||||
meta = func_metadata(func_str)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {"result": {"title": "Result", "type": "string"}},
|
||||
"required": ["result"],
|
||||
"title": "func_strOutput",
|
||||
}
|
||||
|
||||
# Test int
|
||||
meta = func_metadata(func_int)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {"result": {"title": "Result", "type": "integer"}},
|
||||
"required": ["result"],
|
||||
"title": "func_intOutput",
|
||||
}
|
||||
|
||||
# Test float
|
||||
meta = func_metadata(func_float)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {"result": {"title": "Result", "type": "number"}},
|
||||
"required": ["result"],
|
||||
"title": "func_floatOutput",
|
||||
}
|
||||
|
||||
# Test bool
|
||||
meta = func_metadata(func_bool)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {"result": {"title": "Result", "type": "boolean"}},
|
||||
"required": ["result"],
|
||||
"title": "func_boolOutput",
|
||||
}
|
||||
|
||||
# Test bytes
|
||||
meta = func_metadata(func_bytes)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {"result": {"title": "Result", "type": "string", "format": "binary"}},
|
||||
"required": ["result"],
|
||||
"title": "func_bytesOutput",
|
||||
}
|
||||
|
||||
|
||||
def test_structured_output_generic_types():
|
||||
"""Test structured output with generic types (list, dict, Union, etc.)"""
|
||||
|
||||
def func_list_str() -> list[str]:
|
||||
return ["a", "b", "c"]
|
||||
|
||||
def func_dict_str_int() -> dict[str, int]:
|
||||
return {"a": 1, "b": 2}
|
||||
|
||||
def func_union() -> str | int:
|
||||
return "hello"
|
||||
|
||||
def func_optional() -> str | None:
|
||||
return None
|
||||
|
||||
# Test list
|
||||
meta = func_metadata(func_list_str)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {"result": {"title": "Result", "type": "array", "items": {"type": "string"}}},
|
||||
"required": ["result"],
|
||||
"title": "func_list_strOutput",
|
||||
}
|
||||
|
||||
# Test dict[str, int] - should NOT be wrapped
|
||||
meta = func_metadata(func_dict_str_int)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "integer"},
|
||||
"title": "func_dict_str_intDictOutput",
|
||||
}
|
||||
|
||||
# Test Union
|
||||
meta = func_metadata(func_union)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {"result": {"title": "Result", "anyOf": [{"type": "string"}, {"type": "integer"}]}},
|
||||
"required": ["result"],
|
||||
"title": "func_unionOutput",
|
||||
}
|
||||
|
||||
# Test Optional
|
||||
meta = func_metadata(func_optional)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {"result": {"title": "Result", "anyOf": [{"type": "string"}, {"type": "null"}]}},
|
||||
"required": ["result"],
|
||||
"title": "func_optionalOutput",
|
||||
}
|
||||
|
||||
|
||||
def test_structured_output_dataclass():
|
||||
"""Test structured output with dataclass return types"""
|
||||
|
||||
@dataclass
|
||||
class PersonDataClass:
|
||||
name: str
|
||||
age: int
|
||||
email: str | None = None
|
||||
tags: list[str] | None = None
|
||||
|
||||
def func_returning_dataclass() -> PersonDataClass:
|
||||
return PersonDataClass(name="Bob", age=25)
|
||||
|
||||
meta = func_metadata(func_returning_dataclass)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"age": {"title": "Age", "type": "integer"},
|
||||
"email": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None, "title": "Email"},
|
||||
"tags": {
|
||||
"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": "Tags",
|
||||
},
|
||||
},
|
||||
"required": ["name", "age"],
|
||||
"title": "PersonDataClass",
|
||||
}
|
||||
|
||||
|
||||
def test_structured_output_typeddict():
|
||||
"""Test structured output with TypedDict return types"""
|
||||
|
||||
class PersonTypedDictOptional(TypedDict, total=False):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
def func_returning_typeddict_optional() -> PersonTypedDictOptional:
|
||||
return {"name": "Dave"} # Only returning one field to test partial dict
|
||||
|
||||
meta = func_metadata(func_returning_typeddict_optional)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"title": "Name", "type": "string", "default": None},
|
||||
"age": {"title": "Age", "type": "integer", "default": None},
|
||||
},
|
||||
"title": "PersonTypedDictOptional",
|
||||
}
|
||||
|
||||
# Test with total=True (all required)
|
||||
class PersonTypedDictRequired(TypedDict):
|
||||
name: str
|
||||
age: int
|
||||
email: str | None
|
||||
|
||||
def func_returning_typeddict_required() -> PersonTypedDictRequired:
|
||||
return {"name": "Eve", "age": 40, "email": None} # Testing None value
|
||||
|
||||
meta = func_metadata(func_returning_typeddict_required)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"age": {"title": "Age", "type": "integer"},
|
||||
"email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Email"},
|
||||
},
|
||||
"required": ["name", "age", "email"],
|
||||
"title": "PersonTypedDictRequired",
|
||||
}
|
||||
|
||||
|
||||
def test_structured_output_ordinary_class():
|
||||
"""Test structured output with ordinary annotated classes"""
|
||||
|
||||
class PersonClass:
|
||||
name: str
|
||||
age: int
|
||||
email: str | None
|
||||
|
||||
def __init__(self, name: str, age: int, email: str | None = None):
|
||||
self.name = name
|
||||
self.age = age
|
||||
self.email = email
|
||||
|
||||
def func_returning_class() -> PersonClass:
|
||||
return PersonClass("Helen", 55)
|
||||
|
||||
meta = func_metadata(func_returning_class)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"age": {"title": "Age", "type": "integer"},
|
||||
"email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Email"},
|
||||
},
|
||||
"required": ["name", "age", "email"],
|
||||
"title": "PersonClass",
|
||||
}
|
||||
|
||||
|
||||
def test_unstructured_output_unannotated_class():
|
||||
# Test with class that has no annotations
|
||||
class UnannotatedClass:
|
||||
def __init__(self, x, y):
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
def func_returning_unannotated() -> UnannotatedClass:
|
||||
return UnannotatedClass(1, 2)
|
||||
|
||||
meta = func_metadata(func_returning_unannotated)
|
||||
assert meta.output_schema is None
|
||||
|
||||
|
||||
def test_structured_output_with_field_descriptions():
|
||||
"""Test that Field descriptions are preserved in structured output"""
|
||||
|
||||
class ModelWithDescriptions(BaseModel):
|
||||
name: Annotated[str, Field(description="The person's full name")]
|
||||
age: Annotated[int, Field(description="Age in years", ge=0, le=150)]
|
||||
|
||||
def func_with_descriptions() -> ModelWithDescriptions:
|
||||
return ModelWithDescriptions(name="Ian", age=60)
|
||||
|
||||
meta = func_metadata(func_with_descriptions)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"title": "Name", "type": "string", "description": "The person's full name"},
|
||||
"age": {"title": "Age", "type": "integer", "description": "Age in years", "minimum": 0, "maximum": 150},
|
||||
},
|
||||
"required": ["name", "age"],
|
||||
"title": "ModelWithDescriptions",
|
||||
}
|
||||
|
||||
|
||||
def test_structured_output_nested_models():
|
||||
"""Test structured output with nested models"""
|
||||
|
||||
class Address(BaseModel):
|
||||
street: str
|
||||
city: str
|
||||
zipcode: str
|
||||
|
||||
class PersonWithAddress(BaseModel):
|
||||
name: str
|
||||
address: Address
|
||||
|
||||
def func_nested() -> PersonWithAddress:
|
||||
return PersonWithAddress(name="Jack", address=Address(street="123 Main St", city="Anytown", zipcode="12345"))
|
||||
|
||||
meta = func_metadata(func_nested)
|
||||
assert meta.output_schema == {
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"Address": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"street": {"title": "Street", "type": "string"},
|
||||
"city": {"title": "City", "type": "string"},
|
||||
"zipcode": {"title": "Zipcode", "type": "string"},
|
||||
},
|
||||
"required": ["street", "city", "zipcode"],
|
||||
"title": "Address",
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"address": {"$ref": "#/$defs/Address"},
|
||||
},
|
||||
"required": ["name", "address"],
|
||||
"title": "PersonWithAddress",
|
||||
}
|
||||
|
||||
|
||||
def test_structured_output_unserializable_type_error():
|
||||
"""Test error when structured_output=True is used with unserializable types"""
|
||||
from typing import NamedTuple
|
||||
|
||||
from mcp.server.fastmcp.exceptions import InvalidSignature
|
||||
|
||||
# Test with a class that has non-serializable default values
|
||||
class ConfigWithCallable:
|
||||
name: str
|
||||
# Callable defaults are not JSON serializable and will trigger Pydantic warnings
|
||||
callback: Any = lambda x: x * 2
|
||||
|
||||
def func_returning_config_with_callable() -> ConfigWithCallable:
|
||||
return ConfigWithCallable()
|
||||
|
||||
# Should work without structured_output=True (returns None for output_schema)
|
||||
meta = func_metadata(func_returning_config_with_callable)
|
||||
assert meta.output_schema is None
|
||||
|
||||
# Should raise error with structured_output=True
|
||||
with pytest.raises(InvalidSignature) as exc_info:
|
||||
func_metadata(func_returning_config_with_callable, structured_output=True)
|
||||
assert "is not serializable for structured output" in str(exc_info.value)
|
||||
assert "ConfigWithCallable" in str(exc_info.value)
|
||||
|
||||
# Also test with NamedTuple for good measure
|
||||
class Point(NamedTuple):
|
||||
x: int
|
||||
y: int
|
||||
|
||||
def func_returning_namedtuple() -> Point:
|
||||
return Point(1, 2)
|
||||
|
||||
# Should work without structured_output=True (returns None for output_schema)
|
||||
meta = func_metadata(func_returning_namedtuple)
|
||||
assert meta.output_schema is None
|
||||
|
||||
# Should raise error with structured_output=True
|
||||
with pytest.raises(InvalidSignature) as exc_info:
|
||||
func_metadata(func_returning_namedtuple, structured_output=True)
|
||||
assert "is not serializable for structured output" in str(exc_info.value)
|
||||
assert "Point" in str(exc_info.value)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import base64
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pydantic import AnyUrl
|
||||
from pydantic import AnyUrl, BaseModel
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
|
@ -274,6 +274,9 @@ class TestServerTools:
|
|||
content = result.content[0]
|
||||
assert isinstance(content, TextContent)
|
||||
assert content.text == "3"
|
||||
# Check structured content - int return type should have structured output
|
||||
assert result.structuredContent is not None
|
||||
assert result.structuredContent == {"result": 3}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_image_helper(self, tmp_path: Path):
|
||||
|
|
@ -293,6 +296,8 @@ class TestServerTools:
|
|||
# Verify base64 encoding
|
||||
decoded = base64.b64decode(content.data)
|
||||
assert decoded == b"fake png data"
|
||||
# Check structured content - Image return type should NOT have structured output
|
||||
assert result.structuredContent is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_mixed_content(self):
|
||||
|
|
@ -310,6 +315,20 @@ class TestServerTools:
|
|||
assert isinstance(content3, AudioContent)
|
||||
assert content3.mimeType == "audio/wav"
|
||||
assert content3.data == "def"
|
||||
assert result.structuredContent is not None
|
||||
assert "result" in result.structuredContent
|
||||
structured_result = result.structuredContent["result"]
|
||||
assert len(structured_result) == 3
|
||||
|
||||
expected_content = [
|
||||
{"type": "text", "text": "Hello"},
|
||||
{"type": "image", "data": "abc", "mimeType": "image/png"},
|
||||
{"type": "audio", "data": "def", "mimeType": "audio/wav"},
|
||||
]
|
||||
|
||||
for i, expected in enumerate(expected_content):
|
||||
for key, value in expected.items():
|
||||
assert structured_result[i][key] == value
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_mixed_list_with_image(self, tmp_path: Path):
|
||||
|
|
@ -349,6 +368,169 @@ class TestServerTools:
|
|||
content4 = result.content[3]
|
||||
assert isinstance(content4, TextContent)
|
||||
assert content4.text == "direct content"
|
||||
# Check structured content - untyped list with Image objects should NOT have structured output
|
||||
assert result.structuredContent is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_structured_output_basemodel(self):
|
||||
"""Test tool with structured output returning BaseModel"""
|
||||
|
||||
class UserOutput(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
active: bool = True
|
||||
|
||||
def get_user(user_id: int) -> UserOutput:
|
||||
"""Get user by ID"""
|
||||
return UserOutput(name="John Doe", age=30)
|
||||
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(get_user)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
# Check that the tool has outputSchema
|
||||
tools = await client.list_tools()
|
||||
tool = next(t for t in tools.tools if t.name == "get_user")
|
||||
assert tool.outputSchema is not None
|
||||
assert tool.outputSchema["type"] == "object"
|
||||
assert "name" in tool.outputSchema["properties"]
|
||||
assert "age" in tool.outputSchema["properties"]
|
||||
|
||||
# Call the tool and check structured output
|
||||
result = await client.call_tool("get_user", {"user_id": 123})
|
||||
assert result.isError is False
|
||||
assert result.structuredContent is not None
|
||||
assert result.structuredContent == {"name": "John Doe", "age": 30, "active": True}
|
||||
# Content should be JSON serialized version
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert '"name": "John Doe"' in result.content[0].text
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_structured_output_primitive(self):
|
||||
"""Test tool with structured output returning primitive type"""
|
||||
|
||||
def calculate_sum(a: int, b: int) -> int:
|
||||
"""Add two numbers"""
|
||||
return a + b
|
||||
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(calculate_sum)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
# Check that the tool has outputSchema
|
||||
tools = await client.list_tools()
|
||||
tool = next(t for t in tools.tools if t.name == "calculate_sum")
|
||||
assert tool.outputSchema is not None
|
||||
# Primitive types are wrapped
|
||||
assert tool.outputSchema["type"] == "object"
|
||||
assert "result" in tool.outputSchema["properties"]
|
||||
assert tool.outputSchema["properties"]["result"]["type"] == "integer"
|
||||
|
||||
# Call the tool
|
||||
result = await client.call_tool("calculate_sum", {"a": 5, "b": 7})
|
||||
assert result.isError is False
|
||||
assert result.structuredContent is not None
|
||||
assert result.structuredContent == {"result": 12}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_structured_output_list(self):
|
||||
"""Test tool with structured output returning list"""
|
||||
|
||||
def get_numbers() -> list[int]:
|
||||
"""Get a list of numbers"""
|
||||
return [1, 2, 3, 4, 5]
|
||||
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(get_numbers)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
result = await client.call_tool("get_numbers", {})
|
||||
assert result.isError is False
|
||||
assert result.structuredContent is not None
|
||||
assert result.structuredContent == {"result": [1, 2, 3, 4, 5]}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_structured_output_server_side_validation_error(self):
|
||||
"""Test that server-side validation errors are handled properly"""
|
||||
|
||||
def get_numbers() -> list[int]:
|
||||
return [1, 2, 3, 4, [5]] # type: ignore
|
||||
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(get_numbers)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
result = await client.call_tool("get_numbers", {})
|
||||
assert result.isError is True
|
||||
assert result.structuredContent is None
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_structured_output_dict_str_any(self):
|
||||
"""Test tool with dict[str, Any] structured output"""
|
||||
|
||||
def get_metadata() -> dict[str, Any]:
|
||||
"""Get metadata dictionary"""
|
||||
return {
|
||||
"version": "1.0.0",
|
||||
"enabled": True,
|
||||
"count": 42,
|
||||
"tags": ["production", "stable"],
|
||||
"config": {"nested": {"value": 123}},
|
||||
}
|
||||
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(get_metadata)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
# Check schema
|
||||
tools = await client.list_tools()
|
||||
tool = next(t for t in tools.tools if t.name == "get_metadata")
|
||||
assert tool.outputSchema is not None
|
||||
assert tool.outputSchema["type"] == "object"
|
||||
# dict[str, Any] should have minimal schema
|
||||
assert (
|
||||
"additionalProperties" not in tool.outputSchema or tool.outputSchema.get("additionalProperties") is True
|
||||
)
|
||||
|
||||
# Call tool
|
||||
result = await client.call_tool("get_metadata", {})
|
||||
assert result.isError is False
|
||||
assert result.structuredContent is not None
|
||||
expected = {
|
||||
"version": "1.0.0",
|
||||
"enabled": True,
|
||||
"count": 42,
|
||||
"tags": ["production", "stable"],
|
||||
"config": {"nested": {"value": 123}},
|
||||
}
|
||||
assert result.structuredContent == expected
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_structured_output_dict_str_typed(self):
|
||||
"""Test tool with dict[str, T] structured output for specific T"""
|
||||
|
||||
def get_settings() -> dict[str, str]:
|
||||
"""Get settings as string dictionary"""
|
||||
return {"theme": "dark", "language": "en", "timezone": "UTC"}
|
||||
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(get_settings)
|
||||
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
# Check schema
|
||||
tools = await client.list_tools()
|
||||
tool = next(t for t in tools.tools if t.name == "get_settings")
|
||||
assert tool.outputSchema is not None
|
||||
assert tool.outputSchema["type"] == "object"
|
||||
assert tool.outputSchema["additionalProperties"]["type"] == "string"
|
||||
|
||||
# Call tool
|
||||
result = await client.call_tool("get_settings", {})
|
||||
assert result.isError is False
|
||||
assert result.structuredContent == {"theme": "dark", "language": "en", "timezone": "UTC"}
|
||||
|
||||
|
||||
class TestServerResources:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -10,7 +12,7 @@ from mcp.server.fastmcp.tools import Tool, ToolManager
|
|||
from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata
|
||||
from mcp.server.session import ServerSessionT
|
||||
from mcp.shared.context import LifespanContextT, RequestT
|
||||
from mcp.types import ToolAnnotations
|
||||
from mcp.types import TextContent, ToolAnnotations
|
||||
|
||||
|
||||
class TestAddTools:
|
||||
|
|
@ -450,3 +452,184 @@ class TestToolAnnotations:
|
|||
assert tools[0].annotations is not None
|
||||
assert tools[0].annotations.title == "Echo Tool"
|
||||
assert tools[0].annotations.readOnlyHint is True
|
||||
|
||||
|
||||
class TestStructuredOutput:
|
||||
"""Test structured output functionality in tools."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_with_basemodel_output(self):
|
||||
"""Test tool with BaseModel return type."""
|
||||
|
||||
class UserOutput(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
def get_user(user_id: int) -> UserOutput:
|
||||
"""Get user by ID."""
|
||||
return UserOutput(name="John", age=30)
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_user)
|
||||
result = await manager.call_tool("get_user", {"user_id": 1}, convert_result=True)
|
||||
# don't test unstructured output here, just the structured conversion
|
||||
assert len(result) == 2 and result[1] == {"name": "John", "age": 30}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_with_primitive_output(self):
|
||||
"""Test tool with primitive return type."""
|
||||
|
||||
def double_number(n: int) -> int:
|
||||
"""Double a number."""
|
||||
return 10
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(double_number)
|
||||
result = await manager.call_tool("double_number", {"n": 5})
|
||||
assert result == 10
|
||||
result = await manager.call_tool("double_number", {"n": 5}, convert_result=True)
|
||||
assert isinstance(result[0][0], TextContent) and result[1] == {"result": 10}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_with_typeddict_output(self):
|
||||
"""Test tool with TypedDict return type."""
|
||||
|
||||
class UserDict(TypedDict):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
expected_output = {"name": "Alice", "age": 25}
|
||||
|
||||
def get_user_dict(user_id: int) -> UserDict:
|
||||
"""Get user as dict."""
|
||||
return UserDict(name="Alice", age=25)
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_user_dict)
|
||||
result = await manager.call_tool("get_user_dict", {"user_id": 1})
|
||||
assert result == expected_output
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_with_dataclass_output(self):
|
||||
"""Test tool with dataclass return type."""
|
||||
|
||||
@dataclass
|
||||
class Person:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
expected_output = {"name": "Bob", "age": 40}
|
||||
|
||||
def get_person() -> Person:
|
||||
"""Get a person."""
|
||||
return Person("Bob", 40)
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_person)
|
||||
result = await manager.call_tool("get_person", {}, convert_result=True)
|
||||
# don't test unstructured output here, just the structured conversion
|
||||
assert len(result) == 2 and result[1] == expected_output
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_with_list_output(self):
|
||||
"""Test tool with list return type."""
|
||||
|
||||
expected_list = [1, 2, 3, 4, 5]
|
||||
expected_output = {"result": expected_list}
|
||||
|
||||
def get_numbers() -> list[int]:
|
||||
"""Get a list of numbers."""
|
||||
return expected_list
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_numbers)
|
||||
result = await manager.call_tool("get_numbers", {})
|
||||
assert result == expected_list
|
||||
result = await manager.call_tool("get_numbers", {}, convert_result=True)
|
||||
assert isinstance(result[0][0], TextContent) and result[1] == expected_output
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_without_structured_output(self):
|
||||
"""Test that tools work normally when structured_output=False."""
|
||||
|
||||
def get_dict() -> dict:
|
||||
"""Get a dict."""
|
||||
return {"key": "value"}
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_dict, structured_output=False)
|
||||
result = await manager.call_tool("get_dict", {})
|
||||
assert isinstance(result, dict)
|
||||
assert result == {"key": "value"}
|
||||
|
||||
def test_tool_output_schema_property(self):
|
||||
"""Test that Tool.output_schema property works correctly."""
|
||||
|
||||
class UserOutput(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
def get_user() -> UserOutput:
|
||||
return UserOutput(name="Test", age=25)
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(get_user)
|
||||
|
||||
# Test that output_schema is populated
|
||||
expected_schema = {
|
||||
"properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}},
|
||||
"required": ["name", "age"],
|
||||
"title": "UserOutput",
|
||||
"type": "object",
|
||||
}
|
||||
assert tool.output_schema == expected_schema
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_with_dict_str_any_output(self):
|
||||
"""Test tool with dict[str, Any] return type."""
|
||||
|
||||
def get_config() -> dict[str, Any]:
|
||||
"""Get configuration"""
|
||||
return {"debug": True, "port": 8080, "features": ["auth", "logging"]}
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(get_config)
|
||||
|
||||
# Check output schema
|
||||
assert tool.output_schema is not None
|
||||
assert tool.output_schema["type"] == "object"
|
||||
assert "properties" not in tool.output_schema # dict[str, Any] has no constraints
|
||||
|
||||
# Test raw result
|
||||
result = await manager.call_tool("get_config", {})
|
||||
expected = {"debug": True, "port": 8080, "features": ["auth", "logging"]}
|
||||
assert result == expected
|
||||
|
||||
# Test converted result
|
||||
result = await manager.call_tool("get_config", {})
|
||||
assert result == expected
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_with_dict_str_typed_output(self):
|
||||
"""Test tool with dict[str, T] return type for specific T."""
|
||||
|
||||
def get_scores() -> dict[str, int]:
|
||||
"""Get player scores"""
|
||||
return {"alice": 100, "bob": 85, "charlie": 92}
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(get_scores)
|
||||
|
||||
# Check output schema
|
||||
assert tool.output_schema is not None
|
||||
assert tool.output_schema["type"] == "object"
|
||||
assert tool.output_schema["additionalProperties"]["type"] == "integer"
|
||||
|
||||
# Test raw result
|
||||
result = await manager.call_tool("get_scores", {})
|
||||
expected = {"alice": 100, "bob": 85, "charlie": 92}
|
||||
assert result == expected
|
||||
|
||||
# Test converted result
|
||||
result = await manager.call_tool("get_scores", {})
|
||||
assert result == expected
|
||||
|
|
|
|||
Loading…
Reference in a new issue