Jeremiah Lowin Claude commited on
Commit
3ce4c1b
·
unverified ·
1 Parent(s): 341332a

Optimize OpenAPI payload size by 46% (#1452)

Browse files

Co-authored-by: Claude <noreply@anthropic.com>

src/fastmcp/experimental/server/openapi/server.py CHANGED
@@ -11,7 +11,7 @@ from jsonschema_path import SchemaPath
11
  from fastmcp.experimental.utilities.openapi import (
12
  HTTPRoute,
13
  extract_output_schema_from_responses,
14
- format_description_with_responses,
15
  parse_openapi_to_http_routes,
16
  )
17
  from fastmcp.experimental.utilities.openapi.director import RequestDirector
@@ -267,7 +267,9 @@ class FastMCPOpenAPI(FastMCP):
267
 
268
  # Extract output schema from OpenAPI responses
269
  output_schema = extract_output_schema_from_responses(
270
- route.responses, route.schema_definitions, route.openapi_version
 
 
271
  )
272
 
273
  # Get a unique tool name
@@ -279,10 +281,9 @@ class FastMCPOpenAPI(FastMCP):
279
  or f"Executes {route.method} {route.path}"
280
  )
281
 
282
- # Format enhanced description with parameters and request body
283
- enhanced_description = format_description_with_responses(
284
  base_description=base_description,
285
- responses=route.responses,
286
  parameters=route.parameters,
287
  request_body=route.request_body,
288
  )
@@ -331,10 +332,9 @@ class FastMCPOpenAPI(FastMCP):
331
  route.description or route.summary or f"Represents {route.path}"
332
  )
333
 
334
- # Format enhanced description with parameters and request body
335
- enhanced_description = format_description_with_responses(
336
  base_description=base_description,
337
- responses=route.responses,
338
  parameters=route.parameters,
339
  request_body=route.request_body,
340
  )
@@ -388,10 +388,9 @@ class FastMCPOpenAPI(FastMCP):
388
  route.description or route.summary or f"Template for {route.path}"
389
  )
390
 
391
- # Format enhanced description with parameters and request body
392
- enhanced_description = format_description_with_responses(
393
  base_description=base_description,
394
- responses=route.responses,
395
  parameters=route.parameters,
396
  request_body=route.request_body,
397
  )
 
11
  from fastmcp.experimental.utilities.openapi import (
12
  HTTPRoute,
13
  extract_output_schema_from_responses,
14
+ format_simple_description,
15
  parse_openapi_to_http_routes,
16
  )
17
  from fastmcp.experimental.utilities.openapi.director import RequestDirector
 
267
 
268
  # Extract output schema from OpenAPI responses
269
  output_schema = extract_output_schema_from_responses(
270
+ route.responses,
271
+ route.response_schemas,
272
+ route.openapi_version,
273
  )
274
 
275
  # Get a unique tool name
 
281
  or f"Executes {route.method} {route.path}"
282
  )
283
 
284
+ # Use simplified description formatter for tools
285
+ enhanced_description = format_simple_description(
286
  base_description=base_description,
 
287
  parameters=route.parameters,
288
  request_body=route.request_body,
289
  )
 
332
  route.description or route.summary or f"Represents {route.path}"
333
  )
334
 
335
+ # Use simplified description for resources
336
+ enhanced_description = format_simple_description(
337
  base_description=base_description,
 
338
  parameters=route.parameters,
339
  request_body=route.request_body,
340
  )
 
388
  route.description or route.summary or f"Template for {route.path}"
389
  )
390
 
391
+ # Use simplified description for resource templates
392
+ enhanced_description = format_simple_description(
393
  base_description=base_description,
 
394
  parameters=route.parameters,
395
  request_body=route.request_body,
396
  )
src/fastmcp/experimental/utilities/openapi/__init__.py CHANGED
@@ -20,6 +20,7 @@ from .formatters import (
20
  format_deep_object_parameter,
21
  format_description_with_responses,
22
  format_json_for_description,
 
23
  generate_example_from_schema,
24
  )
25
 
@@ -54,6 +55,7 @@ __all__ = [
54
  "format_deep_object_parameter",
55
  "format_description_with_responses",
56
  "format_json_for_description",
 
57
  "generate_example_from_schema",
58
  # Schemas
59
  "_combine_schemas",
 
20
  format_deep_object_parameter,
21
  format_description_with_responses,
22
  format_json_for_description,
23
+ format_simple_description,
24
  generate_example_from_schema,
25
  )
