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
645 lines
23 KiB
Python
645 lines
23 KiB
Python
"""Unit tests for schema processing and parameter mapping."""
|
|
|
|
import pytest
|
|
|
|
from fastmcp.utilities.json_schema import compress_schema
|
|
from fastmcp.utilities.openapi.models import (
|
|
HTTPRoute,
|
|
ParameterInfo,
|
|
RequestBodyInfo,
|
|
)
|
|
from fastmcp.utilities.openapi.schemas import (
|
|
_combine_schemas,
|
|
_combine_schemas_and_map_params,
|
|
_replace_ref_with_defs,
|
|
)
|
|
|
|
|
|
class TestSchemaProcessing:
|
|
"""Test schema processing utilities."""
|
|
|
|
@pytest.fixture
|
|
def simple_route(self):
|
|
"""Create a simple route for testing."""
|
|
return HTTPRoute(
|
|
path="/users/{id}",
|
|
method="GET",
|
|
operation_id="get_user",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="id",
|
|
location="path",
|
|
required=True,
|
|
schema={"type": "integer"},
|
|
)
|
|
],
|
|
)
|
|
|
|
@pytest.fixture
|
|
def collision_route(self):
|
|
"""Create a route with parameter name collisions."""
|
|
return HTTPRoute(
|
|
path="/users/{id}",
|
|
method="PUT",
|
|
operation_id="update_user",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="id",
|
|
location="path",
|
|
required=True,
|
|
schema={"type": "integer"},
|
|
description="User ID in path",
|
|
)
|
|
],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "integer", "description": "User ID in body"},
|
|
"name": {"type": "string"},
|
|
"email": {"type": "string"},
|
|
},
|
|
"required": ["name"],
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
@pytest.fixture
|
|
def complex_route(self):
|
|
"""Create a complex route with multiple parameter types."""
|
|
return HTTPRoute(
|
|
path="/items/{id}",
|
|
method="PATCH",
|
|
operation_id="update_item",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="id",
|
|
location="path",
|
|
required=True,
|
|
schema={"type": "string"},
|
|
),
|
|
ParameterInfo(
|
|
name="version",
|
|
location="query",
|
|
required=False,
|
|
schema={"type": "integer", "default": 1},
|
|
),
|
|
ParameterInfo(
|
|
name="X-Client-Version",
|
|
location="header",
|
|
required=False,
|
|
schema={"type": "string"},
|
|
),
|
|
],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {
|
|
"title": {"type": "string"},
|
|
"description": {"type": "string"},
|
|
"tags": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
},
|
|
},
|
|
"required": ["title"],
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
def test_combine_schemas_simple(self, simple_route):
|
|
"""Test combining schemas for a simple route."""
|
|
combined_schema = _combine_schemas(simple_route)
|
|
|
|
assert combined_schema["type"] == "object"
|
|
assert "properties" in combined_schema
|
|
|
|
properties = combined_schema["properties"]
|
|
assert "id" in properties
|
|
assert properties["id"]["type"] == "integer"
|
|
|
|
required = combined_schema.get("required", [])
|
|
assert "id" in required
|
|
|
|
def test_combine_schemas_with_collisions(self, collision_route):
|
|
"""Test combining schemas with parameter name collisions."""
|
|
combined_schema = _combine_schemas(collision_route)
|
|
|
|
assert combined_schema["type"] == "object"
|
|
properties = combined_schema["properties"]
|
|
|
|
# Should handle collision by suffixing
|
|
id_params = [key for key in properties.keys() if "id" in key]
|
|
assert len(id_params) >= 2 # Should have both path and body id
|
|
|
|
# Should have other body parameters
|
|
assert "name" in properties
|
|
assert "email" in properties
|
|
|
|
def test_combine_schemas_complex(self, complex_route):
|
|
"""Test combining schemas for complex route."""
|
|
combined_schema = _combine_schemas(complex_route)
|
|
|
|
properties = combined_schema["properties"]
|
|
|
|
# Should have path parameter
|
|
assert "id" in properties
|
|
|
|
# Should have query parameter
|
|
assert "version" in properties
|
|
assert properties["version"].get("default") == 1
|
|
|
|
# Should have header parameter
|
|
assert "X-Client-Version" in properties
|
|
|
|
# Should have body parameters
|
|
assert "title" in properties
|
|
assert "description" in properties
|
|
assert "tags" in properties
|
|
|
|
# Check required fields
|
|
required = combined_schema.get("required", [])
|
|
assert "id" in required # Path parameters are required
|
|
assert "title" in required # Required body parameter
|
|
|
|
def test_combine_schemas_and_map_params_simple(self, simple_route):
|
|
"""Test combining schemas and creating parameter map."""
|
|
combined_schema, param_map = _combine_schemas_and_map_params(simple_route)
|
|
|
|
# Check schema
|
|
assert combined_schema["type"] == "object"
|
|
assert "id" in combined_schema["properties"]
|
|
|
|
# Check parameter map
|
|
assert len(param_map) == 1
|
|
assert "id" in param_map
|
|
assert param_map["id"]["location"] == "path"
|
|
assert param_map["id"]["openapi_name"] == "id"
|
|
|
|
def test_combine_schemas_and_map_params_with_collisions(self, collision_route):
|
|
"""Test parameter mapping with collisions."""
|
|
combined_schema, param_map = _combine_schemas_and_map_params(collision_route)
|
|
|
|
# Check that we have entries for both conflicting parameters
|
|
path_id_key = None
|
|
body_id_key = None
|
|
|
|
for key, mapping in param_map.items():
|
|
if mapping["location"] == "path" and mapping["openapi_name"] == "id":
|
|
path_id_key = key
|
|
elif mapping["location"] == "body" and mapping["openapi_name"] == "id":
|
|
body_id_key = key
|
|
|
|
assert path_id_key is not None
|
|
assert body_id_key is not None
|
|
assert path_id_key != body_id_key # Should be different keys
|
|
|
|
# Both should exist in schema
|
|
assert path_id_key in combined_schema["properties"]
|
|
assert body_id_key in combined_schema["properties"]
|
|
|
|
# Should also have non-conflicting parameters
|
|
assert "name" in param_map
|
|
assert "email" in param_map
|
|
|
|
def test_combine_schemas_and_map_params_complex(self, complex_route):
|
|
"""Test parameter mapping for complex route."""
|
|
combined_schema, param_map = _combine_schemas_and_map_params(complex_route)
|
|
|
|
# Should have all parameters mapped
|
|
actual_locations = {mapping["location"] for mapping in param_map.values()}
|
|
|
|
# Should have representatives from each location
|
|
assert "path" in actual_locations
|
|
assert "body" in actual_locations
|
|
# May or may not have query/header depending on whether they're included
|
|
|
|
# Check specific mappings
|
|
id_mapping = param_map["id"]
|
|
assert id_mapping["location"] == "path"
|
|
assert id_mapping["openapi_name"] == "id"
|
|
|
|
title_mapping = param_map["title"]
|
|
assert title_mapping["location"] == "body"
|
|
assert title_mapping["openapi_name"] == "title"
|
|
|
|
def test_replace_ref_with_defs(self):
|
|
"""Test replacing $ref with $defs for JSON Schema compatibility."""
|
|
|
|
schema_with_ref = {
|
|
"type": "object",
|
|
"properties": {
|
|
"user": {"$ref": "#/components/schemas/User"},
|
|
"items": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/components/schemas/Item"},
|
|
},
|
|
},
|
|
}
|
|
|
|
# Use our recursive replacement approach
|
|
result = _replace_ref_with_defs(schema_with_ref)
|
|
|
|
assert result["properties"]["user"]["$ref"] == "#/$defs/User"
|
|
assert result["properties"]["items"]["items"]["$ref"] == "#/$defs/Item"
|
|
|
|
def test_replace_ref_with_defs_nested(self):
|
|
"""Test replacing $ref in deeply nested structures."""
|
|
|
|
nested_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"data": {
|
|
"type": "object",
|
|
"properties": {
|
|
"nested": {"$ref": "#/components/schemas/Nested"},
|
|
},
|
|
},
|
|
"items": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"ref_prop": {"$ref": "#/components/schemas/RefProp"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
# Use our recursive replacement approach
|
|
result = _replace_ref_with_defs(nested_schema)
|
|
|
|
# Check nested object property
|
|
nested_prop = result["properties"]["data"]["properties"]["nested"]
|
|
assert nested_prop["$ref"] == "#/$defs/Nested"
|
|
|
|
# Check array item property
|
|
array_item_prop = result["properties"]["items"]["items"]["properties"][
|
|
"ref_prop"
|
|
]
|
|
assert array_item_prop["$ref"] == "#/$defs/RefProp"
|
|
|
|
def test_replace_ref_with_defs_in_additional_properties(self):
|
|
"""Test replacing $ref deeply in 'additionalProperties'."""
|
|
|
|
add_props_schema = {
|
|
"description": "An invoice with a fixed header and a flexible set of line items.",
|
|
"type": "object",
|
|
"properties": {
|
|
"invoice_number": {
|
|
"type": "string",
|
|
"description": "The unique identifier for the invoice.",
|
|
},
|
|
"customer_name": {
|
|
"type": "string",
|
|
"description": "The name of the customer.",
|
|
},
|
|
"total_amount": {
|
|
"type": "number",
|
|
"description": "The total amount of the invoice.",
|
|
},
|
|
},
|
|
"required": ["invoice_number", "customer_name", "total_amount"],
|
|
"additionalProperties": {"$ref": "#/components/schemas/Link"},
|
|
}
|
|
|
|
# Use our recursive replacement approach
|
|
result = _replace_ref_with_defs(add_props_schema)
|
|
|
|
# Check additional properties
|
|
add_props = result["additionalProperties"]
|
|
assert add_props["$ref"] == "#/$defs/Link"
|
|
|
|
def test_replace_ref_with_defs_with_bool_additional_properties(self):
|
|
"""Test replacing a bool 'additionalProperties'."""
|
|
|
|
add_props_schema = {
|
|
"description": "An invoice with a fixed header and a flexible set of line items.",
|
|
"type": "object",
|
|
"properties": {
|
|
"invoice_number": {
|
|
"type": "string",
|
|
"description": "The unique identifier for the invoice.",
|
|
},
|
|
"customer_name": {
|
|
"type": "string",
|
|
"description": "The name of the customer.",
|
|
},
|
|
"total_amount": {
|
|
"type": "number",
|
|
"description": "The total amount of the invoice.",
|
|
},
|
|
},
|
|
"required": ["invoice_number", "customer_name", "total_amount"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
# Use our recursive replacement approach
|
|
result = _replace_ref_with_defs(add_props_schema)
|
|
|
|
# Check additional properties
|
|
add_props = result["additionalProperties"]
|
|
assert add_props is False
|
|
|
|
def test_replace_ref_with_defs_with_inner_schema_additional_properties(self):
|
|
"""Test replacing a inner schema 'additionalProperties'."""
|
|
|
|
add_props_schema = {
|
|
"description": "An invoice with a fixed header and a flexible set of line items.",
|
|
"type": "object",
|
|
"properties": {
|
|
"invoice_number": {
|
|
"type": "string",
|
|
"description": "The unique identifier for the invoice.",
|
|
},
|
|
"customer_name": {
|
|
"type": "string",
|
|
"description": "The name of the customer.",
|
|
},
|
|
"total_amount": {
|
|
"type": "number",
|
|
"description": "The total amount of the invoice.",
|
|
},
|
|
},
|
|
"required": ["invoice_number", "customer_name", "total_amount"],
|
|
"additionalProperties": {
|
|
"type": "integer",
|
|
"format": "int32",
|
|
"description": "The total amount of the invoice.",
|
|
},
|
|
}
|
|
|
|
# Use our recursive replacement approach
|
|
result = _replace_ref_with_defs(add_props_schema)
|
|
|
|
# Check additional properties
|
|
add_props = result["additionalProperties"]
|
|
assert add_props == {
|
|
"type": "integer",
|
|
"format": "int32",
|
|
"description": "The total amount of the invoice.",
|
|
}
|
|
|
|
def test_parameter_collision_suffixing_logic(self):
|
|
"""Test the specific logic for parameter collision suffixing."""
|
|
# Create a route that would definitely cause collisions
|
|
route = HTTPRoute(
|
|
path="/test/{id}",
|
|
method="POST",
|
|
operation_id="test_collision",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="id", location="path", required=True, schema={"type": "string"}
|
|
),
|
|
ParameterInfo(
|
|
name="name",
|
|
location="query",
|
|
required=False,
|
|
schema={"type": "string"},
|
|
),
|
|
ParameterInfo(
|
|
name="name",
|
|
location="header",
|
|
required=False,
|
|
schema={"type": "string"},
|
|
),
|
|
],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "integer"},
|
|
"name": {"type": "string"},
|
|
"description": {"type": "string"},
|
|
},
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
# Check that all parameters are included with unique keys
|
|
param_keys = list(param_map.keys())
|
|
assert len(param_keys) == len(set(param_keys)) # All keys should be unique
|
|
|
|
# Should have some form of id and name parameters
|
|
id_keys = [key for key in param_keys if "id" in key]
|
|
name_keys = [key for key in param_keys if "name" in key]
|
|
|
|
assert len(id_keys) >= 2 # Path id and body id
|
|
assert len(name_keys) >= 3 # Query name, header name, and body name
|
|
|
|
# Check that locations are correctly mapped
|
|
path_params = [
|
|
key for key, mapping in param_map.items() if mapping["location"] == "path"
|
|
]
|
|
query_params = [
|
|
key for key, mapping in param_map.items() if mapping["location"] == "query"
|
|
]
|
|
header_params = [
|
|
key for key, mapping in param_map.items() if mapping["location"] == "header"
|
|
]
|
|
body_params = [
|
|
key for key, mapping in param_map.items() if mapping["location"] == "body"
|
|
]
|
|
|
|
assert len(path_params) == 1
|
|
assert len(query_params) == 1
|
|
assert len(header_params) == 1
|
|
assert len(body_params) >= 3 # id, name, description from body
|
|
|
|
|
|
class TestEdgeCases:
|
|
"""Test edge cases in schema processing."""
|
|
|
|
def test_empty_route(self):
|
|
"""Test schema processing with empty route."""
|
|
empty_route = HTTPRoute(
|
|
path="/empty",
|
|
method="GET",
|
|
operation_id="empty_op",
|
|
parameters=[],
|
|
)
|
|
|
|
combined_schema = _combine_schemas(empty_route)
|
|
|
|
assert combined_schema["type"] == "object"
|
|
assert combined_schema["properties"] == {}
|
|
assert combined_schema.get("required", []) == []
|
|
|
|
def test_route_without_request_body(self):
|
|
"""Test route with only parameters, no request body."""
|
|
route = HTTPRoute(
|
|
path="/test/{id}",
|
|
method="GET",
|
|
operation_id="test_get",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="id", location="path", required=True, schema={"type": "string"}
|
|
),
|
|
ParameterInfo(
|
|
name="filter",
|
|
location="query",
|
|
required=False,
|
|
schema={"type": "string"},
|
|
),
|
|
],
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
assert "id" in combined_schema["properties"]
|
|
assert "filter" in combined_schema["properties"]
|
|
assert len(param_map) == 2
|
|
|
|
def test_route_with_only_request_body(self):
|
|
"""Test route with only request body, no parameters."""
|
|
route = HTTPRoute(
|
|
path="/create",
|
|
method="POST",
|
|
operation_id="create_item",
|
|
parameters=[],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"description": {"type": "string"},
|
|
},
|
|
"required": ["name"],
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
assert "name" in combined_schema["properties"]
|
|
assert "description" in combined_schema["properties"]
|
|
assert "name" in combined_schema["required"]
|
|
assert len(param_map) == 2
|
|
|
|
def test_parameter_without_schema(self):
|
|
"""Test handling parameters without schema."""
|
|
# Use minimal schema to avoid validation error
|
|
route = HTTPRoute(
|
|
path="/test",
|
|
method="GET",
|
|
operation_id="test_no_schema",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="param1", location="query", required=False, schema={}
|
|
), # Empty schema
|
|
],
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
# Should handle gracefully
|
|
assert combined_schema["type"] == "object"
|
|
assert isinstance(param_map, dict)
|
|
|
|
def test_request_body_multiple_content_types(self):
|
|
"""Test request body with multiple content types."""
|
|
route = HTTPRoute(
|
|
path="/upload",
|
|
method="POST",
|
|
operation_id="upload_file",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {"metadata": {"type": "string"}},
|
|
},
|
|
"multipart/form-data": {
|
|
"type": "object",
|
|
"properties": {"file": {"type": "string", "format": "binary"}},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
|
|
combined_schema, param_map = _combine_schemas_and_map_params(route)
|
|
|
|
# Should use the first content type found
|
|
properties = combined_schema["properties"]
|
|
assert (
|
|
len(properties) > 0
|
|
) # Should have some properties from one of the content types
|
|
|
|
def test_oneof_reference_dereferenced(self):
|
|
"""Test that schemas referenced in oneOf are preserved and unused defs pruned."""
|
|
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"data": {"oneOf": [{"$ref": "#/$defs/TestSchema"}]}},
|
|
"$defs": {
|
|
"TestSchema": {"type": "string"},
|
|
"UnusedSchema": {"type": "number"},
|
|
},
|
|
}
|
|
|
|
result = compress_schema(schema)
|
|
|
|
# UnusedSchema should be pruned, TestSchema should be kept
|
|
assert "UnusedSchema" not in result.get("$defs", {})
|
|
assert result["$defs"]["TestSchema"] == {"type": "string"}
|
|
|
|
# $ref should be preserved in oneOf
|
|
assert result["properties"]["data"]["oneOf"] == [{"$ref": "#/$defs/TestSchema"}]
|
|
|
|
def test_anyof_reference_dereferenced(self):
|
|
"""Test that schemas referenced in anyOf are preserved and unused defs pruned."""
|
|
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"data": {"anyOf": [{"$ref": "#/$defs/TestSchema"}]}},
|
|
"$defs": {
|
|
"TestSchema": {"type": "string"},
|
|
"UnusedSchema": {"type": "number"},
|
|
},
|
|
}
|
|
|
|
result = compress_schema(schema)
|
|
|
|
# UnusedSchema should be pruned, TestSchema should be kept
|
|
assert "UnusedSchema" not in result.get("$defs", {})
|
|
assert result["$defs"]["TestSchema"] == {"type": "string"}
|
|
|
|
# $ref should be preserved in anyOf
|
|
assert result["properties"]["data"]["anyOf"] == [{"$ref": "#/$defs/TestSchema"}]
|
|
|
|
def test_allof_reference_dereferenced(self):
|
|
"""Test that schemas referenced in allOf are preserved and unused defs pruned."""
|
|
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"data": {"allOf": [{"$ref": "#/$defs/TestSchema"}]}},
|
|
"$defs": {
|
|
"TestSchema": {"type": "string"},
|
|
"UnusedSchema": {"type": "number"},
|
|
},
|
|
}
|
|
|
|
result = compress_schema(schema)
|
|
|
|
# UnusedSchema should be pruned, TestSchema should be kept
|
|
assert "UnusedSchema" not in result.get("$defs", {})
|
|
assert result["$defs"]["TestSchema"] == {"type": "string"}
|
|
|
|
# $ref should be preserved in allOf
|
|
assert result["properties"]["data"]["allOf"] == [{"$ref": "#/$defs/TestSchema"}]
|