60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
324 lines
12 KiB
Python
324 lines
12 KiB
Python
"""Test handling of direct array schemas in request bodies (FastAPI list parameters)."""
|
|
|
|
from fastmcp.utilities.openapi.models import (
|
|
HTTPRoute,
|
|
ParameterInfo,
|
|
RequestBodyInfo,
|
|
)
|
|
from fastmcp.utilities.openapi.schemas import (
|
|
_combine_schemas_and_map_params,
|
|
)
|
|
|
|
|
|
class TestDirectArraySchemas:
|
|
"""Test handling of direct array schemas like those generated by FastAPI for list[str] parameters."""
|
|
|
|
def test_simple_direct_array_schema(self):
|
|
"""Test route with direct array request body schema."""
|
|
route = HTTPRoute(
|
|
path="/simple_list",
|
|
method="POST",
|
|
operation_id="simple_list_tool",
|
|
summary="Simple List Tool",
|
|
description="A simple tool that takes a list of strings.",
|
|
parameters=[],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"items": {"type": "string"},
|
|
"type": "array",
|
|
"title": "Values",
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
# Should create a single parameter from the title
|
|
assert combined_schema["type"] == "object"
|
|
assert "values" in combined_schema["properties"]
|
|
assert "values" in combined_schema["required"]
|
|
|
|
# Check the array schema is preserved
|
|
values_schema = combined_schema["properties"]["values"]
|
|
assert values_schema["type"] == "array"
|
|
assert values_schema["items"]["type"] == "string"
|
|
assert values_schema["title"] == "Values"
|
|
|
|
# Check parameter mapping
|
|
assert "values" in param_map
|
|
assert param_map["values"]["location"] == "body"
|
|
assert param_map["values"]["openapi_name"] == "values"
|
|
|
|
def test_int_array_schema(self):
|
|
"""Test route with integer array request body schema."""
|
|
route = HTTPRoute(
|
|
path="/int_list",
|
|
method="POST",
|
|
operation_id="int_list_tool",
|
|
summary="Integer List Tool",
|
|
description="A simple tool that takes a list of integers.",
|
|
parameters=[],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"items": {"type": "integer"},
|
|
"type": "array",
|
|
"title": "Numbers",
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
# Should create a single parameter from the title
|
|
assert combined_schema["type"] == "object"
|
|
assert "numbers" in combined_schema["properties"]
|
|
assert "numbers" in combined_schema["required"]
|
|
|
|
# Check the array schema is preserved
|
|
numbers_schema = combined_schema["properties"]["numbers"]
|
|
assert numbers_schema["type"] == "array"
|
|
assert numbers_schema["items"]["type"] == "integer"
|
|
assert numbers_schema["title"] == "Numbers"
|
|
|
|
def test_mixed_params_with_direct_array(self):
|
|
"""Test route with both URL parameters and direct array request body."""
|
|
route = HTTPRoute(
|
|
path="/mixed_params",
|
|
method="POST",
|
|
operation_id="mixed_params_tool",
|
|
summary="Mixed Params Tool",
|
|
description="A tool with both list and simple parameters.",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="prefix",
|
|
location="query",
|
|
required=True,
|
|
schema={"type": "string", "title": "Prefix"},
|
|
)
|
|
],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"title": "Values",
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
# Should have both parameters
|
|
assert combined_schema["type"] == "object"
|
|
assert "prefix" in combined_schema["properties"] # From query param
|
|
assert "values" in combined_schema["properties"] # From body array
|
|
assert "prefix" in combined_schema["required"]
|
|
assert "values" in combined_schema["required"]
|
|
|
|
# Check query parameter
|
|
prefix_schema = combined_schema["properties"]["prefix"]
|
|
assert prefix_schema["type"] == "string"
|
|
|
|
# Check body array parameter
|
|
values_schema = combined_schema["properties"]["values"]
|
|
assert values_schema["type"] == "array"
|
|
assert values_schema["items"]["type"] == "string"
|
|
|
|
# Check parameter mappings
|
|
assert param_map["prefix"]["location"] == "query"
|
|
assert param_map["values"]["location"] == "body"
|
|
|
|
def test_direct_array_no_title(self):
|
|
"""Test direct array schema without title."""
|
|
route = HTTPRoute(
|
|
path="/no_title",
|
|
method="POST",
|
|
operation_id="no_title_tool",
|
|
parameters=[],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
# No title
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
# Should fall back to "body" (default title falls back)
|
|
assert combined_schema["type"] == "object"
|
|
assert "body" in combined_schema["properties"]
|
|
assert "body" in combined_schema["required"]
|
|
|
|
# Check parameter mapping
|
|
assert param_map["body"]["location"] == "body"
|
|
assert param_map["body"]["openapi_name"] == "body"
|
|
|
|
def test_direct_primitive_schema(self):
|
|
"""Test direct primitive (non-array) request body schema."""
|
|
route = HTTPRoute(
|
|
path="/primitive",
|
|
method="POST",
|
|
operation_id="primitive_tool",
|
|
parameters=[],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {"type": "string", "title": "Message"}
|
|
},
|
|
),
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
# Should create a single parameter from the title
|
|
assert combined_schema["type"] == "object"
|
|
assert "message" in combined_schema["properties"]
|
|
assert "message" in combined_schema["required"]
|
|
|
|
# Check the primitive schema is preserved
|
|
message_schema = combined_schema["properties"]["message"]
|
|
assert message_schema["type"] == "string"
|
|
assert message_schema["title"] == "Message"
|
|
|
|
def test_direct_array_optional(self):
|
|
"""Test direct array schema that's not required."""
|
|
route = HTTPRoute(
|
|
path="/optional_list",
|
|
method="POST",
|
|
operation_id="optional_list_tool",
|
|
parameters=[],
|
|
request_body=RequestBodyInfo(
|
|
required=False, # Not required
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"title": "OptionalValues",
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
# Should create parameter but not mark as required
|
|
assert combined_schema["type"] == "object"
|
|
assert "optionalvalues" in combined_schema["properties"]
|
|
assert "optionalvalues" not in combined_schema["required"]
|
|
|
|
def test_direct_array_title_sanitization(self):
|
|
"""Test that titles with special characters are sanitized."""
|
|
route = HTTPRoute(
|
|
path="/special_chars",
|
|
method="POST",
|
|
operation_id="special_chars_tool",
|
|
parameters=[],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"title": "Special-Chars & Spaces!",
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
# Should sanitize the title to a valid parameter name (& becomes multiple underscores)
|
|
assert combined_schema["type"] == "object"
|
|
assert "special_chars___spaces_" in combined_schema["properties"]
|
|
|
|
def test_direct_array_title_starting_with_number(self):
|
|
"""Test that titles starting with numbers are handled."""
|
|
route = HTTPRoute(
|
|
path="/number_start",
|
|
method="POST",
|
|
operation_id="number_start_tool",
|
|
parameters=[],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"title": "123Values",
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
# Should fall back to "body_data" since "123values" starts with a number
|
|
assert combined_schema["type"] == "object"
|
|
assert "body_data" in combined_schema["properties"]
|
|
|
|
def test_preserve_existing_behavior_object_body(self):
|
|
"""Test that existing object-based request bodies still work."""
|
|
route = HTTPRoute(
|
|
path="/object_body",
|
|
method="POST",
|
|
operation_id="object_body_tool",
|
|
parameters=[],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"age": {"type": "integer"},
|
|
},
|
|
"required": ["name"],
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
# Should handle object bodies as before
|
|
assert combined_schema["type"] == "object"
|
|
assert "name" in combined_schema["properties"]
|
|
assert "age" in combined_schema["properties"]
|
|
assert "name" in combined_schema["required"]
|
|
assert "age" not in combined_schema["required"]
|
|
|
|
def test_preserve_existing_behavior_ref_body(self):
|
|
"""Test that $ref-based request bodies still work."""
|
|
route = HTTPRoute(
|
|
path="/ref_body",
|
|
method="POST",
|
|
operation_id="ref_body_tool",
|
|
parameters=[],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {"$ref": "#/components/schemas/UserCreate"}
|
|
},
|
|
),
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
# Should handle $ref bodies as before (refs get converted to $defs)
|
|
assert combined_schema["type"] == "object"
|
|
assert "body" in combined_schema["properties"]
|
|
assert combined_schema["properties"]["body"]["$ref"] == "#/$defs/UserCreate"
|