Jeremiah Lowin commited on
Commit
60e47cc
·
unverified ·
1 Parent(s): 85b13ea

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

Browse files
pyproject.toml CHANGED
@@ -15,6 +15,7 @@ dependencies = [
15
  "pydantic[email]>=2.11.7",
16
  "pyperclip>=1.9.0",
17
  "openapi-core>=0.19.5",
 
18
  ]
19
  requires-python = ">=3.10"
20
  readme = "README.md"
 
15
  "pydantic[email]>=2.11.7",
16
  "pyperclip>=1.9.0",
17
  "openapi-core>=0.19.5",
18
+ "msgspec>=0.19.0",
19
  ]
20
  requires-python = ">=3.10"
21
  readme = "README.md"
src/fastmcp/experimental/server/openapi/routing.py CHANGED
@@ -113,7 +113,7 @@ def _determine_route_type(
113
  # We know mcp_type is not None here due to post_init validation
114
  assert route_map.mcp_type is not None
115
  logger.debug(
116
- f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}"
117
  )
118
  return route_map
119
 
 
113
  # We know mcp_type is not None here due to post_init validation
114
  assert route_map.mcp_type is not None
115
  logger.debug(
116
+ f"Route {route.method} {route.path} mapped to {route_map.mcp_type.name}"
117
  )
118
  return route_map
119
 
src/fastmcp/experimental/server/openapi/server.py CHANGED
@@ -151,9 +151,6 @@ class FastMCPOpenAPI(FastMCP):
151
  try:
152
  self._spec = SchemaPath.from_dict(openapi_spec) # type: ignore[arg-type]
153
  self._director = RequestDirector(self._spec)
154
- logger.debug(
155
- "Initialized OpenAPI RequestDirector for stateless request building"
156
- )
157
  except Exception as e:
158
  logger.error(f"Failed to initialize RequestDirector: {e}")
159
  raise ValueError(f"Invalid OpenAPI specification: {e}") from e
@@ -318,9 +315,6 @@ class FastMCPOpenAPI(FastMCP):
318
 
319
  # Register the tool by directly assigning to the tools dictionary
320
  self._tool_manager._tools[final_tool_name] = tool
321
- logger.debug(
322
- f"Registered TOOL: {final_tool_name} ({route.method} {route.path}) with tags: {route.tags}"
323
- )
324
 
