Skip to content

Commit e598110

Browse files
committed
fix(tool): 兼容 additionalProperties schema 解析
修复 MCP 与 toolset schema 中 additionalProperties 为对象时的解析失败。\n\n改动前,ToolSchema 只接受布尔类型的 additionalProperties,\n在 list_tools 解析 anyOf/object 场景时会抛出 ValidationError。\n\n改动后,tool 与 toolset 两套 schema 模型都支持\nboolean 或子 schema 形式的 additionalProperties,并在\nto_json_schema 时保持正确回写。\n\n同时补充回归测试,覆盖 OpenAPI schema 解析与\nToolInfo.from_mcp_tool 的真实失败场景。\n\n校验结果:\n- uv run pytest tests/unittests/tool/test_model.py tests/unittests/toolset/test_model.py\n- uv run mypy --config-file mypy.ini . Change-Id: I791587f292a9c42759a536dbfe5fdc0da669a416
1 parent 7ee7010 commit e598110

4 files changed

Lines changed: 147 additions & 9 deletions

File tree

agentrun/tool/model.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"""
66

77
from enum import Enum
8-
from typing import Any, Dict, List, Optional
8+
from typing import Any, Dict, List, Optional, Union
99

1010
from agentrun.utils.model import BaseModel
1111

@@ -194,8 +194,8 @@ class ToolSchema(BaseModel):
194194
required: Optional[List[str]] = None
195195
"""必填字段 / Required fields"""
196196

197-
additional_properties: Optional[bool] = None
198-
"""是否允许额外属性 / Whether additional properties are allowed"""
197+
additional_properties: Optional[Union[bool, "ToolSchema"]] = None
198+
"""额外属性约束 / Additional properties constraint"""
199199

200200
items: Optional["ToolSchema"] = None
201201
"""数组元素类型 / Array item type"""
@@ -291,13 +291,20 @@ def from_any_openapi_schema(cls, schema: Any) -> "ToolSchema":
291291
else None
292292
)
293293

294+
additional_properties_raw = pydash_get(schema, "additionalProperties")
295+
additional_properties = (
296+
cls.from_any_openapi_schema(additional_properties_raw)
297+
if isinstance(additional_properties_raw, dict)
298+
else additional_properties_raw
299+
)
300+
294301
return cls(
295302
type=pydash_get(schema, "type"),
296303
description=pydash_get(schema, "description"),
297304
title=pydash_get(schema, "title"),
298305
properties=properties,
299306
required=pydash_get(schema, "required"),
300-
additional_properties=pydash_get(schema, "additionalProperties"),
307+
additional_properties=additional_properties,
301308
items=items,
302309
min_items=pydash_get(schema, "minItems"),
303310
max_items=pydash_get(schema, "maxItems"),
@@ -334,7 +341,11 @@ def to_json_schema(self) -> Dict[str, Any]:
334341
if self.required:
335342
result["required"] = self.required
336343
if self.additional_properties is not None:
337-
result["additionalProperties"] = self.additional_properties
344+
result["additionalProperties"] = (
345+
self.additional_properties.to_json_schema()
346+
if isinstance(self.additional_properties, ToolSchema)
347+
else self.additional_properties
348+
)
338349

339350
if self.items:
340351
result["items"] = self.items.to_json_schema()

agentrun/toolset/model.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"""
66

77
from enum import Enum
8-
from typing import Any, Dict, List, Optional
8+
from typing import Any, Dict, List, Optional, Union
99

1010
from agentrun.utils.model import BaseModel, Field, PageableInput
1111

@@ -103,7 +103,7 @@ class ToolSchema(BaseModel):
103103
# 对象类型字段
104104
properties: Optional[Dict[str, "ToolSchema"]] = None
105105
required: Optional[List[str]] = None
106-
additional_properties: Optional[bool] = None
106+
additional_properties: Optional[Union[bool, "ToolSchema"]] = None
107107

108108
# 数组类型字段
109109
items: Optional["ToolSchema"] = None
@@ -179,6 +179,13 @@ def from_any_openapi_schema(cls, schema: Any) -> "ToolSchema":
179179
else None
180180
)
181181

