feat: Add structured output support for tool functions (#993)

This commit is contained in:
bhosmer-ant 2025-06-26 04:36:17 -04:00 • committed by GitHub
commit 43bb24f62b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1774 additions and 61 deletions

View file

@ -0,0 +1,198 @@
import logging
from contextlib import contextmanager
from unittest.mock import patch
import pytest
from mcp.server.lowlevel import Server
from mcp.shared.memory import (
create_connected_server_and_client_session as client_session,
)
from mcp.types import Tool
@contextmanager
def bypass_server_output_validation():
"""
Context manager that bypasses server-side output validation.
This simulates a malicious or non-compliant server that doesn't validate
its outputs, allowing us to test client-side validation.
"""
# Patch jsonschema.validate in the server module to disable all validation
with patch("mcp.server.lowlevel.server.jsonschema.validate"):
# The mock will simply return None (do nothing) for all validation calls
yield
class TestClientOutputSchemaValidation:
"""Test client-side validation of structured output from tools"""
@pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_basemodel(self):
"""Test that client validates structured content against schema for BaseModel outputs"""
# Create a malicious low-level server that returns invalid structured content
server = Server("test-server")
# Define the expected schema for our tool
output_schema = {
"type": "object",
"properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}},
"required": ["name", "age"],
"title": "UserOutput",
}
@server.list_tools()
async def list_tools():
return [
Tool(
name="get_user",
description="Get user data",
inputSchema={"type": "object"},
outputSchema=output_schema,
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
# Return invalid structured content - age is string instead of integer
# The low-level server will wrap this in CallToolResult
return {"name": "John", "age": "invalid"} # Invalid: age should be int
# Test that client validates the structured content
with bypass_server_output_validation():
async with client_session(server) as client:
# The client validates structured content and should raise an error
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("get_user", {})
# Verify it's a validation error
assert "Invalid structured content returned by tool get_user" in str(exc_info.value)
@pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_primitive(self):
"""Test that client validates structured content for primitive outputs"""
server = Server("test-server")
# Primitive types are wrapped in {"result": value}
output_schema = {
"type": "object",
"properties": {"result": {"type": "integer", "title": "Result"}},
"required": ["result"],
"title": "calculate_Output",
}
@server.list_tools()
async def list_tools():
return [
Tool(
name="calculate",
description="Calculate something",
inputSchema={"type": "object"},
outputSchema=output_schema,
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
# Return invalid structured content - result is string instead of integer
return {"result": "not_a_number"} # Invalid: should be int
with bypass_server_output_validation():
async with client_session(server) as client:
# The client validates structured content and should raise an error
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("calculate", {})
assert "Invalid structured content returned by tool calculate" in str(exc_info.value)
@pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_dict_typed(self):
"""Test that client validates dict[str, T] structured content"""
server = Server("test-server")
# dict[str, int] schema
output_schema = {"type": "object", "additionalProperties": {"type": "integer"}, "title": "get_scores_Output"}
@server.list_tools()
async def list_tools():
return [
Tool(
name="get_scores",
description="Get scores",
inputSchema={"type": "object"},
outputSchema=output_schema,
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
# Return invalid structured content - values should be integers
return {"alice": "100", "bob": "85"} # Invalid: values should be int
with bypass_server_output_validation():
async with client_session(server) as client:
# The client validates structured content and should raise an error
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("get_scores", {})
assert "Invalid structured content returned by tool get_scores" in str(exc_info.value)
@pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_missing_required(self):
"""Test that client validates missing required fields"""
server = Server("test-server")
output_schema = {
"type": "object",
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}, "email": {"type": "string"}},
"required": ["name", "age", "email"], # All fields required
"title": "PersonOutput",
}
@server.list_tools()
async def list_tools():
return [
Tool(
name="get_person",
description="Get person data",
inputSchema={"type": "object"},
outputSchema=output_schema,
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
# Return structured content missing required field 'email'
return {"name": "John", "age": 30} # Missing required 'email'
with bypass_server_output_validation():
async with client_session(server) as client:
# The client validates structured content and should raise an error
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("get_person", {})
assert "Invalid structured content returned by tool get_person" in str(exc_info.value)
@pytest.mark.anyio
async def test_tool_not_listed_warning(self, caplog):
"""Test that client logs warning when tool is not in list_tools but has outputSchema"""
server = Server("test-server")
@server.list_tools()
async def list_tools():
# Return empty list - tool is not listed
return []
@server.call_tool()
async def call_tool(name: str, arguments: dict):
# Server still responds to the tool call with structured content
return {"result": 42}
# Set logging level to capture warnings
caplog.set_level(logging.WARNING)
with bypass_server_output_validation():
async with client_session(server) as client:
# Call a tool that wasn't listed
result = await client.call_tool("mystery_tool", {})
assert result.structuredContent == {"result": 42}
assert result.isError is False
# Check that warning was logged
assert "Tool mystery_tool not listed" in caplog.text

View file

@ -8,6 +8,7 @@ import anyio
import pytest
from anyio.abc import TaskStatus
from mcp import types
from mcp.client.session import ClientSession
from mcp.server.lowlevel import Server
from mcp.shared.exceptions import McpError
@ -30,6 +31,21 @@ async def test_notification_validation_error(tmp_path: Path):
slow_request_started = anyio.Event()
slow_request_complete = anyio.Event()
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="slow",
description="A slow tool",
inputSchema={"type": "object"},
),
types.Tool(
name="fast",
description="A fast tool",
inputSchema={"type": "object"},
),
]
@server.call_tool()
async def slow_tool(name: str, arg) -> Sequence[ContentBlock]:
nonlocal request_count

View file

@ -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)

View file

@ -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:

View file

@ -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