26
 
 
55
  "format_deep_object_parameter",
56
  "format_description_with_responses",
57
  "format_json_for_description",
58
+ "format_simple_description",
59
  "generate_example_from_schema",
60
  # Schemas
61
  "_combine_schemas",
src/fastmcp/experimental/utilities/openapi/formatters.py CHANGED
@@ -189,6 +189,39 @@ def format_json_for_description(data: Any, indent: int = 2) -> str:
189
  return f"```\nCould not serialize to JSON: {data}\n```"
190
 
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  def format_description_with_responses(
193
  base_description: str,
194
  responses: dict[
@@ -351,5 +384,6 @@ __all__ = [
351
  "format_deep_object_parameter",
352
  "format_description_with_responses",
353
  "format_json_for_description",
 
354
  "generate_example_from_schema",
355
  ]
 
189
  return f"```\nCould not serialize to JSON: {data}\n```"
190
 
191
 
192
+ def format_simple_description(
193
+ base_description: str,
194
+ parameters: list[ParameterInfo] | None = None,
195
+ request_body: RequestBodyInfo | None = None,
196
+ ) -> str:
197
+ """
198
+ Formats a simple description for MCP objects (tools, resources, prompts).
199
+ Excludes response details, examples, and verbose status codes.
200
+
201
+ Args:
202
+ base_description (str): The initial description to be formatted.
203
+ parameters (list[ParameterInfo] | None, optional): A list of parameter information.
204
+ request_body (RequestBodyInfo | None, optional): Information about the request body.
205
+
206
+ Returns:
207
+ str: The formatted description string with minimal details.
208
+ """
209
+ desc_parts = [base_description]
210
+
211
+ # Only add critical parameter information if they have descriptions
212
+ if parameters:
213
+ path_params = [p for p in parameters if p.location == "path" and p.description]
214
+ if path_params:
215
+ desc_parts.append("\n\n**Path Parameters:**")
216
+ for param in path_params:
217
+ desc_parts.append(f"\n- **{param.name}**: {param.description}")
218
+
219
+ # Skip query parameters, request body details, and all response information
220
+ # These are already captured in the inputSchema
221
+
222
+ return "\n".join(desc_parts)
223
+
224
+
225
  def format_description_with_responses(
226
  base_description: str,
227
  responses: dict[
 
384
  "format_deep_object_parameter",
385
  "format_description_with_responses",
386
  "format_json_for_description",
387
+ "format_simple_description",
388
  "generate_example_from_schema",
389
  ]
src/fastmcp/experimental/utilities/openapi/models.py CHANGED
@@ -58,9 +58,12 @@ class HTTPRoute(FastMCPBaseModel):
58
  responses: dict[str, ResponseInfo] = Field(
59
  default_factory=dict
60
  ) # Key: status code str
61
- schema_definitions: dict[str, JsonSchema] = Field(
62
  default_factory=dict
63
- ) # Store component schemas
 
 
 
64
  extensions: dict[str, Any] = Field(default_factory=dict)
65
  openapi_version: str | None = None
66
 
 
58
  responses: dict[str, ResponseInfo] = Field(
59
  default_factory=dict
60
  ) # Key: status code str
61
+ request_schemas: dict[str, JsonSchema] = Field(
62
  default_factory=dict
63
+ ) # Store schemas needed for input (parameters/request body)
64
+ response_schemas: dict[str, JsonSchema] = Field(
65
+ default_factory=dict
66
+ ) # Store schemas needed for output (responses)
67
  extensions: dict[str, Any] = Field(default_factory=dict)
68
  openapi_version: str | None = None
69
 
src/fastmcp/experimental/utilities/openapi/parser.py CHANGED
@@ -402,77 +402,116 @@ class OpenAPIParser(
402
  )
403
  return None
404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  def _extract_responses(
406
  self, operation_responses: dict[str, Any] | None
407
  ) -> dict[str, ResponseInfo]:
408
- """Extract and resolve response information."""
409
  extracted_responses: dict[str, ResponseInfo] = {}
410
 
411
  if not operation_responses:
412
  return extracted_responses
413
 
414
- for status_code, resp_or_ref in operation_responses.items():
415
- try:
416
- response = self._resolve_ref(resp_or_ref)
 
 
417
 
418
- if not isinstance(response, self.response_cls):
419
- logger.warning(
420
- f"Expected Response after resolving for status code {status_code}, "
421
- f"got {type(response)}. Skipping."
422
- )
423
- continue
424
 
425
- # Create response info
426
- resp_info = ResponseInfo(description=response.description)
427
 
428
- # Extract content schemas
429
- if hasattr(response, "content") and response.content:
430
- for media_type_str, media_type_obj in response.content.items():
431
- if (
432
- media_type_obj
433
- and hasattr(media_type_obj, "media_type_schema")
434
- and media_type_obj.media_type_schema
435
- ):
436
- try:
437
- schema_dict = self._extract_schema_as_dict(
438
- media_type_obj.media_type_schema
439
- )
440
- resp_info.content_schema[media_type_str] = schema_dict
441
- except ValueError as e:
442
- # Re-raise ValueError for external reference errors
443
- if (
444
- "External or non-local reference not supported"
445
- in str(e)
446
- ):
447
- raise
448
- logger.error(
449
- f"Failed to extract schema for media type '{media_type_str}' "
450
- f"in response {status_code}: {e}"
451
- )
452
- except Exception as e:
453
- logger.error(
454
- f"Failed to extract schema for media type '{media_type_str}' "
455
- f"in response {status_code}: {e}"
456
- )
457
-
458
- extracted_responses[str(status_code)] = resp_info
459
- except ValueError as e:
460
- # Re-raise ValueError for external reference errors
461
- if "External or non-local reference not supported" in str(e):
462
- raise
463
- ref_name = getattr(resp_or_ref, "ref", "unknown")
464
- logger.error(
465
- f"Failed to extract response for status code {status_code} "
466
- f"from reference '{ref_name}': {e}",
467
- exc_info=False,
468
- )
469
- except Exception as e:
470
- ref_name = getattr(resp_or_ref, "ref", "unknown")
471
- logger.error(
472
- f"Failed to extract response for status code {status_code} "
473
- f"from reference '{ref_name}': {e}",
474
- exc_info=False,
475
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
 
477
  return extracted_responses
478
 
@@ -525,24 +564,22 @@ class OpenAPIParser(
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
 
@@ -558,6 +595,28 @@ class OpenAPIParser(
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:
@@ -567,7 +626,7 @@ class OpenAPIParser(
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
  }
@@ -661,10 +720,13 @@ class OpenAPIParser(
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
  )
@@ -680,7 +742,8 @@ class OpenAPIParser(
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
  )
 
402
  )
403
  return None
404
 
405
+ def _is_success_status_code(self, status_code: str) -> bool:
406
+ """Check if a status code represents a successful response (2xx)."""
407
+ try:
408
+ code_int = int(status_code)
409
+ return 200 <= code_int < 300
410
+ except (ValueError, TypeError):
411
+ # Handle special cases like 'default' or other non-numeric codes
412
+ return status_code.lower() in ["default", "2xx"]
413
+
414
+ def _get_primary_success_response(
415
+ self, operation_responses: dict[str, Any]
416
+ ) -> tuple[str, Any] | None:
417
+ """Get the primary success response for an MCP tool. We only need one success response."""
418
+ if not operation_responses:
419
+ return None
420
+
421
+ # Priority order: 200, 201, 202, 204, 207, then any other 2xx
422
+ priority_codes = ["200", "201", "202", "204", "207"]
423
+
424
+ # First check priority codes
425
+ for code in priority_codes:
426
+ if code in operation_responses:
427
+ return (code, operation_responses[code])
428
+
429
+ # Then check any other 2xx codes
430
+ for status_code, resp_or_ref in operation_responses.items():
431
+ if self._is_success_status_code(status_code):
432
+ return (status_code, resp_or_ref)
433
+
434
+ # If no success codes found, return None (tool will have no output schema)
435
+ return None
436
+
437
  def _extract_responses(
438
  self, operation_responses: dict[str, Any] | None
439
  ) -> dict[str, ResponseInfo]:
440
+ """Extract and resolve response information. Only includes the primary success response for MCP tools."""
441
  extracted_responses: dict[str, ResponseInfo] = {}
442
 
443
  if not operation_responses:
444
  return extracted_responses
445
 
446
+ # For MCP tools, we only need the primary success response
447
+ primary_response = self._get_primary_success_response(operation_responses)
448
+ if not primary_response:
449
+ logger.debug("No success responses found, tool will have no output schema")
450
+ return extracted_responses
451
 
452
+ status_code, resp_or_ref = primary_response
453
+ logger.debug(f"Using primary success response: {status_code}")
 
 
 
 
454
 
455
+ try:
456
+ response = self._resolve_ref(resp_or_ref)
457
 
458
+ if not isinstance(response, self.response_cls):
459
+ logger.warning(
460
+ f"Expected Response after resolving for status code {status_code}, "
461
+ f"got {type(response)}. Returning empty responses."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  )
463
+ return extracted_responses
464
+
465
+ # Create response info
466
+ resp_info = ResponseInfo(description=response.description)
467
+
468
+ # Extract content schemas
469
+ if hasattr(response, "content") and response.content:
470
+ for media_type_str, media_type_obj in response.content.items():
471
+ if (
472
+ media_type_obj
473
+ and hasattr(media_type_obj, "media_type_schema")
474
+ and media_type_obj.media_type_schema
475
+ ):
476
+ try:
477
+ schema_dict = self._extract_schema_as_dict(
478
+ media_type_obj.media_type_schema
479
+ )
480
+ resp_info.content_schema[media_type_str] = schema_dict
481
+ except ValueError as e:
482
+ # Re-raise ValueError for external reference errors
483
+ if "External or non-local reference not supported" in str(
484
+ e
485
+ ):
486
+ raise
487
+ logger.error(
488
+ f"Failed to extract schema for media type '{media_type_str}' "
489
+ f"in response {status_code}: {e}"
490
+ )
491
+ except Exception as e:
492
+ logger.error(
493
+ f"Failed to extract schema for media type '{media_type_str}' "
494
+ f"in response {status_code}: {e}"
495
+ )
496
+
497
+ extracted_responses[str(status_code)] = resp_info
498
+ except ValueError as e:
499
+ # Re-raise ValueError for external reference errors
500
+ if "External or non-local reference not supported" in str(e):
501
+ raise
502
+ ref_name = getattr(resp_or_ref, "ref", "unknown")
503
+ logger.error(
504
+ f"Failed to extract response for status code {status_code} "
505
+ f"from reference '{ref_name}': {e}",
506
+ exc_info=False,
507
+ )
508
+ except Exception as e:
509
+ ref_name = getattr(resp_or_ref, "ref", "unknown")
510
+ logger.error(
511
+ f"Failed to extract response for status code {status_code} "
512
+ f"from reference '{ref_name}': {e}",
513
+ exc_info=False,
514
+ )
515
 
516
  return extracted_responses
517
 
 
564
  find_refs(schema)
565
  return collected
566
 
567
+ def _extract_input_schema_dependencies(
568
  self,
569
  parameters: list[ParameterInfo],
570
  request_body: RequestBodyInfo | None,
 
571
  all_schemas: dict[str, Any],
572
  ) -> dict[str, Any]:
573
  """
574
+ Extract only the schema definitions needed for input (parameters and request body).
575
 
576
  Args:
577
  parameters: Route parameters
578
  request_body: Route request body
 
579
  all_schemas: All available schema definitions
580
 
581
  Returns:
582
+ Dictionary containing only the schemas needed for input
583
  """
584
  needed_schemas = set()
585
 
 
595
  deps = self._extract_schema_dependencies(content_schema, all_schemas)
596
  needed_schemas.update(deps)
597
 
598
+ # Return only the needed input schemas
599
+ return {
600
+ name: all_schemas[name] for name in needed_schemas if name in all_schemas
601
+ }
602
+
603
+ def _extract_output_schema_dependencies(
604
+ self,
605
+ responses: dict[str, ResponseInfo],
606
+ all_schemas: dict[str, Any],
607
+ ) -> dict[str, Any]:
608
+ """
609
+ Extract only the schema definitions needed for outputs (responses).
610
+
611
+ Args:
612
+ responses: Route responses
613
+ all_schemas: All available schema definitions
614
+
615
+ Returns:
616
+ Dictionary containing only the schemas needed for outputs
617
+ """
618
+ needed_schemas = set()
619
+
620
  # Check responses for schema references
621
  for response in responses.values():
622
  if response.content_schema:
 
626
  )
627
  needed_schemas.update(deps)
628
 
629
+ # Return only the needed output schemas
630
  return {
631
  name: all_schemas[name] for name in needed_schemas if name in all_schemas
632
  }
 
720
  if k.startswith("x-")
721
  }
722
 
723
+ # Extract schemas separately for input and output
724
+ input_schemas = self._extract_input_schema_dependencies(
725
  parameters,
726
  request_body_info,
727
+ schema_definitions,
728
+ )
729
+ output_schemas = self._extract_output_schema_dependencies(
730
  responses,
731
  schema_definitions,
732
  )
 
742
  parameters=parameters,
743
  request_body=request_body_info,
744
  responses=responses,
745
+ request_schemas=input_schemas,
746
+ response_schemas=output_schemas,
747
  extensions=extensions,
748
  openapi_version=self.openapi_version,
749
  )
src/fastmcp/experimental/utilities/openapi/schemas.py CHANGED
@@ -364,10 +364,11 @@ def _combine_schemas_and_map_params(
364
  "required": required,
365
  }
366
  # Add schema definitions if available
367
- if route.schema_definitions:
 
368
  if convert_refs:
369
  # Need to convert refs and prune
370
- all_defs = route.schema_definitions.copy()
371
  # Convert each schema definition recursively
372
  for name, schema in all_defs.items():
373
  if isinstance(schema, dict):
@@ -409,7 +410,7 @@ def _combine_schemas_and_map_params(
409
  }
410
  else:
411
  # From parser - already converted and pruned
412
- result["$defs"] = route.schema_definitions
413
 
414
  return result, parameter_map
415
 
 
364
  "required": required,
365
  }
366
  # Add schema definitions if available
367
+ schema_defs = route.request_schemas
368
+ if schema_defs:
369
  if convert_refs:
370
  # Need to convert refs and prune
371
+ all_defs = schema_defs.copy()
372
  # Convert each schema definition recursively
373
  for name, schema in all_defs.items():
374
  if isinstance(schema, dict):
 
410
  }
