Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions fastapi_mcp/openapi/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from .utils import (
clean_schema_for_display,
flatten_nullable_anyof,
generate_example_from_schema,
resolve_schema_references,
get_single_param_type_from_schema,
Expand Down Expand Up @@ -227,6 +228,10 @@ def convert_openapi_to_mcp_tools(
if "type" not in properties[param_name]:
properties[param_name]["type"] = get_single_param_type_from_schema(param_schema)

# Flatten nullable anyOf patterns so that type-specific fields
# (e.g. "items" for arrays) are hoisted to the top level
flatten_nullable_anyof(properties[param_name])

if "default" in param_schema:
properties[param_name]["default"] = param_schema["default"]

Expand All @@ -247,6 +252,10 @@ def convert_openapi_to_mcp_tools(
if "type" not in properties[param_name]:
properties[param_name]["type"] = get_single_param_type_from_schema(param_schema)

# Flatten nullable anyOf patterns so that type-specific fields
# (e.g. "items" for arrays) are hoisted to the top level
flatten_nullable_anyof(properties[param_name])

if "default" in param_schema:
properties[param_name]["default"] = param_schema["default"]

Expand Down
50 changes: 50 additions & 0 deletions fastapi_mcp/openapi/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,56 @@ def get_single_param_type_from_schema(param_schema: Dict[str, Any]) -> str:
return param_schema.get("type", "string")


def flatten_nullable_anyof(param_schema: Dict[str, Any]) -> None:
"""
Flatten anyOf nullable patterns into a single schema with full type information.

When OpenAPI represents optional typed parameters (e.g. Optional[List[int]]),
it produces an anyOf with the typed schema and a null variant:

{"anyOf": [{"type": "array", "items": {"type": "integer"}}, {"type": "null"}]}

After ``get_single_param_type_from_schema`` injects ``type`` at the top level,
the ``items`` (or ``properties``, etc.) remain buried inside the ``anyOf`` variant.
LLM clients that validate against JSON Schema reject this because the top-level
``type: "array"`` lacks an ``items`` definition.

This function hoists the non-null variant's fields (``items``, ``properties``,
``required``, ``additionalProperties``, ``enum``, ``format``, ``minItems``,
``maxItems``, ``minimum``, ``maximum``, etc.) to the top level and removes the
``anyOf`` key, producing a flat, valid schema.

Only applies when ``anyOf`` is a simple nullable pattern (exactly one non-null
type plus a null variant). Complex unions with multiple non-null types are left
unchanged.

Mutates ``param_schema`` in place.
"""
if "anyOf" not in param_schema:
return

variants = param_schema["anyOf"]
if not isinstance(variants, list):
return

non_null = [v for v in variants if isinstance(v, dict) and v.get("type") != "null"]
null_variants = [v for v in variants if isinstance(v, dict) and v.get("type") == "null"]

# Only flatten simple nullable patterns: one non-null type + null
if len(non_null) != 1 or len(null_variants) == 0:
return

source = non_null[0]

# Hoist all fields from the non-null variant that aren't already at the top level,
# except "type" which is already set by get_single_param_type_from_schema
for key, value in source.items():
if key != "type" and key not in param_schema:
param_schema[key] = value

del param_schema["anyOf"]


def resolve_schema_references(schema_part: Dict[str, Any], reference_schema: Dict[str, Any]) -> Dict[str, Any]:
"""
Resolve schema references in OpenAPI schemas.
Expand Down
176 changes: 176 additions & 0 deletions tests/test_openapi_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from fastapi_mcp.openapi.convert import convert_openapi_to_mcp_tools
from fastapi_mcp.openapi.utils import (
clean_schema_for_display,
flatten_nullable_anyof,
generate_example_from_schema,
get_single_param_type_from_schema,
)
Expand Down Expand Up @@ -422,3 +423,178 @@ 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_flatten_nullable_anyof_hoists_array_items():
"""Nullable anyOf with a typed array should hoist 'items' to the top level."""
schema = {
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
"title": "time_values",
"type": "array",
}
flatten_nullable_anyof(schema)

assert "anyOf" not in schema
assert schema["type"] == "array"
assert "items" in schema
assert schema["items"] == {"type": "integer"}
assert schema["title"] == "time_values"


def test_flatten_nullable_anyof_preserves_non_nullable():
"""Schemas without anyOf should be left unchanged."""
schema = {"type": "array", "items": {"type": "string"}, "title": "tags"}
original = schema.copy()
flatten_nullable_anyof(schema)
assert schema == original


def test_flatten_nullable_anyof_skips_multi_type_unions():
"""Complex unions with multiple non-null types should be left unchanged."""
schema = {
"anyOf": [
{"type": "string"},
{"type": "integer"},
{"type": "null"},
],
"title": "mixed",
"type": "string",
}
flatten_nullable_anyof(schema)

# Should NOT flatten because there are two non-null types
assert "anyOf" in schema


def test_flatten_nullable_anyof_hoists_object_properties():
"""Nullable anyOf with an object schema should hoist 'properties' and 'required'."""
schema = {
"anyOf": [
{
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
{"type": "null"},
],
"title": "config",
"type": "object",
}
flatten_nullable_anyof(schema)

assert "anyOf" not in schema
assert schema["type"] == "object"
assert "properties" in schema
assert schema["properties"] == {"name": {"type": "string"}}
assert schema["required"] == ["name"]


def test_typed_array_body_params_preserve_items():
"""
Optional[List[int]] body parameters should produce a schema with
both 'type: array' and 'items' at the top level.

Regression test for https://github.com/tadata-org/fastapi_mcp/issues/57
"""
openapi_schema = {
"openapi": "3.1.0",
"info": {"title": "Test", "version": "1.0.0"},
"paths": {
"/analyze": {
"post": {
"operationId": "analyze_data",
"summary": "Analyze data",
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"values": {
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
"title": "Values",
},
"labels": {
"type": "array",
"items": {"type": "string"},
"title": "Labels",
},
},
"required": ["labels"],
}
}
},
},
"responses": {"200": {"description": "OK"}},
}
}
},
}

tools, _ = convert_openapi_to_mcp_tools(openapi_schema)
assert len(tools) == 1

props = tools[0].inputSchema["properties"]

# Required List[str] — should keep items as-is
assert props["labels"]["type"] == "array"
assert props["labels"]["items"] == {"type": "string"}

# Optional List[int] — should flatten anyOf and hoist items
assert props["values"]["type"] == "array"
assert "items" in props["values"], "items must be hoisted from anyOf to the top level"
assert props["values"]["items"] == {"type": "integer"}
assert "anyOf" not in props["values"], "anyOf should be removed after flattening"


def test_typed_array_query_params_preserve_items():
"""
Optional[List[str]] query parameters should produce a schema with
both 'type: array' and 'items' at the top level.

Regression test for https://github.com/tadata-org/fastapi_mcp/issues/57
"""
openapi_schema = {
"openapi": "3.1.0",
"info": {"title": "Test", "version": "1.0.0"},
"paths": {
"/search": {
"get": {
"operationId": "search_items",
"summary": "Search",
"parameters": [
{
"name": "tags",
"in": "query",
"required": False,
"schema": {
"anyOf": [
{"type": "array", "items": {"type": "string"}},
{"type": "null"},
],
"title": "Tags",
},
}
],
"responses": {"200": {"description": "OK"}},
}
}
},
}

tools, _ = convert_openapi_to_mcp_tools(openapi_schema)
assert len(tools) == 1

props = tools[0].inputSchema["properties"]

assert props["tags"]["type"] == "array"
assert "items" in props["tags"], "items must be hoisted from anyOf to the top level"
assert props["tags"]["items"] == {"type": "string"}
assert "anyOf" not in props["tags"], "anyOf should be removed after flattening"