Jeremiah Lowin Claude commited on
Commit
c4df749
·
unverified ·
1 Parent(s): 0a39b15

Fix nullable field handling in OpenAPI to JSON Schema conversion (#1279)

Browse files
src/fastmcp/experimental/utilities/openapi/schemas.py CHANGED
@@ -199,6 +199,86 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
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]]]:
@@ -499,6 +579,19 @@ def extract_output_schema_from_responses(
499
  # Clean and copy the schema
500
  output_schema = schema.copy()
501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  # MCP requires output schemas to be objects. If this schema is not an object,
503
  # we need to wrap it similar to how ParsedFunction.from_function() does it
504
  if output_schema.get("type") != "object":
@@ -511,9 +604,13 @@ def extract_output_schema_from_responses(
511
  }
512
  output_schema = wrapped_schema
513
 
514
- # Add schema definitions if available
515
- if schema_definitions:
516
- output_schema["$defs"] = schema_definitions.copy()
 
 
 
 
517
 
518
  # Use lightweight compression - prune additionalProperties and unused definitions
519
  if output_schema.get("additionalProperties") is False:
@@ -564,5 +661,6 @@ __all__ = [
564
  "extract_output_schema_from_responses",
565
  "_replace_ref_with_defs",
566
  "_make_optional_parameter_nullable",
 
567
  "_adjust_union_types",
568
  ]
 
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]]]:
 
579
  # Clean and copy the schema
580
  output_schema = schema.copy()
581
 
582
+ # If schema has a $ref, resolve it first before processing nullable fields
583
+ if "$ref" in output_schema and schema_definitions:
584
+ ref_path = output_schema["$ref"]
585
+ if ref_path.startswith("#/components/schemas/"):
586
+ schema_name = ref_path.split("/")[-1]
587
+ if schema_name in schema_definitions:
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
597
  if output_schema.get("type") != "object":
 
604
  }
605
  output_schema = wrapped_schema
606
 
607
+ # Add schema definitions if available and handle nullable fields in them
608
+ # Only add $defs if we didn't resolve the $ref inline above
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
616
  if output_schema.get("additionalProperties") is False:
 
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
  ]
src/fastmcp/utilities/openapi.py CHANGED
@@ -187,6 +187,7 @@ __all__ = [
187
  "parse_openapi_to_http_routes",
188
  "extract_output_schema_from_responses",
189
  "format_deep_object_parameter",
 
190
  ]
191
 
192
  # Type variables for generic parser
@@ -1169,6 +1170,86 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
1169
  return schema
1170
 
1171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1172
  def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
