fix: complete recursive MCP schema handling

resolve_refs now returns type-preserving stubs instead of {} for
circular $refs, and create_model_from_schema catches JsonRefError
to fall back to lazy top-level-only inlining.
This commit is contained in:
Greyson LaLonde
2026-04-17 00:06:02 +08:00
committed by GitHub
parent 749fe85325
commit 63fc2e7588
2 changed files with 135 additions and 2 deletions

View File

@@ -120,7 +120,11 @@ def resolve_refs(schema: dict[str, Any]) -> dict[str, Any]:
if def_name not in defs:
raise KeyError(f"Definition '{def_name}' not found in $defs.")
if def_name in expanding:
return {}
def_schema = defs[def_name]
stub: dict[str, Any] = {"type": def_schema.get("type", "object")}
if "description" in def_schema:
stub["description"] = def_schema["description"]
return stub
expanding.add(def_name)
try:
return _resolve(deepcopy(defs[def_name]))
@@ -759,6 +763,25 @@ def build_rich_field_description(prop_schema: dict[str, Any]) -> str:
return ". ".join(parts) if parts else ""
def _inline_top_level_ref(schema: dict[str, Any]) -> dict[str, Any]:
"""Resolve only the top-level ``$ref``, preserving ``$defs`` for lazy inner resolution.
Used as a fallback when ``jsonref.replace_refs`` fails on circular schemas.
Inner ``$ref`` pointers are left intact so that :func:`_resolve_ref` can
resolve them during model construction, with cycle detection via ``in_progress``.
"""
schema = deepcopy(schema)
ref = schema.get("$ref")
if isinstance(ref, str) and ref.startswith("#/$defs/"):
def_name = ref[len("#/$defs/") :]
defs = schema.get("$defs", {})
if def_name in defs:
resolved: dict[str, Any] = deepcopy(defs[def_name])
resolved.setdefault("$defs", defs)
return resolved
return schema
def create_model_from_schema( # type: ignore[no-any-unimported]
json_schema: dict[str, Any],
*,
@@ -813,7 +836,10 @@ def create_model_from_schema( # type: ignore[no-any-unimported]
>>> person.name
'John'
"""
json_schema = dict(jsonref.replace_refs(json_schema, proxies=False))
try:
json_schema = dict(jsonref.replace_refs(json_schema, proxies=False))
except (jsonref.JsonRefError, RecursionError):
json_schema = _inline_top_level_ref(json_schema)
effective_root = root_schema or json_schema

View File

@@ -882,3 +882,110 @@ class TestEndToEndMCPSchema:
)
assert obj.filters.date_from == datetime.date(2025, 1, 1)
assert obj.filters.categories == ["news", "tech"]
# ---------------------------------------------------------------------------
# Recursive / circular $ref schemas (GH-5490)
# ---------------------------------------------------------------------------
RECURSIVE_NODE_SCHEMA: dict = {
"$defs": {
"Node": {
"type": "object",
"properties": {
"name": {"type": "string"},
"children": {
"type": "array",
"items": {"$ref": "#/$defs/Node"},
},
},
"required": ["name"],
}
},
"$ref": "#/$defs/Node",
}
MUTUAL_RECURSION_SCHEMA: dict = {
"$defs": {
"A": {
"type": "object",
"properties": {
"val": {"type": "string"},
"b": {"$ref": "#/$defs/B"},
},
"required": ["val"],
},
"B": {
"type": "object",
"properties": {
"val": {"type": "integer"},
"a": {"$ref": "#/$defs/A"},
},
"required": ["val"],
},
},
"$ref": "#/$defs/A",
}
class TestResolveRefsRecursive:
def test_circular_ref_preserves_type(self) -> None:
from crewai.utilities.pydantic_schema_utils import resolve_refs
resolved = resolve_refs(deepcopy(RECURSIVE_NODE_SCHEMA))
items = resolved["properties"]["children"]["items"]
assert items != {}, "Circular ref should not degrade to {}"
assert items.get("type") == "object"
def test_non_recursive_schema_still_resolves(self) -> None:
from crewai.utilities.pydantic_schema_utils import resolve_refs
schema = {
"$defs": {"Foo": {"type": "object", "properties": {"x": {"type": "integer"}}}},
"$ref": "#/$defs/Foo",
}
resolved = resolve_refs(schema)
assert resolved["properties"]["x"]["type"] == "integer"
class TestSanitizeRecursiveSchemas:
def test_anthropic_strict_preserves_recursive_type(self) -> None:
from crewai.utilities.pydantic_schema_utils import sanitize_tool_params_for_anthropic_strict
san = sanitize_tool_params_for_anthropic_strict(deepcopy(RECURSIVE_NODE_SCHEMA))
items = san["properties"]["children"]["items"]
assert items != {}
assert items.get("type") == "object"
def test_openai_strict_preserves_recursive_type(self) -> None:
from crewai.utilities.pydantic_schema_utils import sanitize_tool_params_for_openai_strict
san = sanitize_tool_params_for_openai_strict(deepcopy(RECURSIVE_NODE_SCHEMA))
items = san["properties"]["children"]["items"]
assert items != {}
assert items.get("type") == "object"
class TestCreateModelFromSchemaRecursive:
def test_model_creation_succeeds(self) -> None:
model = create_model_from_schema(deepcopy(RECURSIVE_NODE_SCHEMA), model_name="Node")
assert model is not None
assert model.__name__ == "Node"
def test_model_accepts_valid_recursive_data(self) -> None:
model = create_model_from_schema(deepcopy(RECURSIVE_NODE_SCHEMA), model_name="Node")
instance = model(name="root", children=[{"name": "child", "children": []}])
assert instance.name == "root"
assert len(instance.children) == 1
def test_model_rejects_missing_required_field(self) -> None:
import pytest
model = create_model_from_schema(deepcopy(RECURSIVE_NODE_SCHEMA), model_name="Node")
with pytest.raises(Exception):
model(children=[])
def test_mutual_recursion_schema(self) -> None:
model = create_model_from_schema(deepcopy(MUTUAL_RECURSION_SCHEMA), model_name="A")
instance = model(val="hello", b={"val": 42})
assert instance.val == "hello"