Skip to content

Commit 8543906

Browse files
committed
fix(core): Recursive schema parsing for array items
Fixes #552 & googleapis/genai-toolbox#2437 This PR refactors `McpHttpTransportBase` to support recursive schema parsing for nested array types and arrays of complex objects. Previously, `items` definitions were dropped for array parameters, leading to validation errors when mandatory `items` fields were missing.
1 parent 03dc7e6 commit 8543906

File tree

4 files changed

+118
-31
lines changed

4 files changed

+118
-31
lines changed

packages/toolbox-core/src/toolbox_core/mcp_transport/transport_base.py

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,45 @@ def _process_tool_result_content(self, content: list) -> str:
8181

8282
return "".join(texts) or "null"
8383

84+
def _convert_parameter_schema(
85+
self, name: str, schema: dict, required_fields: list[str]
86+
) -> ParameterSchema:
87+
"""Recursively converts a JSON Schema node to a ParameterSchema."""
88+
param_type = schema.get("type", "string")
89+
description = schema.get("description", "")
90+
91+
# Handle Array Recursion
92+
items_schema = None
93+
if param_type == "array" and "items" in schema:
94+
items_data = schema["items"]
95+
if isinstance(items_data, dict):
96+
# Recursive call for items (items don't have separate required fields usually in this context)
97+
items_schema = self._convert_parameter_schema("", items_data, [])
98+
99+
# Handle Object (Map) AdditionalProperties
100+
additional_properties = None
101+
if param_type == "object":
102+
add_props = schema.get("additionalProperties")
103+
if isinstance(add_props, dict) and "type" in add_props:
104+
# If it has a type, it's a typed map
105+
additional_properties = AdditionalPropertiesSchema(
106+
type=add_props["type"]
107+
)
108+
elif isinstance(add_props, bool):
109+
# If boolean (e.g. True), it allows anything
110+
additional_properties = add_props
111+
# If None, it technically allows anything (Any) by default in our protocol
112+
113+
return ParameterSchema(
114+
name=name,
115+
type=param_type,
116+
description=description,
117+
required=name in required_fields if name else True,
118+
items=items_schema,
119+
additionalProperties=additional_properties,
120+
# Auth is handled by _convert_tool_schema
121+
)
122+
84123
def _convert_tool_schema(self, tool_data: dict) -> ToolSchema:
85124
"""
86125
Safely converts the raw tool dictionary from the server into a ToolSchema object,
@@ -106,28 +145,14 @@ def _convert_tool_schema(self, tool_data: dict) -> ToolSchema:
106145
required = input_schema.get("required", [])
107146

108147
for name, schema in properties.items():
109-
additional_props = schema.get("additionalProperties")
110-
if isinstance(additional_props, dict):
111-
additional_props = AdditionalPropertiesSchema(
112-
type=additional_props["type"]
113-
)
114-
else:
115-
additional_props = True
116-
148+
# Convert basic schema recursively
149+
param_schema = self._convert_parameter_schema(name, schema, required)
150+
151+
# Apply top-level auth metadata (not recursive for now as per protocol)
117152
if param_auth and name in param_auth:
118-
auth_sources = param_auth[name]
119-
else:
120-
auth_sources = None
121-
parameters.append(
122-
ParameterSchema(
123-
name=name,
124-
type=schema["type"],
125-
description=schema.get("description", ""),
126-
required=name in required,
127-
additionalProperties=additional_props,
128-
authSources=auth_sources,
129-
)
130-
)
153+
param_schema.authSources = param_auth[name]
154+
155+
parameters.append(param_schema)
131156

132157
return ToolSchema(
133158
description=tool_data.get("description") or "",

packages/toolbox-core/src/toolbox_core/protocol.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,9 @@ def __get_type(self) -> Type:
9292
base_type: Type
9393
if self.type == "array":
9494
if self.items is None:
95-
raise ValueError("Unexpected value: type is 'array' but items is None")
96-
base_type = list[self.items.__get_type()] # type: ignore
95+
base_type = list[Any]
96+
else:
97+
base_type = list[self.items.__get_type()] # type: ignore
9798
elif self.type == "object":
9899
if isinstance(self.additionalProperties, AdditionalPropertiesSchema):
99100
value_type = self.additionalProperties.get_value_type()

packages/toolbox-core/tests/mcp_transport/test_base.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,60 @@ def test_process_tool_result_content(self, transport):
245245
SimpleNamespace(type="text", text="kept"),
246246
]
247247
assert transport._process_tool_result_content(c6) == "kept"
248+
249+
def test_convert_tool_schema_recursive(self, transport):
250+
"""Test converting schema with recursive types (nested arrays, arrays of maps)."""
251+
raw_tool = {
252+
"name": "recursive_tool",
253+
"inputSchema": {
254+
"type": "object",
255+
"properties": {
256+
# List[List[str]]
257+
"nested_array": {
258+
"type": "array",
259+
"items": {
260+
"type": "array",
261+
"items": {"type": "string"}
262+
}
263+
},
264+
# List[Dict[str, int]]
265+
"array_of_maps": {
266+
"type": "array",
267+
"items": {
268+
"type": "object",
269+
"additionalProperties": {"type": "integer"}
270+
}
271+
},
272+
# Dict[str, List[int]]
273+
"map_of_arrays": {
274+
"type": "object",
275+
"additionalProperties": {
276+
"type": "array",
277+
"items": {"type": "integer"}
278+
}
279+
}
280+
}
281+
}
282+
}
283+
284+
schema = transport._convert_tool_schema(raw_tool)
285+
286+
# 1. Nested Array
287+
p_nested = next(p for p in schema.parameters if p.name == "nested_array")
288+
assert p_nested.type == "array"
289+
assert p_nested.items is not None
290+
assert p_nested.items.type == "array"
291+
assert p_nested.items.items is not None
292+
assert p_nested.items.items.type == "string"
293+
294+
# 2. Array of Maps
295+
p_arr_map = next(p for p in schema.parameters if p.name == "array_of_maps")
296+
assert p_arr_map.type == "array"
297+
assert p_arr_map.items is not None
298+
assert p_arr_map.items.type == "object"
299+
assert p_arr_map.items.additionalProperties.type == "integer"
300+
301+
# 3. Map of Arrays
302+
p_map_arr = next(p for p in schema.parameters if p.name == "map_of_arrays")
303+
assert p_map_arr.type == "object"
304+
assert p_map_arr.additionalProperties.type == "array"

packages/toolbox-core/tests/test_protocol.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,18 +93,22 @@ def test_parameter_schema_array_integer():
9393
assert param.kind == Parameter.POSITIONAL_OR_KEYWORD
9494

9595

96-
def test_parameter_schema_array_no_items_error():
97-
"""Tests that 'array' type raises error if 'items' is None."""
96+
97+
def test_parameter_schema_array_no_items_defaults_to_any():
98+
"""Tests that 'array' type defaults to list[Any] if 'items' is None."""
9899
schema = ParameterSchema(
99-
name="bad_list", type="array", description="List without item type"
100+
name="any_list", type="array", description="List without item type"
100101
)
101102

102-
expected_error_msg = "Unexpected value: type is 'array' but items is None"
103-
with pytest.raises(ValueError, match=expected_error_msg):
104-
schema._ParameterSchema__get_type()
103+
expected_type = list[Any]
104+
assert schema._ParameterSchema__get_type() == expected_type
105+
106+
param = schema.to_param()
107+
assert isinstance(param, Parameter)
108+
assert param.name == "any_list"
109+
assert param.annotation == expected_type
110+
assert param.kind == Parameter.POSITIONAL_OR_KEYWORD
105111

106-
with pytest.raises(ValueError, match=expected_error_msg):
107-
schema.to_param()
108112

109113

110114
def test_parameter_schema_unsupported_type_error():

0 commit comments

Comments
 (0)