49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
1311 lines
46 KiB
Python
1311 lines
46 KiB
Python
# 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": "<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"]: # 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
|