Skip to content

convert_openapi_to_mcp_tools adds "type" field, breaking nullable schemas #246

Description

@NTSER

Here is sample code:

from fastapi_mcp.openapi.convert import convert_openapi_to_mcp_tools
from jsonschema import validate
openapi_schema = {
    "openapi": "3.0.3",
    "info": {
        "title": "Union Test",
        "version": "1.0.0"
    },
    "paths": {
        "/test": {
            "post": {
                "operationId": "test_union",
                "summary": "Test endpoint with a union type",
                "requestBody": {
                    "required": True,
                    "content": {
                        "application/json": {
                            "schema": {
                                "type": "object",
                                "properties": {
                                    "value": {
                                        "anyOf": [
                                            {"type": "string", "maxLength": 2000},
                                            {"type": "null"}
                                        ],
                                        "title": "value"
                                    }
                                },
                                "required": ["value"]
                            }
                        }
                    }
                },
                "responses": {
                    "200": {
                        "description": "OK"
                    }
                }
            }
        }
    }
}

mcp_tools = convert_openapi_to_mcp_tools(openapi_schema=openapi_schema)
first_tool = mcp_tools[0][0]
mcp_input_schema = first_tool.inputSchema


# Validate using original OpenAPI schema (works)
validate(instance={"value": None}, schema=openapi_schema)

# Validate using MCP-generated schema (fails)
validate(instance={"value": None}, schema=mcp_input_schema)
# jsonschema.exceptions.ValidationError: None is not of type 'string'

I am new in the field and maybe that is expected behaviour but still for me it was very confusing.
The unexpected behaviour happens inside the convert_openapi_to_mcp_tools function. It modifies the schema

from:

{
    "value": {
        "anyOf": [
            {"type": "string", "maxLength": 2000},
            {"type": "null"}
        ],
        "title": "value"
    }
}

to:

{
    "value": {
        "anyOf": [
            {"type": "string", "maxLength": 2000},
            {"type": "null"}
        ],
        "title": "value",
        "type": "string"
    }
}

In my use case, I had a FastAPI app and added MCP. When sending requests via LangChain, I ran into issues because the LLM sometimes sent None, which then triggered a validation error. This caused a lot of debugging time.

If it's not a bug and is expected behavior, It might be helpful if the function issued a warning in such cases to alert users about potential type conflicts, which could significantly reduce debugging effort.

For now, I believe the solution is to make the original field non-nullable. If there is a more appropriate approach, I would appreciate suggestions.

Activity

  1. added a commit that references this issue on Mar 1, 2026
    17f3b38
  2. Br1an67 commented on Mar 1, 2026

    @Br1an67

    I'd like to work on this. The schema converter injects a type field even when anyOf is present, breaking nullable validation — skip the injection when anyOf already exists.

    PR: #264

  3. roni-frantchi commented on Mar 23, 2026

    @roni-frantchi

    We're hitting the same root cause but with oneOf discriminated unions rather than nullable anyOf.

    get_single_param_type_from_schema only handles anyOf — when it encounters a oneOf (e.g. a Pydantic discriminated union), it falls through to return param_schema.get("type", "string"), injecting "type": "string" on what is actually a union of objects.

    Example: a profileConfig field defined as a discriminated union of three profile objects gets this generated schema:

    {
      "oneOf": [
        {"type": "object", "properties": {"profile": {"const": "COMPREHENSIVE"}}, ...},
        {"type": "object", "properties": {"profile": {"const": "NEW_FINDINGS"}}, ...},
        {"type": "object", "properties": {"profile": {"const": "VALIDATE_FIXES"}, "findingIds": ...}, ...}
      ],
      "discriminator": {"propertyName": "profile", ...},
      "type": "string"  // ← injected, contradicts oneOf
    }

    The LLM sees type: string and sends it as a JSON string instead of an object, causing validation failures.

    The fix in get_single_param_type_from_schema would be to also handle oneOf:

    def get_single_param_type_from_schema(param_schema: Dict[str, Any]) -> str:
        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))
            return "string"
        if "oneOf" in param_schema:
            types = {schema.get("type") for schema in param_schema["oneOf"] if schema.get("type")}
            if types:
                return next(iter(types))
            return "object"
        return param_schema.get("type", "string")

    Or better yet, the caller in convert.py:248 should not inject a type at all when oneOf or anyOf is already present — the union itself defines the valid types.

  4. K4bain commented on Sep 3, 2026

    @K4bain

    Root cause analyzed in #307 and fixed by #345 (don't inject a single \type\ when a union has 2+ non-null variants — the anyOf is preserved verbatim instead).

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions