fastmcp/tests/utilities/test_json_schema_expansion.py
Jeremiah Lowin 54657de2e0
Keep $ref pointers when inlining would produce an oversized schema (#5421)
`dereference_refs` inlines local `$ref` pointers when tools and resource templates are listed (`dereference_schemas=True`). Inlining is a good default for ordinary schemas, but some definition graphs are far larger once fully expanded than they are as written. For those, keeping `$defs` is the better result for clients and for the server.

`dereference_refs` now measures the expanded size before inlining, checks the structure `jsonref` builds, and checks the final text size. Schemas that would exceed 100,000 nodes, 128 levels or about 5 million characters use the existing behavior for circular schemas: the root `$ref` is resolved, and `$defs` and nested `$ref` pointers are kept. Sibling keywords are now merged from the definition each pointer resolves to. Typical Pydantic and MCP schemas are well under these limits; the largest MCP protocol model schema measures about 25,000 nodes.

Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 11:06:02 -04:00

269 lines
8.9 KiB
Python

"""`dereference_refs` inlines references unless the result would be very large."""
from collections.abc import Callable
from typing import Any
import pytest
from fastmcp import Client, FastMCP
from fastmcp.tools import Tool
from fastmcp.utilities.json_schema import dereference_refs
def nested_union_schema(
levels: int,
*,
defs_key: str = "$defs",
pointer: str = "#/$defs/{}",
) -> dict[str, Any]:
"""Each level is a union of three references to the level below."""
defs: dict[str, Any] = {"L0": {"type": "string"}}
for i in range(1, levels + 1):
defs[f"L{i}"] = {"anyOf": [{"$ref": pointer.format(f"L{i - 1}")}] * 3}
return {
"type": "object",
"properties": {"x": {"$ref": pointer.format(f"L{levels}")}},
defs_key: defs,
}
def lists_through_refs_schema(levels: int) -> dict[str, Any]:
"""Each `P{i}` refers to `D{i}`, so `#/$defs/P{i}/x` means `D{i}["x"]`."""
defs: dict[str, Any] = {"D0": {"x": [0]}, "P0": {"$ref": "#/$defs/D0", "x": 0}}
for i in range(1, levels + 1):
defs[f"D{i}"] = {"x": [{"$ref": f"#/$defs/P{i - 1}/x"}] * 3}
defs[f"P{i}"] = {"$ref": f"#/$defs/D{i}", "x": 0}
return {
"type": "object",
"properties": {"v": {"items": {"$ref": f"#/$defs/P{levels}/x"}}},
"$defs": defs,
}
def lists_with_tab_in_pointers_schema(levels: int) -> dict[str, Any]:
"""URL parsing removes the tab, so `#/$defs/D\\t{i}` means `D{i}`."""
defs: dict[str, Any] = {"D0": [0]}
for i in range(1, levels + 1):
defs[f"D{i}"] = [{"$ref": f"#/$defs/D\t{i - 1}"}] * 3
defs[f"D\t{i - 1}"] = 0
return {
"type": "object",
"properties": {"v": {"items": {"$ref": f"#/$defs/D{levels}"}}},
"$defs": defs,
}
POINTER_FORMS = [
pytest.param("$defs", "#/$defs/{}", id="defs"),
pytest.param("definitions", "#/definitions/{}", id="definitions"),
pytest.param("definitions", "#definitions/{}", id="no-leading-slash"),
pytest.param("definitions", "#//definitions/{}", id="double-slash"),
pytest.param("my defs", "#/my%20defs/{}", id="percent-encoded"),
pytest.param("my/defs", "#/my~1defs/{}", id="tilde-encoded"),
]
LIST_SCHEMAS = [lists_through_refs_schema, lists_with_tab_in_pointers_schema]
@pytest.mark.parametrize(("defs_key", "pointer"), POINTER_FORMS)
def test_large_schema_keeps_defs(defs_key: str, pointer: str) -> None:
schema = nested_union_schema(9, defs_key=defs_key, pointer=pointer)
assert dereference_refs(schema) == schema
@pytest.mark.parametrize(("defs_key", "pointer"), POINTER_FORMS)
def test_schema_is_inlined(defs_key: str, pointer: str) -> None:
schema = nested_union_schema(4, defs_key=defs_key, pointer=pointer)
value = dereference_refs(schema)["properties"]["x"]
for _ in range(4):
assert len(value["anyOf"]) == 3
value = value["anyOf"][0]
assert value == {"type": "string"}
@pytest.mark.parametrize("build", LIST_SCHEMAS)
def test_large_list_schema_keeps_defs(
build: Callable[[int], dict[str, Any]],
) -> None:
schema = build(11)
assert dereference_refs(schema) == schema
@pytest.mark.parametrize("build", LIST_SCHEMAS)
def test_list_schema_is_inlined(build: Callable[[int], dict[str, Any]]) -> None:
value = dereference_refs(build(3))["properties"]["v"]["items"]
for _ in range(3):
assert len(value) == 3
value = value[0]
assert value == [0]
def alias_pointer_schema() -> dict[str, Any]:
"""`#/$defs/Alias/properties/value` passes through `Alias`, a reference to `Actual`."""
return {
"type": "object",
"properties": {"v": {"$ref": "#/$defs/Alias/properties/value"}},
"$defs": {
"Alias": {"$ref": "#/$defs/Actual"},
"Actual": {
"type": "object",
"properties": {"value": {"type": "string"}},
},
},
}
def test_pointer_through_alias_definition_is_inlined() -> None:
result = dereference_refs(alias_pointer_schema())
assert result["properties"]["v"] == {"type": "string"}
assert "$defs" not in result
def test_pointer_through_chain_of_aliases_is_inlined() -> None:
schema = alias_pointer_schema()
schema["$defs"]["Alias"] = {"$ref": "#/$defs/Alias2"}
schema["$defs"]["Alias2"] = {"$ref": "#/$defs/Actual"}
result = dereference_refs(schema)
assert result["properties"]["v"] == {"type": "string"}
def test_pointer_through_cyclic_aliases_keeps_defs() -> None:
schema = alias_pointer_schema()
schema["$defs"]["Alias"] = {"$ref": "#/$defs/Alias2"}
schema["$defs"]["Alias2"] = {"$ref": "#/$defs/Alias"}
assert dereference_refs(schema) == schema
def test_pointer_through_unresolvable_alias_keeps_defs() -> None:
schema = alias_pointer_schema()
schema["$defs"]["Alias"] = {"$ref": "#/$defs/Missing"}
assert dereference_refs(schema) == schema
def test_large_unused_definitions_keep_defs() -> None:
schema = nested_union_schema(9)
schema["properties"] = {}
assert dereference_refs(schema) == schema
def test_repeated_long_description_keeps_defs() -> None:
schema = {
"type": "object",
"properties": {f"p{i}": {"$ref": "#/$defs/Leaf"} for i in range(40)},
"$defs": {"Leaf": {"type": "string", "description": "x" * 150_000}},
}
assert dereference_refs(schema) == schema
def test_repeated_large_examples_keep_defs() -> None:
schema = {
"type": "object",
"properties": {f"p{i}": {"$ref": "#/$defs/Leaf"} for i in range(150)},
"$defs": {"Leaf": {"type": "integer", "examples": [10**4000] * 10}},
}
assert dereference_refs(schema) == schema
def test_large_discriminated_union_keeps_defs() -> None:
schema = {
"type": "object",
"properties": {
"x": {
"discriminator": {"propertyName": "t" * 3000},
"anyOf": [{"type": "object"}] * 2000,
}
},
"$defs": {},
}
assert dereference_refs(schema) == schema
def test_long_reference_chain_keeps_defs() -> None:
defs: dict[str, Any] = {
f"L{i}": {"$ref": f"#/$defs/L{i - 1}"} for i in range(1999, 0, -1)
}
defs["L0"] = {"type": "string"}
schema = {
"type": "object",
"properties": {"x": {"$ref": "#/$defs/L1999"}},
"$defs": defs,
}
assert dereference_refs(schema) == schema
def test_large_schema_still_resolves_root_reference() -> None:
schema = nested_union_schema(9)
defs = schema.pop("$defs")
defs["Root"] = schema
result = dereference_refs({"$ref": "#/$defs/Root", "$defs": defs})
assert result == {**schema, "$defs": defs}
@pytest.mark.parametrize(("defs_key", "pointer"), POINTER_FORMS)
def test_large_schema_resolves_root_reference_in_every_pointer_form(
defs_key: str, pointer: str
) -> None:
schema = nested_union_schema(9, defs_key=defs_key, pointer=pointer)
defs = schema.pop(defs_key)
defs["Root"] = schema
result = dereference_refs({"$ref": pointer.format("Root"), defs_key: defs})
assert result == {**schema, defs_key: defs}
@pytest.mark.parametrize("aliases", [1, 2, 3])
def test_large_schema_resolves_root_reference_through_aliases(aliases: int) -> None:
schema = nested_union_schema(9)
defs = schema.pop("$defs")
defs["Actual"] = schema
names = [f"Alias{i}" for i in range(aliases)] + ["Actual"]
for name, target in zip(names, names[1:], strict=False):
defs[name] = {"$ref": f"#/$defs/{target}"}
result = dereference_refs({"$ref": f"#/$defs/{names[0]}", "$defs": defs})
assert result == {**schema, "$defs": defs}
def test_sibling_keywords_come_from_the_referenced_definition() -> None:
schema = {
"type": "object",
"properties": {"x": {"$ref": "#/$defs/Holder/properties/Item"}},
"$defs": {
"Holder": {"properties": {"Item": {"type": "integer"}}},
"Item": {"$ref": "#/$defs/Text", "description": "A text item"},
"Text": {"type": "string"},
},
}
assert dereference_refs(schema)["properties"]["x"] == {"type": "integer"}
@pytest.mark.parametrize(
("key", "pointer"),
[
("", "#/properties/"),
("a/b", "#/properties/a~1b"),
("a b", "#/properties/a%20b"),
("a~b", "#/properties/a~0b"),
("a", "#properties/a"),
],
)
def test_pointer_segments_resolve_like_jsonref(key: str, pointer: str) -> None:
schema = {"properties": {key: {"type": "string"}, "copy": {"$ref": pointer}}}
assert dereference_refs(schema)["properties"]["copy"] == {"type": "string"}
async def test_list_tools_keeps_defs_for_large_schema() -> None:
def echo(x: str) -> str:
return x
schema = nested_union_schema(9)
server = FastMCP("test")
server.add_tool(
Tool.from_function(echo).model_copy(
update={"parameters": schema, "output_schema": schema}
)
)
async with Client(server) as client:
tools = await client.list_tools()
assert tools[0].input_schema == schema
assert tools[0].output_schema == schema