Fix OpenAPI transitive references and performance (#1372) (#1426)

This commit is contained in:
Jeremiah Lowin 2025-08-09 20:53:51 -04:00 committed by GitHub
commit 558c7d2a66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1132 additions and 150 deletions

View file

@ -15,6 +15,7 @@ dependencies = [
"pydantic[email]>=2.11.7",
"pyperclip>=1.9.0",
"openapi-core>=0.19.5",
"msgspec>=0.19.0",
]
requires-python = ">=3.10"
readme = "README.md"

View file

@ -113,7 +113,7 @@ def _determine_route_type(
# We know mcp_type is not None here due to post_init validation
assert route_map.mcp_type is not None
logger.debug(
f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}"
f"Route {route.method} {route.path} mapped to {route_map.mcp_type.name}"
)
return route_map

View file

@ -151,9 +151,6 @@ class FastMCPOpenAPI(FastMCP):
try:
self._spec = SchemaPath.from_dict(openapi_spec) # type: ignore[arg-type]
self._director = RequestDirector(self._spec)
logger.debug(
"Initialized OpenAPI RequestDirector for stateless request building"
)
except Exception as e:
logger.error(f"Failed to initialize RequestDirector: {e}")
raise ValueError(f"Invalid OpenAPI specification: {e}") from e
@ -318,9 +315,6 @@ class FastMCPOpenAPI(FastMCP):
# Register the tool by directly assigning to the tools dictionary
self._tool_manager._tools[final_tool_name] = tool
logger.debug(
f"Registered TOOL: {final_tool_name} ({route.method} {route.path}) with tags: {route.tags}"
)
def _create_openapi_resource(
self,
@ -372,9 +366,6 @@ class FastMCPOpenAPI(FastMCP):
# Register the resource by directly assigning to the resources dictionary
self._resource_manager._resources[final_resource_uri] = resource
logger.debug(
f"Registered RESOURCE: {final_resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
)
def _create_openapi_template(
self,
@ -455,9 +446,6 @@ class FastMCPOpenAPI(FastMCP):
# Register the template by directly assigning to the templates dictionary
self._resource_manager._templates[final_template_uri] = template
logger.debug(
f"Registered TEMPLATE: {final_template_uri} ({route.method} {route.path}) with tags: {route.tags}"
)
# Export public symbols

View file

@ -28,7 +28,6 @@ from .schemas import (
_combine_schemas,
extract_output_schema_from_responses,
clean_schema_for_display,
_replace_ref_with_defs,
_make_optional_parameter_nullable,
)
@ -60,7 +59,6 @@ __all__ = [
"_combine_schemas",
"extract_output_schema_from_responses",
"clean_schema_for_display",
"_replace_ref_with_defs",
"_make_optional_parameter_nullable",
# JSON Schema Converter
"convert_openapi_schema_to_json_schema",

View file

@ -34,7 +34,10 @@ from .models import (
RequestBodyInfo,
ResponseInfo,
)
from .schemas import _combine_schemas_and_map_params, _replace_ref_with_defs
from .schemas import (
_combine_schemas_and_map_params,
_replace_ref_with_defs_recursive,
)
logger = get_logger(__name__)
@ -63,7 +66,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
if openapi_version.startswith("3.0"):
# Use OpenAPI 3.0 models
openapi_30 = OpenAPI_30.model_validate(openapi_dict)
logger.info(
logger.debug(
f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
)
parser = OpenAPIParser(
@ -81,7 +84,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
else:
# Default to OpenAPI 3.1 models
openapi_31 = OpenAPI.model_validate(openapi_dict)
logger.info(
logger.debug(
f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
)
parser = OpenAPIParser(
@ -207,7 +210,7 @@ class OpenAPIParser(
try:
resolved_schema = self._resolve_ref(schema_obj)
if isinstance(resolved_schema, (self.schema_cls)):
if isinstance(resolved_schema, self.schema_cls):
# Convert schema to dictionary
result = resolved_schema.model_dump(
mode="json", by_alias=True, exclude_none=True
@ -220,7 +223,10 @@ class OpenAPIParser(
)
result = {}
return _replace_ref_with_defs(result)
# Convert refs from OpenAPI format to JSON Schema format using recursive approach
result = _replace_ref_with_defs_recursive(result)
return result
except ValueError as e:
# Re-raise ValueError for external reference errors and other validation issues
if "External or non-local reference not supported" in str(e):
@ -470,6 +476,102 @@ class OpenAPIParser(
return extracted_responses
def _extract_schema_dependencies(
self,
schema: dict,
all_schemas: dict[str, Any],
collected: set[str] | None = None,
) -> set[str]:
"""
Extract all schema names referenced by a schema (including transitive dependencies).
Args:
schema: The schema to analyze
all_schemas: All available schema definitions
collected: Set of already collected schema names (for recursion)
Returns:
Set of schema names that are referenced
"""
if collected is None:
collected = set()
def find_refs(obj):
"""Recursively find all $ref references."""
if isinstance(obj, dict):
if "$ref" in obj and isinstance(obj["$ref"], str):
ref = obj["$ref"]
# Handle both converted and unconverted refs
if ref.startswith("#/$defs/"):
schema_name = ref.split("/")[-1]
elif ref.startswith("#/components/schemas/"):
schema_name = ref.split("/")[-1]
else:
return
# Add this schema and recursively find its dependencies
if schema_name not in collected and schema_name in all_schemas:
collected.add(schema_name)
# Recursively find dependencies of this schema
find_refs(all_schemas[schema_name])
# Continue searching in all values
for value in obj.values():
find_refs(value)
elif isinstance(obj, list):
for item in obj:
find_refs(item)
find_refs(schema)
return collected
def _extract_route_schema_dependencies(
self,
parameters: list[ParameterInfo],
request_body: RequestBodyInfo | None,
responses: dict[str, ResponseInfo],
all_schemas: dict[str, Any],
) -> dict[str, Any]:
"""
Extract only the schema definitions needed for a specific route.
Args:
parameters: Route parameters
request_body: Route request body
responses: Route responses
all_schemas: All available schema definitions
Returns:
Dictionary containing only the schemas needed for this route
"""
needed_schemas = set()
# Check parameters for schema references
for param in parameters:
if param.schema_:
deps = self._extract_schema_dependencies(param.schema_, all_schemas)
needed_schemas.update(deps)
# Check request body for schema references
if request_body and request_body.content_schema:
for content_schema in request_body.content_schema.values():
deps = self._extract_schema_dependencies(content_schema, all_schemas)
needed_schemas.update(deps)
# Check responses for schema references
for response in responses.values():
if response.content_schema:
for content_schema in response.content_schema.values():
deps = self._extract_schema_dependencies(
content_schema, all_schemas
)
needed_schemas.update(deps)
# Return only the needed schemas
return {
name: all_schemas[name] for name in needed_schemas if name in all_schemas
}
def parse(self) -> list[HTTPRoute]:
"""Parse the OpenAPI schema into HTTP routes."""
routes: list[HTTPRoute] = []
@ -499,6 +601,13 @@ class OpenAPIParser(
f"Failed to extract schema definition '{name}': {e}"
)
# Convert schema definitions refs from OpenAPI to JSON Schema format (once)
if schema_definitions:
# Convert each schema definition recursively
for name, schema in schema_definitions.items():
if isinstance(schema, dict):
schema_definitions[name] = _replace_ref_with_defs_recursive(schema)
# Process paths and operations
for path_str, path_item_obj in self.openapi.paths.items():
if not isinstance(path_item_obj, self.path_item_cls):
@ -552,6 +661,14 @@ class OpenAPIParser(
if k.startswith("x-")
}
# Extract only the schemas needed for this route
route_schemas = self._extract_route_schema_dependencies(
parameters,
request_body_info,
responses,
schema_definitions,
)
# Create initial route without pre-calculated fields
route = HTTPRoute(
path=path_str,
@ -563,7 +680,7 @@ class OpenAPIParser(
parameters=parameters,
request_body=request_body_info,
responses=responses,
schema_definitions=schema_definitions,
schema_definitions=route_schemas, # Use pre-pruned schemas
extensions=extensions,
openapi_version=self.openapi_version,
)
@ -571,7 +688,8 @@ class OpenAPIParser(
# Pre-calculate schema and parameter mapping for performance
try:
flat_schema, param_map = _combine_schemas_and_map_params(
route
route,
convert_refs=False, # Parser already converted refs
)
route.flat_param_schema = flat_schema
route.parameter_map = param_map
@ -586,9 +704,6 @@ class OpenAPIParser(
}
route.parameter_map = {}
routes.append(route)
logger.info(
f"Successfully extracted route: {method_upper} {path_str}"
)
except ValueError as op_error:
# Re-raise ValueError for external reference errors
if "External or non-local reference not supported" in str(
@ -607,7 +722,7 @@ class OpenAPIParser(
exc_info=True,
)
logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
logger.debug(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
return routes

View file

@ -71,11 +71,11 @@ def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
return cleaned
def _replace_ref_with_defs(
def _replace_ref_with_defs_recursive(
info: dict[str, Any], description: str | None = None
) -> dict[str, Any]:
"""
Replace openapi $ref with jsonschema $defs
Replace openapi $ref with jsonschema $defs recursively.
Examples:
- {"type": "object", "properties": {"$ref": "#/components/schemas/..."}}
@ -106,22 +106,35 @@ def _replace_ref_with_defs(
)
elif properties := schema.get("properties"):
if "$ref" in properties:
schema["properties"] = _replace_ref_with_defs(properties)
schema["properties"] = _replace_ref_with_defs_recursive(properties)
else:
schema["properties"] = {
prop_name: _replace_ref_with_defs(prop_schema)
prop_name: _replace_ref_with_defs_recursive(prop_schema)
for prop_name, prop_schema in properties.items()
}
elif item_schema := schema.get("items"):
schema["items"] = _replace_ref_with_defs(item_schema)
schema["items"] = _replace_ref_with_defs_recursive(item_schema)
for section in ["anyOf", "allOf", "oneOf"]:
for i, item in enumerate(schema.get(section, [])):
schema[section][i] = _replace_ref_with_defs(item)
schema[section][i] = _replace_ref_with_defs_recursive(item)
if info.get("description", description) and not schema.get("description"):
schema["description"] = description
return schema
def _ensure_refs_converted(schema: dict[str, Any]) -> dict[str, Any]:
"""
Ensure all OpenAPI refs are converted to JSON Schema format using recursive approach.
Args:
schema: Schema that may contain OpenAPI refs
Returns:
Schema with all refs converted to JSON Schema format
"""
return _replace_ref_with_defs_recursive(schema)
def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
"""
Make an optional parameter schema nullable to allow None values.
@ -202,6 +215,7 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
def _combine_schemas_and_map_params(
route: HTTPRoute,
convert_refs: bool = True,
) -> tuple[dict[str, Any], dict[str, dict[str, str]]]:
"""
Combines parameter and request body schemas into a single schema.
@ -233,10 +247,17 @@ def _combine_schemas_and_map_params(
if route.request_body and route.request_body.content_schema:
content_type = next(iter(route.request_body.content_schema))
body_schema = _replace_ref_with_defs(
route.request_body.content_schema[content_type].copy(),
route.request_body.description,
)
# Convert refs if needed
if convert_refs:
body_schema = _ensure_refs_converted(
route.request_body.content_schema[content_type]
)
else:
body_schema = route.request_body.content_schema[content_type]
if route.request_body.description and not body_schema.get("description"):
body_schema["description"] = route.request_body.description
# Handle allOf at the top level by merging all schemas
if "allOf" in body_schema and isinstance(body_schema["allOf"], list):
@ -287,10 +308,11 @@ def _combine_schemas_and_map_params(
"openapi_name": param.name,
}
# Add location info to description
param_schema = _replace_ref_with_defs(
param.schema_.copy(), param.description
)
# Convert refs if needed
if convert_refs:
param_schema = _ensure_refs_converted(param.schema_)
else:
param_schema = param.schema_
original_desc = param_schema.get("description", "")
location_desc = f"({param.location.capitalize()} parameter)"
if original_desc:
@ -313,9 +335,11 @@ def _combine_schemas_and_map_params(
"openapi_name": param.name,
}
param_schema = _replace_ref_with_defs(
param.schema_.copy(), param.description
)
# Convert refs if needed
if convert_refs:
param_schema = _ensure_refs_converted(param.schema_)
else:
param_schema = param.schema_
# Don't make optional parameters nullable - they can simply be omitted
# The OpenAPI specification doesn't require optional parameters to accept null values
@ -324,14 +348,28 @@ def _combine_schemas_and_map_params(
# Add request body properties (no suffixes for body parameters)
if route.request_body and route.request_body.content_schema:
for prop_name, prop_schema in body_props.items():
properties[prop_name] = prop_schema
# If body is just a $ref, we need to handle it differently
if "$ref" in body_schema and not body_props:
# The entire body is a reference to a schema
# We need to expand this inline or keep the ref
# For simplicity, we'll keep it as a single property
properties["body"] = body_schema
if route.request_body.required:
required.append("body")
parameter_map["body"] = {"location": "body", "openapi_name": "body"}
else:
# Normal case: body has properties
for prop_name, prop_schema in body_props.items():
properties[prop_name] = prop_schema
# Track parameter mapping for body properties
parameter_map[prop_name] = {"location": "body", "openapi_name": prop_name}
# Track parameter mapping for body properties
parameter_map[prop_name] = {
"location": "body",
"openapi_name": prop_name,
}
if route.request_body.required:
required.extend(body_schema.get("required", []))
if route.request_body.required:
required.extend(body_schema.get("required", []))
result = {
"type": "object",
@ -340,42 +378,51 @@ def _combine_schemas_and_map_params(
}
# Add schema definitions if available
if route.schema_definitions:
result["$defs"] = route.schema_definitions.copy()
if convert_refs:
# Need to convert refs and prune
all_defs = route.schema_definitions.copy()
# Convert each schema definition recursively
for name, schema in all_defs.items():
if isinstance(schema, dict):
all_defs[name] = _replace_ref_with_defs_recursive(schema)
# Use lightweight compression - prune additionalProperties and unused definitions
if result.get("additionalProperties") is False:
result.pop("additionalProperties")
# Prune to only needed schemas
used_refs = set()
# Remove unused definitions (lightweight approach - just check direct $ref usage)
if "$defs" in result:
used_refs = set()
def find_refs_in_value(value):
"""Recursively find all $ref references."""
if isinstance(value, dict):
if "$ref" in value and isinstance(value["$ref"], str):
ref = value["$ref"]
if ref.startswith("#/$defs/"):
used_refs.add(ref.split("/")[-1])
for v in value.values():
find_refs_in_value(v)
elif isinstance(value, list):
for item in value:
find_refs_in_value(item)
def find_refs_in_value(value):
if isinstance(value, dict):
if "$ref" in value and isinstance(value["$ref"], str):
ref = value["$ref"]
if ref.startswith("#/$defs/"):
used_refs.add(ref.split("/")[-1])
for v in value.values():
find_refs_in_value(v)
elif isinstance(value, list):
for item in value:
find_refs_in_value(item)
# Find refs in properties
find_refs_in_value(properties)
# Find refs in the main schema (excluding $defs section)
for key, value in result.items():
if key != "$defs":
find_refs_in_value(value)
# Collect transitive dependencies
if used_refs:
collected_all = False
while not collected_all:
initial_count = len(used_refs)
for name in list(used_refs):
if name in all_defs:
find_refs_in_value(all_defs[name])
collected_all = len(used_refs) == initial_count
# Remove unused definitions
if used_refs:
result["$defs"] = {
name: def_schema
for name, def_schema in result["$defs"].items()
if name in used_refs
}
result["$defs"] = {
name: def_schema
for name, def_schema in all_defs.items()
if name in used_refs
}
else:
result.pop("$defs")
# From parser - already converted and pruned
result["$defs"] = route.schema_definitions
return result, parameter_map
@ -466,17 +513,17 @@ def extract_output_schema_from_responses(
if not schema or not isinstance(schema, dict):
return None
# Clean and copy the schema
output_schema = schema.copy()
# Convert refs if needed
output_schema = _ensure_refs_converted(schema)
# If schema has a $ref, resolve it first before processing nullable fields
if "$ref" in output_schema and schema_definitions:
ref_path = output_schema["$ref"]
if ref_path.startswith("#/components/schemas/"):
if ref_path.startswith("#/$defs/"):
schema_name = ref_path.split("/")[-1]
if schema_name in schema_definitions:
# Replace $ref with the actual schema definition
output_schema = schema_definitions[schema_name].copy()
output_schema = _ensure_refs_converted(schema_definitions[schema_name])
# Convert OpenAPI schema to JSON Schema format
# Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
@ -499,57 +546,26 @@ def extract_output_schema_from_responses(
}
output_schema = wrapped_schema
# Add schema definitions if available and handle nullable fields in them
# Only add $defs if we didn't resolve the $ref inline above
if schema_definitions and "$ref" not in schema.copy():
processed_defs = {}
for def_name, def_schema in schema_definitions.items():
# Convert OpenAPI schema definitions to JSON Schema format
if openapi_version and openapi_version.startswith("3.0"):
from .json_schema_converter import convert_openapi_schema_to_json_schema
# Add schema definitions if available
if schema_definitions:
# Convert refs if needed
processed_defs = schema_definitions.copy()
# Convert each schema definition recursively
for name, schema in processed_defs.items():
if isinstance(schema, dict):
processed_defs[name] = _replace_ref_with_defs_recursive(schema)
# Convert OpenAPI schema definitions to JSON Schema format if needed
if openapi_version and openapi_version.startswith("3.0"):
from .json_schema_converter import convert_openapi_schema_to_json_schema
for def_name in list(processed_defs.keys()):
processed_defs[def_name] = convert_openapi_schema_to_json_schema(
def_schema, openapi_version
processed_defs[def_name], openapi_version
)
else:
processed_defs[def_name] = def_schema
output_schema["$defs"] = processed_defs
# Use lightweight compression - prune additionalProperties and unused definitions
if output_schema.get("additionalProperties") is False:
output_schema.pop("additionalProperties")
# Remove unused definitions (lightweight approach - just check direct $ref usage)
if "$defs" in output_schema:
used_refs = set()
def find_refs_in_value(value):
if isinstance(value, dict):
if "$ref" in value and isinstance(value["$ref"], str):
ref = value["$ref"]
if ref.startswith("#/$defs/"):
used_refs.add(ref.split("/")[-1])
for v in value.values():
find_refs_in_value(v)
elif isinstance(value, list):
for item in value:
find_refs_in_value(item)
# Find refs in the main schema (excluding $defs section)
for key, value in output_schema.items():
if key != "$defs":
find_refs_in_value(value)
# Remove unused definitions
if used_refs:
output_schema["$defs"] = {
name: def_schema
for name, def_schema in output_schema["$defs"].items()
if name in used_refs
}
else:
output_schema.pop("$defs")
return output_schema
@ -559,6 +575,5 @@ __all__ = [
"_combine_schemas",
"_combine_schemas_and_map_params",
"extract_output_schema_from_responses",
"_replace_ref_with_defs",
"_make_optional_parameter_nullable",
]

View file

@ -212,7 +212,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
if openapi_version.startswith("3.0"):
# Use OpenAPI 3.0 models
openapi_30 = OpenAPI_30.model_validate(openapi_dict)
logger.info(
logger.debug(
f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
)
parser = OpenAPIParser(
@ -230,7 +230,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
else:
# Default to OpenAPI 3.1 models
openapi_31 = OpenAPI.model_validate(openapi_dict)
logger.info(
logger.debug(
f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
)
parser = OpenAPIParser(
@ -713,7 +713,7 @@ class OpenAPIParser(
openapi_version=self.openapi_version,
)
routes.append(route)
logger.info(
logger.debug(
f"Successfully extracted route: {method_upper} {path_str}"
)
except ValueError as op_error:
@ -734,7 +734,7 @@ class OpenAPIParser(
exc_info=True,
)
logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
logger.debug(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
return routes

View file

@ -0,0 +1,11 @@
"""Shared fixtures for openapi_new utilities tests."""
import pytest
from fastmcp.utilities.tests import temporary_settings
@pytest.fixture(autouse=True)
def use_new_openapi_parser():
with temporary_settings(experimental__enable_new_openapi_parser=True):
yield

View file

@ -22,8 +22,8 @@ def use_new_openapi_parser():
class TestOpenAPIPerformance:
"""Performance tests for OpenAPI parsing with real-world large schemas."""
# 20 second maximum timeout for this test no matter what
@pytest.mark.timeout(20)
# 10 second maximum timeout for this test no matter what
@pytest.mark.timeout(10)
async def test_github_api_schema_performance(self):
"""
Test that GitHub's full API schema parses quickly.

View file

@ -215,7 +215,7 @@ class TestPerformanceComparison:
# Performance should be comparable (within reasonable margin)
performance_ratio = max(new_avg, legacy_avg) / min(new_avg, legacy_avg)
assert performance_ratio < 2.0, (
assert performance_ratio < 3.0, (
f"Performance should be comparable, ratio: {performance_ratio:.2f}x"
)
@ -286,6 +286,6 @@ class TestPerformanceComparison:
current_refs = len(gc.get_objects())
# Allow reasonable memory growth but not exponential
growth_ratio = current_refs / max(baseline_refs, 1)
assert growth_ratio < 5, (
assert growth_ratio < 3.0, (
f"Memory usage grew by {growth_ratio}x, which seems excessive"
)

View file

@ -2,6 +2,14 @@
import pytest
from fastmcp.utilities.tests import temporary_settings
@pytest.fixture(autouse=True)
def use_new_openapi_parser():
with temporary_settings(experimental__enable_new_openapi_parser=True):
yield
@pytest.fixture
def basic_openapi_30_spec():

View file

@ -203,6 +203,135 @@ class TestOpenAPIParser:
assert param.location == "path"
assert param.required is True
def test_parse_simple_transitive_refs(self):
"""Test that A->B->C transitive references are preserved.
When a request body references schema A, which references B, which references C:
- A is expanded inline (expected optimization)
- B and C MUST be included in $defs (the bug fix for #1372)
"""
spec = {
"openapi": "3.0.0",
"info": {"title": "Test", "version": "1.0.0"},
"components": {
"schemas": {
"SchemaA": {
"type": "object",
"properties": {
"refToB": {"$ref": "#/components/schemas/SchemaB"}
},
},
"SchemaB": {
"type": "object",
"properties": {
"refToC": {"$ref": "#/components/schemas/SchemaC"}
},
},
"SchemaC": {
"type": "string",
"enum": ["value1", "value2"],
},
}
},
"paths": {
"/test": {
"post": {
"operationId": "test_op",
"requestBody": {
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/SchemaA"}
}
}
},
"responses": {"200": {"description": "OK"}},
}
}
},
}
routes = parse_openapi_to_http_routes(spec)
route = routes[0]
# SchemaA is expanded inline, so it's NOT in schema_definitions
assert "SchemaA" not in route.schema_definitions
# But SchemaB and SchemaC MUST be there (transitive dependencies)
assert "SchemaB" in route.schema_definitions
assert "SchemaC" in route.schema_definitions
# Same in the flat parameter schema
assert "SchemaB" in route.flat_param_schema["$defs"]
assert "SchemaC" in route.flat_param_schema["$defs"]
def test_parse_tspicer_issue_1372(self):
"""Reproduce the exact bug from issue #1372 (tspicer's report).
Issue: Profile -> {countryCode, AccountInfo} transitive refs were missing from $defs.
"""
spec = {
"openapi": "3.0.1",
"info": {"title": "Test", "version": "1.0.0"},
"components": {
"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"},
},
},
}
},
"paths": {
"/profile": {
"post": {
"operationId": "create_profile",
"requestBody": {
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Profile"}
}
},
},
"responses": {"200": {"description": "OK"}},
}
}
},
}
routes = parse_openapi_to_http_routes(spec)
route = routes[0]
# Profile is expanded inline, NOT in schema_defs
assert "Profile" not in route.schema_definitions
# Bug fix: countryCode and AccountInfo MUST be in schema_defs
assert "countryCode" in route.schema_definitions # Was missing in #1372
assert "AccountInfo" in route.schema_definitions # Was missing in #1372
# Same in flat parameter schema
assert "countryCode" in route.flat_param_schema["$defs"]
assert "AccountInfo" in route.flat_param_schema["$defs"]
# Verify Profile's properties were inlined correctly
props = route.flat_param_schema["properties"]
assert "profileId" in props
assert props["countryCode"]["$ref"] == "#/$defs/countryCode"
assert props["accountInfo"]["$ref"] == "#/$defs/AccountInfo"
def test_parameter_schema_extraction(self, complex_spec):
"""Test that parameter schemas are properly extracted."""
routes = parse_openapi_to_http_routes(complex_spec)

View file

@ -10,8 +10,9 @@ from fastmcp.experimental.utilities.openapi.models import (
from fastmcp.experimental.utilities.openapi.schemas import (
_combine_schemas,
_combine_schemas_and_map_params,
_replace_ref_with_defs,
_replace_ref_with_defs_recursive,
)
from fastmcp.utilities.json_schema import compress_schema
class TestSchemaProcessing:
@ -230,6 +231,7 @@ class TestSchemaProcessing:
def test_replace_ref_with_defs(self):
"""Test replacing $ref with $defs for JSON Schema compatibility."""
schema_with_ref = {
"type": "object",
"properties": {
@ -241,13 +243,15 @@ class TestSchemaProcessing:
},
}
result = _replace_ref_with_defs(schema_with_ref)
# Use our recursive replacement approach
result = _replace_ref_with_defs_recursive(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": {
@ -269,7 +273,8 @@ class TestSchemaProcessing:
},
}
result = _replace_ref_with_defs(nested_schema)
# Use our recursive replacement approach
result = _replace_ref_with_defs_recursive(nested_schema)
# Check nested object property
nested_prop = result["properties"]["data"]["properties"]["nested"]
@ -476,7 +481,6 @@ class TestEdgeCases:
def test_oneof_reference_preserved(self):
"""Test that schemas referenced in oneOf are preserved."""
from fastmcp.utilities.json_schema import compress_schema
schema = {
"type": "object",
@ -497,7 +501,6 @@ class TestEdgeCases:
def test_anyof_reference_preserved(self):
"""Test that schemas referenced in anyOf are preserved."""
from fastmcp.utilities.json_schema import compress_schema
schema = {
"type": "object",
@ -515,7 +518,6 @@ class TestEdgeCases:
def test_allof_reference_preserved(self):
"""Test that schemas referenced in allOf are preserved."""
from fastmcp.utilities.json_schema import compress_schema
schema = {
"type": "object",

View file

@ -0,0 +1,677 @@
"""Comprehensive tests for transitive and nested reference handling (Issue #1372)."""
from fastmcp.experimental.utilities.openapi.models import (
HTTPRoute,
ParameterInfo,
RequestBodyInfo,
ResponseInfo,
)
from fastmcp.experimental.utilities.openapi.schemas import (
_combine_schemas_and_map_params,
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"}},
}
},
),
schema_definitions={
"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"}
},
),
schema_definitions={
"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"}
},
),
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"],
},
},
)
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"},
}
},
}
},
),
schema_definitions={
"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"}}},
]
},
},
}
},
),
schema_definitions={
"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"}
},
),
schema_definitions={
"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"}
},
),
schema_definitions={
"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"},
},
}
},
),
schema_definitions={
"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"}
},
),
schema_definitions={
"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"},
},
}
},
),
schema_definitions={
"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"}
},
),
schema_definitions={
"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"
)

