Jeremiah Lowin commited on
Commit
4394198
·
unverified ·
1 Parent(s): c4df749

Improve OpenAPI-to-JSONSchema conversion utilities (#1283)

Browse files
src/fastmcp/experimental/server/openapi/server.py CHANGED
@@ -5,7 +5,7 @@ from collections import Counter
5
  from typing import Any, Literal
6
 
7
  import httpx
8
- from openapi_core import Spec
9
 
10
  # Import from our new utilities and components
11
  from fastmcp.experimental.utilities.openapi import (
@@ -149,7 +149,7 @@ class FastMCPOpenAPI(FastMCP):
149
 
150
  # Create openapi-core Spec and RequestDirector for stateless request building
151
  try:
152
- self._spec = Spec.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"
@@ -270,7 +270,7 @@ class FastMCPOpenAPI(FastMCP):
270
 
271
  # Extract output schema from OpenAPI responses
272
  output_schema = extract_output_schema_from_responses(
273
- route.responses, route.schema_definitions
274
  )
275
 
276
  # Get a unique tool name
 
5
  from typing import Any, Literal
6
 
7
  import httpx
8
+ from jsonschema_path import SchemaPath
9
 
10
  # Import from our new utilities and components
11
  from fastmcp.experimental.utilities.openapi import (
 
149
 
150
  # Create openapi-core Spec and RequestDirector for stateless request building
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"
 
270
 
271
  # Extract output schema from OpenAPI responses
272
  output_schema = extract_output_schema_from_responses(
273
+ route.responses, route.schema_definitions, route.openapi_version
274
  )
275
 
276
  # Get a unique tool name
src/fastmcp/experimental/utilities/openapi/README.md CHANGED
@@ -36,13 +36,13 @@ The new implementation follows a **stateless request building strategy** using `
36
  ### Initialization Process
37
 
38
  ```
39
- OpenAPI Spec → Parser → HTTPRoute with Pre-calculated Fields → RequestDirector + openapi-core Spec
40
  ```
41
 
42
  1. **Input**: Raw OpenAPI specification (dict)
43
  2. **Parsing**: Extract operations to `HTTPRoute` models
44
  3. **Pre-calculation**: Generate combined schemas and parameter maps during parsing
45
- 4. **Director Setup**: Create `RequestDirector` with `openapi-core` Spec for request building
46
 
47
  ### Request Processing
48
 
@@ -125,10 +125,10 @@ async with httpx.AsyncClient() as client:
125
 
126
  ```python
127
  from fastmcp.experimental.utilities.openapi.director import RequestDirector
128
- from openapi_core import Spec
129
 
130
  # Create RequestDirector manually
131
- spec = Spec.from_dict(openapi_spec)
132
  director = RequestDirector(spec)
133
 
134
  # Build HTTP request
@@ -210,7 +210,7 @@ Tests are located in `/tests/server/openapi_new/`:
210
  ### Common Issues
211
 
212
  1. **RequestDirector Initialization Fails**
213
- - Check OpenAPI spec validity with `openapi-core`
214
  - Verify spec format is correct JSON/YAML
215
  - Ensure all required OpenAPI fields are present
216
 
 
36
  ### Initialization Process
37
 
38
  ```
39
+ OpenAPI Spec → Parser → HTTPRoute with Pre-calculated Fields → RequestDirector + SchemaPath
40
  ```
41
 
42
  1. **Input**: Raw OpenAPI specification (dict)
43
  2. **Parsing**: Extract operations to `HTTPRoute` models
44
  3. **Pre-calculation**: Generate combined schemas and parameter maps during parsing
45
+ 4. **Director Setup**: Create `RequestDirector` with `SchemaPath` for request building
46
 
47
  ### Request Processing
48
 
 
125
 
126
  ```python
127
  from fastmcp.experimental.utilities.openapi.director import RequestDirector
128
+ from jsonschema_path import SchemaPath
129
 
130
  # Create RequestDirector manually
131
+ spec = SchemaPath.from_dict(openapi_spec)
132
  director = RequestDirector(spec)
133
 
134
  # Build HTTP request
 
210
  ### Common Issues
211
 
212
  1. **RequestDirector Initialization Fails**
213
+ - Check OpenAPI spec validity with `jsonschema-path`
214
  - Verify spec format is correct JSON/YAML
215
  - Ensure all required OpenAPI fields are present
216
 
src/fastmcp/experimental/utilities/openapi/__init__.py CHANGED
@@ -30,7 +30,12 @@ from .schemas import (
30
  clean_schema_for_display,
31
  _replace_ref_with_defs,
32
  _make_optional_parameter_nullable,
33
- _adjust_union_types,
 
 
 
 
 
34
  )
35
 
36
  # Export public symbols - maintaining backward compatibility
@@ -57,5 +62,7 @@ __all__ = [
57
  "clean_schema_for_display",
58
  "_replace_ref_with_defs",
59
  "_make_optional_parameter_nullable",
60
- "_adjust_union_types",
 
 
61
  ]
 
30
  clean_schema_for_display,
31
  _replace_ref_with_defs,
32
  _make_optional_parameter_nullable,
33
+ )
34
+
35
+ # Import from json_schema_converter
36
+ from .json_schema_converter import (
37
+ convert_openapi_schema_to_json_schema,
38
+ convert_schema_definitions,
39
  )
40
 
41
  # Export public symbols - maintaining backward compatibility
 
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",
67
+ "convert_schema_definitions",
68
  ]
src/fastmcp/experimental/utilities/openapi/director.py CHANGED
@@ -4,7 +4,7 @@ from typing import Any
4
  from urllib.parse import urljoin
5
 
6
  import httpx
7
- from openapi_core import Spec
8
 
9
  from fastmcp.utilities.logging import get_logger
10
 
@@ -16,8 +16,8 @@ logger = get_logger(__name__)
16
  class RequestDirector:
17
  """Builds httpx.Request objects from HTTPRoute and arguments using openapi-core."""
18
 
19
- def __init__(self, spec: Spec):
20
- """Initialize with a parsed openapi-core Spec object."""
21
  self._spec = spec
22
 
23
  def build(
@@ -66,7 +66,7 @@ class RequestDirector:
66
  if isinstance(body, dict) or isinstance(body, list):
67
  request_data["json"] = body
68
  else:
69
- request_data["data"] = body
70
 
71
  # Step 5: Create httpx.Request
72
  return httpx.Request(**{k: v for k, v in request_data.items() if v is not None})
 
4
  from urllib.parse import urljoin
5
 
6
  import httpx
7
+ from jsonschema_path import SchemaPath
8
 
9
  from fastmcp.utilities.logging import get_logger
10
 
 
16
  class RequestDirector:
17
  """Builds httpx.Request objects from HTTPRoute and arguments using openapi-core."""
18
 
19
+ def __init__(self, spec: SchemaPath):
20
+ """Initialize with a parsed SchemaPath object."""
21
  self._spec = spec
22
 
23
  def build(
 
66
  if isinstance(body, dict) or isinstance(body, list):
67
  request_data["json"] = body
68
  else:
69
+ request_data["content"] = body
70
 
71
  # Step 5: Create httpx.Request
72
  return httpx.Request(**{k: v for k, v in request_data.items() if v is not None})
src/fastmcp/experimental/utilities/openapi/json_schema_converter.py ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Clean OpenAPI 3.0 to JSON Schema converter for the experimental parser.
3
+
4
+ This module provides a systematic approach to converting OpenAPI 3.0 schemas
5
+ to JSON Schema, inspired by py-openapi-schema-to-json-schema but optimized
6
+ for our specific use case.
7
+ """
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # OpenAPI-specific fields that should be removed from JSON Schema
15
+ OPENAPI_SPECIFIC_FIELDS = {
16
+ "nullable", # Handled by converting to type arrays
17
+ "discriminator", # OpenAPI-specific
18
+ "readOnly", # OpenAPI-specific metadata
19
+ "writeOnly", # OpenAPI-specific metadata
20
+ "xml", # OpenAPI-specific metadata
21
+ "externalDocs", # OpenAPI-specific metadata
22
+ "deprecated", # Can be kept but not part of JSON Schema core
23
+ }
24
+
25
+ # Fields that should be recursively processed
26
+ RECURSIVE_FIELDS = {
27
+ "properties": dict,
28
+ "items": dict,
29
+ "additionalProperties": dict,
30
+ "allOf": list,
31
+ "anyOf": list,
32
+ "oneOf": list,
33
+ "not": dict,
34
+ }
35
+
36
+
37
+ def convert_openapi_schema_to_json_schema(
38
+ schema: dict[str, Any],
39
+ openapi_version: str | None = None,
40
+ remove_read_only: bool = False,
41
+ remove_write_only: bool = False,
42
+ convert_one_of_to_any_of: bool = True,
43
+ ) -> dict[str, Any]:
44
+ """
45
+ Convert an OpenAPI schema to JSON Schema format.
46
+
47
+ This is a clean, systematic approach that:
48
+ 1. Removes OpenAPI-specific fields
49
+ 2. Converts nullable fields to type arrays (for OpenAPI 3.0 only)
50
+ 3. Converts oneOf to anyOf for overlapping union handling
51
+ 4. Recursively processes nested schemas
52
+ 5. Optionally removes readOnly/writeOnly properties
53
+
54
+ Args:
55
+ schema: OpenAPI schema dictionary
56
+ openapi_version: OpenAPI version for optimization
57
+ remove_read_only: Whether to remove readOnly properties
58
+ remove_write_only: Whether to remove writeOnly properties
59
+ convert_one_of_to_any_of: Whether to convert oneOf to anyOf
60
+
61
+ Returns:
62
+ JSON Schema compatible dictionary
63
+ """
64
+ if not isinstance(schema, dict):
65
+ return schema
66
+
67
+ # Early exit optimization - check if conversion is needed
68
+ needs_conversion = (
69
+ any(field in schema for field in OPENAPI_SPECIFIC_FIELDS)
70
+ or (remove_read_only and _has_read_only_properties(schema))
71
+ or (remove_write_only and _has_write_only_properties(schema))
72
+ or (convert_one_of_to_any_of and "oneOf" in schema)
73
+ or _needs_recursive_processing(
74
+ schema,
75
+ openapi_version,
76
+ remove_read_only,
77
+ remove_write_only,
78
+ convert_one_of_to_any_of,
79
+ )
80
+ )
81
+
82
+ if not needs_conversion:
83
+ return schema
84
+
85
+ # Work on a copy to avoid mutation
86
+ result = schema.copy()
87
+
88
+ # Step 1: Handle nullable field conversion (OpenAPI 3.0 only)
89
+ if openapi_version and openapi_version.startswith("3.0"):
90
+ result = _convert_nullable_field(result)
91
+
92
+ # Step 2: Convert oneOf to anyOf if requested
93
+ if convert_one_of_to_any_of and "oneOf" in result:
94
+ result["anyOf"] = result.pop("oneOf")
95
+
96
+ # Step 3: Remove OpenAPI-specific fields
97
+ for field in OPENAPI_SPECIFIC_FIELDS:
98
+ result.pop(field, None)
99
+
100
+ # Step 4: Handle readOnly/writeOnly property removal
101
+ if remove_read_only or remove_write_only:
102
+ result = _filter_properties_by_access(
103
+ result, remove_read_only, remove_write_only
104
+ )
105
+
106
+ # Step 5: Recursively process nested schemas
107
+ for field_name, field_type in RECURSIVE_FIELDS.items():
108
+ if field_name in result:
109
+ if field_type is dict and isinstance(result[field_name], dict):
110
+ if field_name == "properties":
111
+ # Handle properties specially - each property is a schema
112
+ result[field_name] = {
113
+ prop_name: convert_openapi_schema_to_json_schema(
114
+ prop_schema,
115
+ openapi_version,
116
+ remove_read_only,
117
+ remove_write_only,
118
+ convert_one_of_to_any_of,
119
+ )
120
+ if isinstance(prop_schema, dict)
121
+ else prop_schema
122
+ for prop_name, prop_schema in result[field_name].items()
123
+ }
124
+ else:
125
+ result[field_name] = convert_openapi_schema_to_json_schema(
126
+ result[field_name],
127
+ openapi_version,
128
+ remove_read_only,
129
+ remove_write_only,
130
+ convert_one_of_to_any_of,
131
+ )
132
+ elif field_type is list and isinstance(result[field_name], list):
133
+ result[field_name] = [
134
+ convert_openapi_schema_to_json_schema(
135
+ item,
136
+ openapi_version,
137
+ remove_read_only,
138
+ remove_write_only,
139
+ convert_one_of_to_any_of,
140
+ )
141
+ if isinstance(item, dict)
142
+ else item
143
+ for item in result[field_name]
144
+ ]
145
+
146
+ return result
147
+
148
+
149
+ def _convert_nullable_field(schema: dict[str, Any]) -> dict[str, Any]:
150
+ """Convert OpenAPI nullable field to JSON Schema type array."""
151
+ if "nullable" not in schema:
152
+ return schema
153
+
154
+ result = schema.copy()
155
+ nullable_value = result.pop("nullable")
156
+
157
+ # Only convert if nullable is True and we have a type structure
158
+ if not nullable_value:
159
+ return result
160
+
161
+ if "type" in result:
162
+ current_type = result["type"]
163
+ if isinstance(current_type, str):
164
+ result["type"] = [current_type, "null"]
165
+ elif isinstance(current_type, list) and "null" not in current_type:
166
+ result["type"] = current_type + ["null"]
167
+ elif "oneOf" in result:
168
+ # Convert oneOf to anyOf with null
169
+ result["anyOf"] = result.pop("oneOf") + [{"type": "null"}]
170
+ elif "anyOf" in result:
171
+ # Add null to anyOf if not present
172
+ if not any(item.get("type") == "null" for item in result["anyOf"]):
173
+ result["anyOf"].append({"type": "null"})
174
+ elif "allOf" in result:
175
+ # Wrap allOf in anyOf with null option
176
+ result["anyOf"] = [{"allOf": result.pop("allOf")}, {"type": "null"}]
177
+
178
+ return result
179
+
180
+
181
+ def _has_read_only_properties(schema: dict[str, Any]) -> bool:
182
+ """Quick check if schema has any readOnly properties."""
183
+ if "properties" not in schema:
184
+ return False
185
+ return any(
186
+ isinstance(prop, dict) and prop.get("readOnly")
187
+ for prop in schema["properties"].values()
188
+ )
189
+
190
+
191
+ def _has_write_only_properties(schema: dict[str, Any]) -> bool:
192
+ """Quick check if schema has any writeOnly properties."""
193
+ if "properties" not in schema:
194
+ return False
195
+ return any(
196
+ isinstance(prop, dict) and prop.get("writeOnly")
197
+ for prop in schema["properties"].values()
198
+ )
199
+
200
+
201
+ def _needs_recursive_processing(
202
+ schema: dict[str, Any],
203
+ openapi_version: str | None,
204
+ remove_read_only: bool,
205
+ remove_write_only: bool,
206
+ convert_one_of_to_any_of: bool,
207
+ ) -> bool:
208
+ """Check if the schema needs recursive processing (smarter than just checking for recursive fields)."""
209
+ for field_name, field_type in RECURSIVE_FIELDS.items():
210
+ if field_name in schema:
211
+ if field_type is dict and isinstance(schema[field_name], dict):
212
+ if field_name == "properties":
213
+ # Check if any property needs conversion
214
+ for prop_schema in schema[field_name].values():
215
+ if isinstance(prop_schema, dict):
216
+ nested_needs_conversion = (
217
+ any(
218
+ field in prop_schema
219
+ for field in OPENAPI_SPECIFIC_FIELDS
220
+ )
221
+ or (remove_read_only and prop_schema.get("readOnly"))
222
+ or (remove_write_only and prop_schema.get("writeOnly"))
223
+ or (convert_one_of_to_any_of and "oneOf" in prop_schema)
224
+ or _needs_recursive_processing(
225
+ prop_schema,
226
+ openapi_version,
227
+ remove_read_only,
228
+ remove_write_only,
229
+ convert_one_of_to_any_of,
230
+ )
231
+ )
232
+ if nested_needs_conversion:
233
+ return True
234
+ else:
235
+ # Check if nested schema needs conversion
236
+ nested_needs_conversion = (
237
+ any(
238
+ field in schema[field_name]
239
+ for field in OPENAPI_SPECIFIC_FIELDS
240
+ )
241
+ or (
242
+ remove_read_only
243
+ and _has_read_only_properties(schema[field_name])
244
+ )
245
+ or (
246
+ remove_write_only
247
+ and _has_write_only_properties(schema[field_name])
248
+ )
249
+ or (convert_one_of_to_any_of and "oneOf" in schema[field_name])
250
+ or _needs_recursive_processing(
251
+ schema[field_name],
252
+ openapi_version,
253
+ remove_read_only,
254
+ remove_write_only,
255
+ convert_one_of_to_any_of,
256
+ )
257
+ )
258
+ if nested_needs_conversion:
259
+ return True
260
+ elif field_type is list and isinstance(schema[field_name], list):
261
+ # Check if any list item needs conversion
262
+ for item in schema[field_name]:
263
+ if isinstance(item, dict):
264
+ nested_needs_conversion = (
265
+ any(field in item for field in OPENAPI_SPECIFIC_FIELDS)
266
+ or (remove_read_only and _has_read_only_properties(item))
267
+ or (remove_write_only and _has_write_only_properties(item))
268
+ or (convert_one_of_to_any_of and "oneOf" in item)
269
+ or _needs_recursive_processing(
270
+ item,
271
+ openapi_version,
272
+ remove_read_only,
273
+ remove_write_only,
274
+ convert_one_of_to_any_of,
275
+ )
276
+ )
277
+ if nested_needs_conversion:
278
+ return True
279
+ return False
280
+
281
+
282
+ def _filter_properties_by_access(
283
+ schema: dict[str, Any], remove_read_only: bool, remove_write_only: bool
284
+ ) -> dict[str, Any]:
285
+ """Remove readOnly and/or writeOnly properties from schema."""
286
+ if "properties" not in schema:
287
+ return schema
288
+
289
+ result = schema.copy()
290
+ filtered_properties = {}
291
+
292
+ for prop_name, prop_schema in result["properties"].items():
293
+ if not isinstance(prop_schema, dict):
294
+ filtered_properties[prop_name] = prop_schema
295
+ continue
296
+
297
+ should_remove = (remove_read_only and prop_schema.get("readOnly")) or (
298
+ remove_write_only and prop_schema.get("writeOnly")
299
+ )
300
+
301
+ if not should_remove:
302
+ filtered_properties[prop_name] = prop_schema
303
+
304
+ result["properties"] = filtered_properties
305
+
306
+ # Clean up required array if properties were removed
307
+ if "required" in result and filtered_properties:
308
+ result["required"] = [
309
+ prop for prop in result["required"] if prop in filtered_properties
310
+ ]
311
+ if not result["required"]:
312
+ result.pop("required")
313
+
314
+ return result
315
+
316
+
317
+ def convert_schema_definitions(
318
+ schema_definitions: dict[str, Any] | None,
319
+ openapi_version: str | None = None,
320
+ **kwargs,
321
+ ) -> dict[str, Any]:
322
+ """
323
+ Convert a dictionary of OpenAPI schema definitions to JSON Schema.
324
+
325
+ Args:
326
+ schema_definitions: Dictionary of schema definitions
327
+ openapi_version: OpenAPI version for optimization
328
+ **kwargs: Additional arguments passed to convert_openapi_schema_to_json_schema
329
+
330
+ Returns:
331
+ Dictionary of converted schema definitions
332
+ """
333
+ if not schema_definitions:
334
+ return {}
335
+
336
+ return {
337
+ name: convert_openapi_schema_to_json_schema(schema, openapi_version, **kwargs)
338
+ for name, schema in schema_definitions.items()
339
+ }
src/fastmcp/experimental/utilities/openapi/models.py CHANGED
@@ -62,6 +62,7 @@ class HTTPRoute(FastMCPBaseModel):
62
  default_factory=dict
63
  ) # Store component schemas
64
  extensions: dict[str, Any] = Field(default_factory=dict)
 
65
 
66
  # Pre-calculated fields for performance
67
  flat_param_schema: 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
 
67
  # Pre-calculated fields for performance
68
  flat_param_schema: JsonSchema = Field(
src/fastmcp/experimental/utilities/openapi/parser.py CHANGED
@@ -74,6 +74,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
74
  Response_30,
75
  Operation_30,
76
  PathItem_30,
 
77
  )
78
  return parser.parse()
79
  else:
@@ -91,6 +92,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
91
  Response,
92
  Operation,
93
  PathItem,
 
94
  )
95
  return parser.parse()
96
  except ValidationError as e:
@@ -124,6 +126,7 @@ class OpenAPIParser(
124
  response_cls: type[TResponse],
125
  operation_cls: type[TOperation],
126
  path_item_cls: type[TPathItem],
 
127
  ):
128
  """Initialize the parser with the OpenAPI schema and type classes."""
129
  self.openapi = openapi
@@ -134,6 +137,7 @@ class OpenAPIParser(
134
  self.response_cls = response_cls
135
  self.operation_cls = operation_cls
136
  self.path_item_cls = path_item_cls
 
137
 
138
  def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
139
  """Convert string parameter location to our ParameterLocation type."""
@@ -560,6 +564,7 @@ class OpenAPIParser(
560
  responses=responses,
561
  schema_definitions=schema_definitions,
562
  extensions=extensions,
 
563
  )
564
 
565
  # Pre-calculate schema and parameter mapping for performance
 
74
  Response_30,
75
  Operation_30,
76
  PathItem_30,
77
+ openapi_version,
78
  )
79
  return parser.parse()
80
  else:
 
92
  Response,
93
  Operation,
94
  PathItem,
95
+ openapi_version,
96
  )
97
  return parser.parse()
98
  except ValidationError as e:
 
126
  response_cls: type[TResponse],
127
  operation_cls: type[TOperation],
128
  path_item_cls: type[TPathItem],
129
+ openapi_version: str,
130
  ):
131
  """Initialize the parser with the OpenAPI schema and type classes."""
132
  self.openapi = openapi
 
137
  self.response_cls = response_cls
138
  self.operation_cls = operation_cls
139
  self.path_item_cls = path_item_cls
140
+ self.openapi_version = openapi_version
141
 
142
  def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
143
  """Convert string parameter location to our ParameterLocation type."""
 
564
  responses=responses,
565
  schema_definitions=schema_definitions,
566
  extensions=extensions,
567
+ openapi_version=self.openapi_version,
568
  )
569
 
570
  # Pre-calculate schema and parameter mapping for performance
src/fastmcp/experimental/utilities/openapi/schemas.py CHANGED
@@ -1,7 +1,7 @@
1
  """Schema manipulation utilities for OpenAPI operations."""
2
 
3
  import logging
4
- from typing import Any, cast
5
 
6
  from .models import HTTPRoute, JsonSchema, ResponseInfo
7
 
@@ -199,86 +199,6 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
199
  return schema
200
 
201
 
202
- def _add_null_to_type(schema: dict[str, Any]) -> None:
203
- """Add 'null' to the schema's type field or handle oneOf/anyOf/allOf constructs if not already present."""
204
- if "type" in schema:
205
- current_type = schema["type"]
206
-
207
- if isinstance(current_type, str):
208
- # Convert string type to array with null
209
- schema["type"] = [current_type, "null"]
210
- elif isinstance(current_type, list):
211
- # Add null to array if not already present
212
- if "null" not in current_type:
213
- schema["type"] = current_type + ["null"]
214
- elif "oneOf" in schema:
215
- # Convert oneOf to anyOf with null type
216
- schema["anyOf"] = schema.pop("oneOf") + [{"type": "null"}]
217
- elif "anyOf" in schema:
218
- # Add null type to anyOf if not already present
219
- if not any(item.get("type") == "null" for item in schema["anyOf"]):
220
- schema["anyOf"].append({"type": "null"})
221
- elif "allOf" in schema:
222
- # For allOf, wrap in anyOf with null - this means (all conditions) OR null
223
- schema["anyOf"] = [{"allOf": schema.pop("allOf")}, {"type": "null"}]
224
-
225
-
226
- def _handle_nullable_fields(schema: dict[str, Any] | Any) -> dict[str, Any] | Any:
227
- """Convert OpenAPI nullable fields to JSON Schema format: {"type": "string",
228
- "nullable": true} -> {"type": ["string", "null"]}"""
229
-
230
- if not isinstance(schema, dict):
231
- return schema
232
-
233
- # Check if we need to modify anything first to avoid unnecessary copying
234
- has_root_nullable_field = "nullable" in schema
235
- has_root_nullable_true = (
236
- has_root_nullable_field
237
- and schema["nullable"]
238
- and (
239
- "type" in schema
240
- or "oneOf" in schema
241
- or "anyOf" in schema
242
- or "allOf" in schema
243
- )
244
- )
245
-
246
- has_property_nullable_field = False
247
- if "properties" in schema:
248
- for prop_schema in schema["properties"].values():
249
- if isinstance(prop_schema, dict) and "nullable" in prop_schema:
250
- has_property_nullable_field = True
251
- break
252
-
253
- # If no nullable fields at all, return original schema unchanged
254
- if not has_root_nullable_field and not has_property_nullable_field:
255
- return schema
256
-
257
- # Only copy if we need to modify
258
- result = schema.copy()
259
-
260
- # Handle root level nullable - always remove the field, convert type if true
261
- if has_root_nullable_field:
262
- result.pop("nullable")
263
- if has_root_nullable_true:
264
- _add_null_to_type(result)
265
-
266
- # Handle properties nullable fields
267
- if has_property_nullable_field and "properties" in result:
268
- for prop_name, prop_schema in result["properties"].items():
269
- if isinstance(prop_schema, dict) and "nullable" in prop_schema:
270
- nullable_value = prop_schema.pop("nullable")
271
- if nullable_value and (
272
- "type" in prop_schema
273
- or "oneOf" in prop_schema
274
- or "anyOf" in prop_schema
275
- or "allOf" in prop_schema
276
- ):
277
- _add_null_to_type(prop_schema)
278
-
279
- return result
280
-
281
-
282
  def _combine_schemas_and_map_params(
283
  route: HTTPRoute,
284
  ) -> tuple[dict[str, Any], dict[str, dict[str, str]]]:
@@ -450,68 +370,10 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
450
  return schema
451
 
452
 
453
- def _has_one_of(obj: dict[str, Any] | list[Any]) -> bool:
454
- """Quickly check if schema contains any 'oneOf' keys without deep traversal."""
455
- if isinstance(obj, dict):
456
- if "oneOf" in obj:
457
- return True
458
- # Only check likely schema containers, skip examples/defaults
459
- for k, v in obj.items():
460
- if k in [
461
- "properties",
462
- "items",
463
- "allOf",
464
- "anyOf",
465
- "additionalProperties",
466
- ] and isinstance(v, dict | list):
467
- if _has_one_of(v):
468
- return True
469
- elif isinstance(obj, list):
470
- for item in obj:
471
- if isinstance(item, dict | list) and _has_one_of(item):
472
- return True
473
- return False
474
-
475
-
476
- def _adjust_union_types(
477
- schema: dict[str, Any] | list[Any], _depth: int = 0
478
- ) -> dict[str, Any] | list[Any]:
479
- """Recursively replace 'oneOf' with 'anyOf' in schema to handle overlapping unions."""
480
- # MAJOR OPTIMIZATION: Skip entirely if schema has no oneOf keys
481
- if _depth == 0 and not _has_one_of(schema):
482
- return schema
483
-
484
- # OPTIMIZATION: Early termination for very deep structures to prevent exponential slowdown
485
- if _depth > 30: # Reduced from 50 for better performance
486
- return schema
487
-
488
- if isinstance(schema, dict):
489
- # Work on a copy to avoid mutating the input
490
- result = schema.copy()
491
- if "oneOf" in result:
492
- result["anyOf"] = result.pop("oneOf")
493
- # OPTIMIZATION: Only recurse into values that could contain more schemas
494
- for k, v in result.items():
495
- if isinstance(v, dict | list) and k not in [
496
- "examples",
497
- "example",
498
- "default",
499
- ]:
500
- result[k] = _adjust_union_types(v, _depth + 1)
501
- return result
502
- elif isinstance(schema, list):
503
- # Process list items without mutating the input list
504
- return [
505
- _adjust_union_types(item, _depth + 1)
506
- if isinstance(item, dict | list)
507
- else item
508
- for item in schema
509
- ]
510
- return schema
511
-
512
-
513
  def extract_output_schema_from_responses(
514
- responses: dict[str, ResponseInfo], schema_definitions: dict[str, Any] | None = None
 
 
515
  ) -> dict[str, Any] | None:
516
  """
517
  Extract output schema from OpenAPI responses for use as MCP tool output schema.
@@ -523,6 +385,7 @@ def extract_output_schema_from_responses(
523
  Args:
524
  responses: Dictionary of ResponseInfo objects keyed by status code
525
  schema_definitions: Optional schema definitions to include in the output schema
 
526
 
527
  Returns:
528
  dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
@@ -588,9 +451,14 @@ def extract_output_schema_from_responses(
588
  # Replace $ref with the actual schema definition
589
  output_schema = schema_definitions[schema_name].copy()
590
 
591
- # Handle OpenAPI nullable fields by converting them to JSON Schema format
592
- # This prevents "None is not of type 'string'" validation errors
593
- output_schema = _handle_nullable_fields(output_schema)
 
 
 
 
 
594
 
595
  # MCP requires output schemas to be objects. If this schema is not an object,
596
  # we need to wrap it similar to how ParsedFunction.from_function() does it
@@ -609,7 +477,15 @@ def extract_output_schema_from_responses(
609
  if schema_definitions and "$ref" not in schema.copy():
610
  processed_defs = {}
611
  for def_name, def_schema in schema_definitions.items():
612
- processed_defs[def_name] = _handle_nullable_fields(def_schema)
 
 
 
 
 
 
 
 
613
  output_schema["$defs"] = processed_defs
614
 
615
  # Use lightweight compression - prune additionalProperties and unused definitions
@@ -647,9 +523,6 @@ def extract_output_schema_from_responses(
647
  else:
648
  output_schema.pop("$defs")
649
 
650
- # Adjust union types to handle overlapping unions
651
- output_schema = cast(dict[str, Any], _adjust_union_types(output_schema))
652
-
653
  return output_schema
654
 
655
 
@@ -661,6 +534,4 @@ __all__ = [
661
  "extract_output_schema_from_responses",
662
  "_replace_ref_with_defs",
663
  "_make_optional_parameter_nullable",
664
- "_handle_nullable_fields",
665
- "_adjust_union_types",
666
  ]
 
1
  """Schema manipulation utilities for OpenAPI operations."""
2
 
3
  import logging
4
+ from typing import Any
5
 
6
  from .models import HTTPRoute, JsonSchema, ResponseInfo
7
 
 
199
  return schema
200
 
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  def _combine_schemas_and_map_params(
203
  route: HTTPRoute,
204
  ) -> tuple[dict[str, Any], dict[str, dict[str, str]]]:
 
370
  return schema
371
 
372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  def extract_output_schema_from_responses(
374
+ responses: dict[str, ResponseInfo],
375
+ schema_definitions: dict[str, Any] | None = None,
376
+ openapi_version: str | None = None,
377
  ) -> dict[str, Any] | None:
378
  """
379
  Extract output schema from OpenAPI responses for use as MCP tool output schema.
 
385
  Args:
386
  responses: Dictionary of ResponseInfo objects keyed by status code
387
  schema_definitions: Optional schema definitions to include in the output schema
388
+ openapi_version: OpenAPI version string, used to optimize nullable field handling
389
 
390
  Returns:
391
  dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
 
451
  # Replace $ref with the actual schema definition
452
  output_schema = schema_definitions[schema_name].copy()
453
 
454
+ # Convert OpenAPI schema to JSON Schema format
455
+ # Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
456
+ if openapi_version and openapi_version.startswith("3.0"):
457
+ from .json_schema_converter import convert_openapi_schema_to_json_schema
458
+
459
+ output_schema = convert_openapi_schema_to_json_schema(
460
+ output_schema, openapi_version
461
+ )
462
 
463
  # MCP requires output schemas to be objects. If this schema is not an object,
464
  # we need to wrap it similar to how ParsedFunction.from_function() does it
 
477
  if schema_definitions and "$ref" not in schema.copy():
478
  processed_defs = {}
479
  for def_name, def_schema in schema_definitions.items():
480
+ # Convert OpenAPI schema definitions to JSON Schema format
481
+ if openapi_version and openapi_version.startswith("3.0"):
482
+ from .json_schema_converter import convert_openapi_schema_to_json_schema
483
+
484
+ processed_defs[def_name] = convert_openapi_schema_to_json_schema(
485
+ def_schema, openapi_version
486
+ )
487
+ else:
488
+ processed_defs[def_name] = def_schema
489
  output_schema["$defs"] = processed_defs
490
 
491
  # Use lightweight compression - prune additionalProperties and unused definitions
 
523
  else:
524
  output_schema.pop("$defs")
525
 
 
 
 
526
  return output_schema
527
 
528
 
 
534
  "extract_output_schema_from_responses",
535
  "_replace_ref_with_defs",
536
  "_make_optional_parameter_nullable",
 
 
537
  ]
src/fastmcp/server/openapi.py CHANGED
@@ -892,7 +892,7 @@ class FastMCPOpenAPI(FastMCP):
892
 
893
  # Extract output schema from OpenAPI responses
894
  output_schema = extract_output_schema_from_responses(
895
- route.responses, route.schema_definitions
896
  )
897
 
898
  # Get a unique tool name
 
892
 
893
  # Extract output schema from OpenAPI responses
894
  output_schema = extract_output_schema_from_responses(
895
+ route.responses, route.schema_definitions, route.openapi_version
896
  )
897
 
898
  # Get a unique tool name
src/fastmcp/utilities/openapi.py CHANGED
@@ -173,6 +173,7 @@ class HTTPRoute(FastMCPBaseModel):
173
  default_factory=dict
174
  ) # Store component schemas
175
  extensions: dict[str, Any] = Field(default_factory=dict)
 
176
 
177
 
178
  # Export public symbols
@@ -227,6 +228,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
227
  Response_30,
228
  Operation_30,
229
  PathItem_30,
 
230
  )
231
  return parser.parse()
232
  else:
@@ -244,6 +246,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
244
  Response,
245
  Operation,
246
  PathItem,
 
247
  )
248
  return parser.parse()
249
  except ValidationError as e:
@@ -277,6 +280,7 @@ class OpenAPIParser(
277
  response_cls: type[TResponse],
278
  operation_cls: type[TOperation],
279
  path_item_cls: type[TPathItem],
 
280
  ):
281
  """Initialize the parser with the OpenAPI schema and type classes."""
282
  self.openapi = openapi
@@ -287,6 +291,7 @@ class OpenAPIParser(
287
  self.response_cls = response_cls
288
  self.operation_cls = operation_cls
289
  self.path_item_cls = path_item_cls
 
290
 
291
  def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
292
  """Convert string parameter location to our ParameterLocation type."""
@@ -709,6 +714,7 @@ class OpenAPIParser(
709
  responses=responses,
710
  schema_definitions=schema_definitions,
711
  extensions=extensions,
 
712
  )
713
  routes.append(route)
714
  logger.info(
@@ -1401,7 +1407,9 @@ def _adjust_union_types(
1401
 
1402
 
1403
  def extract_output_schema_from_responses(
1404
- responses: dict[str, ResponseInfo], schema_definitions: dict[str, Any] | None = None
 
 
1405
  ) -> dict[str, Any] | None:
1406
  """
1407
  Extract output schema from OpenAPI responses for use as MCP tool output schema.
@@ -1413,6 +1421,7 @@ def extract_output_schema_from_responses(
1413
  Args:
1414
  responses: Dictionary of ResponseInfo objects keyed by status code
1415
  schema_definitions: Optional schema definitions to include in the output schema
 
1416
 
1417
  Returns:
1418
  dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
@@ -1480,7 +1489,9 @@ def extract_output_schema_from_responses(
1480
 
1481
  # Handle OpenAPI nullable fields by converting them to JSON Schema format
1482
  # This prevents "None is not of type 'string'" validation errors
1483
- output_schema = _handle_nullable_fields(output_schema)
 
 
1484
 
1485
  # MCP requires output schemas to be objects. If this schema is not an object,
1486
  # we need to wrap it similar to how ParsedFunction.from_function() does it
@@ -1499,7 +1510,11 @@ def extract_output_schema_from_responses(
1499
  if schema_definitions and "$ref" not in schema.copy():
1500
  processed_defs = {}
1501
  for def_name, def_schema in schema_definitions.items():
1502
- processed_defs[def_name] = _handle_nullable_fields(def_schema)
 
 
 
 
1503
  output_schema["$defs"] = processed_defs
1504
 
1505
  # Use lightweight compression - prune additionalProperties and unused definitions
 
173
  default_factory=dict
174
  ) # Store component schemas
175
  extensions: dict[str, Any] = Field(default_factory=dict)
176
+ openapi_version: str | None = None
177
 
178
 
179
  # Export public symbols
 
228
  Response_30,
229
  Operation_30,
230
  PathItem_30,
231
+ openapi_version,
232
  )
233
  return parser.parse()
234
  else:
 
246
  Response,
247
  Operation,
248
  PathItem,
249
+ openapi_version,
250
  )
251
  return parser.parse()
252
  except ValidationError as e:
 
280
  response_cls: type[TResponse],
281
  operation_cls: type[TOperation],
282
  path_item_cls: type[TPathItem],
283
+ openapi_version: str,
284
  ):
285
  """Initialize the parser with the OpenAPI schema and type classes."""
286
  self.openapi = openapi
 
291
  self.response_cls = response_cls
292
  self.operation_cls = operation_cls
293
  self.path_item_cls = path_item_cls
294
+ self.openapi_version = openapi_version
295
 
296
  def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
297
  """Convert string parameter location to our ParameterLocation type."""
 
714
  responses=responses,
715
  schema_definitions=schema_definitions,
716
  extensions=extensions,
717
+ openapi_version=self.openapi_version,
718
  )
719
  routes.append(route)
720
  logger.info(
 
1407
 
1408
 
1409
  def extract_output_schema_from_responses(
1410
+ responses: dict[str, ResponseInfo],
1411
+ schema_definitions: dict[str, Any] | None = None,
1412
+ openapi_version: str | None = None,
1413
  ) -> dict[str, Any] | None:
1414
  """
1415
  Extract output schema from OpenAPI responses for use as MCP tool output schema.
 
1421
  Args:
1422
  responses: Dictionary of ResponseInfo objects keyed by status code
1423
  schema_definitions: Optional schema definitions to include in the output schema
1424
+ openapi_version: OpenAPI version string, used to optimize nullable field handling
1425
 
1426
  Returns:
1427
  dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
 
1489
 
1490
  # Handle OpenAPI nullable fields by converting them to JSON Schema format
1491
  # This prevents "None is not of type 'string'" validation errors
1492
+ # Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
1493
+ if openapi_version and openapi_version.startswith("3.0"):
1494
+ output_schema = _handle_nullable_fields(output_schema)
1495
 
1496
  # MCP requires output schemas to be objects. If this schema is not an object,
1497
  # we need to wrap it similar to how ParsedFunction.from_function() does it
 
1510
  if schema_definitions and "$ref" not in schema.copy():
1511
  processed_defs = {}
1512
  for def_name, def_schema in schema_definitions.items():
1513
+ # Only handle nullable fields for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
1514
+ if openapi_version and openapi_version.startswith("3.0"):
1515
+ processed_defs[def_name] = _handle_nullable_fields(def_schema)
1516
+ else:
1517
+ processed_defs[def_name] = def_schema
1518
  output_schema["$defs"] = processed_defs
1519
 
1520
  # Use lightweight compression - prune additionalProperties and unused definitions
tests/experimental/utilities/openapi/test_director.py CHANGED
@@ -1,7 +1,7 @@
1
  """Unit tests for RequestDirector."""
2
 
3
  import pytest
4
- from openapi_core import Spec
5
 
6
  from fastmcp.experimental.utilities.openapi.director import RequestDirector
7
  from fastmcp.experimental.utilities.openapi.models import (
@@ -148,12 +148,12 @@ class TestRequestDirector:
148
  @pytest.fixture
149
  def director(self, basic_openapi_30_spec):
150
  """Create a RequestDirector instance."""
151
- spec = Spec.from_dict(basic_openapi_30_spec)
152
  return RequestDirector(spec)
153
 
154
  def test_director_initialization(self, basic_openapi_30_spec):
155
  """Test RequestDirector initialization."""
156
- spec = Spec.from_dict(basic_openapi_30_spec)
157
  director = RequestDirector(spec)
158
 
159
  assert director._spec is not None
@@ -350,7 +350,7 @@ class TestRequestDirector:
350
  request = director.build(route, flat_args, "https://api.example.com")
351
 
352
  assert request.method == "POST"
353
- # For non-JSON content, httpx uses 'data' parameter which becomes bytes
354
  assert request.content == b"Hello, World!"
355
 
356
  def test_body_construction_multiple_properties_non_object_schema(self, director):
@@ -392,7 +392,7 @@ class TestRequestDirectorIntegration:
392
  assert len(routes) == 1
393
 
394
  route = routes[0]
395
- spec = Spec.from_dict(basic_openapi_30_spec)
396
  director = RequestDirector(spec)
397
 
398
  flat_args = {"id": 42}
@@ -407,7 +407,7 @@ class TestRequestDirectorIntegration:
407
  assert len(routes) == 1
408
 
409
  route = routes[0]
410
- spec = Spec.from_dict(collision_spec)
411
  director = RequestDirector(spec)
412
 
413
  # Use the parameter names from the actual parameter map
@@ -440,7 +440,7 @@ class TestRequestDirectorIntegration:
440
  assert len(routes) == 1
441
 
442
  route = routes[0]
443
- spec = Spec.from_dict(deepobject_spec)
444
  director = RequestDirector(spec)
445
 
446
  # DeepObject parameters should be flattened in the parameter map
 
1
  """Unit tests for RequestDirector."""
2
 
3
  import pytest
4
+ from jsonschema_path import SchemaPath
5
 
6
  from fastmcp.experimental.utilities.openapi.director import RequestDirector
7
  from fastmcp.experimental.utilities.openapi.models import (
 
148
  @pytest.fixture
149
  def director(self, basic_openapi_30_spec):
150
  """Create a RequestDirector instance."""
151
+ spec = SchemaPath.from_dict(basic_openapi_30_spec)
152
  return RequestDirector(spec)
153
 
154
  def test_director_initialization(self, basic_openapi_30_spec):
155
  """Test RequestDirector initialization."""
156
+ spec = SchemaPath.from_dict(basic_openapi_30_spec)
157
  director = RequestDirector(spec)
158
 
159
  assert director._spec is not None
 
350
  request = director.build(route, flat_args, "https://api.example.com")
351
 
352
  assert request.method == "POST"
353
+ # For non-JSON content, httpx uses 'content' parameter which becomes bytes
354
  assert request.content == b"Hello, World!"
355
 
356
  def test_body_construction_multiple_properties_non_object_schema(self, director):
 
392
  assert len(routes) == 1
393
 
394
  route = routes[0]
395
+ spec = SchemaPath.from_dict(basic_openapi_30_spec)
396
  director = RequestDirector(spec)
397
 
398
  flat_args = {"id": 42}
 
407
  assert len(routes) == 1
408
 
409
  route = routes[0]
410
+ spec = SchemaPath.from_dict(collision_spec)
411
  director = RequestDirector(spec)
412
 
413
  # Use the parameter names from the actual parameter map
 
440
  assert len(routes) == 1
441
 
442
  route = routes[0]
443
+ spec = SchemaPath.from_dict(deepobject_spec)
444
  director = RequestDirector(spec)
445
 
446
  # DeepObject parameters should be flattened in the parameter map
tests/experimental/utilities/openapi/test_nullable_fields.py CHANGED
@@ -1,6 +1,8 @@
1
  """Tests for nullable field handling in OpenAPI schemas."""
2
 
3
- from fastmcp.experimental.utilities.openapi.schemas import _handle_nullable_fields
 
 
4
 
5
 
6
  class TestHandleNullableFields:
@@ -10,21 +12,21 @@ class TestHandleNullableFields:
10
  """Test nullable string at root level."""
11
  input_schema = {"type": "string", "nullable": True}
12
  expected = {"type": ["string", "null"]}
13
- result = _handle_nullable_fields(input_schema)
14
  assert result == expected
15
 
16
  def test_root_level_nullable_integer(self):
17
  """Test nullable integer at root level."""
18
  input_schema = {"type": "integer", "nullable": True}
19
  expected = {"type": ["integer", "null"]}
20
- result = _handle_nullable_fields(input_schema)
21
  assert result == expected
22
 
23
  def test_root_level_nullable_boolean(self):
24
  """Test nullable boolean at root level."""
25
  input_schema = {"type": "boolean", "nullable": True}
26
  expected = {"type": ["boolean", "null"]}
27
- result = _handle_nullable_fields(input_schema)
28
  assert result == expected
29
 
30
  def test_property_level_nullable_fields(self):
@@ -47,7 +49,7 @@ class TestHandleNullableFields:
47
  "active": {"type": ["boolean", "null"]},
48
  },
49
  }
50
- result = _handle_nullable_fields(input_schema)
51
  assert result == expected
52
 
53
  def test_mixed_nullable_and_non_nullable(self):
@@ -70,14 +72,14 @@ class TestHandleNullableFields:
70
  },
71
  "required": ["required_field"],
72
  }
73
- result = _handle_nullable_fields(input_schema)
74
  assert result == expected
75
 
76
  def test_nullable_false_ignored(self):
77
  """Test that nullable: false is ignored (removed but no type change)."""
78
  input_schema = {"type": "string", "nullable": False}
79
  expected = {"type": "string"}
80
- result = _handle_nullable_fields(input_schema)
81
  assert result == expected
82
 
83
  def test_no_nullable_field_unchanged(self):
@@ -87,14 +89,14 @@ class TestHandleNullableFields:
87
  "properties": {"name": {"type": "string"}},
88
  }
89
  expected = input_schema.copy()
90
- result = _handle_nullable_fields(input_schema)
91
  assert result == expected
92
 
93
  def test_nullable_without_type_removes_nullable(self):
94
  """Test that nullable field is removed even without type."""
95
  input_schema = {"nullable": True, "description": "Some field"}
96
  expected = {"description": "Some field"}
97
- result = _handle_nullable_fields(input_schema)
98
  assert result == expected
99
 
100
  def test_preserves_other_fields(self):
@@ -112,15 +114,15 @@ class TestHandleNullableFields:
112
  "example": "test",
113
  "format": "email",
114
  }
115
- result = _handle_nullable_fields(input_schema)
116
  assert result == expected
117
 
118
  def test_non_dict_input_unchanged(self):
119
  """Test that non-dict inputs are returned unchanged."""
120
- assert _handle_nullable_fields("string") == "string" # type: ignore[arg-type]
121
- assert _handle_nullable_fields(123) == 123 # type: ignore[arg-type]
122
- assert _handle_nullable_fields(None) is None # type: ignore[arg-type]
123
- assert _handle_nullable_fields([1, 2, 3]) == [1, 2, 3] # type: ignore[arg-type]
124
 
125
  def test_performance_optimization_no_copy_when_unchanged(self):
126
  """Test that schemas without nullable fields return the same object (no copy)."""
@@ -128,7 +130,7 @@ class TestHandleNullableFields:
128
  "type": "object",
129
  "properties": {"name": {"type": "string"}},
130
  }
131
- result = _handle_nullable_fields(input_schema)
132
  # Should return the exact same object, not a copy
133
  assert result is input_schema
134
 
@@ -136,14 +138,14 @@ class TestHandleNullableFields:
136
  """Test nullable handling with existing union types (type as array)."""
137
  input_schema = {"type": ["string", "integer"], "nullable": True}
138
  expected = {"type": ["string", "integer", "null"]}
139
- result = _handle_nullable_fields(input_schema)
140
  assert result == expected
141
 
142
  def test_already_nullable_union_unchanged(self):
143
  """Test that union types already containing null are not modified."""
144
  input_schema = {"type": ["string", "null"], "nullable": True}
145
  expected = {"type": ["string", "null"]}
146
- result = _handle_nullable_fields(input_schema)
147
  assert result == expected
148
 
149
  def test_property_level_union_with_nullable(self):
@@ -156,19 +158,19 @@ class TestHandleNullableFields:
156
  "type": "object",
157
  "properties": {"value": {"type": ["string", "integer", "null"]}},
158
  }
159
- result = _handle_nullable_fields(input_schema)
160
  assert result == expected
161
 
162
  def test_complex_union_nullable_scenarios(self):
163
  """Test various complex union type scenarios."""
164
  # Already has null in different position
165
  input1 = {"type": ["null", "string", "integer"], "nullable": True}
166
- result1 = _handle_nullable_fields(input1)
167
  assert result1 == {"type": ["null", "string", "integer"]}
168
 
169
  # Single item array
170
  input2 = {"type": ["string"], "nullable": True}
171
- result2 = _handle_nullable_fields(input2)
172
  assert result2 == {"type": ["string", "null"]}
173
 
174
  def test_oneof_with_nullable(self):
@@ -180,7 +182,7 @@ class TestHandleNullableFields:
180
  expected = {
181
  "anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
182
  }
183
- result = _handle_nullable_fields(input_schema)
184
  assert result == expected
185
 
186
  def test_anyof_with_nullable(self):
@@ -192,7 +194,7 @@ class TestHandleNullableFields:
192
  expected = {
193
  "anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
194
  }
195
- result = _handle_nullable_fields(input_schema)
196
  assert result == expected
197
 
198
  def test_anyof_already_nullable(self):
@@ -202,7 +204,7 @@ class TestHandleNullableFields:
202
  "nullable": True,
203
  }
204
  expected = {"anyOf": [{"type": "string"}, {"type": "null"}]}
205
- result = _handle_nullable_fields(input_schema)
206
  assert result == expected
207
 
208
  def test_allof_with_nullable(self):
@@ -217,7 +219,7 @@ class TestHandleNullableFields:
217
  {"type": "null"},
218
  ]
219
  }
220
- result = _handle_nullable_fields(input_schema)
221
  assert result == expected
222
 
223
  def test_property_level_oneof_with_nullable(self):
@@ -239,5 +241,5 @@ class TestHandleNullableFields:
239
  }
240
  },
241
  }
242
- result = _handle_nullable_fields(input_schema)
243
  assert result == expected
 
1
  """Tests for nullable field handling in OpenAPI schemas."""
2
 
3
+ from fastmcp.experimental.utilities.openapi.json_schema_converter import (
4
+ convert_openapi_schema_to_json_schema,
5
+ )
6
 
7
 
8
  class TestHandleNullableFields:
 
12
  """Test nullable string at root level."""
13
  input_schema = {"type": "string", "nullable": True}
14
  expected = {"type": ["string", "null"]}
15
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
16
  assert result == expected
17
 
18
  def test_root_level_nullable_integer(self):
19
  """Test nullable integer at root level."""
20
  input_schema = {"type": "integer", "nullable": True}
21
  expected = {"type": ["integer", "null"]}
22
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
23
  assert result == expected
24
 
25
  def test_root_level_nullable_boolean(self):
26
  """Test nullable boolean at root level."""
27
  input_schema = {"type": "boolean", "nullable": True}
28
  expected = {"type": ["boolean", "null"]}
29
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
30
  assert result == expected
31
 
32
  def test_property_level_nullable_fields(self):
 
49
  "active": {"type": ["boolean", "null"]},
50
  },
51
  }
52
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
53
  assert result == expected
54
 
55
  def test_mixed_nullable_and_non_nullable(self):
 
72
  },
73
  "required": ["required_field"],
74
  }
75
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
76
  assert result == expected
77
 
78
  def test_nullable_false_ignored(self):
79
  """Test that nullable: false is ignored (removed but no type change)."""
80
  input_schema = {"type": "string", "nullable": False}
81
  expected = {"type": "string"}
82
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
83
  assert result == expected
84
 
85
  def test_no_nullable_field_unchanged(self):
 
89
  "properties": {"name": {"type": "string"}},
90
  }
91
  expected = input_schema.copy()
92
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
93
  assert result == expected
94
 
95
  def test_nullable_without_type_removes_nullable(self):
96
  """Test that nullable field is removed even without type."""
97
  input_schema = {"nullable": True, "description": "Some field"}
98
  expected = {"description": "Some field"}
99
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
100
  assert result == expected
101
 
102
  def test_preserves_other_fields(self):
 
114
  "example": "test",
115
  "format": "email",
116
  }
117
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
118
  assert result == expected
119
 
120
  def test_non_dict_input_unchanged(self):
121
  """Test that non-dict inputs are returned unchanged."""
122
+ assert convert_openapi_schema_to_json_schema("string", "3.0.0") == "string" # type: ignore[arg-type]
123
+ assert convert_openapi_schema_to_json_schema(123, "3.0.0") == 123 # type: ignore[arg-type]
124
+ assert convert_openapi_schema_to_json_schema(None, "3.0.0") is None # type: ignore[arg-type]
125
+ assert convert_openapi_schema_to_json_schema([1, 2, 3], "3.0.0") == [1, 2, 3] # type: ignore[arg-type]
126
 
127
  def test_performance_optimization_no_copy_when_unchanged(self):
128
  """Test that schemas without nullable fields return the same object (no copy)."""
 
130
  "type": "object",
131
  "properties": {"name": {"type": "string"}},
132
  }
133
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
134
  # Should return the exact same object, not a copy
135
  assert result is input_schema
136
 
 
138
  """Test nullable handling with existing union types (type as array)."""
139
  input_schema = {"type": ["string", "integer"], "nullable": True}
140
  expected = {"type": ["string", "integer", "null"]}
141
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
142
  assert result == expected
143
 
144
  def test_already_nullable_union_unchanged(self):
145
  """Test that union types already containing null are not modified."""
146
  input_schema = {"type": ["string", "null"], "nullable": True}
147
  expected = {"type": ["string", "null"]}
148
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
149
  assert result == expected
150
 
151
  def test_property_level_union_with_nullable(self):
 
158
  "type": "object",
159
  "properties": {"value": {"type": ["string", "integer", "null"]}},
160
  }
161
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
162
  assert result == expected
163
 
164
  def test_complex_union_nullable_scenarios(self):
165
  """Test various complex union type scenarios."""
166
  # Already has null in different position
167
  input1 = {"type": ["null", "string", "integer"], "nullable": True}
168
+ result1 = convert_openapi_schema_to_json_schema(input1, "3.0.0")
169
  assert result1 == {"type": ["null", "string", "integer"]}
170
 
171
  # Single item array
172
  input2 = {"type": ["string"], "nullable": True}
173
+ result2 = convert_openapi_schema_to_json_schema(input2, "3.0.0")
174
  assert result2 == {"type": ["string", "null"]}
175
 
176
  def test_oneof_with_nullable(self):
 
182
  expected = {
183
  "anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
184
  }
185
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
186
  assert result == expected
187
 
188
  def test_anyof_with_nullable(self):
 
194
  expected = {
195
  "anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
196
  }
197
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
198
  assert result == expected
199
 
200
  def test_anyof_already_nullable(self):
 
204
  "nullable": True,
205
  }
206
  expected = {"anyOf": [{"type": "string"}, {"type": "null"}]}
207
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
208
  assert result == expected
209
 
210
  def test_allof_with_nullable(self):
 
219
  {"type": "null"},
220
  ]
221
  }
222
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
223
  assert result == expected
224
 
225
  def test_property_level_oneof_with_nullable(self):
 
241
  }
242
  },
243
  }
244
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
245
  assert result == expected