From b600a941a4efaca961f5cdbb5fc5f8fed40393f2 Mon Sep 17 00:00:00 2001 From: K4bain <296577378+K4bain@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:46:08 +0500 Subject: [PATCH 1/3] fix(openapi): preserve anyOf for non-null union body params instead of injecting an arbitrary type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem For a request body field typed as a union of two real types (e.g. `dict[str, list[str]] | list[str]`), the projected MCP tool schema gets a single arbitrary `"type"` injected alongside `anyOf`. The MCP server's jsonschema validator then enforces that `"type"` strictly and rejects every call using the variant that lost the pick, with a misleading error like: ``` Input validation error: {'Skills': [...]} is not of type 'array' ``` The injected type is also non-deterministic (`next(iter(set))`), so which variant breaks can differ per run. Fixes #307. ## Root cause In `convert_openapi_to_mcp_tools`, both the query-param and body-param loops do: ```python if "type" not in properties[param_name]: properties[param_name]["type"] = get_single_param_type_from_schema(param_schema) ``` For `T | U` (both real types) Pydantic emits `{"anyOf": [...]}` with no top-level `"type"`, so the branch fires and one arbitrary variant type wins. ## Fix Only fall back to `get_single_param_type_from_schema` when the union has at most one non-null variant. `T | None` (nullable) keeps the existing collapse behavior — its existing tests still pass unchanged — while `T | U` keeps `anyOf` verbatim, which the JSON Schema validator at the MCP layer handles correctly. ## Verification - New test `test_union_type_schema_not_collapsed` asserts `anyOf` is preserved with no injected `"type"` and both `object`/`array` variants present. - Repro from the issue validated with `jsonschema`: both the dict variant and the list variant pass validation (previously one of them always failed). - Full suite: 70 passed (the 20 `test_sse_real_transport` errors are the pre-existing Windows `os.fork` limitation tracked in #332). --- fastapi_mcp/openapi/convert.py | 10 ++++++-- tests/test_openapi_conversion.py | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/fastapi_mcp/openapi/convert.py b/fastapi_mcp/openapi/convert.py index 22e5c5e1..47f90985 100644 --- a/fastapi_mcp/openapi/convert.py +++ b/fastapi_mcp/openapi/convert.py @@ -225,7 +225,10 @@ def convert_openapi_to_mcp_tools( properties[param_name]["description"] = param_desc if "type" not in properties[param_name]: - properties[param_name]["type"] = get_single_param_type_from_schema(param_schema) + any_of = param_schema.get("anyOf") or [] + non_null_types = {v.get("type") for v in any_of if v.get("type") and v.get("type") != "null"} + if len(non_null_types) <= 1: + properties[param_name]["type"] = get_single_param_type_from_schema(param_schema) if "default" in param_schema: properties[param_name]["default"] = param_schema["default"] @@ -245,7 +248,10 @@ def convert_openapi_to_mcp_tools( properties[param_name]["description"] = param_desc if "type" not in properties[param_name]: - properties[param_name]["type"] = get_single_param_type_from_schema(param_schema) + any_of = param_schema.get("anyOf") or [] + non_null_types = {v.get("type") for v in any_of if v.get("type") and v.get("type") != "null"} + if len(non_null_types) <= 1: + properties[param_name]["type"] = get_single_param_type_from_schema(param_schema) if "default" in param_schema: properties[param_name]["default"] = param_schema["default"] diff --git a/tests/test_openapi_conversion.py b/tests/test_openapi_conversion.py index aefe6433..45b4026f 100644 --- a/tests/test_openapi_conversion.py +++ b/tests/test_openapi_conversion.py @@ -1,5 +1,8 @@ +from typing import Dict, List, Union + from fastapi import FastAPI from fastapi.openapi.utils import get_openapi +from pydantic import BaseModel, Field import mcp.types as types from fastapi_mcp.openapi.convert import convert_openapi_to_mcp_tools @@ -422,3 +425,39 @@ def test_body_params_edge_cases(complex_fastapi_app: FastAPI): if "items" in properties: item_props = properties["items"]["items"]["properties"] assert "total" in item_props + + +def test_union_type_schema_not_collapsed(): + """A body field typed as a union of two real types (e.g. dict | list) must keep its + anyOf in the projected MCP tool schema. + + Injecting a single arbitrary "type" alongside anyOf makes the MCP server's jsonschema + validator reject every variant except the one picked (issue #307). + """ + + app = FastAPI() + + class SaveRequest(BaseModel): + tags: Union[Dict[str, List[str]], List[str]] = Field(default_factory=dict) + + @app.post("/save", operation_id="save") + def save(body: SaveRequest): + return {"ok": True} + + openapi_schema = get_openapi( + title=app.title, + version=app.version, + openapi_version=app.openapi_version, + routes=app.routes, + ) + + tools, _ = convert_openapi_to_mcp_tools(openapi_schema) + + save_tool = next(tool for tool in tools if tool.name == "save") + tags_prop = save_tool.inputSchema["properties"]["tags"] + + assert "anyOf" in tags_prop + assert "type" not in tags_prop + variant_types = {v.get("type") for v in tags_prop["anyOf"]} + assert "object" in variant_types + assert "array" in variant_types From 8e14a982f2ea60dbcb24238ce1be0ffcd63c72ba Mon Sep 17 00:00:00 2001 From: K4bain <296577378+K4bain@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:25:25 +0500 Subject: [PATCH 2/3] fix(openapi): also preserve oneOf for union schemas without anyOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-written OpenAPI specs can express unions with oneOf instead of anyOf. The injection guard only looked at anyOf, so a oneOf-only body schema still received a bogus "type": "string" alongside the union — same rejection risk as #307. Treat anyOf/oneOf uniformly. --- fastapi_mcp/openapi/convert.py | 12 ++++++---- tests/test_openapi_conversion.py | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/fastapi_mcp/openapi/convert.py b/fastapi_mcp/openapi/convert.py index 47f90985..4688dfe6 100644 --- a/fastapi_mcp/openapi/convert.py +++ b/fastapi_mcp/openapi/convert.py @@ -225,8 +225,10 @@ def convert_openapi_to_mcp_tools( properties[param_name]["description"] = param_desc if "type" not in properties[param_name]: - any_of = param_schema.get("anyOf") or [] - non_null_types = {v.get("type") for v in any_of if v.get("type") and v.get("type") != "null"} + union_variants = param_schema.get("anyOf") or param_schema.get("oneOf") or [] + non_null_types = { + v.get("type") for v in union_variants if v.get("type") and v.get("type") != "null" + } if len(non_null_types) <= 1: properties[param_name]["type"] = get_single_param_type_from_schema(param_schema) @@ -248,8 +250,10 @@ def convert_openapi_to_mcp_tools( properties[param_name]["description"] = param_desc if "type" not in properties[param_name]: - any_of = param_schema.get("anyOf") or [] - non_null_types = {v.get("type") for v in any_of if v.get("type") and v.get("type") != "null"} + union_variants = param_schema.get("anyOf") or param_schema.get("oneOf") or [] + non_null_types = { + v.get("type") for v in union_variants if v.get("type") and v.get("type") != "null" + } if len(non_null_types) <= 1: properties[param_name]["type"] = get_single_param_type_from_schema(param_schema) diff --git a/tests/test_openapi_conversion.py b/tests/test_openapi_conversion.py index 45b4026f..9044ff2a 100644 --- a/tests/test_openapi_conversion.py +++ b/tests/test_openapi_conversion.py @@ -461,3 +461,44 @@ def save(body: SaveRequest): variant_types = {v.get("type") for v in tags_prop["anyOf"]} assert "object" in variant_types assert "array" in variant_types + + +def test_oneof_body_schema_not_collapsed(): + """Same as above but for a hand-written OpenAPI spec using oneOf instead of anyOf.""" + openapi_schema = { + "openapi": "3.1.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/save": { + "post": { + "operationId": "save", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "oneOf": [ + {"type": "object"}, + {"type": "array"}, + ] + } + }, + } + } + } + }, + "responses": {"200": {"description": "ok"}}, + } + } + }, + } + + tools, _ = convert_openapi_to_mcp_tools(openapi_schema) + + save_tool = next(tool for tool in tools if tool.name == "save") + value_prop = save_tool.inputSchema["properties"]["value"] + + assert "oneOf" in value_prop + assert "type" not in value_prop From 48c3bed79c412cd26d297bb4ca36b0d10474df2d Mon Sep 17 00:00:00 2001 From: K4bain <296577378+K4bain@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:42:46 +0500 Subject: [PATCH 3/3] fix(openapi): also preserve anyOf/oneOf for union path params The path-param loop injected a default "type" with no union awareness, so a path param typed int | str received both anyOf and a bogus "type": "string" - the same corruption #307 describes, through the one loop the previous commits missed. Apply the same guard used in the query and body loops. --- fastapi_mcp/openapi/convert.py | 7 ++++++- tests/test_openapi_conversion.py | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/fastapi_mcp/openapi/convert.py b/fastapi_mcp/openapi/convert.py index 4688dfe6..e8b02972 100644 --- a/fastapi_mcp/openapi/convert.py +++ b/fastapi_mcp/openapi/convert.py @@ -208,7 +208,12 @@ def convert_openapi_to_mcp_tools( properties[param_name]["description"] = param_desc if "type" not in properties[param_name]: - properties[param_name]["type"] = param_schema.get("type", "string") + union_variants = param_schema.get("anyOf") or param_schema.get("oneOf") or [] + non_null_types = { + v.get("type") for v in union_variants if v.get("type") and v.get("type") != "null" + } + if len(non_null_types) <= 1: + properties[param_name]["type"] = param_schema.get("type", "string") if param_required: required_props.append(param_name) diff --git a/tests/test_openapi_conversion.py b/tests/test_openapi_conversion.py index 9044ff2a..ced38e94 100644 --- a/tests/test_openapi_conversion.py +++ b/tests/test_openapi_conversion.py @@ -502,3 +502,28 @@ def test_oneof_body_schema_not_collapsed(): assert "oneOf" in value_prop assert "type" not in value_prop + + +def test_union_path_param_not_collapsed(): + """Path params with union types (e.g. int | str) must keep anyOf too — + the path-param loop previously injected a bogus "type": "string".""" + app = FastAPI() + + @app.get("/items/{item_id}", operation_id="get_item") + def get_item(item_id: Union[int, str]): + return {"id": item_id} + + openapi_schema = get_openapi( + title=app.title, + version=app.version, + openapi_version=app.openapi_version, + routes=app.routes, + ) + + tools, _ = convert_openapi_to_mcp_tools(openapi_schema) + + tool = next(t for t in tools if t.name == "get_item") + prop = tool.inputSchema["properties"]["item_id"] + + assert "anyOf" in prop + assert "type" not in prop