From fa36238aac3dc61a6a2f4b705c4ad88cbb2c69bb Mon Sep 17 00:00:00 2001 From: K4bain <296577378+K4bain@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:19:09 +0500 Subject: [PATCH] fix(openapi): preserve anyOf/oneOf/allOf in projected MCP input schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit convert_openapi_to_mcp_tools() injected a sibling type whenever the resolved property lacked one — including properties using anyOf, oneOf or allOf. A sibling type over-constrains the schema (validators AND the constraints), so Optional[...] unions rejected null and other unions became unsatisfiable. Skip the type injection when a composition keyword is present. Adopted from #308 (original patch by @AlexisMarasigan). --- fastapi_mcp/openapi/convert.py | 17 +++++- tests/test_openapi_conversion.py | 98 +++++++++++++++++++++++++++++--- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/fastapi_mcp/openapi/convert.py b/fastapi_mcp/openapi/convert.py index 22e5c5e1..d853a6f1 100644 --- a/fastapi_mcp/openapi/convert.py +++ b/fastapi_mcp/openapi/convert.py @@ -14,6 +14,14 @@ logger = logging.getLogger(__name__) +_COMPOSITION_KEYWORDS = ("anyOf", "oneOf", "allOf") + + +def _has_composition_keyword(schema: Dict[str, Any]) -> bool: + """True when ``schema`` uses ``anyOf`` / ``oneOf`` / ``allOf``.""" + return any(keyword in schema for keyword in _COMPOSITION_KEYWORDS) + + def convert_openapi_to_mcp_tools( openapi_schema: Dict[str, Any], describe_all_responses: bool = False, @@ -207,7 +215,10 @@ def convert_openapi_to_mcp_tools( if param_desc: properties[param_name]["description"] = param_desc - if "type" not in properties[param_name]: + # Skip the "type" inject when anyOf/oneOf/allOf is present — + # adding a sibling "type" over-constrains the schema (the + # validator AND's both). + if not _has_composition_keyword(properties[param_name]) and "type" not in properties[param_name]: properties[param_name]["type"] = param_schema.get("type", "string") if param_required: @@ -224,7 +235,7 @@ def convert_openapi_to_mcp_tools( if param_desc: properties[param_name]["description"] = param_desc - if "type" not in properties[param_name]: + if not _has_composition_keyword(properties[param_name]) and "type" not in properties[param_name]: properties[param_name]["type"] = get_single_param_type_from_schema(param_schema) if "default" in param_schema: @@ -244,7 +255,7 @@ def convert_openapi_to_mcp_tools( if param_desc: properties[param_name]["description"] = param_desc - if "type" not in properties[param_name]: + if not _has_composition_keyword(properties[param_name]) and "type" 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 aefe6433..c5a54efa 100644 --- a/tests/test_openapi_conversion.py +++ b/tests/test_openapi_conversion.py @@ -176,16 +176,19 @@ 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 + # ``ProductCategory | None`` — Pydantic emits anyOf which we now preserve + # (previously a sibling "type": "string" was injected, breaking null per #246). + assert "anyOf" in properties["category"] + assert "type" not in properties["category"] 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" + # ``Optional[float]`` — anyOf preserved (no sibling "type" injected). + assert "anyOf" in properties["min_price"] + assert "type" not in properties["min_price"] assert "description" in properties["min_price"] assert "Minimum price filter" in properties["min_price"]["description"] - if "minimum" in properties["min_price"]: - assert properties["min_price"]["minimum"] > 0 # gt=0 in Query param assert "in_stock_only" in properties assert properties["in_stock_only"].get("type") == "boolean" @@ -204,7 +207,9 @@ 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" + # ``Optional[List[str]]`` — anyOf preserved. + assert "anyOf" in properties["tag"] + assert "type" not in properties["tag"] required = list_products_tool.inputSchema.get("required", []) assert "page" not in required # Has default value @@ -416,9 +421,88 @@ 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: str | None`` — anyOf preserved; no sibling type injected. + assert "anyOf" in properties["notes"] + assert "type" not in properties["notes"] if "items" in properties: item_props = properties["items"]["items"]["properties"] assert "total" in item_props + + +def test_anyof_union_preserved_in_input_schema(): + """``T | U`` body fields keep their anyOf — no sibling ``type`` injected.""" + from fastapi import FastAPI + from fastapi.openapi.utils import get_openapi + from pydantic import BaseModel, Field + + from fastapi_mcp.openapi.convert import convert_openapi_to_mcp_tools + + class SaveBody(BaseModel): + tags: dict[str, list[str]] | list[str] = Field(default_factory=dict) + + app = FastAPI() + + @app.post("/save", operation_id="save") + def save(body: SaveBody) -> dict: + return {"ok": True} + + openapi_schema = get_openapi( + title=app.title, + version=app.version, + openapi_version=app.openapi_version, + description=app.description, + routes=app.routes, + ) + + tools, _ = convert_openapi_to_mcp_tools(openapi_schema) + tags_prop = next(t for t in tools if t.name == "save").inputSchema["properties"]["tags"] + + assert "anyOf" in tags_prop + assert "type" not in tags_prop + + +def test_oneof_and_allof_preserved_in_input_schema(): + """``oneOf`` / ``allOf`` body fields survive without a top-level ``type``.""" + from fastapi_mcp.openapi.convert import convert_openapi_to_mcp_tools + + openapi_schema = { + "openapi": "3.1.0", + "info": {"title": "test", "version": "1.0.0"}, + "paths": { + "/save": { + "post": { + "operationId": "save", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "kind_a": {"oneOf": [{"type": "string"}, {"type": "integer"}]}, + "kind_b": { + "allOf": [ + {"$ref": "#/components/schemas/Base"}, + {"type": "object", "properties": {"x": {"type": "integer"}}}, + ] + }, + }, + } + } + }, + }, + "responses": {"200": {"description": "OK"}}, + } + } + }, + "components": {"schemas": {"Base": {"type": "object", "properties": {"y": {"type": "string"}}}}}, + } + + tools, _ = convert_openapi_to_mcp_tools(openapi_schema) + props = next(t for t in tools if t.name == "save").inputSchema["properties"] + + assert "oneOf" in props["kind_a"] + assert "type" not in props["kind_a"] + assert "allOf" in props["kind_b"] + assert isinstance(props["kind_b"]["allOf"], list)