diff --git a/src/fastmcp/utilities/openapi/json_schema_converter.py b/src/fastmcp/utilities/openapi/json_schema_converter.py index c92b2f394..5d0e02eed 100644 --- a/src/fastmcp/utilities/openapi/json_schema_converter.py +++ b/src/fastmcp/utilities/openapi/json_schema_converter.py @@ -26,6 +26,8 @@ OPENAPI_SPECIFIC_FIELDS = { # Fields that should be recursively processed RECURSIVE_FIELDS = { "properties": dict, + "$defs": dict, + "$definitions": dict, "items": dict, "additionalProperties": dict, "allOf": list, @@ -108,19 +110,19 @@ def convert_openapi_schema_to_json_schema( for field_name, field_type in RECURSIVE_FIELDS.items(): if field_name in result: if field_type is dict and isinstance(result[field_name], dict): - if field_name == "properties": - # Handle properties specially - each property is a schema + if field_name in ("properties", "$defs", "$definitions"): + # Handle maps of schemas (properties, $defs, $definitions) result[field_name] = { - prop_name: convert_openapi_schema_to_json_schema( - prop_schema, + name: convert_openapi_schema_to_json_schema( + sub_schema, openapi_version, remove_read_only, remove_write_only, convert_one_of_to_any_of, ) - if isinstance(prop_schema, dict) - else prop_schema - for prop_name, prop_schema in result[field_name].items() + if isinstance(sub_schema, dict) + else sub_schema + for name, sub_schema in result[field_name].items() } else: result[field_name] = convert_openapi_schema_to_json_schema( @@ -214,20 +216,20 @@ def _needs_recursive_processing( for field_name, field_type in RECURSIVE_FIELDS.items(): if field_name in schema: if field_type is dict and isinstance(schema[field_name], dict): - if field_name == "properties": - # Check if any property needs conversion - for prop_schema in schema[field_name].values(): - if isinstance(prop_schema, dict): + if field_name in ("properties", "$defs", "$definitions"): + # Check if any schema in the map needs conversion + for sub_schema in schema[field_name].values(): + if isinstance(sub_schema, dict): nested_needs_conversion = ( any( - field in prop_schema + field in sub_schema for field in OPENAPI_SPECIFIC_FIELDS ) - or (remove_read_only and prop_schema.get("readOnly")) - or (remove_write_only and prop_schema.get("writeOnly")) - or (convert_one_of_to_any_of and "oneOf" in prop_schema) + or (remove_read_only and sub_schema.get("readOnly")) + or (remove_write_only and sub_schema.get("writeOnly")) + or (convert_one_of_to_any_of and "oneOf" in sub_schema) or _needs_recursive_processing( - prop_schema, + sub_schema, openapi_version, remove_read_only, remove_write_only, diff --git a/src/fastmcp/utilities/openapi/schemas.py b/src/fastmcp/utilities/openapi/schemas.py index fa93b6c8d..6eadffb5f 100644 --- a/src/fastmcp/utilities/openapi/schemas.py +++ b/src/fastmcp/utilities/openapi/schemas.py @@ -4,6 +4,7 @@ from typing import Any from fastmcp.utilities.logging import get_logger +from .json_schema_converter import convert_openapi_schema_to_json_schema from .models import HTTPRoute, JsonSchema, ResponseInfo logger = get_logger(__name__) @@ -451,6 +452,9 @@ def _combine_schemas_and_map_params( # From parser - already converted and pruned result["$defs"] = schema_defs + if route.openapi_version and route.openapi_version.startswith("3"): + result = convert_openapi_schema_to_json_schema(result, route.openapi_version) + return result, parameter_map @@ -555,8 +559,6 @@ def extract_output_schema_from_responses( if openapi_version and openapi_version.startswith("3"): # Convert OpenAPI 3.x schema to JSON Schema format for proper handling # of constructs like oneOf, anyOf, and nullable fields - from .json_schema_converter import convert_openapi_schema_to_json_schema - output_schema = convert_openapi_schema_to_json_schema( output_schema, openapi_version ) @@ -584,8 +586,6 @@ def extract_output_schema_from_responses( # Convert OpenAPI schema definitions to JSON Schema format if needed if openapi_version and openapi_version.startswith("3"): - from .json_schema_converter import convert_openapi_schema_to_json_schema - for def_name in list(processed_defs.keys()): processed_defs[def_name] = convert_openapi_schema_to_json_schema( processed_defs[def_name], openapi_version diff --git a/tests/utilities/openapi/test_nullable_fields.py b/tests/utilities/openapi/test_nullable_fields.py index 9cc163f00..2331fc3ed 100644 --- a/tests/utilities/openapi/test_nullable_fields.py +++ b/tests/utilities/openapi/test_nullable_fields.py @@ -1,8 +1,12 @@ """Tests for nullable field handling in OpenAPI schemas.""" +import httpx import pytest from jsonschema import ValidationError, validate +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.server.providers.openapi import OpenAPIProvider from fastmcp.utilities.openapi.json_schema_converter import ( convert_openapi_schema_to_json_schema, ) @@ -350,6 +354,70 @@ class TestHandleNullableFields: result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0") assert result == expected + def test_nullable_in_definitions(self): + """Test nullable field inside $defs.""" + input_schema = { + "type": "object", + "properties": {"user": {"$ref": "#/$defs/User"}}, + "$defs": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "bio": {"type": "string", "nullable": True}, + }, + } + }, + } + expected = { + "type": "object", + "properties": {"user": {"$ref": "#/$defs/User"}}, + "$defs": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "bio": {"type": ["string", "null"]}, + }, + } + }, + } + result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0") + assert result == expected + + def test_nullable_in_nested_properties(self): + """Test nullable field in deeply nested properties.""" + input_schema = { + "type": "object", + "properties": { + "a": { + "type": "object", + "properties": { + "b": { + "type": "object", + "properties": {"c": {"type": "string", "nullable": True}}, + } + }, + } + }, + } + expected = { + "type": "object", + "properties": { + "a": { + "type": "object", + "properties": { + "b": { + "type": "object", + "properties": {"c": {"type": ["string", "null"]}}, + } + }, + } + }, + } + result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0") + assert result == expected + class TestNullableFieldValidation: """Test that converted schemas validate correctly with jsonschema.""" @@ -383,3 +451,108 @@ class TestNullableFieldValidation: # Invalid values should fail with pytest.raises(ValidationError): validate(instance="INVALID", schema=json_schema) + + +class TestNullableInputSchemaIntegration: + """Test that nullable fields are converted in tool input schemas end-to-end. + + These tests exercise the full pipeline: OpenAPI spec -> OpenAPIProvider -> + tool.inputSchema, verifying that `nullable: true` doesn't leak through. + """ + + async def test_nullable_query_param_converted_in_tool_input_schema(self): + """Nullable query parameter should produce type union in tool input schema.""" + spec = { + "openapi": "3.0.0", + "info": {"title": "Test", "version": "1.0.0"}, + "paths": { + "/search": { + "get": { + "operationId": "search", + "parameters": [ + { + "name": "query", + "in": "query", + "required": True, + "schema": {"type": "string"}, + }, + { + "name": "category", + "in": "query", + "schema": {"type": "string", "nullable": True}, + }, + ], + "responses": {"200": {"description": "OK"}}, + } + } + }, + } + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider(openapi_spec=spec, client=client) + mcp = FastMCP("test") + mcp.add_provider(provider) + + async with Client(mcp) as mcp_client: + tools = await mcp_client.list_tools() + assert len(tools) == 1 + schema = tools[0].inputSchema + category_prop = schema["properties"]["category"] + assert "nullable" not in category_prop + assert category_prop["type"] == ["string", "null"] + + async def test_nullable_in_request_body_defs_converted(self): + """Nullable field inside $defs referenced by request body should be converted.""" + spec = { + "openapi": "3.0.0", + "info": {"title": "Test", "version": "1.0.0"}, + "paths": { + "/users": { + "post": { + "operationId": "create_user", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUser" + } + } + }, + }, + "responses": {"201": {"description": "Created"}}, + } + } + }, + "components": { + "schemas": { + "CreateUser": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "bio": {"type": "string", "nullable": True}, + }, + } + } + }, + } + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider(openapi_spec=spec, client=client) + mcp = FastMCP("test") + mcp.add_provider(provider) + + async with Client(mcp) as mcp_client: + tools = await mcp_client.list_tools() + assert len(tools) == 1 + schema = tools[0].inputSchema + + # Find the bio property — it may be inline or in $defs + if "$defs" in schema: + # Resolve through $defs + user_schema = next(iter(schema["$defs"].values())) + bio_prop = user_schema["properties"]["bio"] + else: + bio_prop = schema["properties"]["bio"] + + assert "nullable" not in bio_prop + assert bio_prop["type"] == ["string", "null"]