411
  else:
412
  # From parser - already converted and pruned
413
+ result["$defs"] = schema_defs
414
 
415
  return result, parameter_map
416
 
tests/experimental/openapi_parser/server/openapi/test_end_to_end_compatibility.py CHANGED
@@ -116,8 +116,10 @@ class TestEndToEndCompatibility:
116
  assert legacy_tool.name == new_tool.name
117
  assert legacy_tool.name == "get_user"
118
 
119
- # Descriptions should be identical
120
- assert legacy_tool.description == new_tool.description
 
 
121
 
122
  # Input schemas should be identical
123
  legacy_schema = legacy_tool.inputSchema
 
116
  assert legacy_tool.name == new_tool.name
117
  assert legacy_tool.name == "get_user"
118
 
119
+ # Descriptions may differ (new server has simplified descriptions)
120
+ # Just check that both have descriptions
121
+ assert legacy_tool.description
122
+ assert new_tool.description
123
 
124
  # Input schemas should be identical
125
  legacy_schema = legacy_tool.inputSchema
tests/experimental/openapi_parser/utilities/openapi/test_models.py CHANGED
@@ -248,7 +248,7 @@ class TestHTTPRoute:
248
  parameters=parameters,
249
  request_body=request_body,
250
  responses=responses,
251
- schema_definitions={"User": {"type": "object"}},
252
  extensions={"x-custom": "value"},
