from dataclasses import dataclass
from typing import Annotated, Any, TypedDict
import annotated_types
import pytest
from pydantic import BaseModel, Field
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
class SomeInputModelA(BaseModel):
pass
class SomeInputModelB(BaseModel):
class InnerModel(BaseModel):
x: int
how_many_shrimp: Annotated[int, Field(description="How many shrimp in the tank???")]
ok: InnerModel
y: None
def complex_arguments_fn(
an_int: int,
must_be_none: None,
must_be_none_dumb_annotation: Annotated[None, "blah"],
list_of_ints: list[int],
# list[str] | str is an interesting case because if it comes in as JSON like
# "[\"a\", \"b\"]" then it will be naively parsed as a string.
list_str_or_str: list[str] | str,
an_int_annotated_with_field: Annotated[int, Field(description="An int with a field")],
an_int_annotated_with_field_and_others: Annotated[
int,
str, # Should be ignored, really
Field(description="An int with a field"),
annotated_types.Gt(1),
],
an_int_annotated_with_junk: Annotated[
int,
"123",
456,
],
field_with_default_via_field_annotation_before_nondefault_arg: Annotated[int, Field(1)],
unannotated,
my_model_a: SomeInputModelA,
my_model_a_forward_ref: "SomeInputModelA",
my_model_b: SomeInputModelB,
an_int_annotated_with_field_default: Annotated[
int,
Field(1, description="An int with a field"),
],
unannotated_with_default=5,
my_model_a_with_default: SomeInputModelA = SomeInputModelA(), # noqa: B008
an_int_with_default: int = 1,
must_be_none_with_default: None = None,
an_int_with_equals_field: int = Field(1, ge=0),
int_annotated_with_default: Annotated[int, Field(description="hey")] = 5,
) -> str:
_ = (
an_int,
must_be_none,
must_be_none_dumb_annotation,
list_of_ints,
list_str_or_str,
an_int_annotated_with_field,
an_int_annotated_with_field_and_others,
an_int_annotated_with_junk,
field_with_default_via_field_annotation_before_nondefault_arg,
unannotated,
an_int_annotated_with_field_default,
unannotated_with_default,
my_model_a,
my_model_a_forward_ref,
my_model_b,
my_model_a_with_default,
an_int_with_default,
must_be_none_with_default,
an_int_with_equals_field,
int_annotated_with_default,
)
return "ok!"
@pytest.mark.anyio
async def test_complex_function_runtime_arg_validation_non_json():
"""Test that basic non-JSON arguments are validated correctly"""
meta = func_metadata(complex_arguments_fn)
# Test with minimum required arguments
result = await meta.call_fn_with_arg_validation(
complex_arguments_fn,
fn_is_async=False,
arguments_to_validate={
"an_int": 1,
"must_be_none": None,
"must_be_none_dumb_annotation": None,
"list_of_ints": [1, 2, 3],
"list_str_or_str": "hello",
"an_int_annotated_with_field": 42,
"an_int_annotated_with_field_and_others": 5,
"an_int_annotated_with_junk": 100,
"unannotated": "test",
"my_model_a": {},
"my_model_a_forward_ref": {},
"my_model_b": {"how_many_shrimp": 5, "ok": {"x": 1}, "y": None},
},
arguments_to_pass_directly=None,
)
assert result == "ok!"
# Test with invalid types
with pytest.raises(ValueError):
await meta.call_fn_with_arg_validation(
complex_arguments_fn,
fn_is_async=False,
arguments_to_validate={"an_int": "not an int"},
arguments_to_pass_directly=None,
)
@pytest.mark.anyio
async def test_complex_function_runtime_arg_validation_with_json():
"""Test that JSON string arguments are parsed and validated correctly"""
meta = func_metadata(complex_arguments_fn)
result = await meta.call_fn_with_arg_validation(
complex_arguments_fn,
fn_is_async=False,
arguments_to_validate={
"an_int": 1,
"must_be_none": None,
"must_be_none_dumb_annotation": None,
"list_of_ints": "[1, 2, 3]", # JSON string
"list_str_or_str": '["a", "b", "c"]', # JSON string
"an_int_annotated_with_field": 42,
"an_int_annotated_with_field_and_others": "5", # JSON string
"an_int_annotated_with_junk": 100,
"unannotated": "test",
"my_model_a": "{}", # JSON string
"my_model_a_forward_ref": "{}", # JSON string
"my_model_b": '{"how_many_shrimp": 5, "ok": {"x": 1}, "y": null}',
},
arguments_to_pass_directly=None,
)
assert result == "ok!"
def test_str_vs_list_str():
"""Test handling of string vs list[str] type annotations.
This is tricky as '"hello"' can be parsed as a JSON string or a Python string.
We want to make sure it's kept as a python string.
"""
def func_with_str_types(str_or_list: str | list[str]):
return str_or_list
meta = func_metadata(func_with_str_types)
# Test string input for union type
result = meta.pre_parse_json({"str_or_list": "hello"})
assert result["str_or_list"] == "hello"
# Test string input that contains valid JSON for union type
# We want to see here that the JSON-vali string is NOT parsed as JSON, but rather
# kept as a raw string
result = meta.pre_parse_json({"str_or_list": '"hello"'})
assert result["str_or_list"] == '"hello"'
# Test list input for union type
result = meta.pre_parse_json({"str_or_list": '["hello", "world"]'})
assert result["str_or_list"] == ["hello", "world"]
def test_skip_names():
"""Test that skipped parameters are not included in the model"""
def func_with_many_params(keep_this: int, skip_this: str, also_keep: float, also_skip: bool):
return keep_this, skip_this, also_keep, also_skip
# Skip some parameters
meta = func_metadata(func_with_many_params, skip_names=["skip_this", "also_skip"])
# Check model fields
assert "keep_this" in meta.arg_model.model_fields
assert "also_keep" in meta.arg_model.model_fields
assert "skip_this" not in meta.arg_model.model_fields
assert "also_skip" not in meta.arg_model.model_fields
# Validate that we can call with only non-skipped parameters
model: BaseModel = meta.arg_model.model_validate({"keep_this": 1, "also_keep": 2.5}) # type: ignore
assert model.keep_this == 1 # type: ignore
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 == {
"additionalProperties": True,
"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"""
fn = lambda x, y=5: x # noqa: E731
meta = func_metadata(lambda x, y=5: x)
# Test schema
assert meta.arg_model.model_json_schema() == {
"properties": {
"x": {"title": "x", "type": "string"},
"y": {"default": 5, "title": "y", "type": "string"},
},
"required": ["x"],
"title": "<lambda>Arguments",
"type": "object",
}
async def check_call(args):
return await meta.call_fn_with_arg_validation(
fn,
fn_is_async=False,
arguments_to_validate=args,
arguments_to_pass_directly=None,
)
# Basic calls
assert await check_call({"x": "hello"}) == "hello"
assert await check_call({"x": "hello", "y": "world"}) == "hello"
assert await check_call({"x": '"hello"'}) == '"hello"'
# Missing required arg
with pytest.raises(ValueError):
await check_call({"y": "world"})
def test_complex_function_json_schema():
"""Test JSON schema generation for complex function arguments.
Note: Different versions of pydantic output slightly different
JSON Schema formats for model fields with defaults. The format changed in 2.9.0:
1. Before 2.9.0:
{
"allOf": [{"$ref": "#/$defs/Model"}],
"default": {}
}
2. Since 2.9.0:
{
"$ref": "#/$defs/Model",
"default": {}
}
Both formats are valid and functionally equivalent. This test accepts either format
to ensure compatibility across our supported pydantic versions.
This change in format does not affect runtime behavior since:
1. Both schemas validate the same way
2. The actual model classes and validation logic are unchanged
3. func_metadata uses model_validate/model_dump, not the schema directly
"""
meta = func_metadata(complex_arguments_fn)
actual_schema = meta.arg_model.model_json_schema()
# Create a copy of the actual schema to normalize
normalized_schema = actual_schema.copy()
# Normalize the my_model_a_with_default field to handle both pydantic formats
if "allOf" in actual_schema["properties"]["my_model_a_with_default"]:
normalized_schema["properties"]["my_model_a_with_default"] = {
"$ref": "#/$defs/SomeInputModelA",
"default": {},
}
assert normalized_schema == {
"$defs": {
"InnerModel": {
"properties": {"x": {"title": "X", "type": "integer"}},
"required": ["x"],
"title": "InnerModel",
"type": "object",
},
"SomeInputModelA": {
"properties": {},
"title": "SomeInputModelA",
"type": "object",
},
"SomeInputModelB": {
"properties": {
"how_many_shrimp": {
"description": "How many shrimp in the tank???",
"title": "How Many Shrimp",
"type": "integer",
},
"ok": {"$ref": "#/$defs/InnerModel"},
"y": {"title": "Y", "type": "null"},
},
"required": ["how_many_shrimp", "ok", "y"],
"title": "SomeInputModelB",
"type": "object",
},
},
"properties": {
"an_int": {"title": "An Int", "type": "integer"},
"must_be_none": {"title": "Must Be None", "type": "null"},
"must_be_none_dumb_annotation": {
"title": "Must Be None Dumb Annotation",
"type": "null",
},
"list_of_ints": {
"items": {"type": "integer"},
"title": "List Of Ints",
"type": "array",
},
"list_str_or_str": {
"anyOf": [
{"items": {"type": "string"}, "type": "array"},
{"type": "string"},
],
"title": "List Str Or Str",
},
"an_int_annotated_with_field": {
"description": "An int with a field",
"title": "An Int Annotated With Field",
"type": "integer",
},
"an_int_annotated_with_field_and_others": {
"description": "An int with a field",
"exclusiveMinimum": 1,
"title": "An Int Annotated With Field And Others",
"type": "integer",
},
"an_int_annotated_with_junk": {
"title": "An Int Annotated With Junk",
"type": "integer",
},
"field_with_default_via_field_annotation_before_nondefault_arg": {
"default": 1,
"title": "Field With Default Via Field Annotation Before Nondefault Arg",
"type": "integer",
},
"unannotated": {"title": "unannotated", "type": "string"},
"my_model_a": {"$ref": "#/$defs/SomeInputModelA"},
"my_model_a_forward_ref": {"$ref": "#/$defs/SomeInputModelA"},
"my_model_b": {"$ref": "#/$defs/SomeInputModelB"},
"an_int_annotated_with_field_default": {
"default": 1,
"description": "An int with a field",
"title": "An Int Annotated With Field Default",
"type": "integer",
},
"unannotated_with_default": {
"default": 5,
"title": "unannotated_with_default",
"type": "string",
},
"my_model_a_with_default": {
"$ref": "#/$defs/SomeInputModelA",
"default": {},
},
"an_int_with_default": {
"default": 1,
"title": "An Int With Default",
"type": "integer",
},
"must_be_none_with_default": {
"default": None,
"title": "Must Be None With Default",
"type": "null",
},
"an_int_with_equals_field": {
"default": 1,
"minimum": 0,
"title": "An Int With Equals Field",
"type": "integer",
},
"int_annotated_with_default": {
"default": 5,
"description": "hey",
"title": "Int Annotated With Default",
"type": "integer",
},
},
"required": [
"an_int",
"must_be_none",
"must_be_none_dumb_annotation",
"list_of_ints",
"list_str_or_str",
"an_int_annotated_with_field",
"an_int_annotated_with_field_and_others",
"an_int_annotated_with_junk",
"unannotated",
"my_model_a",
"my_model_a_forward_ref",
"my_model_b",
],
"title": "complex_arguments_fnArguments",
"type": "object",
}
def test_str_vs_int():
"""
Test that string values are kept as strings even when they contain numbers,
while numbers are parsed correctly.
"""
def func_with_str_and_int(a: str, b: int):
return a
meta = func_metadata(func_with_str_and_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)