Skip to content

Commit 21e59b7

Browse files
author
黑曜
committed
fix nested MCP schema conversion
1 parent 84dfaf2 commit 21e59b7

2 files changed

Lines changed: 145 additions & 7 deletions

File tree

‎agentrun/integration/utils/tool.py‎

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1731,11 +1731,24 @@ def _sanitize_python_identifier(name: str) -> str:
17311731
def _json_schema_to_pydantic(
17321732
name: str,
17331733
schema: Optional[Dict[str, Any]],
1734+
root_schema: Optional[Dict[str, Any]] = None,
17341735
) -> Optional[Type[BaseModel]]:
1735-
"""将 JSON Schema 转换为 Pydantic 模型"""
1736+
"""将 JSON Schema 转换为 Pydantic 模型
1737+
1738+
Args:
1739+
name: 模型名称
1740+
schema: 待转换的 JSON Schema 片段
1741+
root_schema: 顶层 schema, 用于解析深层 ``$ref`` 指向的 ``$defs``。
1742+
首次调用时若为空, 默认取 ``schema`` 自身, 便于二次转换时
1743+
(get_parameters_schema 输出携带 ``$defs``) 正确解引用。
1744+
"""
17361745
if not schema or not isinstance(schema, dict):
17371746
return None
17381747