1173
  """
1174
  Combines parameter and request body schemas into a single schema.
@@ -1388,6 +1469,19 @@ def extract_output_schema_from_responses(
1388
  # Clean and copy the schema
1389
  output_schema = schema.copy()
1390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1391
  # MCP requires output schemas to be objects. If this schema is not an object,
1392
  # we need to wrap it similar to how ParsedFunction.from_function() does it
1393
  if output_schema.get("type") != "object":
@@ -1400,9 +1494,13 @@ def extract_output_schema_from_responses(
1400
  }
1401
  output_schema = wrapped_schema
1402
 
1403
- # Add schema definitions if available
1404
- if schema_definitions:
1405
- output_schema["$defs"] = schema_definitions.copy()
 
 
 
 
1406
 
1407
  # Use lightweight compression - prune additionalProperties and unused definitions
1408
  if output_schema.get("additionalProperties") is False:
 
187
  "parse_openapi_to_http_routes",
188
  "extract_output_schema_from_responses",
189
  "format_deep_object_parameter",
190
+ "_handle_nullable_fields",
191
  ]
192
 
193
  # Type variables for generic parser
 
1170
  return schema
1171
 
1172
 
1173
+ def _add_null_to_type(schema: dict[str, Any]) -> None:
1174
+ """Add 'null' to the schema's type field or handle oneOf/anyOf/allOf constructs if not already present."""
1175
+ if "type" in schema:
1176
+ current_type = schema["type"]
1177
+
1178
+ if isinstance(current_type, str):
1179
+ # Convert string type to array with null
1180
+ schema["type"] = [current_type, "null"]
1181
+ elif isinstance(current_type, list):
1182
+ # Add null to array if not already present
1183
+ if "null" not in current_type:
1184
+ schema["type"] = current_type + ["null"]
1185
+ elif "oneOf" in schema:
1186
+ # Convert oneOf to anyOf with null type
1187
+ schema["anyOf"] = schema.pop("oneOf") + [{"type": "null"}]
1188
+ elif "anyOf" in schema:
1189
+ # Add null type to anyOf if not already present
1190
+ if not any(item.get("type") == "null" for item in schema["anyOf"]):
1191
+ schema["anyOf"].append({"type": "null"})
1192
+ elif "allOf" in schema:
1193
+ # For allOf, wrap in anyOf with null - this means (all conditions) OR null
1194
+ schema["anyOf"] = [{"allOf": schema.pop("allOf")}, {"type": "null"}]
1195
+
1196
+
1197
+ def _handle_nullable_fields(schema: dict[str, Any] | Any) -> dict[str, Any] | Any:
1198
+ """Convert OpenAPI nullable fields to JSON Schema format: {"type": "string",
1199
+ "nullable": true} -> {"type": ["string", "null"]}"""
1200
+
1201
+ if not isinstance(schema, dict):
1202
+ return schema
1203
+
1204
+ # Check if we need to modify anything first to avoid unnecessary copying
1205
+ has_root_nullable_field = "nullable" in schema
1206
+ has_root_nullable_true = (
1207
+ has_root_nullable_field
1208
+ and schema["nullable"]
1209
+ and (
1210
+ "type" in schema
1211
+ or "oneOf" in schema
1212
+ or "anyOf" in schema
1213
+ or "allOf" in schema
1214
+ )
1215
+ )
1216
+
1217
+ has_property_nullable_field = False
1218
+ if "properties" in schema:
1219
+ for prop_schema in schema["properties"].values():
1220
+ if isinstance(prop_schema, dict) and "nullable" in prop_schema:
1221
+ has_property_nullable_field = True
1222
+ break
1223
+
1224
+ # If no nullable fields at all, return original schema unchanged
1225
+ if not has_root_nullable_field and not has_property_nullable_field:
1226
+ return schema
1227
+
1228
+ # Only copy if we need to modify
1229
+ result = schema.copy()
1230
+
1231
+ # Handle root level nullable - always remove the field, convert type if true
1232
+ if has_root_nullable_field:
1233
+ result.pop("nullable")
1234
+ if has_root_nullable_true:
1235
+ _add_null_to_type(result)
1236
+
1237
+ # Handle properties nullable fields
1238
+ if has_property_nullable_field and "properties" in result:
1239
+ for prop_name, prop_schema in result["properties"].items():
1240
+ if isinstance(prop_schema, dict) and "nullable" in prop_schema:
1241
+ nullable_value = prop_schema.pop("nullable")
1242
+ if nullable_value and (
1243
+ "type" in prop_schema
1244
+ or "oneOf" in prop_schema
1245
+ or "anyOf" in prop_schema
1246
+ or "allOf" in prop_schema
1247
+ ):
1248
+ _add_null_to_type(prop_schema)
1249
+
1250
+ return result
1251
+
1252
+
1253
  def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
