From 07e70e2681c801e6c3861fd9673a65c19643a000 Mon Sep 17 00:00:00 2001 From: syf2211 Date: Sat, 27 Jun 2026 20:40:47 +0000 Subject: [PATCH] fix(openapi): preserve array items for Optional[List] MCP tool params Hoist array items from anyOf/oneOf variants before clean_schema_for_display removes composition keys, and apply schema cleaning to tool input properties so Optional[List[X]] body/query params expose valid {type, items} schemas. Fixes #304 --- fastapi_mcp/openapi/convert.py | 6 +++++ fastapi_mcp/openapi/utils.py | 18 +++++++++++++ tests/test_openapi_conversion.py | 46 ++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/fastapi_mcp/openapi/convert.py b/fastapi_mcp/openapi/convert.py index 22e5c5e1..54a08d91 100644 --- a/fastapi_mcp/openapi/convert.py +++ b/fastapi_mcp/openapi/convert.py @@ -213,6 +213,8 @@ def convert_openapi_to_mcp_tools( if param_required: required_props.append(param_name) + properties[param_name] = clean_schema_for_display(properties[param_name]) + # Add query parameters to properties for param_name, param in query_params: param_schema = param.get("schema", {}) @@ -233,6 +235,8 @@ def convert_openapi_to_mcp_tools( if param_required: required_props.append(param_name) + properties[param_name] = clean_schema_for_display(properties[param_name]) + # Add body parameters to properties for param_name, param in body_params: param_schema = param.get("schema", {}) @@ -253,6 +257,8 @@ def convert_openapi_to_mcp_tools( if param_required: required_props.append(param_name) + properties[param_name] = clean_schema_for_display(properties[param_name]) + # Create a proper input schema for the tool input_schema = {"type": "object", "properties": properties, "title": f"{operation_id}Arguments"} diff --git a/fastapi_mcp/openapi/utils.py b/fastapi_mcp/openapi/utils.py index 1821d572..a12a9de9 100644 --- a/fastapi_mcp/openapi/utils.py +++ b/fastapi_mcp/openapi/utils.py @@ -57,6 +57,22 @@ def resolve_schema_references(schema_part: Dict[str, Any], reference_schema: Dic return schema_part +def _hoist_array_fields_from_composition(schema: Dict[str, Any]) -> None: + """Copy array `items` (and `type`) from anyOf/oneOf variants before composition keys are removed.""" + for composition_key in ("anyOf", "oneOf"): + variants = schema.get(composition_key) + if not isinstance(variants, list): + continue + + for variant in variants: + if not isinstance(variant, dict) or variant.get("type") != "array": + continue + if "items" in variant and "items" not in schema: + schema["items"] = variant["items"] + schema.setdefault("type", "array") + break + + def clean_schema_for_display(schema: Dict[str, Any]) -> Dict[str, Any]: """ Clean up a schema for display by removing internal fields. @@ -70,6 +86,8 @@ def clean_schema_for_display(schema: Dict[str, Any]) -> Dict[str, Any]: # Make a copy to avoid modifying the input schema schema = schema.copy() + _hoist_array_fields_from_composition(schema) + # Remove common internal fields that are not helpful for LLMs fields_to_remove = [ "allOf", diff --git a/tests/test_openapi_conversion.py b/tests/test_openapi_conversion.py index aefe6433..aa09e2aa 100644 --- a/tests/test_openapi_conversion.py +++ b/tests/test_openapi_conversion.py @@ -1,5 +1,8 @@ +from typing import List, Optional + from fastapi import FastAPI from fastapi.openapi.utils import get_openapi +from pydantic import BaseModel import mcp.types as types from fastapi_mcp.openapi.convert import convert_openapi_to_mcp_tools @@ -158,6 +161,49 @@ def test_schema_utils(): assert isinstance(array_example[0], str) +def test_clean_schema_for_display_hoists_optional_list_items(): + schema = { + "anyOf": [ + {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}}}}, + {"type": "null"}, + ], + "title": "Images", + "type": "array", + } + + cleaned = clean_schema_for_display(schema) + + assert cleaned["type"] == "array" + assert cleaned["items"] == {"type": "object", "properties": {"id": {"type": "string"}}} + assert "anyOf" not in cleaned + + +def test_optional_list_request_body_includes_array_items(): + app = FastAPI() + + class MyRequest(BaseModel): + tags: Optional[List[str]] = None + + @app.post("/test") + def test_endpoint(req: MyRequest): + 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_schema = tools[0].inputSchema["properties"]["tags"] + + assert tags_schema["type"] == "array" + assert tags_schema["items"] == {"type": "string"} + assert "anyOf" not in tags_schema + + def test_parameter_handling(complex_fastapi_app: FastAPI): openapi_schema = get_openapi( title=complex_fastapi_app.title,