diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py index b33c4f945..75320e1a3 100644 --- a/src/fastmcp/utilities/json_schema.py +++ b/src/fastmcp/utilities/json_schema.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy from collections import defaultdict from typing import Any @@ -287,6 +288,7 @@ def _prune_param(schema: dict[str, Any], param: str) -> dict[str, Any]: """Return a new schema with *param* removed from `properties`, `required`, and (if no longer referenced) `$defs`. """ + schema = copy.deepcopy(schema) # ── 1. drop from properties/required ────────────────────────────── props = schema.get("properties", {}) diff --git a/src/fastmcp/utilities/openapi/json_schema_converter.py b/src/fastmcp/utilities/openapi/json_schema_converter.py index 5d0e02eed..e044d5bf9 100644 --- a/src/fastmcp/utilities/openapi/json_schema_converter.py +++ b/src/fastmcp/utilities/openapi/json_schema_converter.py @@ -173,7 +173,7 @@ def _convert_nullable_field(schema: dict[str, Any]) -> dict[str, Any]: elif "anyOf" in result: # Add null to anyOf if not present if not any(item.get("type") == "null" for item in result["anyOf"]): - result["anyOf"].append({"type": "null"}) + result["anyOf"] = [*result["anyOf"], {"type": "null"}] elif "allOf" in result: # Wrap allOf in anyOf with null option result["anyOf"] = [{"allOf": result.pop("allOf")}, {"type": "null"}] diff --git a/tests/utilities/openapi/test_nullable_fields.py b/tests/utilities/openapi/test_nullable_fields.py index 2331fc3ed..ff1881e46 100644 --- a/tests/utilities/openapi/test_nullable_fields.py +++ b/tests/utilities/openapi/test_nullable_fields.py @@ -8,6 +8,7 @@ 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_nullable_field, convert_openapi_schema_to_json_schema, ) @@ -556,3 +557,14 @@ class TestNullableInputSchemaIntegration: assert "nullable" not in bio_prop assert bio_prop["type"] == ["string", "null"] + + +class TestConvertNullableFieldMutation: + """Test that _convert_nullable_field does not mutate its input.""" + + def test_does_not_mutate_anyof_list(self): + """_convert_nullable_field should not append to the original anyOf list.""" + schema = {"anyOf": [{"type": "string"}], "nullable": True} + original_len = len(schema["anyOf"]) + _convert_nullable_field(schema) + assert len(schema["anyOf"]) == original_len diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index 644366057..5c5f4c860 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -1,3 +1,4 @@ +import copy from unittest.mock import patch from jsonref import replace_refs @@ -51,6 +52,17 @@ class TestPruneParam: result = _prune_param(schema, "foo") assert "required" not in result + def test_does_not_mutate_input(self): + """Test that _prune_param does not mutate the original schema.""" + schema = { + "type": "object", + "properties": {"a": {"type": "string"}, "b": {"type": "integer"}}, + "required": ["a", "b"], + } + original = copy.deepcopy(schema) + _prune_param(schema, "a") + assert schema == original + class TestDereferenceRefs: """Tests for the dereference_refs function."""