From 17f3b38b64af87681341d2ea9c6b74b411f775c0 Mon Sep 17 00:00:00 2001 From: Br1an67 <932039080@qq.com> Date: Sun, 1 Mar 2026 14:59:17 +0800 Subject: [PATCH] fix: preserve anyOf nullable schemas without injecting type field convert_openapi_to_mcp_tools added a top-level 'type' field to parameter schemas that already had 'anyOf' with a null variant. This broke nullable validation because JSON Schema treats 'type' and 'anyOf' as independent constraints that must all be satisfied. Skip the type injection when 'anyOf' is already present in the property schema, for path, query, and body parameters. Closes #246 --- fastapi_mcp/openapi/convert.py | 6 +-- tests/test_openapi_conversion.py | 85 ++++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/fastapi_mcp/openapi/convert.py b/fastapi_mcp/openapi/convert.py index 22e5c5e..86b1fdd 100644 --- a/fastapi_mcp/openapi/convert.py +++ b/fastapi_mcp/openapi/convert.py @@ -207,7 +207,7 @@ def convert_openapi_to_mcp_tools( if param_desc: properties[param_name]["description"] = param_desc - if "type" not in properties[param_name]: + if "type" not in properties[param_name] and "anyOf" not in properties[param_name]: properties[param_name]["type"] = param_schema.get("type", "string") if param_required: @@ -224,7 +224,7 @@ def convert_openapi_to_mcp_tools( if param_desc: properties[param_name]["description"] = param_desc - if "type" not in properties[param_name]: + if "type" not in properties[param_name] and "anyOf" not in properties[param_name]: properties[param_name]["type"] = get_single_param_type_from_schema(param_schema) if "default" in param_schema: @@ -244,7 +244,7 @@ def convert_openapi_to_mcp_tools( if param_desc: properties[param_name]["description"] = param_desc - if "type" not in properties[param_name]: + if "type" not in properties[param_name] and "anyOf" not in properties[param_name]: properties[param_name]["type"] = get_single_param_type_from_schema(param_schema) if "default" in param_schema: diff --git a/tests/test_openapi_conversion.py b/tests/test_openapi_conversion.py index aefe643..9e48d09 100644 --- a/tests/test_openapi_conversion.py +++ b/tests/test_openapi_conversion.py @@ -176,12 +176,14 @@ def test_parameter_handling(complex_fastapi_app: FastAPI): assert "product_id" not in properties # This is from get_product, not list_products assert "category" in properties - assert properties["category"].get("type") == "string" # Enum converted to string + assert "anyOf" in properties["category"] # Nullable enum preserved as anyOf + assert "type" not in properties["category"] # No top-level type for nullable schemas assert "description" in properties["category"] assert "Filter by product category" in properties["category"]["description"] assert "min_price" in properties - assert properties["min_price"].get("type") == "number" + assert "anyOf" in properties["min_price"] # Nullable number preserved as anyOf + assert "type" not in properties["min_price"] # No top-level type for nullable schemas assert "description" in properties["min_price"] assert "Minimum price filter" in properties["min_price"]["description"] if "minimum" in properties["min_price"]: @@ -204,7 +206,7 @@ def test_parameter_handling(complex_fastapi_app: FastAPI): assert properties["size"]["maximum"] <= 100 # le=100 in Query param assert "tag" in properties - assert properties["tag"].get("type") == "array" + assert "anyOf" in properties["tag"] # Nullable array preserved as anyOf required = list_products_tool.inputSchema.get("required", []) assert "page" not in required # Has default value @@ -416,9 +418,82 @@ def test_body_params_edge_cases(complex_fastapi_app: FastAPI): assert properties["customer_id"]["title"] == "customer_id" assert "notes" in properties - assert "type" in properties["notes"] - assert properties["notes"]["type"] in ["string", "object"] # Default should be either string or object + # notes is nullable (anyOf with null), so it should have anyOf or type + assert "type" in properties["notes"] or "anyOf" in properties["notes"] if "items" in properties: item_props = properties["items"]["items"]["properties"] assert "total" in item_props + + +def test_nullable_anyof_schema_preserved(): + """Test that anyOf with null type is preserved without injecting a type field.""" + openapi_schema = { + "openapi": "3.1.0", + "info": {"title": "Test", "version": "0.1.0"}, + "paths": { + "/items": { + "post": { + "operationId": "create_item", + "summary": "Create an item", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "description": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Description", + }, + }, + "required": ["name"], + } + } + }, + "required": True, + }, + "responses": {"200": {"description": "OK"}}, + } + }, + "/items/{item_id}": { + "get": { + "operationId": "get_item", + "summary": "Get an item", + "parameters": [ + { + "name": "item_id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + }, + { + "name": "fields", + "in": "query", + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Fields", + }, + }, + ], + "responses": {"200": {"description": "OK"}}, + } + }, + }, + } + + tools, _ = convert_openapi_to_mcp_tools(openapi_schema) + + # Check body parameter with anyOf + create_tool = next(t for t in tools if t.name == "create_item") + desc_prop = create_tool.inputSchema["properties"]["description"] + assert "anyOf" in desc_prop + assert "type" not in desc_prop, "type field should not be injected when anyOf is present" + + # Check query parameter with anyOf + get_tool = next(t for t in tools if t.name == "get_item") + fields_prop = get_tool.inputSchema["properties"]["fields"] + assert "anyOf" in fields_prop + assert "type" not in fields_prop, "type field should not be injected when anyOf is present"