253
  )
254
 
@@ -261,7 +261,7 @@ class TestHTTPRoute:
261
  assert len(route.parameters) == 1
262
  assert route.request_body is not None
263
  assert "200" in route.responses
264
- assert "User" in route.schema_definitions
265
  assert route.extensions["x-custom"] == "value"
266
 
267
  def test_route_pre_calculated_fields(self):
@@ -303,14 +303,15 @@ class TestHTTPRoute:
303
  tags=[],
304
  parameters=[],
305
  responses={},
306
- schema_definitions={},
307
  extensions={},
308
  )
309
 
310
  assert route.tags == []
311
  assert route.parameters == []
312
  assert route.responses == {}
313
- assert route.schema_definitions == {}
 
314
  assert route.extensions == {}
315
 
316
  def test_route_defaults(self):
@@ -327,7 +328,8 @@ class TestHTTPRoute:
327
  assert route.parameters == []
328
  assert route.request_body is None
329
  assert route.responses == {}
330
- assert route.schema_definitions == {}
 
331
  assert route.extensions == {}
332
  assert route.flat_param_schema == {}
333
  assert route.parameter_map == {}
 
248
  parameters=parameters,
249
  request_body=request_body,
250
  responses=responses,
251
+ request_schemas={"User": {"type": "object"}},
252
  extensions={"x-custom": "value"},
