Jeremiah Lowin commited on
Commit
5050a56
·
1 Parent(s): e1a4799

Ensure openapi defs are loaded

Browse files
src/fastmcp/utilities/json_schema.py CHANGED
@@ -14,6 +14,7 @@ def _prune_param(schema: dict, param: str) -> dict:
14
  removed = props.pop(param, None)
15
  if removed is None: # nothing to do
16
  return schema
 
17
  # Keep empty properties object rather than removing it entirely
18
  schema["properties"] = props
19
  if param in schema.get("required", []):
@@ -21,7 +22,12 @@ def _prune_param(schema: dict, param: str) -> dict:
21
  if not schema["required"]:
22
  schema.pop("required")
23
 
24
- # ── 2. collect all remaining local $ref targets ───────────────────
 
 
 
 
 
25
  used_defs: set[str] = set()
26
 
27
  def walk(node: object) -> None: # depth-first traversal
@@ -37,7 +43,8 @@ def _prune_param(schema: dict, param: str) -> dict:
37
 
38
  walk(schema)
39
 
40
- # ── 3. remove orphaned definitions ────────────────────────────────
 
41
  defs = schema.get("$defs", {})
42
  for def_name in list(defs):
43
  if def_name not in used_defs:
@@ -48,12 +55,28 @@ def _prune_param(schema: dict, param: str) -> dict:
48
  return schema
49
 
50
 
51
- def prune_params(schema: dict, params: list[str]) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
52
  """
53
  Remove the given parameters from the schema.
54
 
55
  """
56
  schema = copy.deepcopy(schema)
57
- for param in params:
58
  schema = _prune_param(schema, param=param)
 
 
 
 
59
  return schema
 
14
  removed = props.pop(param, None)
15
  if removed is None: # nothing to do
16
  return schema
17
+
18
  # Keep empty properties object rather than removing it entirely
19
  schema["properties"] = props
20
  if param in schema.get("required", []):
 
22
  if not schema["required"]:
23
  schema.pop("required")
24
 
25
+ return schema
26
+
27
+
28
+ def _prune_unused_defs(schema: dict) -> dict:
29
+ """Remove unused definitions from the schema."""
30
+ # collect all remaining local $ref targets
31
  used_defs: set[str] = set()
32
 
33
  def walk(node: object) -> None: # depth-first traversal
 
43
 
44
  walk(schema)
45
 
46
+ # remove orphaned definitions
47
+
48
  defs = schema.get("$defs", {})
49
  for def_name in list(defs):
50
  if def_name not in used_defs:
 
55
  return schema
56
 
57
 
58
+ def _prune_additional_properties(schema: dict) -> dict:
59
+ """Remove additionalProperties from the schema if it is False."""
60
+ if schema.get("additionalProperties", None) is False:
61
+ schema.pop("additionalProperties")
62
+ return schema
63
+
64
+
65
+ def compress_schema(
66
+ schema: dict,
67
+ prune_params: list[str] | None = None,
68
+ prune_defs: bool = True,
69
+ prune_additional_properties: bool = True,
70
+ ) -> dict:
71
  """
72
  Remove the given parameters from the schema.
73
 
74
  """
75
  schema = copy.deepcopy(schema)
76
+ for param in prune_params or []:
77
  schema = _prune_param(schema, param=param)
78
+ if prune_defs:
79
+ schema = _prune_unused_defs(schema)
80
+ if prune_additional_properties:
81
+ schema = _prune_additional_properties(schema)
82
  return schema
src/fastmcp/utilities/openapi.py CHANGED
@@ -84,6 +84,9 @@ class HTTPRoute(BaseModel):
84
  responses: dict[str, ResponseInfo] = Field(
85
  default_factory=dict
86
  ) # Key: status code str
 
 
 
87
 
88
 
89
  # Export public symbols
@@ -221,6 +224,27 @@ class OpenAPI31Parser(BaseOpenAPIParser):
221
  logger.warning("OpenAPI schema has no paths defined.")
222
  return []
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  for path_str, path_item_obj in self.openapi.paths.items():
225
  if not isinstance(path_item_obj, PathItem):
226
  logger.warning(
@@ -269,6 +293,7 @@ class OpenAPI31Parser(BaseOpenAPIParser):
269
  parameters=parameters,
270
  request_body=request_body_info,
271
  responses=responses,
 
272
  )
273
  routes.append(route)
