Skip to content

Commit 6202008

Browse files
authored
Don't mutate the caller's schema in compress_schema (#4492)
1 parent 1fca15a commit 6202008

2 files changed

Lines changed: 38 additions & 0 deletions

File tree

fastmcp_slim/fastmcp/utilities/json_schema.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,11 @@ def _single_pass_optimize(
498498
if not (prune_defs or prune_titles or prune_additional_properties):
499499
return schema # Nothing to do
500500

501+
# Work on a copy so the caller's schema is never mutated (see docstring). The
502+
# pruning phases below pop keys/$defs in place, which would otherwise corrupt a
503+
# shared dict such as a live Tool.input_schema passed straight to compress_schema.
504+
schema = copy.deepcopy(schema)
505+
501506
# Phase 1: Collect references and apply simple cleanups
502507
# Track which $defs are referenced from the main schema and from other $defs
503508
root_refs: set[str] = set() # $defs referenced directly from main schema

tests/utilities/test_json_schema.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,39 @@ def test_preserves_property_named_discriminator(self):
358358
class TestCompressSchema:
359359
"""Tests for the compress_schema function."""
360360

361+
def test_does_not_mutate_input(self):
362+
"""compress_schema must return a new dict and leave the caller's schema
363+
untouched, even when it prunes titles, additionalProperties and unused
364+
$defs (a live Tool.input_schema is passed straight in at some call sites)."""
365+
schema = {
366+
"type": "object",
367+
"title": "MySchema",
368+
"additionalProperties": False,
369+
"properties": {
370+
"a": {"type": "string", "title": "A"},
371+
"b": {
372+
"type": "object",
373+
"title": "B",
374+
"properties": {"c": {"type": "integer", "title": "C"}},
375+
},
376+
},
377+
"$defs": {"Unused": {"type": "string", "title": "Unused"}},
378+
}
379+
original = copy.deepcopy(schema)
380+
381+
result = compress_schema(
382+
schema, prune_titles=True, prune_additional_properties=True
383+
)
384+
385+
# The input is untouched...
386+
assert schema == original
387+
assert result is not schema
388+
# ...and the returned copy really was optimized (so it is not a no-op).
389+
assert "title" not in result
390+
assert "title" not in result["properties"]["b"]["properties"]["c"]
391+
assert "additionalProperties" not in result
392+
assert "$defs" not in result
393+
361394
def test_preserves_refs_by_default(self):
362395
"""Test that compress_schema preserves $refs by default."""
363396
schema = {

0 commit comments

Comments
 (0)