253
  )
254
 
 
261
  assert len(route.parameters) == 1
262
  assert route.request_body is not None
263
  assert "200" in route.responses
264
+ assert "User" in route.request_schemas
265
  assert route.extensions["x-custom"] == "value"
266
 
267
  def test_route_pre_calculated_fields(self):
 
303
  tags=[],
304
  parameters=[],
305
  responses={},
306
+ request_schemas={},
307
  extensions={},
308
  )
309
 
310
  assert route.tags == []
311
  assert route.parameters == []
312
  assert route.responses == {}
313
+ assert route.request_schemas == {}
314
+ assert route.response_schemas == {}
315
  assert route.extensions == {}
316
 
317
  def test_route_defaults(self):
 
328
  assert route.parameters == []
329
  assert route.request_body is None
330
  assert route.responses == {}
331
+ assert route.request_schemas == {}
332
+ assert route.response_schemas == {}
333
  assert route.extensions == {}
334
  assert route.flat_param_schema == {}
335
  assert route.parameter_map == {}
tests/experimental/openapi_parser/utilities/openapi/test_parser.py CHANGED
@@ -253,12 +253,12 @@ class TestOpenAPIParser:
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"]
@@ -316,11 +316,11 @@ class TestOpenAPIParser:
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"]
 
253
  routes = parse_openapi_to_http_routes(spec)