182+
additional_properties_raw = pg(schema, "additionalProperties")
183+
additional_properties = (
184+
cls.from_any_openapi_schema(additional_properties_raw)
185+
if isinstance(additional_properties_raw, dict)
186+
else additional_properties_raw
187+
)
188+
182189
return cls(
183190
# 基本字段
184191
type=pg(schema, "type"),
@@ -187,7 +194,7 @@ def from_any_openapi_schema(cls, schema: Any) -> "ToolSchema":
187194
# 对象类型
188195
properties=properties,
189196
required=pg(schema, "required"),
190-
additional_properties=pg(schema, "additionalProperties"),
197+
additional_properties=additional_properties,
191198
# 数组类型
192199
items=items,
193200
min_items=pg(schema, "minItems"),
@@ -231,7 +238,11 @@ def to_json_schema(self) -> Dict[str, Any]:
231238
if self.required:
232239
result["required"] = self.required
233240
if self.additional_properties is not None:
234-
result["additionalProperties"] = self.additional_properties
241+
result["additionalProperties"] = (
242+
self.additional_properties.to_json_schema()
243+
if isinstance(self.additional_properties, ToolSchema)
244+
else self.additional_properties
245+
)
235246

236247
# 数组类型
237248
if self.items:

tests/unittests/tool/test_model.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,48 @@ def test_from_any_openapi_schema_allof(self):
335335
assert schema.all_of[0].type == "object"
336336
assert schema.all_of[1].type == "object"
337337

338+
def test_from_any_openapi_schema_additional_properties_schema(self):
339+
"""测试 additionalProperties 为 schema 对象时的解析"""
340+
openapi_schema = {
341+
"type": "object",
342+
"properties": {
343+
"filters": {
344+
"type": "object",
345+
"additionalProperties": {
346+
"anyOf": [
347+
{"type": "string"},
348+
{"type": "integer"},
349+
]
350+
},
351+
}
352+
},
353+
}
354+
355+
schema = ToolSchema.from_any_openapi_schema(openapi_schema)
356+
357+
assert schema.properties is not None
358+
assert "filters" in schema.properties
359+
filters_schema = schema.properties["filters"]
360+
assert filters_schema.additional_properties is not None
361+
assert filters_schema.additional_properties.any_of is not None
362+
assert len(filters_schema.additional_properties.any_of) == 2
363+
assert filters_schema.additional_properties.any_of[0].type == "string"
364+
assert filters_schema.additional_properties.any_of[1].type == "integer"
365+
366+
json_schema = schema.to_json_schema()
367+
assert (
368+
json_schema["properties"]["filters"]["additionalProperties"][
369+
"anyOf"
370+
][0]["type"]
371+
== "string"
372+
)
373+
assert (
374+
json_schema["properties"]["filters"]["additionalProperties"][
375+
"anyOf"
376+
][1]["type"]
377+
== "integer"
378+
)
379+
338380
def test_to_json_schema_simple(self):
339381
"""测试转换为 JSON Schema - 简单情况"""
340382
schema = ToolSchema(
@@ -659,3 +701,38 @@ def model_dump(self):
659701
assert "param1" in info.parameters.properties
660702
assert "param2" in info.parameters.properties
661703
assert info.parameters.required == ["param1"]
704+
705+
def test_from_mcp_tool_with_schema_additional_properties(self):
706+
"""测试 MCP tool schema 中 additionalProperties 为对象时的解析"""
707+
mcp_tool = {
708+
"name": "get_news_by_date",
709+
"description": "A tool with MCP schema-style date_range",
710+
"inputSchema": {
711+
"type": "object",
712+
"properties": {
713+
"date_range": {
714+
"anyOf": [
715+
{"type": "string"},
716+
{
717+
"type": "object",
718+
"additionalProperties": {"type": "string"},
719+
},
720+
{"type": "null"},
721+
]
722+
}
723+
},
724+
},
725+
}
726+
727+
info = ToolInfo.from_mcp_tool(mcp_tool)
728+
729+
assert info.name == "get_news_by_date"
730+
assert info.parameters is not None
731+
assert info.parameters.properties is not None
732+
date_range_schema = info.parameters.properties["date_range"]
733+
assert date_range_schema.any_of is not None
734+
assert date_range_schema.any_of[1].type == "object"
735+
assert date_range_schema.any_of[1].additional_properties is not None
736+
assert (
737+
date_range_schema.any_of[1].additional_properties.type == "string"
738+
)

tests/unittests/toolset/test_model.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,45 @@ def test_from_any_openapi_schema_with_properties(self):
448448
assert schema.required == ["name"]
449449
assert schema.additional_properties is False
450450

451+
def test_from_any_openapi_schema_with_schema_additional_properties(self):
452+
"""测试 additionalProperties 为 schema 对象时的解析"""
453+
openapi_schema = {
454+
"type": "object",
455+
"properties": {
456+
"filters": {
457+
"type": "object",
458+
"additionalProperties": {
459+
"anyOf": [
460+
{"type": "string"},
461+
{"type": "integer"},
462+
]
463+
},
464+
}
465+
},
466+
}
467+
468+
schema = ToolSchema.from_any_openapi_schema(openapi_schema)
469+
470+
assert schema.properties is not None
471+
filters_schema = schema.properties["filters"]
472+
assert filters_schema.additional_properties is not None
473+
assert filters_schema.additional_properties.any_of is not None
474+
assert len(filters_schema.additional_properties.any_of) == 2
475+
476+
json_schema = schema.to_json_schema()
477+
assert (
478+
json_schema["properties"]["filters"]["additionalProperties"][
479+
"anyOf"
480+
][0]["type"]
481+
== "string"
482+
)
483+
assert (
484+
json_schema["properties"]["filters"]["additionalProperties"][
485+
"anyOf"
486+
][1]["type"]
487+
== "integer"
488+
)
489+
451490
def test_from_any_openapi_schema_with_items(self):
452491
"""测试从带 items 的数组 schema 创建"""
453492
openapi_schema = {

0 commit comments

Comments
 (0)