[BUG] Union types of two real types (non-null) rejected at MCP-server jsonschema validator with misleading "is not of type 'X'"
Describe the bug
For a request body field typed as T | U where both T and U are real types (neither is None), fastapi-mcp projects an MCP tool schema with a single arbitrary "type" field picked from a Python set. The MCP server's own jsonschema validator then rejects every call that uses the type that lost the toss, with the misleading error Input validation error: ... is not of type '<picked>'.
The corresponding OpenAPI schema is correct (FastAPI's generator preserves anyOf); only the MCP-projected schema is broken.
Related but distinct symptoms of the same root cause:
- #246 — same
convert_openapi_to_mcp_toolsadds-a-"type"-field bug, but triggered byT | None(nullable rejection) - #165 — union with arrays loses
itemsproperty (OpenAI-side schema rejection) - #218 — meta: MCP tool inputSchema not valid JSON Schema 2020-12
This issue covers the MCP-server-side jsonschema rejection for non-null unions, which the others don't.
Root cause (current main of 0.4.x)
fastapi_mcp/openapi/utils.py:
def get_single_param_type_from_schema(param_schema):
"""Get the type of a parameter from the schema.
If the schema is a union type, return the first type."""
if "anyOf" in param_schema:
types = {schema.get("type") for schema in param_schema["anyOf"] if schema.get("type")}
if "null" in types: types.remove("null")
if types:
return next(iter(types)) # ← arbitrary set pick, not deterministic
return "string"
return param_schema.get("type", "string")Then in fastapi_mcp/openapi/convert.py:
properties[param_name] = param_schema.copy() # carries anyOf
properties[param_name]["title"] = param_name
if "type" not in properties[param_name]:
properties[param_name]["type"] = get_single_param_type_from_schema(param_schema)For value: dict | list Pydantic produces {"anyOf": [{"type":"object",...}, {"type":"array",...}]} with no top-level type — so the if "type" not in properties[param_name] branch fires and a single "type" gets injected. The MCP server's jsonschema validator then enforces "type" strictly, rejecting whichever side wasn't picked.
T | None is special-cased (the null strip) and works. T | U with both real types is the bug surface.
To reproduce
from typing import Annotated
from fastapi import FastAPI
from pydantic import BaseModel, Field
from fastapi_mcp import FastApiMCP
app = FastAPI()
class SaveRequest(BaseModel):
tags: dict[str, list[str]] | list[str] = Field(default_factory=dict)
@app.post("/save", operation_id="save", tags=["mcp"])
def save(body: SaveRequest):
return {"ok": True}
mcp = FastApiMCP(app, include_tags=["mcp"])
mcp.mount_http()
# Call save with tags as a dict (e.g. {"Skills": ["python"]}) over MCP →
# fails at the MCP layer's jsonschema validator with
# Input validation error: {'Skills': [...]} is not of type 'array'
# (or 'object', depending on which side won the non-deterministic set pick).Expected behaviour
Either:
- Preserve
anyOfverbatim in the projected MCP schema — the JSON Schema 2020-12 validator at the MCP layer already handlesanyOfcorrectly. The injection of a single"type"is what breaks things. - If a single
"type"must be injected for downstream tooling, emit a discriminated-union-style projection (e.g.oneOfwith explicit per-variant subschemas) rather than picking one arbitrarily.
The minimal fix is don't inject "type" when anyOf is already present — let the anyOf constraint stand alone. That covers this case, #246's nullable case, and probably most of #218's spec-conformance gripes.
# Suggested patch in convert.py around L248:
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)System info
- fastapi-mcp version: 0.4.0
- Python: 3.12.8
- FastAPI: latest 0.121.x
- Pydantic: latest 2.x
Source: tadata-org/fastapi_mcp