[BUG] Union types of two real types (non-null) rejected at MCP-server jsonschema validator with misleading "is not of type 'X'" · Issue #307 · tadata-org/fastapi_mcp · GitHub
Skip to content

[BUG] Union types of two real types (non-null) rejected at MCP-server jsonschema validator with misleading "is not of type 'X'" #307

Open

Description

@AlexisMarasigan

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:

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:

  1. Preserve anyOf verbatim in the projected MCP schema — the JSON Schema 2020-12 validator at the MCP layer already handles anyOf correctly. The injection of a single "type" is what breaks things.
  2. If a single "type" must be injected for downstream tooling, emit a discriminated-union-style projection (e.g. oneOf with 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

Activity

  1. cid2230 commented on Sep 23, 2026

    @cid2230

    @AlexisMarasigan — technical note (not a paid offer).

    Union-of-two-real-types rejected at the MCP jsonschema validator (with a misleading type error) is another “declared shape vs what the stack will actually accept” gap — painful when the tool still appears listed.

    Exactlane Preflight: static snapshot × requirements fit under Preview — https://exactlane.dev/ · https://exactlane.dev/llms.txt. Not certification, not a live probe.

    Questions / pilot interest: @Exactlane on X. Decline = stop.

    — Exactlane

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions