From 7ce0a59ea6959a73a46722e3e9884f7f13327072 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 13 Jul 2025 10:23:56 -0400 Subject: [PATCH] Fix optional parameter validation in OpenAPI integration (#1135) Co-authored-by: Claude --- src/fastmcp/utilities/openapi.py | 41 ++++++- .../openapi/test_optional_parameters.py | 102 ++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 tests/server/openapi/test_optional_parameters.py diff --git a/src/fastmcp/utilities/openapi.py b/src/fastmcp/utilities/openapi.py index 830fc655e..f0cb2ea03 100644 --- a/src/fastmcp/utilities/openapi.py +++ b/src/fastmcp/utilities/openapi.py @@ -1095,6 +1095,35 @@ def _replace_ref_with_defs( return schema +def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]: + """ + Make an optional parameter schema nullable to allow None values. + + For optional parameters, we need to allow null values in addition to the + specified type to handle cases where None is passed for optional parameters. + """ + # If schema already has multiple types or is already nullable, don't modify + if "anyOf" in schema or "oneOf" in schema or "allOf" in schema: + return schema + + # If it's already nullable (type includes null), don't modify + if isinstance(schema.get("type"), list) and "null" in schema["type"]: + return schema + + # Create a new schema that allows null in addition to the original type + if "type" in schema: + original_type = schema["type"] + if isinstance(original_type, str): + # Single type - make it a union with null + nullable_schema = schema.copy() + nullable_schema["anyOf"] = [{"type": original_type}, {"type": "null"}] + # Remove the original type since we're using anyOf + del nullable_schema["type"] + return nullable_schema + + return schema + + def _combine_schemas(route: HTTPRoute) -> dict[str, Any]: """ Combines parameter and request body schemas into a single schema. @@ -1156,15 +1185,25 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]: else: param_schema["description"] = location_desc + # Make optional parameters nullable to allow None values + if not param.required: + param_schema = _make_optional_parameter_nullable(param_schema) + properties[suffixed_name] = param_schema else: # No collision, use original name if param.required: required.append(param.name) - properties[param.name] = _replace_ref_with_defs( + param_schema = _replace_ref_with_defs( param.schema_.copy(), param.description ) + # Make optional parameters nullable to allow None values + if not param.required: + param_schema = _make_optional_parameter_nullable(param_schema) + + properties[param.name] = param_schema + # Add request body properties (no suffixes for body parameters) if route.request_body and route.request_body.content_schema: for prop_name, prop_schema in body_props.items(): diff --git a/tests/server/openapi/test_optional_parameters.py b/tests/server/openapi/test_optional_parameters.py new file mode 100644 index 000000000..fda714d9c --- /dev/null +++ b/tests/server/openapi/test_optional_parameters.py @@ -0,0 +1,102 @@ +"""Test for optional parameter handling in FastMCP OpenAPI integration.""" + +import pytest + +from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo, _combine_schemas + + +async def test_optional_parameter_schema_allows_null(): + """Test that optional parameters generate schemas that allow null values.""" + # Create a minimal HTTPRoute with optional parameter + optional_param = ParameterInfo( + name="optional_param", + location="query", + required=False, + schema={"type": "string"}, + description="Optional parameter", + ) + + required_param = ParameterInfo( + name="required_param", + location="query", + required=True, + schema={"type": "string"}, + description="Required parameter", + ) + + route = HTTPRoute( + method="GET", + path="/test", + parameters=[required_param, optional_param], + request_body=None, + responses={}, + summary="Test endpoint", + description=None, + schema_definitions={}, + ) + + # Generate combined schema + schema = _combine_schemas(route) + + # Verify that optional parameter allows null values + optional_param_schema = schema["properties"]["optional_param"] + + # Should have anyOf with string and null types + assert "anyOf" in optional_param_schema + assert {"type": "string"} in optional_param_schema["anyOf"] + assert {"type": "null"} in optional_param_schema["anyOf"] + + # Required parameter should not allow null + required_param_schema = schema["properties"]["required_param"] + assert required_param_schema["type"] == "string" + assert "anyOf" not in required_param_schema + + # Required list should only contain required param + assert "required_param" in schema["required"] + assert "optional_param" not in schema["required"] + + +@pytest.mark.parametrize( + "param_schema", + [ + {"type": "string"}, + {"type": "integer"}, + {"type": "number"}, + {"type": "boolean"}, + {"type": "array", "items": {"type": "string"}}, + {"type": "object", "properties": {"name": {"type": "string"}}}, + ], +) +async def test_optional_parameter_allows_null_for_type(param_schema): + """Test that optional parameters of any type allow null values.""" + optional_param = ParameterInfo( + name="optional_param", + location="query", + required=False, + schema=param_schema, + description="Optional parameter", + ) + + route = HTTPRoute( + method="GET", + path="/test", + parameters=[optional_param], + request_body=None, + responses={}, + summary="Test endpoint", + description=None, + schema_definitions={}, + ) + + # Generate combined schema + schema = _combine_schemas(route) + optional_param_schema = schema["properties"]["optional_param"] + + # Should have anyOf with the original type and null + assert "anyOf" in optional_param_schema + assert {"type": "null"} in optional_param_schema["anyOf"] + # Check that original schema is preserved (either simple type or complex schema) + if "type" in param_schema: + assert {"type": param_schema["type"]} in optional_param_schema["anyOf"] + else: + assert param_schema in optional_param_schema["anyOf"]