Compare commits

..

3 Commits

Author SHA1 Message Date
Devin AI
84a72c4350 fix: remove type: ignore comments that fail on older Python mypy
Use getattr/object.__setattr__ pattern to avoid version-dependent
type: ignore comments that cause unused-ignore errors on Python 3.10/3.11.

Co-Authored-By: João <joao@crewai.com>
2026-04-15 19:19:27 +00:00
Devin AI
1305bfc7ea fix: make _resolving_refs thread-safe via threading.local()
Address Bugbot review: replace module-level set with threading.local()
so concurrent schema conversions in ThreadPoolExecutor don't interfere.

Co-Authored-By: João <joao@crewai.com>
2026-04-15 19:12:39 +00:00
Devin AI
ae09793712 fix: handle circular $ref in MCP tool JSON schemas (#5474)
MCP servers exposing self-referential JSON schemas (e.g. ms-365-mcp-server
with >10 tools) triggered 'maximum recursion depth exceeded' because:

1. jsonref.replace_refs(proxies=False) infinitely inlines circular $refs
2. Downstream recursive visitors (force_additional_properties_false, etc.)
   loop on the resulting circular Python dicts
3. resolve_refs and _json_schema_to_pydantic_type had no cycle detection

Fix:
- Add _has_circular_refs() to detect circular $ref chains
- Add _break_circular_refs() to replace circular refs with {type: object} stubs
- Wrap jsonref.replace_refs in _safe_replace_refs() that breaks cycles first
- Add cycle detection to resolve_refs() using a resolving-set parameter
- Add cycle detection to _json_schema_to_pydantic_type() via _resolving_refs

Tests added for all new helpers and end-to-end circular schema scenarios.

Co-Authored-By: João <joao@crewai.com>
2026-04-15 19:03:12 +00:00
18 changed files with 521 additions and 365 deletions

View File

@@ -4,27 +4,6 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
<Update label="16 أبريل 2026">
## v1.14.2rc1
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.2rc1)
## ما الذي تغير
### إصلاحات الأخطاء
- إصلاح معالجة مخططات JSON الدائرية في أداة MCP
- إصلاح ثغرة أمنية من خلال تحديث python-multipart إلى 0.0.26
- إصلاح ثغرة أمنية من خلال تحديث pypdf إلى 6.10.1
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.14.2a5
## المساهمون
@greysonlalonde
</Update>
<Update label="15 أبريل 2026">
## v1.14.2a5

View File

@@ -4,27 +4,6 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="Apr 16, 2026">
## v1.14.2rc1
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.2rc1)
## What's Changed
### Bug Fixes
- Fix handling of cyclic JSON schemas in MCP tool resolution
- Fix vulnerability by bumping python-multipart to 0.0.26
- Fix vulnerability by bumping pypdf to 6.10.1
### Documentation
- Update changelog and version for v1.14.2a5
## Contributors
@greysonlalonde
</Update>
<Update label="Apr 15, 2026">
## v1.14.2a5

View File

@@ -4,27 +4,6 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
<Update label="2026년 4월 16일">
## v1.14.2rc1
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.2rc1)
## 변경 사항
### 버그 수정
- MCP 도구 해상도에서 순환 JSON 스키마 처리 수정
- python-multipart를 0.0.26으로 업데이트하여 취약점 수정
- pypdf를 6.10.1로 업데이트하여 취약점 수정
### 문서
- v1.14.2a5에 대한 변경 로그 및 버전 업데이트
## 기여자
@greysonlalonde
</Update>
<Update label="2026년 4월 15일">
## v1.14.2a5

View File

@@ -4,27 +4,6 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="16 abr 2026">
## v1.14.2rc1
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.2rc1)
## O que Mudou
### Correções de Bugs
- Corrigir o manuseio de esquemas JSON cíclicos na resolução da ferramenta MCP
- Corrigir vulnerabilidade atualizando python-multipart para 0.0.26
- Corrigir vulnerabilidade atualizando pypdf para 6.10.1
### Documentação
- Atualizar o changelog e a versão para v1.14.2a5
## Contribuidores
@greysonlalonde
</Update>
<Update label="15 abr 2026">
## v1.14.2a5

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.14.2rc1"
__version__ = "1.14.2a5"

View File

@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
"crewai==1.14.2rc1",
"crewai==1.14.2a5",
"tiktoken~=0.8.0",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",

View File

@@ -305,4 +305,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.14.2rc1"
__version__ = "1.14.2a5"

View File

@@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.14.2rc1",
"crewai-tools==1.14.2a5",
]
embeddings = [
"tiktoken~=0.8.0"

View File

@@ -46,7 +46,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.14.2rc1"
__version__ = "1.14.2a5"
_telemetry_submitted = False

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.2rc1"
"crewai[tools]==1.14.2a5"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.2rc1"
"crewai[tools]==1.14.2a5"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.2rc1"
"crewai[tools]==1.14.2a5"
]
[tool.crewai]

View File