254
  route = routes[0]
255
 
256
+ # SchemaA is expanded inline, so it's NOT in request_schemas
257
+ assert "SchemaA" not in route.request_schemas
258
 
259
  # But SchemaB and SchemaC MUST be there (transitive dependencies)
260
+ assert "SchemaB" in route.request_schemas
261
+ assert "SchemaC" in route.request_schemas
262
 
263
  # Same in the flat parameter schema
264
  assert "SchemaB" in route.flat_param_schema["$defs"]
 
316
  route = routes[0]
317
 
318
  # Profile is expanded inline, NOT in schema_defs
319
+ assert "Profile" not in route.request_schemas
320
 
321
  # Bug fix: countryCode and AccountInfo MUST be in schema_defs
322
+ assert "countryCode" in route.request_schemas # Was missing in #1372
323
+ assert "AccountInfo" in route.request_schemas # Was missing in #1372
324
 
325
  # Same in flat parameter schema
326
  assert "countryCode" in route.flat_param_schema["$defs"]
tests/experimental/openapi_parser/utilities/openapi/test_transitive_references.py CHANGED
@@ -6,6 +6,7 @@ from fastmcp.experimental.utilities.openapi.models import (
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,
@@ -35,7 +36,7 @@ class TestTransitiveAndNestedReferences:
35
  }
36
  },
37
  ),
38
- schema_definitions={
39
  "User": {
40
  "type": "object",
41
  "properties": {"profile": {"$ref": "#/components/schemas/Profile"}},
@@ -183,7 +184,7 @@ class TestTransitiveAndNestedReferences:
183
  "application/json": {"$ref": "#/components/schemas/Profile"}
184
  },
185
  ),
186
- schema_definitions={
187
  "Profile": {
188
  "type": "object",
189
  "properties": {
@@ -234,7 +235,7 @@ class TestTransitiveAndNestedReferences:
234
  "application/json": {"$ref": "#/components/schemas/User"}
235
  },
236
  ),
237
- schema_definitions={
238
  "User": {
239
  "type": "object",
240
  "properties": {
@@ -297,7 +298,7 @@ class TestTransitiveAndNestedReferences:
297
  }
298
  },
299
  ),
300
- schema_definitions={
301
  "User": {
302
  "type": "object",
303
  "properties": {"profile": {"$ref": "#/components/schemas/Profile"}},
@@ -352,7 +353,7 @@ class TestTransitiveAndNestedReferences:
352
  }
353
  },
354
  ),