38
uv.lock generated
View file

@ -530,6 +530,7 @@ dependencies = [
{ name = "exceptiongroup" },
{ name = "httpx" },
{ name = "mcp" },
{ name = "msgspec" },
{ name = "openapi-core" },
{ name = "openapi-pydantic" },
{ name = "pydantic", extra = ["email"] },
@ -574,6 +575,7 @@ requires-dist = [
{ name = "exceptiongroup", specifier = ">=1.2.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mcp", specifier = ">=1.10.0" },
{ name = "msgspec", specifier = ">=0.19.0" },
{ name = "openapi-core", specifier = ">=0.19.5" },
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
@ -979,6 +981,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" },
]
[[package]]
name = "msgspec"
version = "0.19.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/9b/95d8ce458462b8b71b8a70fa94563b2498b89933689f3a7b8911edfae3d7/msgspec-0.19.0.tar.gz", hash = "sha256:604037e7cd475345848116e89c553aa9a233259733ab51986ac924ab1b976f8e", size = 216934, upload-time = "2024-12-27T17:40:28.597Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/40/817282b42f58399762267b30deb8ac011d8db373f8da0c212c85fbe62b8f/msgspec-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d8dd848ee7ca7c8153462557655570156c2be94e79acec3561cf379581343259", size = 190019, upload-time = "2024-12-27T17:39:13.803Z" },
{ url = "https://files.pythonhosted.org/packages/92/99/bd7ed738c00f223a8119928661167a89124140792af18af513e6519b0d54/msgspec-0.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0553bbc77662e5708fe66aa75e7bd3e4b0f209709c48b299afd791d711a93c36", size = 183680, upload-time = "2024-12-27T17:39:17.847Z" },
{ url = "https://files.pythonhosted.org/packages/e5/27/322badde18eb234e36d4a14122b89edd4e2973cdbc3da61ca7edf40a1ccd/msgspec-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe2c4bf29bf4e89790b3117470dea2c20b59932772483082c468b990d45fb947", size = 209334, upload-time = "2024-12-27T17:39:19.065Z" },
{ url = "https://files.pythonhosted.org/packages/c6/65/080509c5774a1592b2779d902a70b5fe008532759927e011f068145a16cb/msgspec-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e87ecfa9795ee5214861eab8326b0e75475c2e68a384002aa135ea2a27d909", size = 211551, upload-time = "2024-12-27T17:39:21.767Z" },
{ url = "https://files.pythonhosted.org/packages/6f/2e/1c23c6b4ca6f4285c30a39def1054e2bee281389e4b681b5e3711bd5a8c9/msgspec-0.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3c4ec642689da44618f68c90855a10edbc6ac3ff7c1d94395446c65a776e712a", size = 215099, upload-time = "2024-12-27T17:39:24.71Z" },
{ url = "https://files.pythonhosted.org/packages/83/fe/95f9654518879f3359d1e76bc41189113aa9102452170ab7c9a9a4ee52f6/msgspec-0.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2719647625320b60e2d8af06b35f5b12d4f4d281db30a15a1df22adb2295f633", size = 218211, upload-time = "2024-12-27T17:39:27.396Z" },
{ url = "https://files.pythonhosted.org/packages/79/f6/71ca7e87a1fb34dfe5efea8156c9ef59dd55613aeda2ca562f122cd22012/msgspec-0.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:695b832d0091edd86eeb535cd39e45f3919f48d997685f7ac31acb15e0a2ed90", size = 186174, upload-time = "2024-12-27T17:39:29.647Z" },
{ url = "https://files.pythonhosted.org/packages/24/d4/2ec2567ac30dab072cce3e91fb17803c52f0a37aab6b0c24375d2b20a581/msgspec-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa77046904db764b0462036bc63ef71f02b75b8f72e9c9dd4c447d6da1ed8f8e", size = 187939, upload-time = "2024-12-27T17:39:32.347Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c0/18226e4328897f4f19875cb62bb9259fe47e901eade9d9376ab5f251a929/msgspec-0.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:047cfa8675eb3bad68722cfe95c60e7afabf84d1bd8938979dd2b92e9e4a9551", size = 182202, upload-time = "2024-12-27T17:39:33.633Z" },
{ url = "https://files.pythonhosted.org/packages/81/25/3a4b24d468203d8af90d1d351b77ea3cffb96b29492855cf83078f16bfe4/msgspec-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e78f46ff39a427e10b4a61614a2777ad69559cc8d603a7c05681f5a595ea98f7", size = 209029, upload-time = "2024-12-27T17:39:35.023Z" },
{ url = "https://files.pythonhosted.org/packages/85/2e/db7e189b57901955239f7689b5dcd6ae9458637a9c66747326726c650523/msgspec-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c7adf191e4bd3be0e9231c3b6dc20cf1199ada2af523885efc2ed218eafd011", size = 210682, upload-time = "2024-12-27T17:39:36.384Z" },
{ url = "https://files.pythonhosted.org/packages/03/97/7c8895c9074a97052d7e4a1cc1230b7b6e2ca2486714eb12c3f08bb9d284/msgspec-0.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f04cad4385e20be7c7176bb8ae3dca54a08e9756cfc97bcdb4f18560c3042063", size = 214003, upload-time = "2024-12-27T17:39:39.097Z" },
{ url = "https://files.pythonhosted.org/packages/61/61/e892997bcaa289559b4d5869f066a8021b79f4bf8e955f831b095f47a4cd/msgspec-0.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45c8fb410670b3b7eb884d44a75589377c341ec1392b778311acdbfa55187716", size = 216833, upload-time = "2024-12-27T17:39:41.203Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3d/71b2dffd3a1c743ffe13296ff701ee503feaebc3f04d0e75613b6563c374/msgspec-0.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:70eaef4934b87193a27d802534dc466778ad8d536e296ae2f9334e182ac27b6c", size = 186184, upload-time = "2024-12-27T17:39:43.702Z" },
{ url = "https://files.pythonhosted.org/packages/b2/5f/a70c24f075e3e7af2fae5414c7048b0e11389685b7f717bb55ba282a34a7/msgspec-0.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f98bd8962ad549c27d63845b50af3f53ec468b6318400c9f1adfe8b092d7b62f", size = 190485, upload-time = "2024-12-27T17:39:44.974Z" },
{ url = "https://files.pythonhosted.org/packages/89/b0/1b9763938cfae12acf14b682fcf05c92855974d921a5a985ecc197d1c672/msgspec-0.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43bbb237feab761b815ed9df43b266114203f53596f9b6e6f00ebd79d178cdf2", size = 183910, upload-time = "2024-12-27T17:39:46.401Z" },
{ url = "https://files.pythonhosted.org/packages/87/81/0c8c93f0b92c97e326b279795f9c5b956c5a97af28ca0fbb9fd86c83737a/msgspec-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cfc033c02c3e0aec52b71710d7f84cb3ca5eb407ab2ad23d75631153fdb1f12", size = 210633, upload-time = "2024-12-27T17:39:49.099Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/c5422ce8af73928d194a6606f8ae36e93a52fd5e8df5abd366903a5ca8da/msgspec-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d911c442571605e17658ca2b416fd8579c5050ac9adc5e00c2cb3126c97f73bc", size = 213594, upload-time = "2024-12-27T17:39:51.204Z" },
{ url = "https://files.pythonhosted.org/packages/19/2b/4137bc2ed45660444842d042be2cf5b18aa06efd2cda107cff18253b9653/msgspec-0.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:757b501fa57e24896cf40a831442b19a864f56d253679f34f260dcb002524a6c", size = 214053, upload-time = "2024-12-27T17:39:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/9d/e6/8ad51bdc806aac1dc501e8fe43f759f9ed7284043d722b53323ea421c360/msgspec-0.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5f0f65f29b45e2816d8bded36e6b837a4bf5fb60ec4bc3c625fa2c6da4124537", size = 219081, upload-time = "2024-12-27T17:39:55.142Z" },
{ url = "https://files.pythonhosted.org/packages/b1/ef/27dd35a7049c9a4f4211c6cd6a8c9db0a50647546f003a5867827ec45391/msgspec-0.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:067f0de1c33cfa0b6a8206562efdf6be5985b988b53dd244a8e06f993f27c8c0", size = 187467, upload-time = "2024-12-27T17:39:56.531Z" },
{ url = "https://files.pythonhosted.org/packages/3c/cb/2842c312bbe618d8fefc8b9cedce37f773cdc8fa453306546dba2c21fd98/msgspec-0.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f12d30dd6266557aaaf0aa0f9580a9a8fbeadfa83699c487713e355ec5f0bd86", size = 190498, upload-time = "2024-12-27T17:40:00.427Z" },
{ url = "https://files.pythonhosted.org/packages/58/95/c40b01b93465e1a5f3b6c7d91b10fb574818163740cc3acbe722d1e0e7e4/msgspec-0.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82b2c42c1b9ebc89e822e7e13bbe9d17ede0c23c187469fdd9505afd5a481314", size = 183950, upload-time = "2024-12-27T17:40:04.219Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f0/5b764e066ce9aba4b70d1db8b087ea66098c7c27d59b9dd8a3532774d48f/msgspec-0.19.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19746b50be214a54239aab822964f2ac81e38b0055cca94808359d779338c10e", size = 210647, upload-time = "2024-12-27T17:40:05.606Z" },
{ url = "https://files.pythonhosted.org/packages/9d/87/bc14f49bc95c4cb0dd0a8c56028a67c014ee7e6818ccdce74a4862af259b/msgspec-0.19.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60ef4bdb0ec8e4ad62e5a1f95230c08efb1f64f32e6e8dd2ced685bcc73858b5", size = 213563, upload-time = "2024-12-27T17:40:10.516Z" },
{ url = "https://files.pythonhosted.org/packages/53/2f/2b1c2b056894fbaa975f68f81e3014bb447516a8b010f1bed3fb0e016ed7/msgspec-0.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac7f7c377c122b649f7545810c6cd1b47586e3aa3059126ce3516ac7ccc6a6a9", size = 213996, upload-time = "2024-12-27T17:40:12.244Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5a/4cd408d90d1417e8d2ce6a22b98a6853c1b4d7cb7669153e4424d60087f6/msgspec-0.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5bc1472223a643f5ffb5bf46ccdede7f9795078194f14edd69e3aab7020d327", size = 219087, upload-time = "2024-12-27T17:40:14.881Z" },
{ url = "https://files.pythonhosted.org/packages/23/d8/f15b40611c2d5753d1abb0ca0da0c75348daf1252220e5dda2867bd81062/msgspec-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:317050bc0f7739cb30d257ff09152ca309bf5a369854bbf1e57dffc310c1f20f", size = 187432, upload-time = "2024-12-27T17:40:16.256Z" },
]
[[package]]
name = "nodeenv"
version = "1.9.1"