@@ -417,18 +417,9 @@ class MCPToolResolver:
args_schema = None
if tool_def.get("inputSchema"):
try:
args_schema = self._json_schema_to_pydantic(
tool_name, tool_def["inputSchema"]
)
except Exception as e:
self._logger.log(
"warning",
f"Failed to build args schema for MCP tool "
f"'{tool_name}': {e}. Registering tool without a "
"typed schema.",
)
args_schema = None
args_schema = self._json_schema_to_pydantic(
tool_name, tool_def["inputSchema"]
)
tool_schema = {
"description": tool_def.get("description", ""),

View File

@@ -19,18 +19,8 @@ from collections.abc import Callable
from copy import deepcopy
import datetime
import logging
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Final,
ForwardRef,
Literal,
Optional,
TypedDict,
Union,
cast,
)
import threading
from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, TypedDict, Union, cast
import uuid
import jsonref # type: ignore[import-untyped]
@@ -102,6 +92,9 @@ def resolve_refs(schema: dict[str, Any]) -> dict[str, Any]:
This is needed because Pydantic generates $ref-based schemas that
some consumers (e.g. LLMs, tool frameworks) don't handle well.
Circular references are detected and replaced with a plain
``{"type": "object"}`` stub to prevent infinite recursion.
Args:
schema: JSON Schema dict that may contain "$refs" and "$defs".
@@ -110,26 +103,24 @@ def resolve_refs(schema: dict[str, Any]) -> dict[str, Any]:
"""
defs = schema.get("$defs", {})
schema_copy = deepcopy(schema)
expanding: set[str] = set()
def _resolve(node: Any) -> Any:
def _resolve(node: Any, resolving: frozenset[str] = frozenset()) -> Any:
if isinstance(node, dict):
ref = node.get("$ref")
if isinstance(ref, str) and ref.startswith("#/$defs/"):
def_name = ref.replace("#/$defs/", "")
if def_name not in defs:
raise KeyError(f"Definition '{def_name}' not found in $defs.")
if def_name in expanding:
return {}
expanding.add(def_name)
try:
return _resolve(deepcopy(defs[def_name]))
finally:
expanding.discard(def_name)
return {k: _resolve(v) for k, v in node.items()}
if def_name in resolving:
return {"type": "object"}
if def_name in defs:
return _resolve(
deepcopy(defs[def_name]),
resolving | {def_name},
)
raise KeyError(f"Definition '{def_name}' not found in $defs.")
return {k: _resolve(v, resolving) for k, v in node.items()}
if isinstance(node, list):
return [_resolve(i) for i in node]
return [_resolve(i, resolving) for i in node]
return node
@@ -137,11 +128,7 @@ def resolve_refs(schema: dict[str, Any]) -> dict[str, Any]:
def add_key_in_dict_recursively(
d: dict[str, Any],
key: str,
value: Any,
criteria: Callable[[dict[str, Any]], bool],
_seen: set[int] | None = None,
d: dict[str, Any], key: str, value: Any, criteria: Callable[[dict[str, Any]], bool]
) -> dict[str, Any]:
"""Recursively adds a key/value pair to all nested dicts matching `criteria`.
@@ -150,31 +137,22 @@ def add_key_in_dict_recursively(
key: The key to add.
value: The value to add.
criteria: A function that returns True for dicts that should receive the key.
_seen: Internal set of visited ``id()``s, used to guard cyclic schemas.
Returns:
The modified dictionary.
"""
if _seen is None:
_seen = set()
if isinstance(d, dict):
if id(d) in _seen:
return d
_seen.add(id(d))
if criteria(d) and key not in d:
d[key] = value
for v in d.values():
add_key_in_dict_recursively(v, key, value, criteria, _seen)
add_key_in_dict_recursively(v, key, value, criteria)
elif isinstance(d, list):
if id(d) in _seen:
return d
_seen.add(id(d))
for i in d:
add_key_in_dict_recursively(i, key, value, criteria, _seen)
add_key_in_dict_recursively(i, key, value, criteria)
return d
def force_additional_properties_false(d: Any, _seen: set[int] | None = None) -> Any:
def force_additional_properties_false(d: Any) -> Any:
"""Force additionalProperties=false on all object-type dicts recursively.
OpenAI strict mode requires all objects to have additionalProperties=false.
@@ -185,17 +163,11 @@ def force_additional_properties_false(d: Any, _seen: set[int] | None = None) ->
Args:
d: The dictionary/list to modify.
_seen: Internal set of visited ``id()``s, used to guard cyclic schemas.
Returns:
The modified dictionary/list.
"""
if _seen is None:
_seen = set()
if isinstance(d, dict):
if id(d) in _seen:
return d
_seen.add(id(d))
if d.get("type") == "object":
d["additionalProperties"] = False
if "properties" not in d:
@@ -203,13 +175,10 @@ def force_additional_properties_false(d: Any, _seen: set[int] | None = None) ->
if "required" not in d:
d["required"] = []
for v in d.values():
force_additional_properties_false(v, _seen)
force_additional_properties_false(v)
elif isinstance(d, list):
if id(d) in _seen:
return d
_seen.add(id(d))
for i in d:
force_additional_properties_false(i, _seen)
force_additional_properties_false(i)
return d
@@ -223,7 +192,7 @@ OPENAI_SUPPORTED_FORMATS: Final[
}
def strip_unsupported_formats(d: Any, _seen: set[int] | None = None) -> Any:
def strip_unsupported_formats(d: Any) -> Any:
"""Remove format annotations that OpenAI strict mode doesn't support.
OpenAI only supports: date-time, date, time, duration.
@@ -231,17 +200,11 @@ def strip_unsupported_formats(d: Any, _seen: set[int] | None = None) -> Any:
Args:
d: The dictionary/list to modify.
_seen: Internal set of visited ``id()``s, used to guard cyclic schemas.
Returns:
The modified dictionary/list.
"""
if _seen is None:
_seen = set()
if isinstance(d, dict):
if id(d) in _seen:
return d
_seen.add(id(d))
format_value = d.get("format")
if (
isinstance(format_value, str)
@@ -249,17 +212,14 @@ def strip_unsupported_formats(d: Any, _seen: set[int] | None = None) -> Any:
):
del d["format"]
for v in d.values():
strip_unsupported_formats(v, _seen)
strip_unsupported_formats(v)
elif isinstance(d, list):
if id(d) in _seen:
return d
_seen.add(id(d))
for i in d:
strip_unsupported_formats(i, _seen)
strip_unsupported_formats(i)
return d
def ensure_type_in_schemas(d: Any, _seen: set[int] | None = None) -> Any:
def ensure_type_in_schemas(d: Any) -> Any:
"""Ensure all schema objects in anyOf/oneOf have a 'type' key.
OpenAI strict mode requires every schema to have a 'type' key.
@@ -267,17 +227,11 @@ def ensure_type_in_schemas(d: Any, _seen: set[int] | None = None) -> Any:
Args:
d: The dictionary/list to modify.
_seen: Internal set of visited ``id()``s, used to guard cyclic schemas.
Returns:
The modified dictionary/list.
"""
if _seen is None:
_seen = set()
if isinstance(d, dict):
if id(d) in _seen:
return d
_seen.add(id(d))
for key in ("anyOf", "oneOf"):
if key in d:
schema_list = d[key]
@@ -285,15 +239,12 @@ def ensure_type_in_schemas(d: Any, _seen: set[int] | None = None) -> Any:
if isinstance(schema, dict) and schema == {}:
schema_list[i] = {"type": "object"}
else:
ensure_type_in_schemas(schema, _seen)
ensure_type_in_schemas(schema)
for v in d.values():
ensure_type_in_schemas(v, _seen)
ensure_type_in_schemas(v)
elif isinstance(d, list):
if id(d) in _seen:
return d
_seen.add(id(d))
for item in d:
ensure_type_in_schemas(item, _seen)
ensure_type_in_schemas(item)
return d
@@ -376,9 +327,7 @@ def add_const_to_oneof_variants(schema: dict[str, Any]) -> dict[str, Any]:
return _process_oneof(deepcopy(schema))
def convert_oneof_to_anyof(
schema: dict[str, Any], _seen: set[int] | None = None
) -> dict[str, Any]:
def convert_oneof_to_anyof(schema: dict[str, Any]) -> dict[str, Any]:
"""Convert oneOf to anyOf for OpenAI compatibility.
OpenAI's Structured Outputs support anyOf better than oneOf.
@@ -386,37 +335,26 @@ def convert_oneof_to_anyof(
Args:
schema: JSON schema dictionary.
_seen: Internal set of visited ``id()``s, used to guard cyclic schemas.
Returns:
Modified schema with anyOf instead of oneOf.
"""
if _seen is None:
_seen = set()
if isinstance(schema, dict):
if id(schema) in _seen:
return schema
_seen.add(id(schema))
if "oneOf" in schema:
schema["anyOf"] = schema.pop("oneOf")
for value in schema.values():
if isinstance(value, dict):
convert_oneof_to_anyof(value, _seen)
convert_oneof_to_anyof(value)
elif isinstance(value, list):
if id(value) in _seen:
continue
_seen.add(id(value))
for item in value:
if isinstance(item, dict):
convert_oneof_to_anyof(item, _seen)
convert_oneof_to_anyof(item)
return schema
def ensure_all_properties_required(
schema: dict[str, Any], _seen: set[int] | None = None
) -> dict[str, Any]:
def ensure_all_properties_required(schema: dict[str, Any]) -> dict[str, Any]:
"""Ensure all properties are in the required array for OpenAI strict mode.
OpenAI's strict structured outputs require all properties to be listed
@@ -425,17 +363,11 @@ def ensure_all_properties_required(
Args:
schema: JSON schema dictionary.
_seen: Internal set of visited ``id()``s, used to guard cyclic schemas.
Returns:
Modified schema with all properties marked as required.
"""
if _seen is None:
_seen = set()
if isinstance(schema, dict):
if id(schema) in _seen:
return schema
_seen.add(id(schema))
if schema.get("type") == "object" and "properties" in schema:
properties = schema["properties"]
if properties:
@@ -443,21 +375,16 @@ def ensure_all_properties_required(
for value in schema.values():
if isinstance(value, dict):
ensure_all_properties_required(value, _seen)
ensure_all_properties_required(value)
elif isinstance(value, list):
if id(value) in _seen:
continue
_seen.add(id(value))
for item in value:
if isinstance(item, dict):
ensure_all_properties_required(item, _seen)
ensure_all_properties_required(item)
return schema
def strip_null_from_types(
schema: dict[str, Any], _seen: set[int] | None = None
) -> dict[str, Any]:
def strip_null_from_types(schema: dict[str, Any]) -> dict[str, Any]:
"""Remove null type from anyOf/type arrays.
Pydantic generates `T | None` for optional fields, which creates schemas with
@@ -466,17 +393,11 @@ def strip_null_from_types(
Args:
schema: JSON schema dictionary.
_seen: Internal set of visited ``id()``s, used to guard cyclic schemas.
Returns:
Modified schema with null types removed.
"""
if _seen is None:
_seen = set()
if isinstance(schema, dict):
if id(schema) in _seen:
return schema
_seen.add(id(schema))
if "anyOf" in schema:
any_of = schema["anyOf"]
non_null = [opt for opt in any_of if opt.get("type") != "null"]
@@ -496,14 +417,11 @@ def strip_null_from_types(
for value in schema.values():
if isinstance(value, dict):
strip_null_from_types(value, _seen)
strip_null_from_types(value)
elif isinstance(value, list):
if id(value) in _seen:
continue
_seen.add(id(value))
for item in value:
if isinstance(item, dict):
strip_null_from_types(item, _seen)
strip_null_from_types(item)
return schema
@@ -542,26 +460,16 @@ _CLAUDE_STRICT_UNSUPPORTED: Final[tuple[str, ...]] = (
)
def _strip_keys_recursive(
d: Any, keys: tuple[str, ...], _seen: set[int] | None = None
) -> Any:
def _strip_keys_recursive(d: Any, keys: tuple[str, ...]) -> Any:
"""Recursively delete a fixed set of keys from a schema."""
if _seen is None:
_seen = set()
if isinstance(d, dict):
if id(d) in _seen:
return d
_seen.add(id(d))
for key in keys:
d.pop(key, None)
for v in d.values():
_strip_keys_recursive(v, keys, _seen)
_strip_keys_recursive(v, keys)
elif isinstance(d, list):
if id(d) in _seen:
return d
_seen.add(id(d))
for i in d:
_strip_keys_recursive(i, keys, _seen)
_strip_keys_recursive(i, keys)
return d
@@ -759,6 +667,104 @@ def build_rich_field_description(prop_schema: dict[str, Any]) -> str:
return ". ".join(parts) if parts else ""
# Thread-local storage tracking which ``$ref`` paths are currently being
# resolved. Used by ``_json_schema_to_pydantic_type`` to detect circular
# ``$ref`` chains and break the recursion with a ``dict`` fallback.
# Each thread gets its own independent set so concurrent schema conversions
# (e.g. via ThreadPoolExecutor in MCP tool resolution) don't interfere.
_resolving_refs_local = threading.local()
def _get_resolving_refs() -> set[str]:
"""Return the per-thread resolving-refs set, creating it on first access."""
refs: set[str] | None = getattr(_resolving_refs_local, "refs", None)
if refs is None:
refs = set()
object.__setattr__(_resolving_refs_local, "refs", refs)
return refs
def _safe_replace_refs(json_schema: dict[str, Any]) -> dict[str, Any]:
"""Resolve ``$ref`` pointers in *json_schema*, tolerating circular refs.
``jsonref.replace_refs(proxies=False)`` performs eager, recursive
inlining. When a definition refers back to itself (directly or
transitively) this blows the Python call stack and also produces
Python dicts with circular object references that break all
downstream recursive visitors.
Strategy: always break circular ``$ref`` chains *before* handing the
schema to ``jsonref`` so the library never encounters a cycle.
"""
schema_copy = deepcopy(json_schema)
defs = schema_copy.get("$defs", {})
if defs and _has_circular_refs(schema_copy, defs):
_break_circular_refs(schema_copy, defs, set())
try:
return dict(jsonref.replace_refs(schema_copy, proxies=False))
except RecursionError:
# Last resort - return the manually patched copy as-is.
return schema_copy
def _has_circular_refs(
node: Any,
defs: dict[str, Any],
visiting: set[str] | None = None,
) -> bool:
"""Return ``True`` if *node* contains any circular ``$ref`` chain."""
if visiting is None:
visiting = set()
if isinstance(node, dict):
ref = node.get("$ref")
if isinstance(ref, str) and ref.startswith("#/$defs/"):
def_name = ref.removeprefix("#/$defs/")
if def_name in visiting:
return True
if def_name in defs:
visiting.add(def_name)
if _has_circular_refs(defs[def_name], defs, visiting):
return True
visiting.discard(def_name)
for value in node.values():
if _has_circular_refs(value, defs, visiting):
return True
elif isinstance(node, list):
for item in node:
if _has_circular_refs(item, defs, visiting):
return True
return False
def _break_circular_refs(
node: Any,
defs: dict[str, Any],
visiting: set[str],
) -> None:
"""Walk *node* in-place and replace circular ``$ref`` pointers with stubs."""
if isinstance(node, dict):
ref = node.get("$ref")
if isinstance(ref, str) and ref.startswith("#/$defs/"):
def_name = ref.removeprefix("#/$defs/")
if def_name in visiting:
# Circular - replace the *whole* node content with a stub.
node.clear()
node["type"] = "object"
return
if def_name in defs:
visiting.add(def_name)
_break_circular_refs(defs[def_name], defs, visiting)
visiting.discard(def_name)
for value in node.values():
_break_circular_refs(value, defs, visiting)
elif isinstance(node, list):
for item in node:
_break_circular_refs(item, defs, visiting)
def create_model_from_schema( # type: ignore[no-any-unimported]
json_schema: dict[str, Any],
*,
@@ -778,6 +784,10 @@ def create_model_from_schema( # type: ignore[no-any-unimported]
as nested objects, referenced definitions ($ref), arrays with typed items,
union types (anyOf/oneOf), and string formats.
Circular ``$ref`` chains (common in complex MCP tool schemas) are detected
and broken automatically so that deeply-nested or self-referential schemas
never trigger a ``RecursionError``.
Args:
json_schema: A dictionary representing the JSON schema.
root_schema: The root schema containing $defs. If not provided, the
@@ -813,77 +823,19 @@ def create_model_from_schema( # type: ignore[no-any-unimported]
>>> person.name
'John'
"""
json_schema = dict(jsonref.replace_refs(json_schema, proxies=False))
json_schema = _safe_replace_refs(json_schema)
effective_root = root_schema or json_schema
json_schema = force_additional_properties_false(json_schema)
effective_root = force_additional_properties_false(effective_root)
in_progress: dict[int, Any] = {}
model = _build_model_from_schema(
json_schema,
effective_root,
model_name=model_name,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
__config__=__config__,
__base__=__base__,
__module__=__module__,
__validators__=__validators__,
__cls_kwargs__=__cls_kwargs__,
)
types_namespace: dict[str, Any] = {
entry.__name__: entry
for entry in in_progress.values()
if isinstance(entry, type) and issubclass(entry, BaseModel)
}
for entry in in_progress.values():
if (
isinstance(entry, type)
and issubclass(entry, BaseModel)
and not getattr(entry, "__pydantic_complete__", True)
):
try:
entry.model_rebuild(_types_namespace=types_namespace)
except Exception as e:
logger.debug("model_rebuild failed for %s: %s", entry.__name__, e)
return model
def _build_model_from_schema( # type: ignore[no-any-unimported]
json_schema: dict[str, Any],
effective_root: dict[str, Any],
*,
model_name: str | None,
enrich_descriptions: bool,
in_progress: dict[int, Any],
__config__: ConfigDict | None = None,
__base__: type[BaseModel] | None = None,
__module__: str = __name__,
__validators__: dict[str, AnyClassMethod] | None = None,
__cls_kwargs__: dict[str, Any] | None = None,
) -> type[BaseModel]:
"""Inner builder shared by the public entry point and recursive nested-object creation.
Preprocessing via ``jsonref.replace_refs`` and the sanitization walkers is
run once by the public entry; this helper walks the already-normalized
schema and emits Pydantic models. ``in_progress`` maps ``id(schema)`` to
the model being built for that schema, so a cyclic ``$ref`` graph
degrades to a ``ForwardRef`` back-edge instead of blowing the stack.
"""
original_id = id(json_schema)
if "allOf" in json_schema:
json_schema = _merge_all_of_schemas(json_schema["allOf"], effective_root)
if "title" not in json_schema and "title" in (root_schema or {}):
json_schema["title"] = (root_schema or {}).get("title")
effective_name = model_name or json_schema.get("title") or "DynamicModel"
schema_id = id(json_schema)
in_progress[original_id] = effective_name
if schema_id != original_id:
in_progress[schema_id] = effective_name
field_definitions = {
name: _json_schema_to_pydantic_field(
name,
@@ -891,14 +843,13 @@ def _build_model_from_schema( # type: ignore[no-any-unimported]
json_schema.get("required", []),
effective_root,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
for name, prop in (json_schema.get("properties", {}) or {}).items()
}
effective_config = __config__ or ConfigDict(extra="forbid")
model = create_model_base(
return create_model_base(
effective_name,
__config__=effective_config,
__base__=__base__,
@@ -907,10 +858,6 @@ def _build_model_from_schema( # type: ignore[no-any-unimported]
__cls_kwargs__=__cls_kwargs__,
**field_definitions,
)
in_progress[original_id] = model
if schema_id != original_id:
in_progress[schema_id] = model
return model
def _json_schema_to_pydantic_field(
@@ -920,7 +867,6 @@ def _json_schema_to_pydantic_field(
root_schema: dict[str, Any],
*,
enrich_descriptions: bool = False,
in_progress: dict[int, Any] | None = None,
) -> Any:
"""Convert a JSON schema property to a Pydantic field definition.
@@ -939,7 +885,6 @@ def _json_schema_to_pydantic_field(
root_schema,
name_=name.title(),
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
is_required = name in required
@@ -999,7 +944,7 @@ def _json_schema_to_pydantic_field(
field_params["pattern"] = json_schema["pattern"]
if not is_required:
type_ = Optional[type_] # noqa: UP045 - ForwardRef does not support `|`
type_ = type_ | None
if schema_extra:
field_params["json_schema_extra"] = schema_extra
@@ -1072,7 +1017,6 @@ def _json_schema_to_pydantic_type(
*,
name_: str | None = None,
enrich_descriptions: bool = False,
in_progress: dict[int, Any] | None = None,
) -> Any:
"""Convert a JSON schema to a Python/Pydantic type.
@@ -1081,33 +1025,28 @@ def _json_schema_to_pydantic_type(
root_schema: The root schema for resolving $ref.
name_: Optional name for nested models.
enrich_descriptions: Propagated to nested model creation.
in_progress: Map of ``id(schema_dict)`` to the Pydantic model
currently being built for that schema, or to a placeholder name
as a plain ``str`` while the model is still being constructed.
Populated by :func:`_build_model_from_schema`. Enables cycle
detection so a self-referential ``$ref`` graph resolves to a
:class:`ForwardRef` back-edge rather than recursing forever.
Returns:
A Python type corresponding to the JSON schema.
"""
if in_progress is not None:
cached = in_progress.get(id(json_schema))
if isinstance(cached, str):
return ForwardRef(cached)
if cached is not None:
return cached
ref = json_schema.get("$ref")
if ref:
ref_schema = _resolve_ref(ref, root_schema)
return _json_schema_to_pydantic_type(
ref_schema,
root_schema,
name_=name_,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
# Detect circular $ref chains - if we are already resolving this
# ref higher up the call stack, break the cycle by returning dict.
resolving = _get_resolving_refs()
if ref in resolving:
return dict
resolving.add(ref)
try:
ref_schema = _resolve_ref(ref, root_schema)
return _json_schema_to_pydantic_type(
ref_schema,
root_schema,
name_=name_,
enrich_descriptions=enrich_descriptions,
)
finally:
resolving.discard(ref)
enum_values = json_schema.get("enum")
if enum_values:
@@ -1126,7 +1065,6 @@ def _json_schema_to_pydantic_type(
root_schema,
name_=f"{name_ or 'Union'}Option{i}",
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
for i, schema in enumerate(any_of_schemas)
]
@@ -1140,15 +1078,6 @@ def _json_schema_to_pydantic_type(
root_schema,
name_=name_,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
if in_progress is not None:
return _build_model_from_schema(
json_schema,
root_schema,
model_name=name_,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
merged = _merge_all_of_schemas(all_of_schemas, root_schema)
return _json_schema_to_pydantic_type(
@@ -1156,7 +1085,6 @@ def _json_schema_to_pydantic_type(
root_schema,
name_=name_,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
type_ = json_schema.get("type")
@@ -1177,21 +1105,12 @@ def _json_schema_to_pydantic_type(
root_schema,
name_=name_,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
return list[item_type] # type: ignore[valid-type]
return list
if type_ == "object":
properties = json_schema.get("properties")
if properties:
if in_progress is not None:
return _build_model_from_schema(
json_schema,
root_schema,
model_name=name_,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
json_schema_ = json_schema.copy()
if json_schema_.get("title") is None:
json_schema_["title"] = name_ or "DynamicModel"

View File

@@ -19,6 +19,9 @@ import pytest
from pydantic import BaseModel
from crewai.utilities.pydantic_schema_utils import (
_break_circular_refs,
_has_circular_refs,
_safe_replace_refs,
build_rich_field_description,
convert_oneof_to_anyof,
create_model_from_schema,
@@ -882,3 +885,333 @@ class TestEndToEndMCPSchema:
)
assert obj.filters.date_from == datetime.date(2025, 1, 1)
assert obj.filters.categories == ["news", "tech"]
# ---------------------------------------------------------------------------
# Circular $ref handling (issue #5474)
# ---------------------------------------------------------------------------
class TestCircularRefDetection:
"""Tests for _has_circular_refs helper."""
def test_detects_direct_self_reference(self) -> None:
schema: dict[str, Any] = {
"type": "object",
"properties": {"child": {"$ref": "#/$defs/Node"}},
"$defs": {
"Node": {
"type": "object",
"properties": {
"children": {
"type": "array",
"items": {"$ref": "#/$defs/Node"},
},
},
},
},
}
assert _has_circular_refs(schema, schema["$defs"]) is True
def test_detects_indirect_circular_reference(self) -> None:
schema: dict[str, Any] = {
"type": "object",
"properties": {"a": {"$ref": "#/$defs/A"}},
"$defs": {
"A": {
"type": "object",
"properties": {"b": {"$ref": "#/$defs/B"}},
},
"B": {
"type": "object",
"properties": {"a": {"$ref": "#/$defs/A"}},
},
},
}
assert _has_circular_refs(schema, schema["$defs"]) is True
def test_no_circular_ref(self) -> None:
schema: dict[str, Any] = {
"type": "object",
"properties": {"item": {"$ref": "#/$defs/Item"}},
"$defs": {
"Item": {
"type": "object",
"properties": {"name": {"type": "string"}},
},
},
}
assert _has_circular_refs(schema, schema["$defs"]) is False
class TestBreakCircularRefs:
"""Tests for _break_circular_refs helper."""
def test_breaks_direct_self_reference(self) -> None:
schema: dict[str, Any] = {
"type": "object",
"properties": {"child": {"$ref": "#/$defs/Node"}},
"$defs": {
"Node": {
"type": "object",
"properties": {
"name": {"type": "string"},
"children": {
"type": "array",
"items": {"$ref": "#/$defs/Node"},
},
},
},
},
}
_break_circular_refs(schema, schema["$defs"], set())
# The self-referential $ref inside Node's items should be replaced
items = schema["$defs"]["Node"]["properties"]["children"]["items"]
assert items == {"type": "object"}
assert "$ref" not in items
def test_preserves_non_circular_refs(self) -> None:
schema: dict[str, Any] = {
"type": "object",
"properties": {"item": {"$ref": "#/$defs/Item"}},
"$defs": {
"Item": {
"type": "object",
"properties": {"name": {"type": "string"}},
},
},
}
original = deepcopy(schema)
_break_circular_refs(schema, schema["$defs"], set())
# Non-circular schema should be unchanged
assert schema == original
class TestSafeReplaceRefs:
"""Tests for _safe_replace_refs."""
def test_resolves_non_circular_schema(self) -> None:
schema: dict[str, Any] = {
"type": "object",
"properties": {"item": {"$ref": "#/$defs/Item"}},
"$defs": {
"Item": {
"type": "object",
"properties": {"id": {"type": "integer"}},
},
},
}
result = _safe_replace_refs(schema)
assert "$ref" not in result.get("properties", {}).get("item", {})
assert result["properties"]["item"]["type"] == "object"
def test_handles_circular_schema_without_recursion_error(self) -> None:
schema: dict[str, Any] = {
"type": "object",
"properties": {"root": {"$ref": "#/$defs/TreeNode"}},
"$defs": {
"TreeNode": {
"type": "object",
"properties": {
"name": {"type": "string"},
"children": {
"type": "array",
"items": {"$ref": "#/$defs/TreeNode"},
},
},
},
},
}
# Must not raise RecursionError
result = _safe_replace_refs(schema)
assert isinstance(result, dict)
class TestResolveRefsCircular:
"""Tests that resolve_refs handles circular references."""
def test_circular_ref_does_not_recurse(self) -> None:
schema: dict[str, Any] = {
"type": "object",
"properties": {"root": {"$ref": "#/$defs/Node"}},
"$defs": {
"Node": {
"type": "object",
"properties": {
"child": {"$ref": "#/$defs/Node"},
},
},
},
}
resolved = resolve_refs(schema)
# The circular ref should become {"type": "object"} stub
child = resolved["properties"]["root"]["properties"]["child"]
assert child == {"type": "object"}
def test_indirect_circular_ref(self) -> None:
schema: dict[str, Any] = {
"type": "object",
"properties": {"a": {"$ref": "#/$defs/A"}},
"$defs": {
"A": {
"type": "object",
"properties": {"b": {"$ref": "#/$defs/B"}},
},
"B": {
"type": "object",
"properties": {"a": {"$ref": "#/$defs/A"}},
},
},
}
resolved = resolve_refs(schema)
# A -> B -> A(cycle) => the second A should be a stub
b_schema = resolved["properties"]["a"]["properties"]["b"]
assert b_schema["properties"]["a"] == {"type": "object"}
class TestCreateModelCircularRef:
"""End-to-end tests for create_model_from_schema with circular $ref schemas.
Regression tests for GitHub issue #5474: MCP servers with >10 tools
that expose self-referential JSON schemas caused
``RecursionError: maximum recursion depth exceeded``.
"""
def test_direct_self_referential_schema(self) -> None:
"""A type that references itself (tree-like structure)."""
schema: dict[str, Any] = {
"type": "object",
"properties": {
"name": {"type": "string"},
"children": {
"type": "array",
"items": {"$ref": "#/$defs/TreeNode"},
},
},
"required": ["name"],
"$defs": {
"TreeNode": {
"type": "object",
"properties": {
"name": {"type": "string"},
"children": {
"type": "array",
"items": {"$ref": "#/$defs/TreeNode"},
},
},
"required": ["name"],
},
},
}
Model = create_model_from_schema(schema, model_name="TreeSchema")
assert Model.__name__ == "TreeSchema"
obj = Model(name="root")
assert obj.name == "root"
def test_indirect_circular_reference(self) -> None:
"""Two types that reference each other (A -> B -> A)."""
schema: dict[str, Any] = {
"type": "object",
"properties": {"node": {"$ref": "#/$defs/NodeA"}},
"required": ["node"],
"$defs": {
"NodeA": {
"type": "object",
"properties": {
"name": {"type": "string"},
"linked": {"$ref": "#/$defs/NodeB"},
},
"required": ["name"],
},
"NodeB": {
"type": "object",
"properties": {
"value": {"type": "integer"},
"back": {"$ref": "#/$defs/NodeA"},
},
"required": ["value"],
},
},
}
Model = create_model_from_schema(schema, model_name="MutualRef")
obj = Model(node={"name": "hello", "linked": {"value": 42}})
assert obj.node.name == "hello"
def test_many_tools_with_complex_schemas(self) -> None:
"""Simulate an MCP server exposing >10 tools (issue #5474 trigger)."""
for i in range(15):
tool_schema: dict[str, Any] = {
"type": "object",
"properties": {
"query": {"type": "string"},
"options": {
"type": "object",
"properties": {
"limit": {"type": "integer"},
"filter": {"type": "string"},
},
},
},
"required": ["query"],
}
Model = create_model_from_schema(
tool_schema, model_name=f"Tool{i}Schema"
)
obj = Model(query=f"test_{i}")
assert obj.query == f"test_{i}"
def test_circular_ref_with_enrich_descriptions(self) -> None:
"""Circular schema + enrich_descriptions should not blow up."""
schema: dict[str, Any] = {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Node name"},
"child": {"$ref": "#/$defs/Recursive"},
},
"required": ["name"],
"$defs": {
"Recursive": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Name"},
"child": {"$ref": "#/$defs/Recursive"},
},
},
},
}
Model = create_model_from_schema(
schema,
model_name="EnrichedCircular",
enrich_descriptions=True,
)
assert Model.__name__ == "EnrichedCircular"
obj = Model(name="top")
assert obj.name == "top"
def test_deeply_nested_non_circular_still_works(self) -> None:
"""A deep but non-circular chain of $refs should still resolve."""
schema: dict[str, Any] = {
"type": "object",
"properties": {"l1": {"$ref": "#/$defs/Level1"}},
"required": ["l1"],
"$defs": {
"Level1": {
"type": "object",
"properties": {"l2": {"$ref": "#/$defs/Level2"}},
"required": ["l2"],
},
"Level2": {
"type": "object",
"properties": {"l3": {"$ref": "#/$defs/Level3"}},
"required": ["l3"],
},
"Level3": {
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
},
}
Model = create_model_from_schema(schema, model_name="DeepChain")
obj = Model(l1={"l2": {"l3": {"value": "deep"}}})
assert obj.l1.l2.l3.value == "deep"

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.14.2rc1"
__version__ = "1.14.2a5"

View File

@@ -162,7 +162,7 @@ info = "Commits must follow Conventional Commits 1.0.0."
[tool.uv]
exclude-newer = "1 day"
exclude-newer = "3 days"
# composio-core pins rich<14 but textual requires rich>=14.
# onnxruntime 1.24+ dropped Python 3.10 wheels; cap it so qdrant[fastembed] resolves on 3.10.
@@ -170,9 +170,8 @@ exclude-newer = "1 day"
# langchain-core <1.2.28 has GHSA-926x-3r5x-gfhw (incomplete f-string validation).
# transformers 4.57.6 has CVE-2026-1839; force 5.4+ (docling 2.84 allows huggingface-hub>=1).
# cryptography 46.0.6 has CVE-2026-39892; force 46.0.7+.
# pypdf <6.10.1 has CVE-2026-40260 and GHSA-jj6c-8h6c-hppx; force 6.10.1+.
# pypdf <6.10.0 has CVE-2026-40260; force 6.10.0+.
# uv <0.11.6 has GHSA-pjjw-68hj-v9mw; force 0.11.6+.
# python-multipart <0.0.26 has GHSA-mj87-hwqh-73pj; force 0.0.26+.
override-dependencies = [
"rich>=13.7.1",
"onnxruntime<1.24; python_version < '3.11'",
@@ -181,9 +180,8 @@ override-dependencies = [
"urllib3>=2.6.3",
"transformers>=5.4.0; python_version >= '3.10'",
"cryptography>=46.0.7",
"pypdf>=6.10.1,<7",
"pypdf>=6.10.0,<7",
"uv>=0.11.6,<1",
"python-multipart>=0.0.26,<1",
]
[tool.uv.workspace]

19
uv.lock generated
View File

@@ -13,8 +13,8 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-04-14T20:20:18.36862Z"
exclude-newer-span = "P1D"
exclude-newer = "2026-04-10T18:30:59.748668Z"
exclude-newer-span = "P3D"
[manifest]
members = [
@@ -28,8 +28,7 @@ overrides = [
{ name = "langchain-core", specifier = ">=1.2.28,<2" },
{ name = "onnxruntime", marker = "python_full_version < '3.11'", specifier = "<1.24" },
{ name = "pillow", specifier = ">=12.1.1" },
{ name = "pypdf", specifier = ">=6.10.1,<7" },
{ name = "python-multipart", specifier = ">=0.0.26,<1" },
{ name = "pypdf", specifier = ">=6.10.0,<7" },
{ name = "rich", specifier = ">=13.7.1" },
{ name = "transformers", marker = "python_full_version >= '3.10'", specifier = ">=5.4.0" },
{ name = "urllib3", specifier = ">=2.6.3" },
@@ -6728,14 +6727,14 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.10.1"
version = "6.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/79/f2730c42ec7891a75a2fcea2eb4f356872bcbc671b711418060424796612/pypdf-6.10.1.tar.gz", hash = "sha256:62e6ca7f65aaa28b3d192addb44f97296e4be1748f57ed0f4efb2d4915841880", size = 5315704, upload-time = "2026-04-14T12:55:20.996Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/9f/ca96abf18683ca12602065e4ed2bec9050b672c87d317f1079abc7b6d993/pypdf-6.10.0.tar.gz", hash = "sha256:4c5a48ba258c37024ec2505f7e8fd858525f5502784a2e1c8d415604af29f6ef", size = 5314833, upload-time = "2026-04-10T09:34:57.102Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/04/e3aa7f1f14dbc53429cae34666261eb935d99bd61d24756ab94d7e0309da/pypdf-6.10.1-py3-none-any.whl", hash = "sha256:6331940d3bfe75b7e6601d35db7adabab5fc1d716efaeb384e3c0c3957d033de", size = 335606, upload-time = "2026-04-14T12:55:18.941Z" },
{ url = "https://files.pythonhosted.org/packages/55/f2/7ebe366f633f30a6ad105f650f44f24f98cb1335c4157d21ae47138b3482/pypdf-6.10.0-py3-none-any.whl", hash = "sha256:90005e959e1596c6e6c84c8b0ad383285b3e17011751cedd17f2ce8fcdfc86de", size = 334459, upload-time = "2026-04-10T09:34:54.966Z" },
]
[[package]]
@@ -6989,11 +6988,11 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.26"
version = "0.0.24"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/45/e23b5dc14ddb9918ae4a625379506b17b6f8fc56ca1d82db62462f59aea6/python_multipart-0.0.24.tar.gz", hash = "sha256:9574c97e1c026e00bc30340ef7c7d76739512ab4dfd428fec8c330fa6a5cc3c8", size = 37695, upload-time = "2026-04-05T20:49:13.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
{ url = "https://files.pythonhosted.org/packages/a3/73/89930efabd4da63cea44a3f438aeb753d600123570e6d6264e763617a9ce/python_multipart-0.0.24-py3-none-any.whl", hash = "sha256:9b110a98db707df01a53c194f0af075e736a770dc5058089650d70b4a182f950", size = 24420, upload-time = "2026-04-05T20:49:12.555Z" },
]
[[package]]