1748+
# 记录顶层 schema, 供嵌套字段解析 $ref/$defs 使用
1749+
if root_schema is None:
1750+
root_schema = schema
1751+
17391752
properties = schema.get("properties", {})
17401753
if not properties:
17411754
return None
@@ -1768,7 +1781,7 @@ def _json_schema_to_pydantic(
17681781
needs_populate_by_name = True
17691782

17701783
# 映射类型
1771-
field_type = _json_type_to_python(field_schema)
1784+
field_type = _json_type_to_python(field_schema, root_schema)
17721785
description = field_schema.get("description", "")
17731786
default = field_schema.get("default")
17741787
aliases = field_schema.get("x-aliases")
@@ -1812,25 +1825,53 @@ def _json_schema_to_pydantic(
18121825
)
18131826

18141827

1815-
def _json_type_to_python(field_schema: Dict[str, Any]) -> type:
1816-
"""映射 JSON Schema 类型到 Python 类型"""
1817-
schema_type = field_schema.get("type", "string")
1828+
def _json_type_to_python(
1829+
field_schema: Dict[str, Any],
1830+
root_schema: Optional[Dict[str, Any]] = None,
1831+
) -> type:
1832+
"""映射 JSON Schema 类型到 Python 类型
1833+
1834+
Args:
1835+
field_schema: 字段 schema, 可能是裸类型, 也可能是 anyOf/oneOf/$ref 包装
1836+
root_schema: 顶层 schema, 用于解析 ``$ref`` (二次转换时携带 ``$defs``)
1837+
"""
1838+
# 若字段被 anyOf/oneOf/allOf/$ref 包装 (常见于二次转换的 schema),
1839+
# 先展开出核心 schema, 否则无顶层 type 时会误判为 string, 丢失嵌套 object。
1840+
if root_schema is not None and (
1841+
"anyOf" in field_schema
1842+
or "oneOf" in field_schema
1843+
or "allOf" in field_schema
1844+
or "$ref" in field_schema
1845+
):
1846+
core_schema, _ = _extract_core_schema(field_schema, root_schema)
1847+
if core_schema:
1848+
field_schema = core_schema
1849+
1850+
schema_type = field_schema.get("type")
1851+
if not schema_type:
1852+
# 无显式 type 时, 依据结构推断, 避免默认降级为 string
1853+
if "properties" in field_schema:
1854+
schema_type = "object"
1855+
elif "items" in field_schema:
1856+
schema_type = "array"
1857+
else:
1858+
schema_type = "string"
18181859

18191860
# 处理嵌套对象和数组
18201861
if schema_type == "object":
18211862
# 如果有 properties,递归创建嵌套模型
18221863
properties = field_schema.get("properties")
18231864
if properties:
18241865
nested_model = _json_schema_to_pydantic(
1825-
"NestedObject", field_schema
1866+
"NestedObject", field_schema, root_schema
18261867
)
18271868
return nested_model if nested_model else dict
18281869
return dict
18291870

18301871
if schema_type == "array":
18311872
items_schema = field_schema.get("items", {})
18321873
if isinstance(items_schema, dict):
1833-
item_type = _json_type_to_python(items_schema)
1874+
item_type = _json_type_to_python(items_schema, root_schema)
18341875
from typing import List as TypingList
18351876

18361877
return TypingList[item_type] # type: ignore

‎tests/unittests/integration/test_tool_utils.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,103 @@ class _Args(_BM):
838838
assert merged_map.get("x-alias") == "query"
839839

840840

841+
class TestNestedObjectRoundTrip:
842+
"""覆盖嵌套 object 参数经二次转换后仍保持 object 类型
843+
844+
回归西瓜创客问题: MCP inputSchema 中 attrs.risk / attrs.refund 为嵌套
845+
object, 经 get_parameters_schema -> LangChainToolAdapter.from_canonical ->
846+
convert_to_openai_tool 二次转换后, 深层 anyOf/$ref 未被解析,
847+
_json_type_to_python 无顶层 type 时默认为 string, 导致 object 被降级。
848+
"""
849+
850+
@staticmethod
851+
def _unwrap_anyof(node: Dict[str, Any]) -> Dict[str, Any]:
852+
"""从 anyOf/oneOf 中挑出非 null 的核心 schema, 便于断言真实类型"""
853+
for key in ("anyOf", "oneOf"):
854+
options = node.get(key)
855+
if isinstance(options, list):
856+
for option in options:
857+
if isinstance(option, dict) and option.get("type") != "null":
858+
return option
859+
return node
860+
861+
def test_nested_optional_object_survives_second_conversion(self):
862+
"""attrs.risk / attrs.refund 经二次转换后仍应是 object"""
863+
from agentrun.integration.utils.tool import _build_tool_from_meta
864+
865+
toolset = MagicMock()
866+
867+
meta = {
868+
"name": "create_order",
869+
"description": "demo",
870+
"input_schema": {
871+
"type": "object",
872+
"properties": {
873+
"attrs": {
874+
"type": "object",
875+
"description": "attributes",
876+
"properties": {
877+
"risk": {
878+
"type": "object",
879+
"description": "risk info",
880+
"properties": {
881+
"level": {"type": "string"},
882+
},
883+
},
884+
"refund": {
885+
"type": "object",
886+
"description": "refund info",
887+
"properties": {
888+
"amount": {"type": "number"},
889+
},
890+
},
891+
},
892+
},
893+
},
894+
"required": [],
895+
},
896+
}
897+
898+
tool_obj = _build_tool_from_meta(toolset, meta, None)
899+
assert tool_obj is not None
900+
901+
# 第一次转换: 生成 canonical parameters (含 $defs / anyOf / $ref)
902+
params = tool_obj.get_parameters_schema()
903+
904+
# 第二次转换: canonical -> LangChain -> OpenAI tool
905+
from langchain_core.utils.function_calling import (
906+
convert_to_openai_tool,
907+
)
908+
909+
from agentrun.integration.langchain.tool_adapter import (
910+
LangChainToolAdapter,
911+
)
912+
from agentrun.integration.utils.canonical import CanonicalTool
913+
914+
canonical = CanonicalTool(
915+
name=tool_obj.name,
916+
description=tool_obj.description,
917+
parameters=params,
918+
func=tool_obj.func,
919+
)
920+
lc_tool = LangChainToolAdapter().from_canonical([canonical])[0]
921+
openai_tool = convert_to_openai_tool(lc_tool)
922+
923+
props = openai_tool["function"]["parameters"]["properties"]
924+
attrs = self._unwrap_anyof(props["attrs"])
925+
assert attrs.get("type") == "object"
926+
assert "properties" in attrs
927+
928+
risk = self._unwrap_anyof(attrs["properties"]["risk"])
929+
refund = self._unwrap_anyof(attrs["properties"]["refund"])
930+
931+
# 核心断言: 深层嵌套 object 不应被降级为 string
932+
assert risk.get("type") == "object", risk
933+
assert "level" in risk.get("properties", {})
934+
assert refund.get("type") == "object", refund
935+
assert "amount" in refund.get("properties", {})
936+
937+
841938
class TestBuildToolFromMetaInvalidFieldNames:
842939
"""覆盖 _build_tool_from_meta 完整链路 (回归 'x-access-id' 加载失败)"""
843940

0 commit comments

Comments
 (0)