mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-25 23:14:17 +02:00
* Fix circular reference crash in OpenAPI schemas (#3242, #1206, #1016) * chore: Update SDK documentation --------- Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
1094 lines
41 KiB
Python
1094 lines
41 KiB
Python
"""Comprehensive tests for transitive and nested reference handling (Issue #1372)."""
|
|
|
|
import httpx
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.utilities.openapi.models import (
|
|
HTTPRoute,
|
|
ParameterInfo,
|
|
RequestBodyInfo,
|
|
ResponseInfo,
|
|
)
|
|
from fastmcp.utilities.openapi.parser import parse_openapi_to_http_routes
|
|
from fastmcp.utilities.openapi.schemas import (
|
|
_combine_schemas_and_map_params,
|
|
_replace_ref_with_defs,
|
|
extract_output_schema_from_responses,
|
|
)
|
|
|
|
|
|
class TestTransitiveAndNestedReferences:
|
|
"""Comprehensive tests for transitive and nested reference handling (Issue #1372)."""
|
|
|
|
def test_nested_refs_in_schema_definitions_converted(self):
|
|
"""$refs inside schema definitions must be converted from OpenAPI to JSON Schema format."""
|
|
route = HTTPRoute(
|
|
path="/users/{id}",
|
|
method="POST",
|
|
operation_id="create_user",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="id", location="path", required=True, schema={"type": "string"}
|
|
)
|
|
],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {"user": {"$ref": "#/components/schemas/User"}},
|
|
}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"User": {
|
|
"type": "object",
|
|
"properties": {"profile": {"$ref": "#/components/schemas/Profile"}},
|
|
},
|
|
"Profile": {
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
},
|
|
},
|
|
)
|
|
|
|
combined_schema, _ = _combine_schemas_and_map_params(route)
|
|
|
|
# Root level refs should be converted
|
|
assert combined_schema["properties"]["user"]["$ref"] == "#/$defs/User"
|
|
|
|
# Refs inside schema definitions should also be converted
|
|
user_def = combined_schema["$defs"]["User"]
|
|
assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile"
|
|
|
|
def test_transitive_dependencies_in_response_schemas(self):
|
|
"""Transitive dependencies (A→B→C) must all be preserved in response schemas."""
|
|
# This mimics the exact structure reported in issue #1372
|
|
responses = {
|
|
"201": ResponseInfo(
|
|
description="User created",
|
|
content_schema={
|
|
"application/json": {"$ref": "#/components/schemas/User"}
|
|
},
|
|
)
|
|
}
|
|
|
|
schema_definitions = {
|
|
"User": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string"},
|
|
"profile": {"$ref": "#/components/schemas/Profile"},
|
|
},
|
|
"required": ["id", "profile"],
|
|
},
|
|
"Profile": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"address": {"$ref": "#/components/schemas/Address"},
|
|
},
|
|
"required": ["name", "address"],
|
|
},
|
|
"Address": {
|
|
"type": "object",
|
|
"properties": {
|
|
"street": {"type": "string"},
|
|
"city": {"type": "string"},
|
|
"zipcode": {"type": "string"},
|
|
},
|
|
"required": ["street", "city", "zipcode"],
|
|
},
|
|
}
|
|
|
|
result = extract_output_schema_from_responses(
|
|
responses, schema_definitions=schema_definitions, openapi_version="3.0.3"
|
|
)
|
|
|
|
# All transitive dependencies must be preserved
|
|
assert result is not None
|
|
assert "$defs" in result
|
|
assert "User" in result["$defs"], "User should be preserved"
|
|
assert "Profile" in result["$defs"], "Profile should be preserved"
|
|
assert "Address" in result["$defs"], "Address must be preserved (main bug)"
|
|
|
|
# All refs should be converted to #/$defs format
|
|
user_def = result["$defs"]["User"]
|
|
assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile"
|
|
|
|
profile_def = result["$defs"]["Profile"]
|
|
assert profile_def["properties"]["address"]["$ref"] == "#/$defs/Address"
|
|
|
|
def test_elongl_reported_case_xref_with_nullable_function(self):
|
|
"""Test the specific case reported by elongl with nullable function reference."""
|
|
responses = {
|
|
"200": ResponseInfo(
|
|
description="Success",
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/components/schemas/Xref"},
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
schema_definitions = {
|
|
"Xref": {
|
|
"type": "object",
|
|
"properties": {
|
|
"address": {"type": "string", "title": "Address"},
|
|
"type": {"type": "string", "title": "Type"},
|
|
"function": {
|
|
"anyOf": [
|
|
{"$ref": "#/components/schemas/Function"},
|
|
{"type": "null"},
|
|
]
|
|
},
|
|
},
|
|
"required": ["address", "type", "function"],
|
|
"title": "Xref",
|
|
},
|
|
"Function": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"address": {"type": "string"},
|
|
},
|
|
"title": "Function",
|
|
},
|
|
}
|
|
|
|
result = extract_output_schema_from_responses(
|
|
responses, schema_definitions=schema_definitions
|
|
)
|
|
|
|
# Function must be included in $defs
|
|
assert result is not None
|
|
assert "$defs" in result
|
|
assert "Xref" in result["$defs"], "Xref should be preserved"
|
|
assert "Function" in result["$defs"], (
|
|
"Function must be preserved (reported bug)"
|
|
)
|
|
|
|
# Refs in anyOf should be converted
|
|
xref_def = result["$defs"]["Xref"]
|
|
function_prop = xref_def["properties"]["function"]
|
|
assert function_prop["anyOf"][0]["$ref"] == "#/$defs/Function"
|
|
|
|
def test_tspicer_reported_case_profile_with_nested_refs(self):
|
|
"""Test the specific case reported by tspicer with Profile->countryCode->AccountInfo."""
|
|
route = HTTPRoute(
|
|
path="/profile",
|
|
method="POST",
|
|
operation_id="create_profile",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {"$ref": "#/components/schemas/Profile"}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"Profile": {
|
|
"type": "object",
|
|
"properties": {
|
|
"profileId": {"type": "integer"},
|
|
"countryCode": {"$ref": "#/components/schemas/countryCode"},
|
|
"accountInfo": {"$ref": "#/components/schemas/AccountInfo"},
|
|
},
|
|
},
|
|
"countryCode": {
|
|
"type": "string",
|
|
"enum": ["US", "UK", "CA", "AU"],
|
|
},
|
|
"AccountInfo": {
|
|
"type": "object",
|
|
"properties": {
|
|
"accountId": {"type": "string"},
|
|
"accountType": {"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
combined_schema, _ = _combine_schemas_and_map_params(route)
|
|
|
|
# All referenced schemas must be included in $defs
|
|
assert "Profile" in combined_schema["$defs"], "Profile should be preserved"
|
|
assert "countryCode" in combined_schema["$defs"], (
|
|
"countryCode must be preserved"
|
|
)
|
|
assert "AccountInfo" in combined_schema["$defs"], (
|
|
"AccountInfo must be preserved"
|
|
)
|
|
|
|
# All refs should be converted
|
|
profile_def = combined_schema["$defs"]["Profile"]
|
|
assert profile_def["properties"]["countryCode"]["$ref"] == "#/$defs/countryCode"
|
|
assert profile_def["properties"]["accountInfo"]["$ref"] == "#/$defs/AccountInfo"
|
|
|
|
def test_transitive_refs_in_request_body_schemas(self):
|
|
"""Transitive $refs in request body schemas must be preserved and converted."""
|
|
route = HTTPRoute(
|
|
path="/users",
|
|
method="POST",
|
|
operation_id="create_user",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {"$ref": "#/components/schemas/User"}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"User": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string"},
|
|
"profile": {"$ref": "#/components/schemas/Profile"},
|
|
},
|
|
"required": ["id", "profile"],
|
|
},
|
|
"Profile": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"address": {"$ref": "#/components/schemas/Address"},
|
|
},
|
|
"required": ["name", "address"],
|
|
},
|
|
"Address": {
|
|
"type": "object",
|
|
"properties": {
|
|
"street": {"type": "string"},
|
|
"city": {"type": "string"},
|
|
"zipcode": {"type": "string"},
|
|
},
|
|
"required": ["street", "city", "zipcode"],
|
|
},
|
|
},
|
|
)
|
|
|
|
combined_schema, _ = _combine_schemas_and_map_params(route)
|
|
|
|
# All transitive dependencies should be preserved
|
|
assert "User" in combined_schema["$defs"]
|
|
assert "Profile" in combined_schema["$defs"]
|
|
assert "Address" in combined_schema["$defs"]
|
|
|
|
# All internal refs should be converted to #/$defs format
|
|
user_def = combined_schema["$defs"]["User"]
|
|
assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile"
|
|
|
|
profile_def = combined_schema["$defs"]["Profile"]
|
|
assert profile_def["properties"]["address"]["$ref"] == "#/$defs/Address"
|
|
|
|
def test_refs_in_array_items_converted(self):
|
|
"""$refs inside array items must be converted from OpenAPI to JSON Schema format."""
|
|
route = HTTPRoute(
|
|
path="/users",
|
|
method="POST",
|
|
operation_id="create_users",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {
|
|
"users": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/components/schemas/User"},
|
|
}
|
|
},
|
|
}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"User": {
|
|
"type": "object",
|
|
"properties": {"profile": {"$ref": "#/components/schemas/Profile"}},
|
|
},
|
|
"Profile": {
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
},
|
|
},
|
|
)
|
|
|
|
combined_schema, _ = _combine_schemas_and_map_params(route)
|
|
|
|
# Array item refs should be converted
|
|
assert combined_schema["properties"]["users"]["items"]["$ref"] == "#/$defs/User"
|
|
|
|
# Nested refs should be converted
|
|
user_def = combined_schema["$defs"]["User"]
|
|
assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile"
|
|
|
|
def test_refs_in_composition_keywords_converted(self):
|
|
"""$refs inside oneOf/anyOf/allOf must be converted from OpenAPI to JSON Schema format."""
|
|
route = HTTPRoute(
|
|
path="/data",
|
|
method="POST",
|
|
operation_id="create_data",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {
|
|
"data": {
|
|
"oneOf": [
|
|
{"$ref": "#/components/schemas/TypeA"},
|
|
{"$ref": "#/components/schemas/TypeB"},
|
|
]
|
|
},
|
|
"alternate": {
|
|
"anyOf": [
|
|
{"$ref": "#/components/schemas/TypeC"},
|
|
{"$ref": "#/components/schemas/TypeD"},
|
|
]
|
|
},
|
|
"combined": {
|
|
"allOf": [
|
|
{"$ref": "#/components/schemas/BaseType"},
|
|
{"properties": {"extra": {"type": "string"}}},
|
|
]
|
|
},
|
|
},
|
|
}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"TypeA": {
|
|
"type": "object",
|
|
"properties": {"nested": {"$ref": "#/components/schemas/Nested"}},
|
|
},
|
|
"TypeB": {
|
|
"type": "object",
|
|
"properties": {"value": {"type": "string"}},
|
|
},
|
|
"TypeC": {"type": "string"},
|
|
"TypeD": {"type": "number"},
|
|
"BaseType": {
|
|
"type": "object",
|
|
"properties": {"base": {"type": "string"}},
|
|
},
|
|
"Nested": {"type": "string"},
|
|
},
|
|
)
|
|
|
|
combined_schema, _ = _combine_schemas_and_map_params(route)
|
|
|
|
# oneOf refs should be converted
|
|
oneof_refs = combined_schema["properties"]["data"]["oneOf"]
|
|
assert oneof_refs[0]["$ref"] == "#/$defs/TypeA"
|
|
assert oneof_refs[1]["$ref"] == "#/$defs/TypeB"
|
|
|
|
# anyOf refs should be converted
|
|
anyof_refs = combined_schema["properties"]["alternate"]["anyOf"]
|
|
assert anyof_refs[0]["$ref"] == "#/$defs/TypeC"
|
|
assert anyof_refs[1]["$ref"] == "#/$defs/TypeD"
|
|
|
|
# allOf refs should be converted
|
|
allof_refs = combined_schema["properties"]["combined"]["allOf"]
|
|
assert allof_refs[0]["$ref"] == "#/$defs/BaseType"
|
|
|
|
# Transitive refs should be converted
|
|
type_a_def = combined_schema["$defs"]["TypeA"]
|
|
assert type_a_def["properties"]["nested"]["$ref"] == "#/$defs/Nested"
|
|
|
|
def test_deeply_nested_transitive_refs_preserved(self):
|
|
"""Deeply nested transitive refs (A→B→C→D→E) must all be preserved."""
|
|
route = HTTPRoute(
|
|
path="/deep",
|
|
method="POST",
|
|
operation_id="create_deep",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {"$ref": "#/components/schemas/Level1"}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"Level1": {
|
|
"type": "object",
|
|
"properties": {"level2": {"$ref": "#/components/schemas/Level2"}},
|
|
},
|
|
"Level2": {
|
|
"type": "object",
|
|
"properties": {"level3": {"$ref": "#/components/schemas/Level3"}},
|
|
},
|
|
"Level3": {
|
|
"type": "object",
|
|
"properties": {"level4": {"$ref": "#/components/schemas/Level4"}},
|
|
},
|
|
"Level4": {
|
|
"type": "object",
|
|
"properties": {"level5": {"$ref": "#/components/schemas/Level5"}},
|
|
},
|
|
"Level5": {
|
|
"type": "object",
|
|
"properties": {"value": {"type": "string"}},
|
|
},
|
|
"UnusedSchema": {"type": "number"},
|
|
},
|
|
)
|
|
|
|
combined_schema, _ = _combine_schemas_and_map_params(route)
|
|
|
|
# All levels should be preserved
|
|
assert "Level1" in combined_schema["$defs"]
|
|
assert "Level2" in combined_schema["$defs"]
|
|
assert "Level3" in combined_schema["$defs"]
|
|
assert "Level4" in combined_schema["$defs"]
|
|
assert "Level5" in combined_schema["$defs"]
|
|
|
|
# Unused should be removed (pruning is allowed for unused schemas)
|
|
assert "UnusedSchema" not in combined_schema["$defs"]
|
|
|
|
# All refs should be converted
|
|
assert (
|
|
combined_schema["$defs"]["Level1"]["properties"]["level2"]["$ref"]
|
|
== "#/$defs/Level2"
|
|
)
|
|
assert (
|
|
combined_schema["$defs"]["Level2"]["properties"]["level3"]["$ref"]
|
|
== "#/$defs/Level3"
|
|
)
|
|
assert (
|
|
combined_schema["$defs"]["Level3"]["properties"]["level4"]["$ref"]
|
|
== "#/$defs/Level4"
|
|
)
|
|
assert (
|
|
combined_schema["$defs"]["Level4"]["properties"]["level5"]["$ref"]
|
|
== "#/$defs/Level5"
|
|
)
|
|
|
|
def test_circular_references_handled(self):
|
|
"""Circular references (A→B→A) must be handled without infinite loops."""
|
|
route = HTTPRoute(
|
|
path="/circular",
|
|
method="POST",
|
|
operation_id="circular_test",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {"$ref": "#/components/schemas/Node"}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"Node": {
|
|
"type": "object",
|
|
"properties": {
|
|
"value": {"type": "string"},
|
|
"children": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/components/schemas/Node"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
combined_schema, _ = _combine_schemas_and_map_params(route)
|
|
|
|
# Node should be preserved
|
|
assert "Node" in combined_schema["$defs"]
|
|
|
|
# Self-reference should be converted
|
|
node_def = combined_schema["$defs"]["Node"]
|
|
assert node_def["properties"]["children"]["items"]["$ref"] == "#/$defs/Node"
|
|
|
|
def test_multiple_reference_paths_to_same_schema(self):
|
|
"""Multiple paths to the same schema (diamond pattern) must preserve the schema."""
|
|
route = HTTPRoute(
|
|
path="/diamond",
|
|
method="POST",
|
|
operation_id="diamond_test",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {
|
|
"left": {"$ref": "#/components/schemas/Left"},
|
|
"right": {"$ref": "#/components/schemas/Right"},
|
|
},
|
|
}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"Left": {
|
|
"type": "object",
|
|
"properties": {"shared": {"$ref": "#/components/schemas/Shared"}},
|
|
},
|
|
"Right": {
|
|
"type": "object",
|
|
"properties": {"shared": {"$ref": "#/components/schemas/Shared"}},
|
|
},
|
|
"Shared": {
|
|
"type": "object",
|
|
"properties": {"value": {"type": "string"}},
|
|
},
|
|
},
|
|
)
|
|
|
|
combined_schema, _ = _combine_schemas_and_map_params(route)
|
|
|
|
# All schemas should be preserved
|
|
assert "Left" in combined_schema["$defs"]
|
|
assert "Right" in combined_schema["$defs"]
|
|
assert "Shared" in combined_schema["$defs"]
|
|
|
|
# All refs should be converted
|
|
assert combined_schema["properties"]["left"]["$ref"] == "#/$defs/Left"
|
|
assert combined_schema["properties"]["right"]["$ref"] == "#/$defs/Right"
|
|
assert (
|
|
combined_schema["$defs"]["Left"]["properties"]["shared"]["$ref"]
|
|
== "#/$defs/Shared"
|
|
)
|
|
assert (
|
|
combined_schema["$defs"]["Right"]["properties"]["shared"]["$ref"]
|
|
== "#/$defs/Shared"
|
|
)
|
|
|
|
def test_refs_in_nested_content_schemas(self):
|
|
"""$refs in nested content schemas (the original bug location) must be converted."""
|
|
route = HTTPRoute(
|
|
path="/content",
|
|
method="POST",
|
|
operation_id="content_test",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {"$ref": "#/components/schemas/Content"}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"Content": {
|
|
"type": "object",
|
|
"properties": {
|
|
"media": {
|
|
"type": "object",
|
|
"properties": {
|
|
"application/json": {
|
|
"$ref": "#/components/schemas/JsonContent"
|
|
}
|
|
},
|
|
}
|
|
},
|
|
},
|
|
"JsonContent": {
|
|
"type": "object",
|
|
"properties": {"data": {"type": "string"}},
|
|
},
|
|
},
|
|
)
|
|
|
|
combined_schema, _ = _combine_schemas_and_map_params(route)
|
|
|
|
# Both schemas should be preserved
|
|
assert "Content" in combined_schema["$defs"]
|
|
assert "JsonContent" in combined_schema["$defs"]
|
|
|
|
# Nested ref should be converted
|
|
content_def = combined_schema["$defs"]["Content"]
|
|
nested_ref = content_def["properties"]["media"]["properties"][
|
|
"application/json"
|
|
]
|
|
assert nested_ref["$ref"] == "#/$defs/JsonContent"
|
|
|
|
def test_unnecessary_defs_preserved_when_referenced(self):
|
|
"""Even seemingly unnecessary $defs must be preserved if they're referenced."""
|
|
route = HTTPRoute(
|
|
path="/test",
|
|
method="POST",
|
|
operation_id="test_unnecessary",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {
|
|
# Reference to a simple type schema
|
|
"simple": {"$ref": "#/components/schemas/SimpleString"},
|
|
# Reference to an empty object schema
|
|
"empty": {"$ref": "#/components/schemas/EmptyObject"},
|
|
},
|
|
}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"SimpleString": {"type": "string"},
|
|
"EmptyObject": {"type": "object"},
|
|
"UnreferencedSchema": {"type": "number"},
|
|
},
|
|
)
|
|
|
|
combined_schema, _ = _combine_schemas_and_map_params(route)
|
|
|
|
# Referenced schemas should be preserved even if simple
|
|
assert "SimpleString" in combined_schema["$defs"]
|
|
assert "EmptyObject" in combined_schema["$defs"]
|
|
|
|
# Unreferenced should be removed
|
|
assert "UnreferencedSchema" not in combined_schema["$defs"]
|
|
|
|
# Refs should be converted
|
|
assert combined_schema["properties"]["simple"]["$ref"] == "#/$defs/SimpleString"
|
|
assert combined_schema["properties"]["empty"]["$ref"] == "#/$defs/EmptyObject"
|
|
|
|
def test_ref_only_request_body_handled(self):
|
|
"""Request bodies that are just a $ref (not an object with properties) must work."""
|
|
route = HTTPRoute(
|
|
path="/direct-ref",
|
|
method="POST",
|
|
operation_id="direct_ref_test",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
# Direct $ref, not wrapped in an object
|
|
"application/json": {"$ref": "#/components/schemas/DirectBody"}
|
|
},
|
|
),
|
|
request_schemas={
|
|
"DirectBody": {
|
|
"type": "object",
|
|
"properties": {
|
|
"field1": {"type": "string"},
|
|
"nested": {"$ref": "#/components/schemas/NestedBody"},
|
|
},
|
|
},
|
|
"NestedBody": {
|
|
"type": "object",
|
|
"properties": {"field2": {"type": "number"}},
|
|
},
|
|
},
|
|
)
|
|
|
|
combined_schema, _ = _combine_schemas_and_map_params(route)
|
|
|
|
# Should handle the direct ref properly
|
|
assert "body" in combined_schema["properties"]
|
|
assert combined_schema["properties"]["body"]["$ref"] == "#/$defs/DirectBody"
|
|
|
|
# Both schemas should be preserved
|
|
assert "DirectBody" in combined_schema["$defs"]
|
|
assert "NestedBody" in combined_schema["$defs"]
|
|
|
|
# Nested ref should be converted
|
|
assert (
|
|
combined_schema["$defs"]["DirectBody"]["properties"]["nested"]["$ref"]
|
|
== "#/$defs/NestedBody"
|
|
)
|
|
|
|
def test_separate_input_output_schemas(self):
|
|
"""Test that input and output schemas contain different schema
|
|
definitions and don't overlap in the ultimate schema definitions."""
|
|
# OpenAPI spec with transitive dependencies to force schema inclusion
|
|
openapi_spec = {
|
|
"openapi": "3.0.1",
|
|
"info": {"title": "Test API", "version": "1.0.0"},
|
|
"paths": {
|
|
"/test": {
|
|
"post": {
|
|
"summary": "Test endpoint",
|
|
"requestBody": {
|
|
"content": {
|
|
"application/json": {
|
|
"schema": {
|
|
"$ref": "#/components/schemas/InputContainer"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"responses": {
|
|
"200": {
|
|
"description": "Success",
|
|
"content": {
|
|
"application/json": {
|
|
"schema": {
|
|
"$ref": "#/components/schemas/OutputContainer"
|
|
}
|
|
}
|
|
},
|
|
}
|
|
},
|
|
}
|
|
}
|
|
},
|
|
"components": {
|
|
"schemas": {
|
|
"InputContainer": {
|
|
"type": "object",
|
|
"properties": {
|
|
"data": {"$ref": "#/components/schemas/InputData"}
|
|
},
|
|
},
|
|
"InputData": {
|
|
"type": "object",
|
|
"properties": {"input_field": {"type": "string"}},
|
|
},
|
|
"OutputContainer": {
|
|
"type": "object",
|
|
"properties": {
|
|
"result": {"$ref": "#/components/schemas/OutputData"}
|
|
},
|
|
},
|
|
"OutputData": {
|
|
"type": "object",
|
|
"properties": {"output_field": {"type": "string"}},
|
|
},
|
|
"UnusedSchema": {
|
|
"type": "object",
|
|
"properties": {"unused_field": {"type": "string"}},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
|
|
routes = parse_openapi_to_http_routes(openapi_spec)
|
|
assert len(routes) == 1
|
|
|
|
route = routes[0]
|
|
|
|
# Check that schemas are properly separated
|
|
input_schema_names = set(route.request_schemas.keys())
|
|
output_schema_names = set(route.response_schemas.keys())
|
|
|
|
# Input should contain transitive dependencies from InputContainer
|
|
assert "InputData" in input_schema_names, (
|
|
f"Expected InputData in request schemas: {input_schema_names}"
|
|
)
|
|
assert "OutputContainer" not in input_schema_names, (
|
|
"OutputContainer should not be in request schemas"
|
|
)
|
|
assert "OutputData" not in input_schema_names, (
|
|
"OutputData should not be in request schemas"
|
|
)
|
|
|
|
# Output should contain transitive dependencies from OutputContainer
|
|
assert "OutputData" in output_schema_names, (
|
|
f"Expected OutputData in response schemas: {output_schema_names}"
|
|
)
|
|
assert "InputContainer" not in output_schema_names, (
|
|
"InputContainer should not be in response schemas"
|
|
)
|
|
assert "InputData" not in output_schema_names, (
|
|
"InputData should not be in response schemas"
|
|
)
|
|
|
|
# Neither should contain unused schema
|
|
assert "UnusedSchema" not in input_schema_names, (
|
|
"UnusedSchema should not be in request schemas"
|
|
)
|
|
assert "UnusedSchema" not in output_schema_names, (
|
|
"UnusedSchema should not be in response schemas"
|
|
)
|
|
|
|
# Verify no overlap
|
|
overlap = input_schema_names & output_schema_names
|
|
assert len(overlap) == 0, (
|
|
f"Found overlapping schemas between input and output: {overlap}"
|
|
)
|
|
|
|
def test_issue_2087_top_level_response_ref_includes_all_nested_schemas(self):
|
|
"""Issue #2087: Top-level response $ref must include itself in response_schemas."""
|
|
spec = {
|
|
"openapi": "3.0.1",
|
|
"info": {"title": "Test", "version": "1.0"},
|
|
"paths": {
|
|
"/persons": {
|
|
"get": {
|
|
"responses": {
|
|
"200": {
|
|
"description": "OK",
|
|
"content": {
|
|
"application/json": {
|
|
"schema": {
|
|
"$ref": "#/components/schemas/PersonList"
|
|
}
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"components": {
|
|
"schemas": {
|
|
"PersonList": {
|
|
"properties": {
|
|
"Items": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/components/schemas/Person"},
|
|
}
|
|
}
|
|
},
|
|
"Person": {
|
|
"properties": {
|
|
"Name": {"$ref": "#/components/schemas/Name"},
|
|
"Job": {"$ref": "#/components/schemas/Job"},
|
|
}
|
|
},
|
|
"Name": {"properties": {"First": {"type": "string"}}},
|
|
"Job": {"properties": {"Company": {"type": "string"}}},
|
|
}
|
|
},
|
|
}
|
|
|
|
routes = parse_openapi_to_http_routes(spec)
|
|
route = routes[0]
|
|
|
|
# Bug was: PersonList missing from response_schemas despite being top-level ref
|
|
assert "PersonList" in route.response_schemas
|
|
assert "Person" in route.response_schemas
|
|
assert "Name" in route.response_schemas
|
|
assert "Job" in route.response_schemas
|
|
|
|
|
|
class TestCircularReferencesSerialization:
|
|
"""Tests for circular/self-referential schemas surviving MCP serialization.
|
|
|
|
Issues: #1016, #1206, #3242
|
|
|
|
The crash occurs when Pydantic's model_dump() encounters the same Python
|
|
dict object at multiple positions in the serialization tree. This happens
|
|
because _replace_ref_with_defs mutates shared list objects (anyOf/allOf/oneOf)
|
|
in place via shallow copy, causing different tools to share internal dict
|
|
references.
|
|
"""
|
|
|
|
def test_replace_ref_with_defs_does_not_mutate_input(self):
|
|
"""_replace_ref_with_defs must not mutate its input dict's lists."""
|
|
schema = {
|
|
"oneOf": [
|
|
{"$ref": "#/components/schemas/Cat"},
|
|
{"$ref": "#/components/schemas/Dog"},
|
|
]
|
|
}
|
|
original_list = schema["oneOf"]
|
|
original_items = list(original_list) # snapshot
|
|
|
|
_replace_ref_with_defs(schema)
|
|
|
|
# The original list object must not have been mutated
|
|
assert original_list is schema["oneOf"]
|
|
assert original_list == original_items
|
|
|
|
def test_replace_ref_with_defs_produces_independent_results(self):
|
|
"""Calling _replace_ref_with_defs twice on the same input must produce
|
|
independent dict trees with no shared mutable objects."""
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"pet": {
|
|
"oneOf": [
|
|
{"$ref": "#/components/schemas/Cat"},
|
|
{"$ref": "#/components/schemas/Dog"},
|
|
]
|
|
}
|
|
},
|
|
}
|
|
|
|
result1 = _replace_ref_with_defs(schema)
|
|
result2 = _replace_ref_with_defs(schema)
|
|
|
|
# The oneOf lists should be different objects
|
|
list1 = result1["properties"]["pet"]["oneOf"]
|
|
list2 = result2["properties"]["pet"]["oneOf"]
|
|
assert list1 is not list2
|
|
|
|
# Items within the lists should also be independent
|
|
assert list1[0] is not list2[0]
|
|
|
|
def test_circular_output_schema_serialization(self):
|
|
"""Output schemas with self-referential types must survive model_dump().
|
|
|
|
This is the exact crash from issue #3242: Pydantic raises
|
|
ValueError('Circular reference detected (id repeated)') when
|
|
serializing MCP Tool objects whose schemas share Python dict references.
|
|
"""
|
|
responses = {
|
|
"200": ResponseInfo(
|
|
description="A tree node",
|
|
content_schema={
|
|
"application/json": {"$ref": "#/components/schemas/Node"}
|
|
},
|
|
)
|
|
}
|
|
schema_definitions = {
|
|
"Node": {
|
|
"type": "object",
|
|
"properties": {
|
|
"value": {"type": "string"},
|
|
"children": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/components/schemas/Node"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
output_schema = extract_output_schema_from_responses(
|
|
responses, schema_definitions=schema_definitions, openapi_version="3.0.0"
|
|
)
|
|
assert output_schema is not None
|
|
|
|
# Build an MCP Tool with this schema and try to serialize it —
|
|
# this is the exact path that crashes in the reported issue.
|
|
from mcp.types import Tool as MCPTool
|
|
|
|
tool = MCPTool(
|
|
name="get_node",
|
|
description="Get a node",
|
|
inputSchema={"type": "object", "properties": {}},
|
|
outputSchema=output_schema,
|
|
)
|
|
# This must not raise ValueError: Circular reference detected
|
|
tool.model_dump(by_alias=True, mode="json", exclude_none=True)
|
|
|
|
def test_mutual_circular_references_serialization(self):
|
|
"""Mutually circular schemas (A→B→A) must survive serialization."""
|
|
responses = {
|
|
"200": ResponseInfo(
|
|
description="A pull request",
|
|
content_schema={
|
|
"application/json": {"$ref": "#/components/schemas/PullRequest"}
|
|
},
|
|
)
|
|
}
|
|
schema_definitions = {
|
|
"PullRequest": {
|
|
"type": "object",
|
|
"properties": {
|
|
"title": {"type": "string"},
|
|
"author": {"$ref": "#/components/schemas/User"},
|
|
},
|
|
},
|
|
"User": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"pull_requests": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/components/schemas/PullRequest"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
output_schema = extract_output_schema_from_responses(
|
|
responses, schema_definitions=schema_definitions, openapi_version="3.0.0"
|
|
)
|
|
assert output_schema is not None
|
|
|
|
from mcp.types import Tool as MCPTool
|
|
|
|
tool = MCPTool(
|
|
name="get_pr",
|
|
description="Get a pull request",
|
|
inputSchema={"type": "object", "properties": {}},
|
|
outputSchema=output_schema,
|
|
)
|
|
tool.model_dump(by_alias=True, mode="json", exclude_none=True)
|
|
|
|
async def test_multiple_tools_sharing_circular_schemas(self):
|
|
"""Multiple tools from the same spec must not share Python dict objects
|
|
in their schemas, which would cause Pydantic to raise circular reference
|
|
errors when serializing the list_tools response."""
|
|
spec = {
|
|
"openapi": "3.0.0",
|
|
"info": {"title": "Test API", "version": "1.0.0"},
|
|
"paths": {
|
|
"/nodes": {
|
|
"get": {
|
|
"operationId": "list_nodes",
|
|
"responses": {
|
|
"200": {
|
|
"description": "List of nodes",
|
|
"content": {
|
|
"application/json": {
|
|
"schema": {
|
|
"type": "array",
|
|
"items": {
|
|
"$ref": "#/components/schemas/Node"
|
|
},
|
|
}
|
|
}
|
|
},
|
|
}
|
|
},
|
|
},
|
|
"post": {
|
|
"operationId": "create_node",
|
|
"requestBody": {
|
|
"required": True,
|
|
"content": {
|
|
"application/json": {
|
|
"schema": {"$ref": "#/components/schemas/Node"}
|
|
}
|
|
},
|
|
},
|
|
"responses": {
|
|
"201": {
|
|
"description": "Created node",
|
|
"content": {
|
|
"application/json": {
|
|
"schema": {"$ref": "#/components/schemas/Node"}
|
|
}
|
|
},
|
|
}
|
|
},
|
|
},
|
|
},
|
|
"/nodes/{id}": {
|
|
"get": {
|
|
"operationId": "get_node",
|
|
"parameters": [
|
|
{
|
|
"name": "id",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
],
|
|
"responses": {
|
|
"200": {
|
|
"description": "A node",
|
|
"content": {
|
|
"application/json": {
|
|
"schema": {"$ref": "#/components/schemas/Node"}
|
|
}
|
|
},
|
|
}
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"components": {
|
|
"schemas": {
|
|
"Node": {
|
|
"type": "object",
|
|
"properties": {
|
|
"value": {"type": "string"},
|
|
"children": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/components/schemas/Node"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
server = FastMCP.from_openapi(spec, httpx.AsyncClient())
|
|
tools = await server.list_tools()
|
|
assert len(tools) >= 3
|
|
|
|
# Simulate what the MCP SDK does: serialize all tools together.
|
|
# This is the exact crash path — model_dump on a list of tools
|
|
# whose schemas share Python dict objects.
|
|
|
|
mcp_tools = [tool.to_mcp_tool(name=tool.name) for tool in tools]
|
|
for mcp_tool in mcp_tools:
|
|
mcp_tool.model_dump(by_alias=True, mode="json", exclude_none=True)
|