1254
  """
1255
  Combines parameter and request body schemas into a single schema.
 
1469
  # Clean and copy the schema
1470
  output_schema = schema.copy()
1471
 
1472
+ # If schema has a $ref, resolve it first before processing nullable fields
1473
+ if "$ref" in output_schema and schema_definitions:
1474
+ ref_path = output_schema["$ref"]
1475
+ if ref_path.startswith("#/components/schemas/"):
1476
+ schema_name = ref_path.split("/")[-1]
1477
+ if schema_name in schema_definitions:
1478
+ # Replace $ref with the actual schema definition
1479
+ output_schema = schema_definitions[schema_name].copy()
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
1487
  if output_schema.get("type") != "object":
 
1494
  }
1495
  output_schema = wrapped_schema
1496
 
1497
+ # Add schema definitions if available and handle nullable fields in them
1498
+ # Only add $defs if we didn't resolve the $ref inline above
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
1506
  if output_schema.get("additionalProperties") is False:
tests/experimental/utilities/openapi/test_nullable_fields.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
7
+ """Test conversion of OpenAPI nullable fields to JSON Schema format."""
8
+
9
+ def test_root_level_nullable_string(self):
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):
31
+ """Test nullable fields in properties."""
32
+ input_schema = {
33
+ "type": "object",
34
+ "properties": {
35
+ "name": {"type": "string"},
36
+ "company": {"type": "string", "nullable": True},
37
+ "age": {"type": "integer", "nullable": True},
38
+ "active": {"type": "boolean", "nullable": True},
39
+ },
40
+ }
41
+ expected = {
42
+ "type": "object",
43
+ "properties": {
44
+ "name": {"type": "string"},
45
+ "company": {"type": ["string", "null"]},
46
+ "age": {"type": ["integer", "null"]},
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):
54
+ """Test mix of nullable and non-nullable fields."""
55
+ input_schema = {
56
+ "type": "object",
57
+ "properties": {
58
+ "required_field": {"type": "string"},
59
+ "optional_nullable": {"type": "string", "nullable": True},
60
+ "optional_non_nullable": {"type": "string"},
61
+ },
62
+ "required": ["required_field"],
63
+ }
64
+ expected = {
65
+ "type": "object",
66
+ "properties": {
67
+ "required_field": {"type": "string"},
68
+ "optional_nullable": {"type": ["string", "null"]},
69
+ "optional_non_nullable": {"type": "string"},
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):
84
+ """Test that schemas without nullable field are unchanged."""
85
+ input_schema = {
86
+ "type": "object",
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):
101
+ """Test that other fields are preserved during conversion."""
102
+ input_schema = {
103
+ "type": "string",
104
+ "nullable": True,
105
+ "description": "A nullable string",
106
+ "example": "test",
107
+ "format": "email",
108
+ }
109
+ expected = {
110
+ "type": ["string", "null"],
111
+ "description": "A nullable string",
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)."""
127
+ input_schema = {
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
+
135
+ def test_union_types_with_nullable(self):
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):
150
+ """Test nullable handling with union types in properties."""
151
+ input_schema = {
152
+ "type": "object",
153
+ "properties": {"value": {"type": ["string", "integer"], "nullable": True}},
154
+ }
155
+ expected = {
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):
175
+ """Test nullable handling with oneOf constructs."""
176
+ input_schema = {
177
+ "oneOf": [{"type": "string"}, {"type": "integer"}],
178
+ "nullable": True,
179
+ }
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):
187
+ """Test nullable handling with anyOf constructs."""
188
+ input_schema = {
189
+ "anyOf": [{"type": "string"}, {"type": "integer"}],
190
+ "nullable": True,
191
+ }
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):
199
+ """Test anyOf that already contains null type."""
200
+ input_schema = {
201
+ "anyOf": [{"type": "string"}, {"type": "null"}],
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):
209
+ """Test nullable handling with allOf constructs."""
210
+ input_schema = {
211
+ "allOf": [{"type": "string"}, {"minLength": 1}],
212
+ "nullable": True,
213
+ }
214
+ expected = {
215
+ "anyOf": [
216
+ {"allOf": [{"type": "string"}, {"minLength": 1}]},
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):
224
+ """Test nullable handling with oneOf in properties."""
225
+ input_schema = {
226
+ "type": "object",
227
+ "properties": {
228
+ "value": {
229
+ "oneOf": [{"type": "string"}, {"type": "integer"}],
230
+ "nullable": True,
231
+ }
232
+ },
233
+ }
234
+ expected = {
235
+ "type": "object",
236
+ "properties": {
237
+ "value": {
238
+ "anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
239
+ }
240
+ },
241
+ }
242
+ result = _handle_nullable_fields(input_schema)
243
+ assert result == expected
tests/utilities/openapi/test_nullable_fields.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for nullable field handling in OpenAPI schemas."""
2
+
3
+ from fastmcp.utilities.openapi import _handle_nullable_fields
4
+
5
+
6
+ class TestHandleNullableFields:
7
+ """Test conversion of OpenAPI nullable fields to JSON Schema format."""
8
+
9
+ def test_root_level_nullable_string(self):
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):
31
+ """Test nullable fields in properties."""
32
+ input_schema = {
33
+ "type": "object",
34
+ "properties": {
35
+ "name": {"type": "string"},
36
+ "company": {"type": "string", "nullable": True},
37
+ "age": {"type": "integer", "nullable": True},
38
+ "active": {"type": "boolean", "nullable": True},
39
+ },
40
+ }
41
+ expected = {
42
+ "type": "object",
43
+ "properties": {
44
+ "name": {"type": "string"},
45
+ "company": {"type": ["string", "null"]},
46
+ "age": {"type": ["integer", "null"]},
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):
54
+ """Test mix of nullable and non-nullable fields."""
55
+ input_schema = {
56
+ "type": "object",
57
+ "properties": {
58
+ "required_field": {"type": "string"},
59
+ "optional_nullable": {"type": "string", "nullable": True},
60
+ "optional_non_nullable": {"type": "string"},
61
+ },
62
+ "required": ["required_field"],
63
+ }
64
+ expected = {
65
+ "type": "object",
66
+ "properties": {
67
+ "required_field": {"type": "string"},
68
+ "optional_nullable": {"type": ["string", "null"]},
69
+ "optional_non_nullable": {"type": "string"},
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):
84
+ """Test that schemas without nullable field are unchanged."""
85
+ input_schema = {
86
+ "type": "object",
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):
101
+ """Test that other fields are preserved during conversion."""
102
+ input_schema = {
103
+ "type": "string",
104
+ "nullable": True,
105
+ "description": "A nullable string",
106
+ "example": "test",
107
+ "format": "email",
108
+ }
109
+ expected = {
110
+ "type": ["string", "null"],
111
+ "description": "A nullable string",
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)."""
127
+ input_schema = {
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
+
135
+ def test_union_types_with_nullable(self):
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):
150
+ """Test nullable handling with union types in properties."""
151
+ input_schema = {
152
+ "type": "object",
153
+ "properties": {"value": {"type": ["string", "integer"], "nullable": True}},
154
+ }
155
+ expected = {
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):
175
+ """Test nullable handling with oneOf constructs."""
176
+ input_schema = {
177
+ "oneOf": [{"type": "string"}, {"type": "integer"}],
178
+ "nullable": True,
179
+ }
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):
187
+ """Test nullable handling with anyOf constructs."""
188
+ input_schema = {
189
+ "anyOf": [{"type": "string"}, {"type": "integer"}],
190
+ "nullable": True,
191
+ }
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):
199
+ """Test anyOf that already contains null type."""
200
+ input_schema = {
201
+ "anyOf": [{"type": "string"}, {"type": "null"}],
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):
209
+ """Test nullable handling with allOf constructs."""
210
+ input_schema = {
211
+ "allOf": [{"type": "string"}, {"minLength": 1}],
212
+ "nullable": True,
213
+ }
214
+ expected = {
215
+ "anyOf": [
216
+ {"allOf": [{"type": "string"}, {"minLength": 1}]},
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):
224
+ """Test nullable handling with oneOf in properties."""
225
+ input_schema = {
226
+ "type": "object",
227
+ "properties": {
228
+ "value": {
229
+ "oneOf": [{"type": "string"}, {"type": "integer"}],
230
+ "nullable": True,
231
+ }
232
+ },
233
+ }
234
+ expected = {
235
+ "type": "object",
236
+ "properties": {
237
+ "value": {
238
+ "anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
239
+ }
240
+ },
241
+ }
242
+ result = _handle_nullable_fields(input_schema)
243
+ assert result == expected