355
- schema_definitions={
356
  "TypeA": {
357
  "type": "object",
358
  "properties": {"nested": {"$ref": "#/components/schemas/Nested"}},
@@ -403,7 +404,7 @@ class TestTransitiveAndNestedReferences:
403
  "application/json": {"$ref": "#/components/schemas/Level1"}
404
  },
405
  ),
406
- schema_definitions={
407
  "Level1": {
408
  "type": "object",
409
  "properties": {"level2": {"$ref": "#/components/schemas/Level2"}},
@@ -470,7 +471,7 @@ class TestTransitiveAndNestedReferences:
470
  "application/json": {"$ref": "#/components/schemas/Node"}
471
  },
472
  ),
473
- schema_definitions={
474
  "Node": {
475
  "type": "object",
476
  "properties": {
@@ -511,7 +512,7 @@ class TestTransitiveAndNestedReferences:
511
  }
512
  },
513
  ),
514
- schema_definitions={
515
  "Left": {
516
  "type": "object",
517
  "properties": {"shared": {"$ref": "#/components/schemas/Shared"}},
@@ -558,7 +559,7 @@ class TestTransitiveAndNestedReferences:
558
  "application/json": {"$ref": "#/components/schemas/Content"}
559
  },
560
  ),
561
- schema_definitions={
562
  "Content": {
563
  "type": "object",
564
  "properties": {
@@ -612,7 +613,7 @@ class TestTransitiveAndNestedReferences:
612
  }
613
  },
614
  ),
615
- schema_definitions={
616
  "SimpleString": {"type": "string"},
617
  "EmptyObject": {"type": "object"},
618
  "UnreferencedSchema": {"type": "number"},
@@ -645,7 +646,7 @@ class TestTransitiveAndNestedReferences:
645
  "application/json": {"$ref": "#/components/schemas/DirectBody"}
646
  },
647
  ),
648
- schema_definitions={
649
  "DirectBody": {
650
  "type": "object",
651
  "properties": {
@@ -675,3 +676,113 @@ class TestTransitiveAndNestedReferences:
675
  combined_schema["$defs"]["DirectBody"]["properties"]["nested"]["$ref"]
676
  == "#/$defs/NestedBody"
677
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  RequestBodyInfo,
7
  ResponseInfo,
8
  )
9
+ from fastmcp.experimental.utilities.openapi.parser import parse_openapi_to_http_routes
10
  from fastmcp.experimental.utilities.openapi.schemas import (
11
  _combine_schemas_and_map_params,
12
  extract_output_schema_from_responses,
 
36
  }
37
  },
38
  ),
39
+ request_schemas={
40
  "User": {
41
  "type": "object",
42
  "properties": {"profile": {"$ref": "#/components/schemas/Profile"}},
 
184
  "application/json": {"$ref": "#/components/schemas/Profile"}
185
  },
186
  ),
187
+ request_schemas={
188
  "Profile": {
189
  "type": "object",
190
  "properties": {
 
235
  "application/json": {"$ref": "#/components/schemas/User"}
236
  },
237
  ),
238
+ request_schemas={
239
  "User": {
240
  "type": "object",
241
  "properties": {
 
298
  }
299
  },
300
  ),
301
+ request_schemas={
302
  "User": {
303
  "type": "object",
304
  "properties": {"profile": {"$ref": "#/components/schemas/Profile"}},
 
353
  }
354
  },
355
  ),
356
+ request_schemas={
357
  "TypeA": {
358
  "type": "object",
359
  "properties": {"nested": {"$ref": "#/components/schemas/Nested"}},
 
404
  "application/json": {"$ref": "#/components/schemas/Level1"}
405
  },
406
  ),
407
+ request_schemas={
408
  "Level1": {
409
  "type": "object",
410
  "properties": {"level2": {"$ref": "#/components/schemas/Level2"}},
 
471
  "application/json": {"$ref": "#/components/schemas/Node"}
472
  },
473
  ),
474
+ request_schemas={
475
  "Node": {
476
  "type": "object",
477
  "properties": {
 
512
  }
513
  },
514
  ),
515
+ request_schemas={
516
  "Left": {
517
  "type": "object",
518
  "properties": {"shared": {"$ref": "#/components/schemas/Shared"}},
 
559
  "application/json": {"$ref": "#/components/schemas/Content"}
560
  },
561
  ),
562
+ request_schemas={
563
  "Content": {
564
  "type": "object",
565
  "properties": {
 
613
  }
614
  },
615
  ),