274
  logger.info(
@@ -386,16 +411,36 @@ class OpenAPI31Parser(BaseOpenAPIParser):
386
 
387
  param_schema_dict = {}
388
  if param_schema_obj: # Check if schema exists
 
 
389
  param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
 
 
 
 
 
 
 
 
390
  elif parameter.content:
391
  # Handle complex parameters with 'content'
392
  first_media_type = next(iter(parameter.content.values()), None)
393
  if (
394
  first_media_type and first_media_type.media_type_schema
395
  ): # CORRECTED: Use 'media_type_schema'
396
- param_schema_dict = self._extract_schema_as_dict(
397
- first_media_type.media_type_schema
398
- )
 
 
 
 
 
 
 
 
 
 
399
  logger.debug(
400
  f"Parameter '{parameter.name}' using schema from 'content' field."
401
  )
@@ -543,6 +588,27 @@ class OpenAPI30Parser(BaseOpenAPIParser):
543
  logger.warning("OpenAPI schema has no paths defined.")
544
  return []
545
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  for path_str, path_item_obj in self.openapi.paths.items():
547
  if not isinstance(path_item_obj, PathItem_30):
548
  logger.warning(
@@ -593,6 +659,7 @@ class OpenAPI30Parser(BaseOpenAPIParser):
593
  parameters=parameters,
594
  request_body=request_body_info,
595
  responses=responses,
 
596
  )
597
  routes.append(route)
598
  logger.info(
@@ -711,14 +778,34 @@ class OpenAPI30Parser(BaseOpenAPIParser):
711
 
712
  param_schema_dict = {}
713
  if param_schema_obj: # Check if schema exists
 
 
714
  param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
 
 
 
 
 
 
 
 
715
  elif parameter.content:
716
  # Handle complex parameters with 'content'
717
  first_media_type = next(iter(parameter.content.values()), None)
718
  if first_media_type and first_media_type.media_type_schema:
719
- param_schema_dict = self._extract_schema_as_dict(
720
- first_media_type.media_type_schema
721
- )
 
 
 
 
 
 
 
 
 
 
722
  logger.debug(
723
  f"Parameter '{parameter.name}' using schema from 'content' field."
724
  )
@@ -1173,6 +1260,23 @@ def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
1173
  # Copy the schema and add description if available
1174
  param_schema = param.schema_.copy() if isinstance(param.schema_, dict) else {}
1175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1176
  # Add parameter description to schema if available and not already present
1177
  if param.description and not param_schema.get("description"):
1178
  param_schema["description"] = param.description
@@ -1193,8 +1297,19 @@ def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
1193
  if route.request_body.required:
1194
  required.extend(body_schema.get("required", []))
1195
 
1196
- return {
1197
  "type": "object",
1198
  "properties": properties,
1199
  "required": required,
1200
  }
 
 
 
 
 
 
 
 
 
 
 
 
84
  responses: dict[str, ResponseInfo] = Field(
85
  default_factory=dict
86
  ) # Key: status code str
87
+ schema_definitions: dict[str, JsonSchema] = Field(
88
+ default_factory=dict
89
+ ) # Store component schemas
90
 
91
 
92
  # Export public symbols
 
224
  logger.warning("OpenAPI schema has no paths defined.")
225
  return []
226
 
227
+ # Extract component schemas to add to each route
228
+ schema_definitions = {}
229
+ if hasattr(self.openapi, "components") and self.openapi.components:
230
+ components = self.openapi.components
231
+ if hasattr(components, "schemas") and components.schemas:
232
+ for name, schema in components.schemas.items():
233
+ try:
234
+ if isinstance(schema, Reference):
235
+ resolved_schema = self._resolve_ref(schema)
236
+ schema_definitions[name] = self._extract_schema_as_dict(
237
+ resolved_schema
238
+ )
239
+ else:
240
+ schema_definitions[name] = self._extract_schema_as_dict(
241
+ schema
242
+ )
243
+ except Exception as e:
244
+ logger.warning(
245
+ f"Failed to extract schema definition '{name}': {e}"
246
+ )
247
+
248
  for path_str, path_item_obj in self.openapi.paths.items():
249
  if not isinstance(path_item_obj, PathItem):
250
  logger.warning(
 
293
  parameters=parameters,
294
  request_body=request_body_info,
295
  responses=responses,
296
+ schema_definitions=schema_definitions,
297
  )
298
  routes.append(route)
299
  logger.info(
 
411
 
412
  param_schema_dict = {}
413
  if param_schema_obj: # Check if schema exists
414
+ # Resolve the schema if it's a reference
415
+ resolved_schema = self._resolve_ref(param_schema_obj)
416
  param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
417
+
418
+ # Ensure default value is preserved from resolved schema
419
+ if (
420
+ not isinstance(resolved_schema, Reference)
421
+ and hasattr(resolved_schema, "default")
422
+ and resolved_schema.default is not None
423
+ ):
424
+ param_schema_dict["default"] = resolved_schema.default
425
  elif parameter.content:
426
  # Handle complex parameters with 'content'
427
  first_media_type = next(iter(parameter.content.values()), None)
428
  if (
429
  first_media_type and first_media_type.media_type_schema
430
  ): # CORRECTED: Use 'media_type_schema'
431
+ # Resolve the schema if it's a reference
432
+ media_schema = first_media_type.media_type_schema
433
+ resolved_media_schema = self._resolve_ref(media_schema)
434
+ param_schema_dict = self._extract_schema_as_dict(media_schema)
435
+
436
+ # Ensure default value is preserved from resolved schema
437
+ if (
438
+ not isinstance(resolved_media_schema, Reference)
439
+ and hasattr(resolved_media_schema, "default")
440
+ and resolved_media_schema.default is not None
441
+ ):
442
+ param_schema_dict["default"] = resolved_media_schema.default
443
+
444
  logger.debug(
445
  f"Parameter '{parameter.name}' using schema from 'content' field."
446
  )
 
588
  logger.warning("OpenAPI schema has no paths defined.")
589
  return []
590
 
591
+ # Extract component schemas to add to each route
592
+ schema_definitions = {}
593
+ if hasattr(self.openapi, "components") and self.openapi.components:
594
+ components = self.openapi.components
595
+ if hasattr(components, "schemas") and components.schemas:
596
+ for name, schema in components.schemas.items():
597
+ try:
598
+ if isinstance(schema, Reference_30):
599
+ resolved_schema = self._resolve_ref(schema)
600
+ schema_definitions[name] = self._extract_schema_as_dict(
601
+ resolved_schema
602
+ )
603
+ else:
604
+ schema_definitions[name] = self._extract_schema_as_dict(
605
+ schema
606
+ )
607
+ except Exception as e:
608
+ logger.warning(
609
+ f"Failed to extract schema definition '{name}': {e}"
610
+ )
611
+
612
  for path_str, path_item_obj in self.openapi.paths.items():
613
  if not isinstance(path_item_obj, PathItem_30):
614
  logger.warning(
 
659
  parameters=parameters,
660
  request_body=request_body_info,
661
  responses=responses,
662
+ schema_definitions=schema_definitions,
663
  )
664
  routes.append(route)
665
  logger.info(
 
778
 
779
  param_schema_dict = {}
780
  if param_schema_obj: # Check if schema exists
781
+ # Resolve the schema if it's a reference
782
+ resolved_schema = self._resolve_ref(param_schema_obj)
783
  param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
784
+
785
+ # Ensure default value is preserved from resolved schema
786
+ if (
787
+ not isinstance(resolved_schema, Reference_30)
788
+ and hasattr(resolved_schema, "default")
789
+ and resolved_schema.default is not None
790
+ ):
791
+ param_schema_dict["default"] = resolved_schema.default
792
  elif parameter.content:
793
  # Handle complex parameters with 'content'
794
  first_media_type = next(iter(parameter.content.values()), None)
795
  if first_media_type and first_media_type.media_type_schema:
796
+ # Resolve the schema if it's a reference
797
+ media_schema = first_media_type.media_type_schema
798
+ resolved_media_schema = self._resolve_ref(media_schema)
799
+ param_schema_dict = self._extract_schema_as_dict(media_schema)
800
+
801
+ # Ensure default value is preserved from resolved schema
802
+ if (
803
+ not isinstance(resolved_media_schema, Reference_30)
804
+ and hasattr(resolved_media_schema, "default")
805
+ and resolved_media_schema.default is not None
806
+ ):
807
+ param_schema_dict["default"] = resolved_media_schema.default
808
+
809
  logger.debug(
810
  f"Parameter '{parameter.name}' using schema from 'content' field."
811
  )
 
1260
  # Copy the schema and add description if available
1261
  param_schema = param.schema_.copy() if isinstance(param.schema_, dict) else {}
1262
 
1263
+ # Convert #/components/schemas references to #/$defs references
1264
+ if isinstance(param_schema, dict) and "$ref" in param_schema:
1265
+ ref_path = param_schema["$ref"]
1266
+ if ref_path.startswith("#/components/schemas/"):
1267
+ schema_name = ref_path.split("/")[-1]
1268
+ param_schema["$ref"] = f"#/$defs/{schema_name}"
1269
+
1270
+ # Also handle anyOf, allOf, oneOf references
1271
+ for section in ["anyOf", "allOf", "oneOf"]:
1272
+ if section in param_schema and isinstance(param_schema[section], list):
1273
+ for i, item in enumerate(param_schema[section]):
1274
+ if isinstance(item, dict) and "$ref" in item:
1275
+ ref_path = item["$ref"]
1276
+ if ref_path.startswith("#/components/schemas/"):
1277
+ schema_name = ref_path.split("/")[-1]
1278
+ param_schema[section][i]["$ref"] = f"#/$defs/{schema_name}"
1279
+
1280
  # Add parameter description to schema if available and not already present
1281
  if param.description and not param_schema.get("description"):
1282
  param_schema["description"] = param.description
 
1297
  if route.request_body.required:
1298
  required.extend(body_schema.get("required", []))
1299
 
1300
+ result = {
1301
  "type": "object",
1302
  "properties": properties,
1303
  "required": required,
1304
  }
1305
+
1306
+ # Add schema definitions if available
1307
+ if route.schema_definitions:
1308
+ result["$defs"] = route.schema_definitions
1309
+
1310
+ # Use compress_schema to remove unused definitions
1311
+ from fastmcp.utilities.json_schema import compress_schema
1312
+
1313
+ result = compress_schema(result)
1314
+
1315
+ return result