# NOTE: Those were added because we actually want to test wrong type annotations. # pyright: reportUnknownParameterType=false # pyright: reportMissingParameterType=false # pyright: reportUnknownArgumentType=false # pyright: reportUnknownLambdaType=false from collections.abc import Callable from dataclasses import dataclass from typing import Annotated, Any, Final, NamedTuple, TypedDict import annotated_types import pytest from dirty_equals import IsPartialDict from mcp_types import CallToolResult, InputRequiredResult from pydantic import BaseModel, Field from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.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: _: Any = ( 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!" @pytest.mark.anyio async def test_call_fn_does_not_mutate_pre_validated(): """A caller-provided `pre_validated` dict must not be mutated by the call.""" def fn(x: int, ctx: str) -> str: return f"{x}:{ctx}" meta = func_metadata(fn, skip_names=["ctx"]) pre_validated = meta.validate_arguments({"x": 1}) snapshot = dict(pre_validated) result = await meta.call_fn_with_arg_validation( fn, fn_is_async=False, arguments_to_validate={"x": 1}, arguments_to_pass_directly={"ctx": "injected"}, pre_validated=pre_validated, ) assert result == "1:injected" assert pre_validated == snapshot # `ctx` was not leaked into the caller's dict 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]): # pragma: no cover 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): # pragma: no cover 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]: # pragma: no cover return {"a": 1, "b": "hello", "c": [1, 2, 3]} meta = func_metadata(func_dict_any) assert meta.output_schema == IsPartialDict(type="object", title="func_dict_anyDictOutput") # Test dict[str, str] def func_dict_str() -> dict[str, str]: # pragma: no cover 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]]: # pragma: no cover 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]: # pragma: no cover 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: Callable[[str, int], str] = 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": "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"]: # pragma: no cover normalized_schema["properties"]["my_model_a_with_default"] = { # pragma: no cover "$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): # pragma: no cover 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 def test_str_annotation_preserves_json_string(): """Regression test for PR #1113: Ensure that when a parameter is annotated as str, valid JSON strings are NOT parsed into Python objects. This test would fail before the fix (JSON string would be parsed to dict) and passes after the fix (JSON string remains as string). """ def process_json_config(config: str, enabled: bool = True) -> str: # pragma: no cover """Function that expects a JSON string as a string parameter.""" # In real use, this function might validate or transform the JSON string # before parsing it, or pass it to another service as-is return f"Processing config: {config}" meta = func_metadata(process_json_config) # Test case 1: JSON object as string json_obj_str = '{"database": "postgres", "port": 5432}' result = meta.pre_parse_json({"config": json_obj_str, "enabled": True}) # The config parameter should remain as a string, NOT be parsed to a dict assert isinstance(result["config"], str) assert result["config"] == json_obj_str # Test case 2: JSON array as string json_array_str = '["item1", "item2", "item3"]' result = meta.pre_parse_json({"config": json_array_str}) # Should remain as string assert isinstance(result["config"], str) assert result["config"] == json_array_str # Test case 3: JSON string value (double-encoded) json_string_str = '"This is a JSON string"' result = meta.pre_parse_json({"config": json_string_str}) # Should remain as the original string with quotes assert isinstance(result["config"], str) assert result["config"] == json_string_str # Test case 4: Complex nested JSON as string complex_json_str = '{"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}], "count": 2}' result = meta.pre_parse_json({"config": complex_json_str}) # Should remain as string assert isinstance(result["config"], str) assert result["config"] == complex_json_str @pytest.mark.anyio async def test_str_annotation_runtime_validation(): """Regression test for PR #1113: Test runtime validation with string parameters containing valid JSON to ensure they are passed as strings, not parsed objects. """ def handle_json_payload(payload: str, strict_mode: bool = False) -> str: """Function that processes a JSON payload as a string.""" # This function expects to receive the raw JSON string # It might parse it later after validation or logging assert isinstance(payload, str), f"Expected str, got {type(payload)}" return f"Handled payload of length {len(payload)}" meta = func_metadata(handle_json_payload) # Test with a JSON object string json_payload = '{"action": "create", "resource": "user", "data": {"name": "Test User"}}' result = await meta.call_fn_with_arg_validation( handle_json_payload, fn_is_async=False, arguments_to_validate={"payload": json_payload, "strict_mode": True}, arguments_to_pass_directly=None, ) # The function should have received the string and returned successfully assert result == f"Handled payload of length {len(json_payload)}" # Test with JSON array string json_array_payload = '["task1", "task2", "task3"]' result = await meta.call_fn_with_arg_validation( handle_json_payload, fn_is_async=False, arguments_to_validate={"payload": json_array_payload}, arguments_to_pass_directly=None, ) assert result == f"Handled payload of length {len(json_array_payload)}" # Tests for structured output functionality def test_structured_output_requires_return_annotation(): """Test that structured_output=True requires a return annotation""" def func_no_annotation(): # pragma: no cover return "hello" def func_none_annotation() -> None: # pragma: no cover 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: # pragma: no cover 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: # pragma: no cover return "hello" def func_int() -> int: # pragma: no cover return 42 def func_float() -> float: # pragma: no cover return 3.14 def func_bool() -> bool: # pragma: no cover return True def func_bytes() -> bytes: # pragma: no cover 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]: # pragma: no cover return ["a", "b", "c"] def func_dict_str_int() -> dict[str, int]: # pragma: no cover return {"a": 1, "b": 2} def func_union() -> str | int: # pragma: no cover return "hello" def func_optional() -> str | None: # pragma: no cover 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: # pragma: no cover 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: # pragma: no cover 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: # pragma: no cover 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): # pragma: no cover self.name = name self.age = age self.email = email def func_returning_class() -> PersonClass: # pragma: no cover 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): # pragma: no cover self.x = x self.y = y def func_returning_unannotated() -> UnannotatedClass: # pragma: no cover return UnannotatedClass(1, 2) meta = func_metadata(func_returning_unannotated) 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=[], structured_content={"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_unioned_with_input_required_result_is_equivalent_to_the_bare_annotated_form(): """Stripping `InputRequiredResult` makes the residual behave exactly as if it were the declared return annotation, including the `Annotated[CallToolResult, Model]` special case — the schema derives from `Model` and `convert_result` validates `structured_content` against it instead of wrapping the whole `CallToolResult`.""" class PersonClass(BaseModel): name: str def fn_bare() -> Annotated[CallToolResult, PersonClass]: return CallToolResult(content=[], structured_content={"name": "Brandon"}) def fn_iir() -> Annotated[CallToolResult, PersonClass] | InputRequiredResult: return CallToolResult(content=[], structured_content={"name": "Brandon"}) bare = func_metadata(fn_bare) iir = func_metadata(fn_iir) assert iir.output_schema == bare.output_schema assert iir.wrap_output == bare.wrap_output assert isinstance(bare.convert_result(fn_bare()), CallToolResult) assert isinstance(iir.convert_result(fn_iir()), 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=[], structured_content={"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""" def func_optional_call_tool_result() -> CallToolResult | None: # pragma: no cover 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""" def func_union_call_tool_result() -> str | CallToolResult: # pragma: no cover 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""" def func_pipe_union_call_tool_result() -> str | CallToolResult: # pragma: no cover 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""" 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: # pragma: no cover 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: # pragma: no cover 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""" # 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: Callable[[Any], Any] = lambda x: x * 2 def func_returning_config_with_callable() -> ConfigWithCallable: # pragma: no cover 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: # pragma: no cover 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) def test_structured_output_aliases(): """Test that field aliases are consistent between schema and output""" class ModelWithAliases(BaseModel): field_first: str | None = Field(default=None, alias="first", description="The first field.") field_second: str | None = Field(default=None, alias="second", description="The second field.") def func_with_aliases() -> ModelWithAliases: # pragma: no cover # When aliases are defined, we must use the aliased names to set values return ModelWithAliases(**{"first": "hello", "second": "world"}) meta = func_metadata(func_with_aliases) # Check that schema uses aliases assert meta.output_schema is not None assert "first" in meta.output_schema["properties"] assert "second" in meta.output_schema["properties"] assert "field_first" not in meta.output_schema["properties"] assert "field_second" not in meta.output_schema["properties"] # Check that the actual output uses aliases too result = ModelWithAliases(**{"first": "hello", "second": "world"}) converted = meta.convert_result(result) assert isinstance(converted, CallToolResult) structured_content = converted.structured_content assert structured_content is not None # The structured content should use aliases to match the schema assert "first" in structured_content assert "second" in structured_content assert "field_first" not in structured_content assert "field_second" not in structured_content assert structured_content["first"] == "hello" assert structured_content["second"] == "world" # Also test the case where we have a model with defaults to ensure aliases work in all cases result_with_defaults = ModelWithAliases() # Uses default None values converted_defaults = meta.convert_result(result_with_defaults) assert isinstance(converted_defaults, CallToolResult) structured_content_defaults = converted_defaults.structured_content assert structured_content_defaults is not None # Even with defaults, should use aliases in output assert "first" in structured_content_defaults assert "second" in structured_content_defaults assert "field_first" not in structured_content_defaults assert "field_second" not in structured_content_defaults assert structured_content_defaults["first"] is None assert structured_content_defaults["second"] is None def test_basemodel_reserved_names(): """Test that functions with parameters named after BaseModel methods work correctly""" def func_with_reserved_names( # pragma: no cover model_dump: str, model_validate: int, dict: list[str], json: dict[str, Any], validate: bool, copy: float, normal_param: str, ) -> str: return f"{model_dump}, {model_validate}, {dict}, {json}, {validate}, {copy}, {normal_param}" meta = func_metadata(func_with_reserved_names) # Check that the schema has all the original parameter names (using aliases) schema = meta.arg_model.model_json_schema(by_alias=True) assert "model_dump" in schema["properties"] assert "model_validate" in schema["properties"] assert "dict" in schema["properties"] assert "json" in schema["properties"] assert "validate" in schema["properties"] assert "copy" in schema["properties"] assert "normal_param" in schema["properties"] @pytest.mark.anyio async def test_basemodel_reserved_names_validation(): """Test that validation and calling works with reserved parameter names""" def func_with_reserved_names( model_dump: str, model_validate: int, dict: list[str], json: dict[str, Any], validate: bool, normal_param: str, ) -> str: return f"{model_dump}|{model_validate}|{len(dict)}|{json}|{validate}|{normal_param}" meta = func_metadata(func_with_reserved_names) # Test validation with reserved names result = await meta.call_fn_with_arg_validation( func_with_reserved_names, fn_is_async=False, arguments_to_validate={ "model_dump": "test_dump", "model_validate": 42, "dict": ["a", "b", "c"], "json": {"key": "value"}, "validate": True, "normal_param": "normal", }, arguments_to_pass_directly=None, ) assert result == "test_dump|42|3|{'key': 'value'}|True|normal" # Test that the model can still call its own methods model_instance = meta.arg_model.model_validate( { "model_dump": "dump_value", "model_validate": 123, "dict": ["x", "y"], "json": {"foo": "bar"}, "validate": False, "normal_param": "test", } ) # The model should still have its methods accessible assert hasattr(model_instance, "model_dump") assert callable(model_instance.model_dump) # model_dump_one_level should return the original parameter names dumped = model_instance.model_dump_one_level() assert dumped["model_dump"] == "dump_value" assert dumped["model_validate"] == 123 assert dumped["dict"] == ["x", "y"] assert dumped["json"] == {"foo": "bar"} assert dumped["validate"] is False assert dumped["normal_param"] == "test" def test_basemodel_reserved_names_with_json_preparsing(): """Test that pre_parse_json works correctly with reserved parameter names""" def func_with_reserved_json( # pragma: no cover json: dict[str, Any], model_dump: list[int], normal: str, ) -> str: return "ok" meta = func_metadata(func_with_reserved_json) # Test pre-parsing with reserved names result = meta.pre_parse_json( { "json": '{"nested": "data"}', # JSON string that should be parsed "model_dump": "[1, 2, 3]", # JSON string that should be parsed "normal": "plain string", # Should remain as string } ) assert result["json"] == {"nested": "data"} assert result["model_dump"] == [1, 2, 3] assert result["normal"] == "plain string" def test_disallowed_type_qualifier(): def func_disallowed_qualifier() -> Final[int]: # type: ignore pass # pragma: no cover with pytest.raises(InvalidSignature) as exc_info: func_metadata(func_disallowed_qualifier) assert "return annotation contains an invalid type qualifier" in str(exc_info.value) def test_preserves_pydantic_metadata(): def func_with_metadata() -> Annotated[int, Field(gt=1)]: ... # pragma: no branch meta = func_metadata(func_with_metadata) assert meta.output_schema is not None assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"} def test_convert_result_passes_input_required_result_through_unchanged(): def fn() -> str | InputRequiredResult: ... # pragma: no branch meta = func_metadata(fn) irr = InputRequiredResult(request_state="opaque") assert meta.convert_result(irr) is irr def test_input_required_result_return_annotation_yields_no_output_schema(): def fn() -> InputRequiredResult: ... # pragma: no branch meta = func_metadata(fn) assert meta.output_schema is None assert meta.output_model is None def test_union_with_input_required_result_derives_schema_from_residual_arm(): def fn() -> str | InputRequiredResult: ... # pragma: no branch meta = func_metadata(fn) assert meta.output_schema is not None assert meta.output_schema["properties"]["result"]["type"] == "string" converted = meta.convert_result("hello") assert isinstance(converted, CallToolResult) assert converted.structured_content == {"result": "hello"} irr = InputRequiredResult(request_state="opaque") assert meta.convert_result(irr) is irr def test_call_tool_result_unioned_with_input_required_result_is_accepted(): def fn() -> CallToolResult | InputRequiredResult: ... # pragma: no branch meta = func_metadata(fn) assert meta.output_schema is None def test_basemodel_union_input_required_result_derives_model_schema(): class Payload(BaseModel): x: int def fn() -> Payload | InputRequiredResult: ... # pragma: no branch meta = func_metadata(fn) assert meta.output_model is Payload assert meta.wrap_output is False assert meta.output_schema == Payload.model_json_schema() def test_call_tool_result_in_union_with_input_required_result_is_still_rejected(): def fn() -> CallToolResult | str | InputRequiredResult: ... # pragma: no branch with pytest.raises(InvalidSignature, match="CallToolResult cannot be used in Union"): func_metadata(fn) def test_union_of_only_input_required_subclasses_yields_no_output_schema(): class StepA(InputRequiredResult): pass class StepB(InputRequiredResult): pass def fn() -> StepA | StepB: ... # pragma: no branch meta = func_metadata(fn) assert meta.output_schema is None