616
+ request_schemas={
617
  "SimpleString": {"type": "string"},
618
  "EmptyObject": {"type": "object"},
619
  "UnreferencedSchema": {"type": "number"},
 
646
  "application/json": {"$ref": "#/components/schemas/DirectBody"}
647
  },
648
  ),
649
+ request_schemas={
650
  "DirectBody": {
651
  "type": "object",
652
  "properties": {
 
676
  combined_schema["$defs"]["DirectBody"]["properties"]["nested"]["$ref"]
677
  == "#/$defs/NestedBody"
678
  )
679
+
680
+ def test_separate_input_output_schemas(self):
681
+ """Test that input and output schemas contain different schema
682
+ definitions and don't overlap in the ultimate schema definitions."""
683
+ # OpenAPI spec with transitive dependencies to force schema inclusion
684
+ openapi_spec = {
685
+ "openapi": "3.0.1",
686
+ "info": {"title": "Test API", "version": "1.0.0"},
687
+ "paths": {
688
+ "/test": {
689
+ "post": {
690
+ "summary": "Test endpoint",
691
+ "requestBody": {
692
+ "content": {
693
+ "application/json": {
694
+ "schema": {
695
+ "$ref": "#/components/schemas/InputContainer"
696
+ }
697
+ }
698
+ }
699
+ },
700
+ "responses": {
701
+ "200": {
702
+ "description": "Success",
703
+ "content": {
704
+ "application/json": {
705
+ "schema": {
706
+ "$ref": "#/components/schemas/OutputContainer"
707
+ }
708
+ }
709
+ },
710
+ }
711
+ },
712
+ }
713
+ }
714
+ },
715
+ "components": {
716
+ "schemas": {
717
+ "InputContainer": {
718
+ "type": "object",
719
+ "properties": {
720
+ "data": {"$ref": "#/components/schemas/InputData"}
721
+ },
722
+ },
723
+ "InputData": {
724
+ "type": "object",
725
+ "properties": {"input_field": {"type": "string"}},
726
+ },
727
+ "OutputContainer": {
728
+ "type": "object",
729
+ "properties": {
730
+ "result": {"$ref": "#/components/schemas/OutputData"}
731
+ },
732
+ },
733
+ "OutputData": {
734
+ "type": "object",
735
+ "properties": {"output_field": {"type": "string"}},
736
+ },
737
+ "UnusedSchema": {
738
+ "type": "object",
739
+ "properties": {"unused_field": {"type": "string"}},
740
+ },
741
+ }
742
+ },
743
+ }
744
+
745
+ routes = parse_openapi_to_http_routes(openapi_spec)
746
+ assert len(routes) == 1
747
+
748
+ route = routes[0]
749
+
750
+ # Check that schemas are properly separated
751
+ input_schema_names = set(route.request_schemas.keys())
752
+ output_schema_names = set(route.response_schemas.keys())
753
+
754
+ # Input should contain transitive dependencies from InputContainer
755
+ assert "InputData" in input_schema_names, (
756
+ f"Expected InputData in request schemas: {input_schema_names}"
757
+ )
758
+ assert "OutputContainer" not in input_schema_names, (
759
+ "OutputContainer should not be in request schemas"
760
+ )
761
+ assert "OutputData" not in input_schema_names, (
762
+ "OutputData should not be in request schemas"
763
+ )
764
+
765
+ # Output should contain transitive dependencies from OutputContainer
766
+ assert "OutputData" in output_schema_names, (
767
+ f"Expected OutputData in response schemas: {output_schema_names}"
768
+ )
769
+ assert "InputContainer" not in output_schema_names, (
770
+ "InputContainer should not be in response schemas"
771
+ )
772
+ assert "InputData" not in output_schema_names, (
773
+ "InputData should not be in response schemas"
774
+ )
775
+
776
+ # Neither should contain unused schema
777
+ assert "UnusedSchema" not in input_schema_names, (
778
+ "UnusedSchema should not be in request schemas"
779
+ )
780
+ assert "UnusedSchema" not in output_schema_names, (
781
+ "UnusedSchema should not be in response schemas"
782
+ )
783
+
784
+ # Verify no overlap
785
+ overlap = input_schema_names & output_schema_names
786
+ assert len(overlap) == 0, (
787
+ f"Found overlapping schemas between input and output: {overlap}"
788
+ )