325
  def _create_openapi_resource(
326
  self,
@@ -372,9 +366,6 @@ class FastMCPOpenAPI(FastMCP):
372
 
373
  # Register the resource by directly assigning to the resources dictionary
374
  self._resource_manager._resources[final_resource_uri] = resource
375
- logger.debug(
376
- f"Registered RESOURCE: {final_resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
377
- )
378
 
379
  def _create_openapi_template(
380
  self,
@@ -455,9 +446,6 @@ class FastMCPOpenAPI(FastMCP):
455
 
456
  # Register the template by directly assigning to the templates dictionary
457
  self._resource_manager._templates[final_template_uri] = template
458
- logger.debug(
459
- f"Registered TEMPLATE: {final_template_uri} ({route.method} {route.path}) with tags: {route.tags}"
460
- )
461
 
462
 
463
  # Export public symbols
 
151
  try:
152
  self._spec = SchemaPath.from_dict(openapi_spec) # type: ignore[arg-type]
153
  self._director = RequestDirector(self._spec)
 
 
 
154
  except Exception as e:
155
  logger.error(f"Failed to initialize RequestDirector: {e}")
156
  raise ValueError(f"Invalid OpenAPI specification: {e}") from e
 
315
 
316
  # Register the tool by directly assigning to the tools dictionary
317
  self._tool_manager._tools[final_tool_name] = tool
 
 
 
318
 
319
  def _create_openapi_resource(
320
  self,
 
366
 
367
  # Register the resource by directly assigning to the resources dictionary
368
  self._resource_manager._resources[final_resource_uri] = resource
 
 
 
369
 
370
  def _create_openapi_template(
371
  self,
 
446
 
447
  # Register the template by directly assigning to the templates dictionary
448
  self._resource_manager._templates[final_template_uri] = template
 
 
 
449
 
450
 
451
  # Export public symbols
src/fastmcp/experimental/utilities/openapi/__init__.py CHANGED
@@ -28,7 +28,6 @@ from .schemas import (
28
  _combine_schemas,
29
  extract_output_schema_from_responses,
30
  clean_schema_for_display,
31
- _replace_ref_with_defs,
32
  _make_optional_parameter_nullable,
33
  )
34
 
@@ -60,7 +59,6 @@ __all__ = [
60
  "_combine_schemas",
61
  "extract_output_schema_from_responses",
62
  "clean_schema_for_display",
63
- "_replace_ref_with_defs",
64
  "_make_optional_parameter_nullable",
65
  # JSON Schema Converter
66
  "convert_openapi_schema_to_json_schema",
 
28
  _combine_schemas,
29
  extract_output_schema_from_responses,
30
  clean_schema_for_display,
 
31
  _make_optional_parameter_nullable,
32
  )
33
 
 
59
  "_combine_schemas",
60
  "extract_output_schema_from_responses",
61
  "clean_schema_for_display",
 
62
  "_make_optional_parameter_nullable",
63
  # JSON Schema Converter
64
  "convert_openapi_schema_to_json_schema",
src/fastmcp/experimental/utilities/openapi/parser.py CHANGED
@@ -34,7 +34,10 @@ from .models import (
34
  RequestBodyInfo,
35
  ResponseInfo,
36
  )
37
- from .schemas import _combine_schemas_and_map_params, _replace_ref_with_defs
 
 
 
38
 
39
  logger = get_logger(__name__)
40
 
@@ -63,7 +66,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
63
  if openapi_version.startswith("3.0"):
64
  # Use OpenAPI 3.0 models
65
  openapi_30 = OpenAPI_30.model_validate(openapi_dict)
66
- logger.info(
67
  f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
68
  )
69
  parser = OpenAPIParser(
@@ -81,7 +84,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
81
  else:
82
  # Default to OpenAPI 3.1 models
83
  openapi_31 = OpenAPI.model_validate(openapi_dict)
84
- logger.info(
85
  f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
86
  )
87
  parser = OpenAPIParser(
@@ -207,7 +210,7 @@ class OpenAPIParser(
207
  try:
208
  resolved_schema = self._resolve_ref(schema_obj)
209
 
210
- if isinstance(resolved_schema, (self.schema_cls)):
211
  # Convert schema to dictionary
212
  result = resolved_schema.model_dump(
213
  mode="json", by_alias=True, exclude_none=True
@@ -220,7 +223,10 @@ class OpenAPIParser(
220
  )
221
  result = {}
222
 
223
- return _replace_ref_with_defs(result)
 
 
 
224
  except ValueError as e:
225
  # Re-raise ValueError for external reference errors and other validation issues
226
  if "External or non-local reference not supported" in str(e):
@@ -470,6 +476,102 @@ class OpenAPIParser(
470
 
471
  return extracted_responses
472
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
  def parse(self) -> list[HTTPRoute]:
474
  """Parse the OpenAPI schema into HTTP routes."""
475
  routes: list[HTTPRoute] = []
@@ -499,6 +601,13 @@ class OpenAPIParser(
499
  f"Failed to extract schema definition '{name}': {e}"
500
  )
501
 
 
 
 
 
 
 
 
502
  # Process paths and operations
503
  for path_str, path_item_obj in self.openapi.paths.items():
504
  if not isinstance(path_item_obj, self.path_item_cls):
@@ -552,6 +661,14 @@ class OpenAPIParser(
552
  if k.startswith("x-")
553
  }
554
 
 
 
 
 
 
 
 
 
555
  # Create initial route without pre-calculated fields
556
  route = HTTPRoute(
557
  path=path_str,
@@ -563,7 +680,7 @@ class OpenAPIParser(
563
  parameters=parameters,
564
  request_body=request_body_info,
565
  responses=responses,
566
- schema_definitions=schema_definitions,
567
  extensions=extensions,
568
  openapi_version=self.openapi_version,
569
  )
@@ -571,7 +688,8 @@ class OpenAPIParser(
571
  # Pre-calculate schema and parameter mapping for performance
572
  try:
573
  flat_schema, param_map = _combine_schemas_and_map_params(
574
- route
 
575
  )
576
  route.flat_param_schema = flat_schema
577
  route.parameter_map = param_map
@@ -586,9 +704,6 @@ class OpenAPIParser(
586
  }
587
  route.parameter_map = {}
588
  routes.append(route)
589
- logger.info(
590
- f"Successfully extracted route: {method_upper} {path_str}"
591
- )
592
  except ValueError as op_error:
593
  # Re-raise ValueError for external reference errors
594
  if "External or non-local reference not supported" in str(
@@ -607,7 +722,7 @@ class OpenAPIParser(
607
  exc_info=True,
608
  )
609
 
610
- logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
611
  return routes
612
 
613
 
 
34
  RequestBodyInfo,
35
  ResponseInfo,
36
  )
37
+ from .schemas import (
38
+ _combine_schemas_and_map_params,
39
+ _replace_ref_with_defs_recursive,
40
+ )
41
 
42
  logger = get_logger(__name__)
43
 
 
66
  if openapi_version.startswith("3.0"):
67
  # Use OpenAPI 3.0 models
68
  openapi_30 = OpenAPI_30.model_validate(openapi_dict)
69
+ logger.debug(
70
  f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
71
  )
72
  parser = OpenAPIParser(
 
84
  else:
85
  # Default to OpenAPI 3.1 models
86
  openapi_31 = OpenAPI.model_validate(openapi_dict)
87
+ logger.debug(
88
  f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
89
  )
90
  parser = OpenAPIParser(
 
210
  try:
211
  resolved_schema = self._resolve_ref(schema_obj)
212
 
213
+ if isinstance(resolved_schema, self.schema_cls):
214
  # Convert schema to dictionary
215
  result = resolved_schema.model_dump(
216
  mode="json", by_alias=True, exclude_none=True
 
223
  )
224
  result = {}
225
 
226
+ # Convert refs from OpenAPI format to JSON Schema format using recursive approach
227
+
228
+ result = _replace_ref_with_defs_recursive(result)
229
+ return result
230
  except ValueError as e:
231
  # Re-raise ValueError for external reference errors and other validation issues
232
  if "External or non-local reference not supported" in str(e):
 
476
 
477
  return extracted_responses
478
 
479
+ def _extract_schema_dependencies(
480
+ self,
481
+ schema: dict,
482
+ all_schemas: dict[str, Any],
483
+ collected: set[str] | None = None,
484
+ ) -> set[str]:
485
+ """
486
+ Extract all schema names referenced by a schema (including transitive dependencies).
487
+
488
+ Args:
489
+ schema: The schema to analyze
490
+ all_schemas: All available schema definitions
491
+ collected: Set of already collected schema names (for recursion)
492
+
493
+ Returns:
494
+ Set of schema names that are referenced
495
+ """
496
+ if collected is None:
497
+ collected = set()
498
+
499
+ def find_refs(obj):
500
+ """Recursively find all $ref references."""
501
+ if isinstance(obj, dict):
502
+ if "$ref" in obj and isinstance(obj["$ref"], str):
503
+ ref = obj["$ref"]
504
+ # Handle both converted and unconverted refs
505
+ if ref.startswith("#/$defs/"):
506
+ schema_name = ref.split("/")[-1]
507
+ elif ref.startswith("#/components/schemas/"):
508
+ schema_name = ref.split("/")[-1]
509
+ else:
510
+ return
511
+
512
+ # Add this schema and recursively find its dependencies
513
+ if schema_name not in collected and schema_name in all_schemas:
514
+ collected.add(schema_name)
515
+ # Recursively find dependencies of this schema
516
+ find_refs(all_schemas[schema_name])
517
+
518
+ # Continue searching in all values
519
+ for value in obj.values():
520
+ find_refs(value)
521
+ elif isinstance(obj, list):
522
+ for item in obj:
523
+ find_refs(item)
524
+
525
+ find_refs(schema)
526
+ return collected
527
+
528
+ def _extract_route_schema_dependencies(
529
+ self,
530
+ parameters: list[ParameterInfo],
531
+ request_body: RequestBodyInfo | None,
532
+ responses: dict[str, ResponseInfo],
533
+ all_schemas: dict[str, Any],
534
+ ) -> dict[str, Any]:
535
+ """
536
+ Extract only the schema definitions needed for a specific route.
537
+
538
+ Args:
539
+ parameters: Route parameters
540
+ request_body: Route request body
541
+ responses: Route responses
542
+ all_schemas: All available schema definitions
543
+
544
+ Returns:
545
+ Dictionary containing only the schemas needed for this route
546
+ """
547
+ needed_schemas = set()
548
+
549
+ # Check parameters for schema references
550
+ for param in parameters:
551
+ if param.schema_:
552
+ deps = self._extract_schema_dependencies(param.schema_, all_schemas)
553
+ needed_schemas.update(deps)
554
+
555
+ # Check request body for schema references
556
+ if request_body and request_body.content_schema:
557
+ for content_schema in request_body.content_schema.values():
558
+ deps = self._extract_schema_dependencies(content_schema, all_schemas)
559
+ needed_schemas.update(deps)
560
+
561
+ # Check responses for schema references
562
+ for response in responses.values():
563
+ if response.content_schema:
564
+ for content_schema in response.content_schema.values():
565
+ deps = self._extract_schema_dependencies(
566
+ content_schema, all_schemas
567
+ )
568
+ needed_schemas.update(deps)
569
+
570
+ # Return only the needed schemas
571
+ return {
572
+ name: all_schemas[name] for name in needed_schemas if name in all_schemas
573
+ }
574
+
575
  def parse(self) -> list[HTTPRoute]:
576
  """Parse the OpenAPI schema into HTTP routes."""
577
  routes: list[HTTPRoute] = []
 
601
  f"Failed to extract schema definition '{name}': {e}"
602
  )
603
 
604
+ # Convert schema definitions refs from OpenAPI to JSON Schema format (once)
605
+ if schema_definitions:
606
+ # Convert each schema definition recursively
607
+ for name, schema in schema_definitions.items():
608
+ if isinstance(schema, dict):
609
+ schema_definitions[name] = _replace_ref_with_defs_recursive(schema)
610
+
611
  # Process paths and operations
612
  for path_str, path_item_obj in self.openapi.paths.items():
613
  if not isinstance(path_item_obj, self.path_item_cls):
 
661
  if k.startswith("x-")
662
  }
663
 
664
+ # Extract only the schemas needed for this route
665
+ route_schemas = self._extract_route_schema_dependencies(
666
+ parameters,
667
+ request_body_info,
668
+ responses,
669
+ schema_definitions,
670
+ )
671
+
672
  # Create initial route without pre-calculated fields
673
  route = HTTPRoute(
674
  path=path_str,
 
680
  parameters=parameters,
681
  request_body=request_body_info,
682
  responses=responses,
683
+ schema_definitions=route_schemas, # Use pre-pruned schemas
684
  extensions=extensions,
685
  openapi_version=self.openapi_version,
686
  )
 
688
  # Pre-calculate schema and parameter mapping for performance
689
  try:
690
  flat_schema, param_map = _combine_schemas_and_map_params(
691
+ route,
692
+ convert_refs=False, # Parser already converted refs
693
  )
694
  route.flat_param_schema = flat_schema
695
  route.parameter_map = param_map
 
704
  }
705
  route.parameter_map = {}
706
  routes.append(route)
 
 
 
707
  except ValueError as op_error:
708
  # Re-raise ValueError for external reference errors
709
  if "External or non-local reference not supported" in str(
 
722
  exc_info=True,
723
  )
724
 
725
+ logger.debug(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
726
  return routes
727
 
728
 
src/fastmcp/experimental/utilities/openapi/schemas.py CHANGED
@@ -71,11 +71,11 @@ def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
71
  return cleaned
72
 
73
 
74
- def _replace_ref_with_defs(
75
  info: dict[str, Any], description: str | None = None
76
  ) -> dict[str, Any]:
77
  """
78
- Replace openapi $ref with jsonschema $defs
79
 
80
  Examples:
81
  - {"type": "object", "properties": {"$ref": "#/components/schemas/..."}}
@@ -106,22 +106,35 @@ def _replace_ref_with_defs(
106
  )
107
  elif properties := schema.get("properties"):
108
  if "$ref" in properties:
109
- schema["properties"] = _replace_ref_with_defs(properties)
110
  else:
111
  schema["properties"] = {
112
- prop_name: _replace_ref_with_defs(prop_schema)
113
  for prop_name, prop_schema in properties.items()
114
  }
115
  elif item_schema := schema.get("items"):
116
- schema["items"] = _replace_ref_with_defs(item_schema)
117
  for section in ["anyOf", "allOf", "oneOf"]:
118
  for i, item in enumerate(schema.get(section, [])):
119
- schema[section][i] = _replace_ref_with_defs(item)
120
  if info.get("description", description) and not schema.get("description"):
121
  schema["description"] = description
122
  return schema
123
 
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
126
  """
127
  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]:
202
 
203
  def _combine_schemas_and_map_params(
204
  route: HTTPRoute,
 
205
  ) -> tuple[dict[str, Any], dict[str, dict[str, str]]]:
206
  """
207
  Combines parameter and request body schemas into a single schema.
@@ -233,10 +247,17 @@ def _combine_schemas_and_map_params(
233
 
234
  if route.request_body and route.request_body.content_schema:
235
  content_type = next(iter(route.request_body.content_schema))
236
- body_schema = _replace_ref_with_defs(
237
- route.request_body.content_schema[content_type].copy(),
238
- route.request_body.description,
239
- )
 
 
 
 
 
 
 
240
 
241
  # Handle allOf at the top level by merging all schemas
242
  if "allOf" in body_schema and isinstance(body_schema["allOf"], list):
@@ -287,10 +308,11 @@ def _combine_schemas_and_map_params(
287
  "openapi_name": param.name,
288
  }
289
 
290
- # Add location info to description
291
- param_schema = _replace_ref_with_defs(
292
- param.schema_.copy(), param.description
293
- )
 
294
  original_desc = param_schema.get("description", "")
295
  location_desc = f"({param.location.capitalize()} parameter)"
296
  if original_desc:
@@ -313,9 +335,11 @@ def _combine_schemas_and_map_params(
313
  "openapi_name": param.name,
314
  }
315
 
316
- param_schema = _replace_ref_with_defs(
317
- param.schema_.copy(), param.description
318
- )
 
 
319
 
320
  # Don't make optional parameters nullable - they can simply be omitted
321
  # The OpenAPI specification doesn't require optional parameters to accept null values
@@ -324,14 +348,28 @@ def _combine_schemas_and_map_params(
324
 
325
  # Add request body properties (no suffixes for body parameters)
326
  if route.request_body and route.request_body.content_schema:
327
- for prop_name, prop_schema in body_props.items():
328
- properties[prop_name] = prop_schema
 
 
 
 
 
 
 
 
 
 
 
329
 
330
- # Track parameter mapping for body properties
331
- parameter_map[prop_name] = {"location": "body", "openapi_name": prop_name}
 
 
 
332
 
333
- if route.request_body.required:
334
- required.extend(body_schema.get("required", []))
335
 
336
  result = {
337
  "type": "object",
@@ -340,42 +378,51 @@ def _combine_schemas_and_map_params(
340
  }
341
  # Add schema definitions if available
342
  if route.schema_definitions:
343
- result["$defs"] = route.schema_definitions.copy()
344
-
345
- # Use lightweight compression - prune additionalProperties and unused definitions
346
- if result.get("additionalProperties") is False:
347
- result.pop("additionalProperties")
348
-
349
- # Remove unused definitions (lightweight approach - just check direct $ref usage)
350
- if "$defs" in result:
351
- used_refs = set()
352
-
353
- def find_refs_in_value(value):
354
- if isinstance(value, dict):
355
- if "$ref" in value and isinstance(value["$ref"], str):
356
- ref = value["$ref"]
357
- if ref.startswith("#/$defs/"):
358
- used_refs.add(ref.split("/")[-1])
359
- for v in value.values():
360
- find_refs_in_value(v)
361
- elif isinstance(value, list):
362
- for item in value:
363
- find_refs_in_value(item)
364
-
365
- # Find refs in the main schema (excluding $defs section)
366
- for key, value in result.items():
367
- if key != "$defs":
368
- find_refs_in_value(value)
369
-
370
- # Remove unused definitions
371
- if used_refs:
372
- result["$defs"] = {
373
- name: def_schema
374
- for name, def_schema in result["$defs"].items()
375
- if name in used_refs
376
- }
 
 
 
 
 
 
 
 
377
  else:
378
- result.pop("$defs")
 
379
 
380
  return result, parameter_map
381
 
@@ -466,17 +513,17 @@ def extract_output_schema_from_responses(
466
  if not schema or not isinstance(schema, dict):
467
  return None
468
 
469
- # Clean and copy the schema
470
- output_schema = schema.copy()
471
 
472
  # If schema has a $ref, resolve it first before processing nullable fields
473
  if "$ref" in output_schema and schema_definitions:
474
  ref_path = output_schema["$ref"]
475
- if ref_path.startswith("#/components/schemas/"):
476
  schema_name = ref_path.split("/")[-1]
477
  if schema_name in schema_definitions:
478
  # Replace $ref with the actual schema definition
479
- output_schema = schema_definitions[schema_name].copy()
480
 
481
  # Convert OpenAPI schema to JSON Schema format
482
  # Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
@@ -499,56 +546,25 @@ def extract_output_schema_from_responses(
499
  }
500
  output_schema = wrapped_schema
501
 
502
- # Add schema definitions if available and handle nullable fields in them
503
- # Only add $defs if we didn't resolve the $ref inline above
504
- if schema_definitions and "$ref" not in schema.copy():
505
- processed_defs = {}
506
- for def_name, def_schema in schema_definitions.items():
507
- # Convert OpenAPI schema definitions to JSON Schema format
508
- if openapi_version and openapi_version.startswith("3.0"):
509
- from .json_schema_converter import convert_openapi_schema_to_json_schema
510
-
 
 
 
 
 
511
  processed_defs[def_name] = convert_openapi_schema_to_json_schema(
512
- def_schema, openapi_version
513
  )
514
- else:
515
- processed_defs[def_name] = def_schema
516
- output_schema["$defs"] = processed_defs
517
 
518
- # Use lightweight compression - prune additionalProperties and unused definitions
519
- if output_schema.get("additionalProperties") is False:
520
- output_schema.pop("additionalProperties")
521
-
522
- # Remove unused definitions (lightweight approach - just check direct $ref usage)
523
- if "$defs" in output_schema:
524
- used_refs = set()
525
-
526
- def find_refs_in_value(value):
527
- if isinstance(value, dict):
528
- if "$ref" in value and isinstance(value["$ref"], str):
529
- ref = value["$ref"]
530
- if ref.startswith("#/$defs/"):
531
- used_refs.add(ref.split("/")[-1])
532
- for v in value.values():
533
- find_refs_in_value(v)
534
- elif isinstance(value, list):
535
- for item in value:
536
- find_refs_in_value(item)
537
-
538
- # Find refs in the main schema (excluding $defs section)
539
- for key, value in output_schema.items():
540
- if key != "$defs":
541
- find_refs_in_value(value)
542
-
543
- # Remove unused definitions
544
- if used_refs:
545
- output_schema["$defs"] = {
546
- name: def_schema
547
- for name, def_schema in output_schema["$defs"].items()
548
- if name in used_refs
549
- }
550
- else:
551
- output_schema.pop("$defs")
552
 
553
  return output_schema
554
 
@@ -559,6 +575,5 @@ __all__ = [
559
  "_combine_schemas",
560
  "_combine_schemas_and_map_params",
561
  "extract_output_schema_from_responses",
562
- "_replace_ref_with_defs",
563
  "_make_optional_parameter_nullable",
564
  ]
 
71
  return cleaned
72
 
73
 
74
+ def _replace_ref_with_defs_recursive(
75
  info: dict[str, Any], description: str | None = None
76
  ) -> dict[str, Any]:
77
  """
78
+ Replace openapi $ref with jsonschema $defs recursively.
79
 
80
  Examples:
81
  - {"type": "object", "properties": {"$ref": "#/components/schemas/..."}}
 
106
  )
107
  elif properties := schema.get("properties"):
108
  if "$ref" in properties:
109
+ schema["properties"] = _replace_ref_with_defs_recursive(properties)
110
  else:
111
  schema["properties"] = {
112
+ prop_name: _replace_ref_with_defs_recursive(prop_schema)
113
  for prop_name, prop_schema in properties.items()
114
  }
115
  elif item_schema := schema.get("items"):
116
+ schema["items"] = _replace_ref_with_defs_recursive(item_schema)
117
  for section in ["anyOf", "allOf", "oneOf"]:
118
  for i, item in enumerate(schema.get(section, [])):
119
+ schema[section][i] = _replace_ref_with_defs_recursive(item)
120
  if info.get("description", description) and not schema.get("description"):
121
  schema["description"] = description
122
  return schema
123
 
124
 
125
+ def _ensure_refs_converted(schema: dict[str, Any]) -> dict[str, Any]:
126
+ """
127
+ Ensure all OpenAPI refs are converted to JSON Schema format using recursive approach.
128
+
129
+ Args:
130
+ schema: Schema that may contain OpenAPI refs
131
+
132
+ Returns:
133
+ Schema with all refs converted to JSON Schema format
134
+ """
135
+ return _replace_ref_with_defs_recursive(schema)
136
+
137
+
138
  def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
139
  """
140
  Make an optional parameter schema nullable to allow None values.
 
215
 
216
  def _combine_schemas_and_map_params(
217
  route: HTTPRoute,
218
+ convert_refs: bool = True,
219
  ) -> tuple[dict[str, Any], dict[str, dict[str, str]]]:
220
  """
221
  Combines parameter and request body schemas into a single schema.
 
247
 
248
  if route.request_body and route.request_body.content_schema:
249
  content_type = next(iter(route.request_body.content_schema))
250
+
251
+ # Convert refs if needed
252
+ if convert_refs:
253
+ body_schema = _ensure_refs_converted(
254
+ route.request_body.content_schema[content_type]
255
+ )
256
+ else:
257
+ body_schema = route.request_body.content_schema[content_type]
258
+
259
+ if route.request_body.description and not body_schema.get("description"):
260
+ body_schema["description"] = route.request_body.description
261
 
262
  # Handle allOf at the top level by merging all schemas
263
  if "allOf" in body_schema and isinstance(body_schema["allOf"], list):
 
308
  "openapi_name": param.name,
309
  }
310
 
311
+ # Convert refs if needed
312
+ if convert_refs:
313
+ param_schema = _ensure_refs_converted(param.schema_)
314
+ else:
315
+ param_schema = param.schema_
316
  original_desc = param_schema.get("description", "")
317
  location_desc = f"({param.location.capitalize()} parameter)"
318
  if original_desc:
 
335
  "openapi_name": param.name,
336
  }
337
 
338
+ # Convert refs if needed
339
+ if convert_refs:
340
+ param_schema = _ensure_refs_converted(param.schema_)
341
+ else:
342
+ param_schema = param.schema_
343
 
344
  # Don't make optional parameters nullable - they can simply be omitted
345
  # The OpenAPI specification doesn't require optional parameters to accept null values
 
348
 
349
  # Add request body properties (no suffixes for body parameters)
350
  if route.request_body and route.request_body.content_schema:
351
+ # If body is just a $ref, we need to handle it differently
352
+ if "$ref" in body_schema and not body_props:
353
+ # The entire body is a reference to a schema
354
+ # We need to expand this inline or keep the ref
355
+ # For simplicity, we'll keep it as a single property
356
+ properties["body"] = body_schema
357
+ if route.request_body.required:
358
+ required.append("body")
359
+ parameter_map["body"] = {"location": "body", "openapi_name": "body"}
360
+ else:
361
+ # Normal case: body has properties
362
+ for prop_name, prop_schema in body_props.items():
363
+ properties[prop_name] = prop_schema
364
 
365
+ # Track parameter mapping for body properties
366
+ parameter_map[prop_name] = {
367
+ "location": "body",
368
+ "openapi_name": prop_name,
369
+ }
370
 
371
+ if route.request_body.required:
372
+ required.extend(body_schema.get("required", []))
373
 
374
  result = {
375
  "type": "object",
 
378
  }
379
  # Add schema definitions if available
380
  if route.schema_definitions:
381
+ if convert_refs:
382
+ # Need to convert refs and prune
383
+ all_defs = route.schema_definitions.copy()
384
+ # Convert each schema definition recursively
385
+ for name, schema in all_defs.items():
386
+ if isinstance(schema, dict):
387
+ all_defs[name] = _replace_ref_with_defs_recursive(schema)
388
+
389
+ # Prune to only needed schemas
390
+ used_refs = set()
391
+
392
+ def find_refs_in_value(value):
393
+ """Recursively find all $ref references."""
394
+ if isinstance(value, dict):
395
+ if "$ref" in value and isinstance(value["$ref"], str):
396
+ ref = value["$ref"]
397
+ if ref.startswith("#/$defs/"):
398
+ used_refs.add(ref.split("/")[-1])
399
+ for v in value.values():
400
+ find_refs_in_value(v)
401
+ elif isinstance(value, list):
402
+ for item in value:
403
+ find_refs_in_value(item)
404
+
405
+ # Find refs in properties
406
+ find_refs_in_value(properties)
407
+
408
+ # Collect transitive dependencies
409
+ if used_refs:
410
+ collected_all = False
411
+ while not collected_all:
412
+ initial_count = len(used_refs)
413
+ for name in list(used_refs):
414
+ if name in all_defs:
415
+ find_refs_in_value(all_defs[name])
416
+ collected_all = len(used_refs) == initial_count
417
+
418
+ result["$defs"] = {
419
+ name: def_schema
420
+ for name, def_schema in all_defs.items()
421
+ if name in used_refs
422
+ }
423
  else:
424
+ # From parser - already converted and pruned
425
+ result["$defs"] = route.schema_definitions
426
 
427
  return result, parameter_map
428
 
 
513
  if not schema or not isinstance(schema, dict):
514
  return None
515
 
516
+ # Convert refs if needed
517
+ output_schema = _ensure_refs_converted(schema)
518
 
519
  # If schema has a $ref, resolve it first before processing nullable fields
520
  if "$ref" in output_schema and schema_definitions:
521
  ref_path = output_schema["$ref"]
522
+ if ref_path.startswith("#/$defs/"):
523
  schema_name = ref_path.split("/")[-1]
524
  if schema_name in schema_definitions:
525
  # Replace $ref with the actual schema definition
526
+ output_schema = _ensure_refs_converted(schema_definitions[schema_name])
527
 
528
  # Convert OpenAPI schema to JSON Schema format
529
  # Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
 
546
  }
547
  output_schema = wrapped_schema
548
 
549
+ # Add schema definitions if available
550
+ if schema_definitions:
551
+ # Convert refs if needed
552
+ processed_defs = schema_definitions.copy()
553
+ # Convert each schema definition recursively
554
+ for name, schema in processed_defs.items():
555
+ if isinstance(schema, dict):
556
+ processed_defs[name] = _replace_ref_with_defs_recursive(schema)
557
+
558
+ # Convert OpenAPI schema definitions to JSON Schema format if needed
559
+ if openapi_version and openapi_version.startswith("3.0"):
560
+ from .json_schema_converter import convert_openapi_schema_to_json_schema
561
+
562
+ for def_name in list(processed_defs.keys()):
563
  processed_defs[def_name] = convert_openapi_schema_to_json_schema(
564
+ processed_defs[def_name], openapi_version
565
  )
 
 
 
566
 
567
+ output_schema["$defs"] = processed_defs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
 
569
  return output_schema
570
 
 
575
  "_combine_schemas",
576
  "_combine_schemas_and_map_params",
577
  "extract_output_schema_from_responses",
 
578
  "_make_optional_parameter_nullable",
579
  ]
src/fastmcp/utilities/openapi.py CHANGED
@@ -212,7 +212,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
212
  if openapi_version.startswith("3.0"):
213
  # Use OpenAPI 3.0 models
214
  openapi_30 = OpenAPI_30.model_validate(openapi_dict)
215
- logger.info(
216
  f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
217
  )
218
  parser = OpenAPIParser(
@@ -230,7 +230,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
230
  else:
231
  # Default to OpenAPI 3.1 models
232
  openapi_31 = OpenAPI.model_validate(openapi_dict)
233
- logger.info(
234
  f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
235
  )
236
  parser = OpenAPIParser(
@@ -713,7 +713,7 @@ class OpenAPIParser(
713
  openapi_version=self.openapi_version,
714
  )
715
  routes.append(route)
716
- logger.info(
717
  f"Successfully extracted route: {method_upper} {path_str}"
718
  )
719
  except ValueError as op_error:
@@ -734,7 +734,7 @@ class OpenAPIParser(
734
  exc_info=True,
735
  )
736
 
737
- logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
738
  return routes
739
 
740
 
 
212
  if openapi_version.startswith("3.0"):
213
  # Use OpenAPI 3.0 models
214
  openapi_30 = OpenAPI_30.model_validate(openapi_dict)
215
+ logger.debug(
216
  f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
217
  )
218
  parser = OpenAPIParser(
 
230
  else:
231
  # Default to OpenAPI 3.1 models
232
  openapi_31 = OpenAPI.model_validate(openapi_dict)
233
+ logger.debug(
234
  f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
235
  )
236
  parser = OpenAPIParser(
 
713
  openapi_version=self.openapi_version,
714
  )
715
  routes.append(route)
716
+ logger.debug(
717
  f"Successfully extracted route: {method_upper} {path_str}"
718
  )
719
  except ValueError as op_error:
 
734
  exc_info=True,
735
  )
736
 
737
+ logger.debug(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
738
  return routes
739
 
740
 
tests/experimental/server/conftest.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared fixtures for openapi_new utilities tests."""
2
+
3
+ import pytest
4
+
5
+ from fastmcp.utilities.tests import temporary_settings
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def use_new_openapi_parser():
10
+ with temporary_settings(experimental__enable_new_openapi_parser=True):
11
+ yield
tests/experimental/server/{test_openapi_performance.py → openapi/test_openapi_performance.py} RENAMED
@@ -22,8 +22,8 @@ def use_new_openapi_parser():
22
  class TestOpenAPIPerformance:
23
  """Performance tests for OpenAPI parsing with real-world large schemas."""
24
 
25
- # 20 second maximum timeout for this test no matter what
26
- @pytest.mark.timeout(20)
27
  async def test_github_api_schema_performance(self):
28
  """
29
  Test that GitHub's full API schema parses quickly.
 
22
  class TestOpenAPIPerformance:
23
  """Performance tests for OpenAPI parsing with real-world large schemas."""
24
 
25
+ # 10 second maximum timeout for this test no matter what
26
+ @pytest.mark.timeout(10)
27
  async def test_github_api_schema_performance(self):
28
  """
29
  Test that GitHub's full API schema parses quickly.
tests/experimental/server/openapi/test_performance_comparison.py CHANGED
@@ -215,7 +215,7 @@ class TestPerformanceComparison:
215
 
216
  # Performance should be comparable (within reasonable margin)
217
  performance_ratio = max(new_avg, legacy_avg) / min(new_avg, legacy_avg)
218
- assert performance_ratio < 2.0, (
219
  f"Performance should be comparable, ratio: {performance_ratio:.2f}x"
220
  )
221
 
@@ -286,6 +286,6 @@ class TestPerformanceComparison:
286
  current_refs = len(gc.get_objects())
287
  # Allow reasonable memory growth but not exponential
288
  growth_ratio = current_refs / max(baseline_refs, 1)
289
- assert growth_ratio < 5, (
290
  f"Memory usage grew by {growth_ratio}x, which seems excessive"
291
  )
 
215
 
216
  # Performance should be comparable (within reasonable margin)
217
  performance_ratio = max(new_avg, legacy_avg) / min(new_avg, legacy_avg)
218
+ assert performance_ratio < 3.0, (
219
  f"Performance should be comparable, ratio: {performance_ratio:.2f}x"
220
  )
221
 
 
286
  current_refs = len(gc.get_objects())
287
  # Allow reasonable memory growth but not exponential
288
  growth_ratio = current_refs / max(baseline_refs, 1)
289
+ assert growth_ratio < 3.0, (
290
  f"Memory usage grew by {growth_ratio}x, which seems excessive"
291
  )
tests/experimental/utilities/openapi/conftest.py CHANGED
@@ -2,6 +2,14 @@
2
 
3
  import pytest
4
 
 
 
 
 
 
 
 
 
5
 
6
  @pytest.fixture
7
  def basic_openapi_30_spec():
 
2
 
3
  import pytest
4
 
5
+ from fastmcp.utilities.tests import temporary_settings
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def use_new_openapi_parser():
10
+ with temporary_settings(experimental__enable_new_openapi_parser=True):
11
+ yield
12
+
13
 
14
  @pytest.fixture
15
  def basic_openapi_30_spec():
tests/experimental/utilities/openapi/test_parser.py CHANGED
@@ -203,6 +203,135 @@ class TestOpenAPIParser:
203
  assert param.location == "path"
204
  assert param.required is True
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  def test_parameter_schema_extraction(self, complex_spec):
207
  """Test that parameter schemas are properly extracted."""
208
  routes = parse_openapi_to_http_routes(complex_spec)
 
203
  assert param.location == "path"
204
  assert param.required is True
205
 
206
+ def test_parse_simple_transitive_refs(self):
207
+ """Test that A->B->C transitive references are preserved.
208
+
209
+ When a request body references schema A, which references B, which references C:
210
+ - A is expanded inline (expected optimization)
211
+ - B and C MUST be included in $defs (the bug fix for #1372)
212
+ """
213
+ spec = {
214
+ "openapi": "3.0.0",
215
+ "info": {"title": "Test", "version": "1.0.0"},
216
+ "components": {
217
+ "schemas": {
218
+ "SchemaA": {
219
+ "type": "object",
220
+ "properties": {
221
+ "refToB": {"$ref": "#/components/schemas/SchemaB"}
222
+ },
223
+ },
224
+ "SchemaB": {
225
+ "type": "object",
226
+ "properties": {
227
+ "refToC": {"$ref": "#/components/schemas/SchemaC"}
228
+ },
229
+ },
230
+ "SchemaC": {
231
+ "type": "string",
232
+ "enum": ["value1", "value2"],
233
+ },
234
+ }
235
+ },
236
+ "paths": {
237
+ "/test": {
238
+ "post": {
239
+ "operationId": "test_op",
240
+ "requestBody": {
241
+ "content": {
242
+ "application/json": {
243
+ "schema": {"$ref": "#/components/schemas/SchemaA"}
244
+ }
245
+ }
246
+ },
247
+ "responses": {"200": {"description": "OK"}},
248
+ }
249
+ }
250
+ },
251
+ }
252
+
253
+ routes = parse_openapi_to_http_routes(spec)
254
+ route = routes[0]
255
+
256
+ # SchemaA is expanded inline, so it's NOT in schema_definitions
257
+ assert "SchemaA" not in route.schema_definitions
258
+
259
+ # But SchemaB and SchemaC MUST be there (transitive dependencies)
260
+ assert "SchemaB" in route.schema_definitions
261
+ assert "SchemaC" in route.schema_definitions
262
+
263
+ # Same in the flat parameter schema
264
+ assert "SchemaB" in route.flat_param_schema["$defs"]
265
+ assert "SchemaC" in route.flat_param_schema["$defs"]
266
+
267
+ def test_parse_tspicer_issue_1372(self):
268
+ """Reproduce the exact bug from issue #1372 (tspicer's report).
269
+
270
+ Issue: Profile -> {countryCode, AccountInfo} transitive refs were missing from $defs.
271
+ """
272
+ spec = {
273
+ "openapi": "3.0.1",
274
+ "info": {"title": "Test", "version": "1.0.0"},
275
+ "components": {
276
+ "schemas": {
277
+ "Profile": {
278
+ "type": "object",
279
+ "properties": {
280
+ "profileId": {"type": "integer"},
281
+ "countryCode": {"$ref": "#/components/schemas/countryCode"},
282
+ "accountInfo": {"$ref": "#/components/schemas/AccountInfo"},
283
+ },
284
+ },
285
+ "countryCode": {
286
+ "type": "string",
287
+ "enum": ["US", "UK", "CA", "AU"],
288
+ },
289
+ "AccountInfo": {
290
+ "type": "object",
291
+ "properties": {
292
+ "accountId": {"type": "string"},
293
+ "accountType": {"type": "string"},
294
+ },
295
+ },
296
+ }
297
+ },
298
+ "paths": {
299
+ "/profile": {
300
+ "post": {
301
+ "operationId": "create_profile",
302
+ "requestBody": {
303
+ "content": {
304
+ "application/json": {
305
+ "schema": {"$ref": "#/components/schemas/Profile"}
306
+ }
307
+ },
308
+ },
309
+ "responses": {"200": {"description": "OK"}},
310
+ }
311
+ }
312
+ },
313
+ }
314
+
315
+ routes = parse_openapi_to_http_routes(spec)
316
+ route = routes[0]
317
+
318
+ # Profile is expanded inline, NOT in schema_defs
319
+ assert "Profile" not in route.schema_definitions
320
+
321
+ # Bug fix: countryCode and AccountInfo MUST be in schema_defs
322
+ assert "countryCode" in route.schema_definitions # Was missing in #1372
323
+ assert "AccountInfo" in route.schema_definitions # Was missing in #1372
324
+
325
+ # Same in flat parameter schema
326
+ assert "countryCode" in route.flat_param_schema["$defs"]
327
+ assert "AccountInfo" in route.flat_param_schema["$defs"]
328
+
329
+ # Verify Profile's properties were inlined correctly
330
+ props = route.flat_param_schema["properties"]
331
+ assert "profileId" in props
332
+ assert props["countryCode"]["$ref"] == "#/$defs/countryCode"
333
+ assert props["accountInfo"]["$ref"] == "#/$defs/AccountInfo"
334
+
335
  def test_parameter_schema_extraction(self, complex_spec):
336
  """Test that parameter schemas are properly extracted."""
337
  routes = parse_openapi_to_http_routes(complex_spec)
tests/experimental/utilities/openapi/test_schemas.py CHANGED
@@ -10,8 +10,9 @@ from fastmcp.experimental.utilities.openapi.models import (
10
  from fastmcp.experimental.utilities.openapi.schemas import (
11
  _combine_schemas,
12
  _combine_schemas_and_map_params,
13
- _replace_ref_with_defs,
14
  )
 
15
 
16
 
17
  class TestSchemaProcessing:
@@ -230,6 +231,7 @@ class TestSchemaProcessing:
230
 
231
  def test_replace_ref_with_defs(self):
232
  """Test replacing $ref with $defs for JSON Schema compatibility."""
 
233
  schema_with_ref = {
234
  "type": "object",
235
  "properties": {
@@ -241,13 +243,15 @@ class TestSchemaProcessing:
241
  },
242
  }
243
 
244
- result = _replace_ref_with_defs(schema_with_ref)
 
245
 
246
  assert result["properties"]["user"]["$ref"] == "#/$defs/User"
247
  assert result["properties"]["items"]["items"]["$ref"] == "#/$defs/Item"
248
 
249
  def test_replace_ref_with_defs_nested(self):
250
  """Test replacing $ref in deeply nested structures."""
 
251
  nested_schema = {
252
  "type": "object",
253
  "properties": {
@@ -269,7 +273,8 @@ class TestSchemaProcessing:
269
  },
270
  }
271
 
272
- result = _replace_ref_with_defs(nested_schema)
 
273
 
274
  # Check nested object property
275
  nested_prop = result["properties"]["data"]["properties"]["nested"]
@@ -476,7 +481,6 @@ class TestEdgeCases:
476
 
477
  def test_oneof_reference_preserved(self):
478
  """Test that schemas referenced in oneOf are preserved."""
479
- from fastmcp.utilities.json_schema import compress_schema
480
 
481
  schema = {
482
  "type": "object",
@@ -497,7 +501,6 @@ class TestEdgeCases:
497
 
498
  def test_anyof_reference_preserved(self):
499
  """Test that schemas referenced in anyOf are preserved."""
500
- from fastmcp.utilities.json_schema import compress_schema
501
 
502
  schema = {
503
  "type": "object",
@@ -515,7 +518,6 @@ class TestEdgeCases:
515
 
516
  def test_allof_reference_preserved(self):
517
  """Test that schemas referenced in allOf are preserved."""
518
- from fastmcp.utilities.json_schema import compress_schema
519
 
520
  schema = {
521
  "type": "object",
 
10
  from fastmcp.experimental.utilities.openapi.schemas import (
11
  _combine_schemas,
12
  _combine_schemas_and_map_params,
13
+ _replace_ref_with_defs_recursive,
14
  )
15
+ from fastmcp.utilities.json_schema import compress_schema
16
 
17
 
18
  class TestSchemaProcessing:
 
231
 
232
  def test_replace_ref_with_defs(self):
233
  """Test replacing $ref with $defs for JSON Schema compatibility."""
234
+
235
  schema_with_ref = {
236
  "type": "object",
237
  "properties": {
 
243
  },
244
  }
245
 
246
+ # Use our recursive replacement approach
247
+ result = _replace_ref_with_defs_recursive(schema_with_ref)
248
 
249
  assert result["properties"]["user"]["$ref"] == "#/$defs/User"
250
  assert result["properties"]["items"]["items"]["$ref"] == "#/$defs/Item"
251
 
252
  def test_replace_ref_with_defs_nested(self):
253
  """Test replacing $ref in deeply nested structures."""
254
+
255
  nested_schema = {
256
  "type": "object",
257
  "properties": {
 
273
  },
274
  }
275
 
276
+ # Use our recursive replacement approach
277
+ result = _replace_ref_with_defs_recursive(nested_schema)
278
 
279
  # Check nested object property
280
  nested_prop = result["properties"]["data"]["properties"]["nested"]
 
481
 
482
  def test_oneof_reference_preserved(self):
483
  """Test that schemas referenced in oneOf are preserved."""
 
484
 
485
  schema = {
486
  "type": "object",
 
501
 
502
  def test_anyof_reference_preserved(self):
503
  """Test that schemas referenced in anyOf are preserved."""
 
504
 
505
  schema = {
506
  "type": "object",
 
518
 
519
  def test_allof_reference_preserved(self):
520
  """Test that schemas referenced in allOf are preserved."""
 
521
 
522
  schema = {
523
  "type": "object",
tests/experimental/utilities/openapi/test_transitive_references.py ADDED
@@ -0,0 +1,677 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Comprehensive tests for transitive and nested reference handling (Issue #1372)."""
2
+
3
+ from fastmcp.experimental.utilities.openapi.models import (
4
+ HTTPRoute,
5
+ ParameterInfo,
6
+ RequestBodyInfo,
7
+ ResponseInfo,
8
+ )
9
+ from fastmcp.experimental.utilities.openapi.schemas import (
10
+ _combine_schemas_and_map_params,
11
+ extract_output_schema_from_responses,
12
+ )
13
+
14
+
15
+ class TestTransitiveAndNestedReferences:
16
+ """Comprehensive tests for transitive and nested reference handling (Issue #1372)."""
17
+
18
+ def test_nested_refs_in_schema_definitions_converted(self):
19
+ """$refs inside schema definitions must be converted from OpenAPI to JSON Schema format."""
20
+ route = HTTPRoute(
21
+ path="/users/{id}",
22
+ method="POST",
23
+ operation_id="create_user",
24
+ parameters=[
25
+ ParameterInfo(
26
+ name="id", location="path", required=True, schema={"type": "string"}
27
+ )
28
+ ],
29
+ request_body=RequestBodyInfo(
30
+ required=True,
31
+ content_schema={
32
+ "application/json": {
33
+ "type": "object",
34
+ "properties": {"user": {"$ref": "#/components/schemas/User"}},
35
+ }
36
+ },
37
+ ),
38
+ schema_definitions={
39
+ "User": {
40
+ "type": "object",
41
+ "properties": {"profile": {"$ref": "#/components/schemas/Profile"}},
42
+ },
43
+ "Profile": {
44
+ "type": "object",
45
+ "properties": {"name": {"type": "string"}},
46
+ },
47
+ },
48
+ )
49
+
50
+ combined_schema, _ = _combine_schemas_and_map_params(route)
51
+
52
+ # Root level refs should be converted
53
+ assert combined_schema["properties"]["user"]["$ref"] == "#/$defs/User"
54
+
55
+ # Refs inside schema definitions should also be converted
56
+ user_def = combined_schema["$defs"]["User"]
57
+ assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile"
58
+
59
+ def test_transitive_dependencies_in_response_schemas(self):
60
+ """Transitive dependencies (A→B→C) must all be preserved in response schemas."""
61
+ # This mimics the exact structure reported in issue #1372
62
+ responses = {
63
+ "201": ResponseInfo(
64
+ description="User created",
65
+ content_schema={
66
+ "application/json": {"$ref": "#/components/schemas/User"}
67
+ },
68
+ )
69
+ }
70
+
71
+ schema_definitions = {
72
+ "User": {
73
+ "type": "object",
74
+ "properties": {
75
+ "id": {"type": "string"},
76
+ "profile": {"$ref": "#/components/schemas/Profile"},
77
+ },
78
+ "required": ["id", "profile"],
79
+ },
80
+ "Profile": {
81
+ "type": "object",
82
+ "properties": {
83
+ "name": {"type": "string"},
84
+ "address": {"$ref": "#/components/schemas/Address"},
85
+ },
86
+ "required": ["name", "address"],
87
+ },
88
+ "Address": {
89
+ "type": "object",
90
+ "properties": {
91
+ "street": {"type": "string"},
92
+ "city": {"type": "string"},
93
+ "zipcode": {"type": "string"},
94
+ },
95
+ "required": ["street", "city", "zipcode"],
96
+ },
97
+ }
98
+
99
+ result = extract_output_schema_from_responses(
100
+ responses, schema_definitions=schema_definitions, openapi_version="3.0.3"
101
+ )
102
+
103
+ # All transitive dependencies must be preserved
104
+ assert result is not None
105
+ assert "$defs" in result
106
+ assert "User" in result["$defs"], "User should be preserved"
107
+ assert "Profile" in result["$defs"], "Profile should be preserved"
108
+ assert "Address" in result["$defs"], "Address must be preserved (main bug)"
109
+
110
+ # All refs should be converted to #/$defs format
111
+ user_def = result["$defs"]["User"]
112
+ assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile"
113
+
114
+ profile_def = result["$defs"]["Profile"]
115
+ assert profile_def["properties"]["address"]["$ref"] == "#/$defs/Address"
116
+
117
+ def test_elongl_reported_case_xref_with_nullable_function(self):
118
+ """Test the specific case reported by elongl with nullable function reference."""
119
+ responses = {
120
+ "200": ResponseInfo(
121
+ description="Success",
122
+ content_schema={
123
+ "application/json": {
124
+ "type": "array",
125
+ "items": {"$ref": "#/components/schemas/Xref"},
126
+ }
127
+ },
128
+ )
129
+ }
130
+
131
+ schema_definitions = {
132
+ "Xref": {
133
+ "type": "object",
134
+ "properties": {
135
+ "address": {"type": "string", "title": "Address"},
136
+ "type": {"type": "string", "title": "Type"},
137
+ "function": {
138
+ "anyOf": [
139
+ {"$ref": "#/components/schemas/Function"},
140
+ {"type": "null"},
141
+ ]
142
+ },
143
+ },
144
+ "required": ["address", "type", "function"],
145
+ "title": "Xref",
146
+ },
147
+ "Function": {
148
+ "type": "object",
149
+ "properties": {
150
+ "name": {"type": "string"},
151
+ "address": {"type": "string"},
152
+ },
153
+ "title": "Function",
154
+ },
155
+ }
156
+
157
+ result = extract_output_schema_from_responses(
158
+ responses, schema_definitions=schema_definitions
159
+ )
160
+
161
+ # Function must be included in $defs
162
+ assert result is not None
163
+ assert "$defs" in result
164
+ assert "Xref" in result["$defs"], "Xref should be preserved"
165
+ assert "Function" in result["$defs"], (
166
+ "Function must be preserved (reported bug)"
167
+ )
168
+
169
+ # Refs in anyOf should be converted
170
+ xref_def = result["$defs"]["Xref"]
171
+ function_prop = xref_def["properties"]["function"]
172
+ assert function_prop["anyOf"][0]["$ref"] == "#/$defs/Function"
173
+
174
+ def test_tspicer_reported_case_profile_with_nested_refs(self):
175
+ """Test the specific case reported by tspicer with Profile->countryCode->AccountInfo."""
176
+ route = HTTPRoute(
177
+ path="/profile",
178
+ method="POST",
179
+ operation_id="create_profile",
180
+ request_body=RequestBodyInfo(
181
+ required=True,
182
+ content_schema={
183
+ "application/json": {"$ref": "#/components/schemas/Profile"}
184
+ },
185
+ ),
186
+ schema_definitions={
187
+ "Profile": {
188
+ "type": "object",
189
+ "properties": {
190
+ "profileId": {"type": "integer"},
191
+ "countryCode": {"$ref": "#/components/schemas/countryCode"},
192
+ "accountInfo": {"$ref": "#/components/schemas/AccountInfo"},
193
+ },
194
+ },
195
+ "countryCode": {
196
+ "type": "string",
197
+ "enum": ["US", "UK", "CA", "AU"],
198
+ },
199
+ "AccountInfo": {
200
+ "type": "object",
201
+ "properties": {
202
+ "accountId": {"type": "string"},
203
+ "accountType": {"type": "string"},
204
+ },
205
+ },
206
+ },
207
+ )
208
+
209
+ combined_schema, _ = _combine_schemas_and_map_params(route)
210
+
211
+ # All referenced schemas must be included in $defs
212
+ assert "Profile" in combined_schema["$defs"], "Profile should be preserved"
213
+ assert "countryCode" in combined_schema["$defs"], (
214
+ "countryCode must be preserved"
215
+ )
216
+ assert "AccountInfo" in combined_schema["$defs"], (
217
+ "AccountInfo must be preserved"
218
+ )
219
+
220
+ # All refs should be converted
221
+ profile_def = combined_schema["$defs"]["Profile"]
222
+ assert profile_def["properties"]["countryCode"]["$ref"] == "#/$defs/countryCode"
223
+ assert profile_def["properties"]["accountInfo"]["$ref"] == "#/$defs/AccountInfo"
224
+
225
+ def test_transitive_refs_in_request_body_schemas(self):
226
+ """Transitive $refs in request body schemas must be preserved and converted."""
227
+ route = HTTPRoute(
228
+ path="/users",
229
+ method="POST",
230
+ operation_id="create_user",
231
+ request_body=RequestBodyInfo(
232
+ required=True,
233
+ content_schema={
234
+ "application/json": {"$ref": "#/components/schemas/User"}
235
+ },
236
+ ),
237
+ schema_definitions={
238
+ "User": {
239
+ "type": "object",
240
+ "properties": {
241
+ "id": {"type": "string"},
242
+ "profile": {"$ref": "#/components/schemas/Profile"},
243
+ },
244
+ "required": ["id", "profile"],
245
+ },
246
+ "Profile": {
247
+ "type": "object",
248
+ "properties": {
249
+ "name": {"type": "string"},
250
+ "address": {"$ref": "#/components/schemas/Address"},
251
+ },
252
+ "required": ["name", "address"],
253
+ },
254
+ "Address": {
255
+ "type": "object",
256
+ "properties": {
257
+ "street": {"type": "string"},
258
+ "city": {"type": "string"},
259
+ "zipcode": {"type": "string"},
260
+ },
261
+ "required": ["street", "city", "zipcode"],
262
+ },
263
+ },
264
+ )
265
+
266
+ combined_schema, _ = _combine_schemas_and_map_params(route)
267
+
268
+ # All transitive dependencies should be preserved
269
+ assert "User" in combined_schema["$defs"]
270
+ assert "Profile" in combined_schema["$defs"]
271
+ assert "Address" in combined_schema["$defs"]
272
+
273
+ # All internal refs should be converted to #/$defs format
274
+ user_def = combined_schema["$defs"]["User"]
275
+ assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile"
276
+
277
+ profile_def = combined_schema["$defs"]["Profile"]
278
+ assert profile_def["properties"]["address"]["$ref"] == "#/$defs/Address"
279
+
280
+ def test_refs_in_array_items_converted(self):
281
+ """$refs inside array items must be converted from OpenAPI to JSON Schema format."""
282
+ route = HTTPRoute(
283
+ path="/users",
284
+ method="POST",
285
+ operation_id="create_users",
286
+ request_body=RequestBodyInfo(
287
+ required=True,
288
+ content_schema={
289
+ "application/json": {
290
+ "type": "object",
291
+ "properties": {
292
+ "users": {
293
+ "type": "array",
294
+ "items": {"$ref": "#/components/schemas/User"},
295
+ }
296
+ },
297
+ }
298
+ },
299
+ ),
300
+ schema_definitions={
301
+ "User": {
302
+ "type": "object",
303
+ "properties": {"profile": {"$ref": "#/components/schemas/Profile"}},
304
+ },
305
+ "Profile": {
306
+ "type": "object",
307
+ "properties": {"name": {"type": "string"}},
308
+ },
309
+ },
310
+ )
311
+
312
+ combined_schema, _ = _combine_schemas_and_map_params(route)
313
+
314
+ # Array item refs should be converted
315
+ assert combined_schema["properties"]["users"]["items"]["$ref"] == "#/$defs/User"
316
+
317
+ # Nested refs should be converted
318
+ user_def = combined_schema["$defs"]["User"]
319
+ assert user_def["properties"]["profile"]["$ref"] == "#/$defs/Profile"
320
+
321
+ def test_refs_in_composition_keywords_converted(self):
322
+ """$refs inside oneOf/anyOf/allOf must be converted from OpenAPI to JSON Schema format."""
323
+ route = HTTPRoute(
324
+ path="/data",
325
+ method="POST",
326
+ operation_id="create_data",
327
+ request_body=RequestBodyInfo(
328
+ required=True,
329
+ content_schema={
330
+ "application/json": {
331
+ "type": "object",
332
+ "properties": {
333
+ "data": {
334
+ "oneOf": [
335
+ {"$ref": "#/components/schemas/TypeA"},
336
+ {"$ref": "#/components/schemas/TypeB"},
337
+ ]
338
+ },
339
+ "alternate": {
340
+ "anyOf": [
341
+ {"$ref": "#/components/schemas/TypeC"},
342
+ {"$ref": "#/components/schemas/TypeD"},
343
+ ]
344
+ },
345
+ "combined": {
346
+ "allOf": [
347
+ {"$ref": "#/components/schemas/BaseType"},
348
+ {"properties": {"extra": {"type": "string"}}},
349
+ ]
350
+ },
351
+ },
352
+ }
353
+ },
354
+ ),
355
+ schema_definitions={
356
+ "TypeA": {
357
+ "type": "object",
358
+ "properties": {"nested": {"$ref": "#/components/schemas/Nested"}},
359
+ },
360
+ "TypeB": {
361
+ "type": "object",
362
+ "properties": {"value": {"type": "string"}},
363
+ },
364
+ "TypeC": {"type": "string"},
365
+ "TypeD": {"type": "number"},
366
+ "BaseType": {
367
+ "type": "object",
368
+ "properties": {"base": {"type": "string"}},
369
+ },
370
+ "Nested": {"type": "string"},
371
+ },
372
+ )
373
+
374
+ combined_schema, _ = _combine_schemas_and_map_params(route)
375
+
376
+ # oneOf refs should be converted
377
+ oneof_refs = combined_schema["properties"]["data"]["oneOf"]
378
+ assert oneof_refs[0]["$ref"] == "#/$defs/TypeA"
379
+ assert oneof_refs[1]["$ref"] == "#/$defs/TypeB"
380
+
381
+ # anyOf refs should be converted
382
+ anyof_refs = combined_schema["properties"]["alternate"]["anyOf"]
383
+ assert anyof_refs[0]["$ref"] == "#/$defs/TypeC"
384
+ assert anyof_refs[1]["$ref"] == "#/$defs/TypeD"
385
+
386
+ # allOf refs should be converted
387
+ allof_refs = combined_schema["properties"]["combined"]["allOf"]
388
+ assert allof_refs[0]["$ref"] == "#/$defs/BaseType"
389
+
390
+ # Transitive refs should be converted
391
+ type_a_def = combined_schema["$defs"]["TypeA"]
392
+ assert type_a_def["properties"]["nested"]["$ref"] == "#/$defs/Nested"
393
+
394
+ def test_deeply_nested_transitive_refs_preserved(self):
395
+ """Deeply nested transitive refs (A→B→C→D→E) must all be preserved."""
396
+ route = HTTPRoute(
397
+ path="/deep",
398
+ method="POST",
399
+ operation_id="create_deep",
400
+ request_body=RequestBodyInfo(
401
+ required=True,
402
+ content_schema={
403
+ "application/json": {"$ref": "#/components/schemas/Level1"}
404
+ },
405
+ ),
406
+ schema_definitions={
407
+ "Level1": {
408
+ "type": "object",
409
+ "properties": {"level2": {"$ref": "#/components/schemas/Level2"}},
410
+ },
411
+ "Level2": {
412
+ "type": "object",
413
+ "properties": {"level3": {"$ref": "#/components/schemas/Level3"}},
414
+ },
415
+ "Level3": {
416
+ "type": "object",
417
+ "properties": {"level4": {"$ref": "#/components/schemas/Level4"}},
418
+ },
419
+ "Level4": {
420
+ "type": "object",
421
+ "properties": {"level5": {"$ref": "#/components/schemas/Level5"}},
422
+ },
423
+ "Level5": {
424
+ "type": "object",
425
+ "properties": {"value": {"type": "string"}},
426
+ },
427
+ "UnusedSchema": {"type": "number"},
428
+ },
429
+ )
430
+
431
+ combined_schema, _ = _combine_schemas_and_map_params(route)
432
+
433
+ # All levels should be preserved
434
+ assert "Level1" in combined_schema["$defs"]
435
+ assert "Level2" in combined_schema["$defs"]
436
+ assert "Level3" in combined_schema["$defs"]
437
+ assert "Level4" in combined_schema["$defs"]
438
+ assert "Level5" in combined_schema["$defs"]
439
+
440
+ # Unused should be removed (pruning is allowed for unused schemas)
441
+ assert "UnusedSchema" not in combined_schema["$defs"]
442
+
443
+ # All refs should be converted
444
+ assert (
445
+ combined_schema["$defs"]["Level1"]["properties"]["level2"]["$ref"]
446
+ == "#/$defs/Level2"
447
+ )
448
+ assert (
449
+ combined_schema["$defs"]["Level2"]["properties"]["level3"]["$ref"]
450
+ == "#/$defs/Level3"
451
+ )
452
+ assert (
453
+ combined_schema["$defs"]["Level3"]["properties"]["level4"]["$ref"]
454
+ == "#/$defs/Level4"
455
+ )
456
+ assert (
457
+ combined_schema["$defs"]["Level4"]["properties"]["level5"]["$ref"]
458
+ == "#/$defs/Level5"
459
+ )
460
+
461
+ def test_circular_references_handled(self):
462
+ """Circular references (A→B→A) must be handled without infinite loops."""
463
+ route = HTTPRoute(
464
+ path="/circular",
465
+ method="POST",
466
+ operation_id="circular_test",
467
+ request_body=RequestBodyInfo(
468
+ required=True,
469
+ content_schema={
470
+ "application/json": {"$ref": "#/components/schemas/Node"}
471
+ },
472
+ ),
473
+ schema_definitions={
474
+ "Node": {
475
+ "type": "object",
476
+ "properties": {
477
+ "value": {"type": "string"},
478
+ "children": {
479
+ "type": "array",
480
+ "items": {"$ref": "#/components/schemas/Node"},
481
+ },
482
+ },
483
+ },
484
+ },
485
+ )
486
+
487
+ combined_schema, _ = _combine_schemas_and_map_params(route)
488
+
489
+ # Node should be preserved
490
+ assert "Node" in combined_schema["$defs"]
491
+
492
+ # Self-reference should be converted
493
+ node_def = combined_schema["$defs"]["Node"]
494
+ assert node_def["properties"]["children"]["items"]["$ref"] == "#/$defs/Node"
495
+
496
+ def test_multiple_reference_paths_to_same_schema(self):
497
+ """Multiple paths to the same schema (diamond pattern) must preserve the schema."""
498
+ route = HTTPRoute(
499
+ path="/diamond",
500
+ method="POST",
501
+ operation_id="diamond_test",
502
+ request_body=RequestBodyInfo(
503
+ required=True,
504
+ content_schema={
505
+ "application/json": {
506
+ "type": "object",
507
+ "properties": {
508
+ "left": {"$ref": "#/components/schemas/Left"},
509
+ "right": {"$ref": "#/components/schemas/Right"},
510
+ },
511
+ }
512
+ },
513
+ ),
514
+ schema_definitions={
515
+ "Left": {
516
+ "type": "object",
517
+ "properties": {"shared": {"$ref": "#/components/schemas/Shared"}},
518
+ },
519
+ "Right": {
520
+ "type": "object",
521
+ "properties": {"shared": {"$ref": "#/components/schemas/Shared"}},
522
+ },
523
+ "Shared": {
524
+ "type": "object",
525
+ "properties": {"value": {"type": "string"}},
526
+ },
527
+ },
528
+ )
529
+
530
+ combined_schema, _ = _combine_schemas_and_map_params(route)
531
+
532
+ # All schemas should be preserved
533
+ assert "Left" in combined_schema["$defs"]
534
+ assert "Right" in combined_schema["$defs"]
535
+ assert "Shared" in combined_schema["$defs"]
536
+
537
+ # All refs should be converted
538
+ assert combined_schema["properties"]["left"]["$ref"] == "#/$defs/Left"
539
+ assert combined_schema["properties"]["right"]["$ref"] == "#/$defs/Right"
540
+ assert (
541
+ combined_schema["$defs"]["Left"]["properties"]["shared"]["$ref"]
542
+ == "#/$defs/Shared"
543
+ )
544
+ assert (
545
+ combined_schema["$defs"]["Right"]["properties"]["shared"]["$ref"]
546
+ == "#/$defs/Shared"
547
+ )
548
+
549
+ def test_refs_in_nested_content_schemas(self):
550
+ """$refs in nested content schemas (the original bug location) must be converted."""
551
+ route = HTTPRoute(
552
+ path="/content",
553
+ method="POST",
554
+ operation_id="content_test",
555
+ request_body=RequestBodyInfo(
556
+ required=True,
557
+ content_schema={
558
+ "application/json": {"$ref": "#/components/schemas/Content"}
559
+ },
560
+ ),
561
+ schema_definitions={
562
+ "Content": {
563
+ "type": "object",
564
+ "properties": {
565
+ "media": {
566
+ "type": "object",
567
+ "properties": {
568
+ "application/json": {
569
+ "$ref": "#/components/schemas/JsonContent"
570
+ }
571
+ },
572
+ }
573
+ },
574
+ },
575
+ "JsonContent": {
576
+ "type": "object",
577
+ "properties": {"data": {"type": "string"}},
578
+ },
579
+ },
580
+ )
581
+
582
+ combined_schema, _ = _combine_schemas_and_map_params(route)
583
+
584
+ # Both schemas should be preserved
585
+ assert "Content" in combined_schema["$defs"]
586
+ assert "JsonContent" in combined_schema["$defs"]
587
+
588
+ # Nested ref should be converted
589
+ content_def = combined_schema["$defs"]["Content"]
590
+ nested_ref = content_def["properties"]["media"]["properties"][
591
+ "application/json"
592
+ ]
593
+ assert nested_ref["$ref"] == "#/$defs/JsonContent"
594
+
595
+ def test_unnecessary_defs_preserved_when_referenced(self):
596
+ """Even seemingly unnecessary $defs must be preserved if they're referenced."""
597
+ route = HTTPRoute(
598
+ path="/test",
599
+ method="POST",
600
+ operation_id="test_unnecessary",
601
+ request_body=RequestBodyInfo(
602
+ required=True,
603
+ content_schema={
604
+ "application/json": {
605
+ "type": "object",
606
+ "properties": {
607
+ # Reference to a simple type schema
608
+ "simple": {"$ref": "#/components/schemas/SimpleString"},
609
+ # Reference to an empty object schema
610
+ "empty": {"$ref": "#/components/schemas/EmptyObject"},
611
+ },
612
+ }
613
+ },
614
+ ),
615
+ schema_definitions={
616
+ "SimpleString": {"type": "string"},
617
+ "EmptyObject": {"type": "object"},
618
+ "UnreferencedSchema": {"type": "number"},
619
+ },
620
+ )
621
+
622
+ combined_schema, _ = _combine_schemas_and_map_params(route)
623
+
624
+ # Referenced schemas should be preserved even if simple
625
+ assert "SimpleString" in combined_schema["$defs"]
626
+ assert "EmptyObject" in combined_schema["$defs"]
627
+
628
+ # Unreferenced should be removed
629
+ assert "UnreferencedSchema" not in combined_schema["$defs"]
630
+
631
+ # Refs should be converted
632
+ assert combined_schema["properties"]["simple"]["$ref"] == "#/$defs/SimpleString"
633
+ assert combined_schema["properties"]["empty"]["$ref"] == "#/$defs/EmptyObject"
634
+
635
+ def test_ref_only_request_body_handled(self):
636
+ """Request bodies that are just a $ref (not an object with properties) must work."""
637
+ route = HTTPRoute(
638
+ path="/direct-ref",
639
+ method="POST",
640
+ operation_id="direct_ref_test",
641
+ request_body=RequestBodyInfo(
642
+ required=True,
643
+ content_schema={
644
+ # Direct $ref, not wrapped in an object
645
+ "application/json": {"$ref": "#/components/schemas/DirectBody"}
646
+ },
647
+ ),
648
+ schema_definitions={
649
+ "DirectBody": {
650
+ "type": "object",
651
+ "properties": {
652
+ "field1": {"type": "string"},
653
+ "nested": {"$ref": "#/components/schemas/NestedBody"},
654
+ },
655
+ },
656
+ "NestedBody": {
657
+ "type": "object",
658
+ "properties": {"field2": {"type": "number"}},
659
+ },
660
+ },
661
+ )
662
+
663
+ combined_schema, _ = _combine_schemas_and_map_params(route)
664
+
665
+ # Should handle the direct ref properly
666
+ assert "body" in combined_schema["properties"]
667
+ assert combined_schema["properties"]["body"]["$ref"] == "#/$defs/DirectBody"
668
+
669
+ # Both schemas should be preserved
670
+ assert "DirectBody" in combined_schema["$defs"]
671
+ assert "NestedBody" in combined_schema["$defs"]
672
+
673
+ # Nested ref should be converted
674
+ assert (
675
+ combined_schema["$defs"]["DirectBody"]["properties"]["nested"]["$ref"]
676
+ == "#/$defs/NestedBody"
677
+ )
uv.lock CHANGED
@@ -530,6 +530,7 @@ dependencies = [
530
  { name = "exceptiongroup" },
531
  { name = "httpx" },
532
  { name = "mcp" },
 
533
  { name = "openapi-core" },
534
  { name = "openapi-pydantic" },
535
  { name = "pydantic", extra = ["email"] },
@@ -574,6 +575,7 @@ requires-dist = [
574
  { name = "exceptiongroup", specifier = ">=1.2.2" },
575
  { name = "httpx", specifier = ">=0.28.1" },
576
  { name = "mcp", specifier = ">=1.10.0" },
 
577
  { name = "openapi-core", specifier = ">=0.19.5" },
578
  { name = "openapi-pydantic", specifier = ">=0.5.1" },
579
  { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
@@ -979,6 +981,42 @@ wheels = [
979
  { 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" },
980
  ]
981
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
982
  [[package]]
983
  name = "nodeenv"
984
  version = "1.9.1"
 
530
  { name = "exceptiongroup" },
531
  { name = "httpx" },
532
  { name = "mcp" },
533
+ { name = "msgspec" },
534
  { name = "openapi-core" },
535
  { name = "openapi-pydantic" },
536
  { name = "pydantic", extra = ["email"] },
 
575
  { name = "exceptiongroup", specifier = ">=1.2.2" },
576
  { name = "httpx", specifier = ">=0.28.1" },
577
  { name = "mcp", specifier = ">=1.10.0" },
578
+ { name = "msgspec", specifier = ">=0.19.0" },
579
  { name = "openapi-core", specifier = ">=0.19.5" },
580
  { name = "openapi-pydantic", specifier = ">=0.5.1" },
581
  { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
 
981
  { 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" },
982
  ]
983
 
984
+ [[package]]
985
+ name = "msgspec"
986
+ version = "0.19.0"
987
+ source = { registry = "https://pypi.org/simple" }
988
+ 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" }
989
+ wheels = [
990
+ { 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" },
991
+ { 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" },
992
+ { 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" },
993
+ { 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" },
994
+ { 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" },
995
+ { 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" },
996
+ { 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" },
997
+ { 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" },
998
+ { 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" },
999
+ { 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" },
1000
+ { 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" },
1001
+ { 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" },
1002
+ { 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" },
1003
+ { 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" },
1004
+ { 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" },
1005
+ { 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" },
1006
+ { 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" },
1007
+ { 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" },
1008
+ { 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" },
1009
+ { 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" },
1010
+ { 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" },
1011
+ { 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" },
1012
+ { 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" },
1013
+ { 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" },
1014
+ { 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" },
1015
+ { 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" },
1016
+ { 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" },
1017
+ { 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" },
1018
+ ]
1019
+
1020
  [[package]]
1021
  name = "nodeenv"
1022
  version = "1.9.1"