Skip to content

Commit ffbed28

Browse files
authored
fix(arcade-core,arcade-mcp-server): Nested-object schemas across all tool-schema converters (#860)
## Summary Arcade renders a tool's `ValueSchema` into JSON Schema in three independent places, and they had drifted into the same class of bug: nested object subschemas (especially `list[<TypedDict>]` items) were emitted incompletely, producing OpenAI-strict-invalid schemas that the OpenAI Responses API rejects with a hard 400. Because OpenAI rejects the whole tool list when one schema is invalid, every eval/tool batch containing that tool fails. This PR fixes all three converter paths and the shared root cause they all read from, so correct schemas come from one corrected source rather than per-converter patches. Resolves: https://linear.app/arcadedev/issue/TOO-990/arcade-core-tool-schema-converters-drop-nested-object-fields-for ## What was wrong (three layers) 1. **`arcade_core/converters/openai.py`** (OpenAI strict) and **`anthropic.py`** (standard) built array `items` from the inner scalar type alone, emitting bare `{"type": "object"}` and dropping object fields. OpenAI strict also requires `additionalProperties: false` + every key in `required` on each object. 2. **`arcade_mcp_server/convert.py`** (MCP `inputSchema`) preserved nested properties/required but never set `additionalProperties: false` on nested objects or array-of-object items (only the top-level wrapper did). This is the path consumers monkey-patch when feeding MCP tool schemas to OpenAI strict mode. 3. **`arcade_core/catalog.py::extract_properties`** extracted `required_keys`/`nullable` for **TypedDict** params but not for **Pydantic** models, so every converter received imprecise data for Pydantic object params (no `required`, no nullability). ## Changes - **OpenAI + Anthropic converters** (commit 1): expand every object recursively at any depth (top-level param, nested object, array-of-object items). OpenAI strict closes every object (`additionalProperties: false`), lists all keys in `required`, and renders optionals as nullable unions; Anthropic uses standard JSON Schema (only required keys). - **MCP converter** (commit 2): set `additionalProperties: false` on objects with a known shape, at every depth. Freeform `dict` params (no properties) stay open, since closing them would reject all keys. - **Root cause** (commit 2): `extract_properties` now populates `required_keys` for Pydantic models via `FieldInfo.is_required()` and marks `nullable` on `Optional` fields, matching the existing TypedDict handling. All three converters get accurate metadata from one place. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes shared schema extraction and all three tool-schema emitters, which can alter API/MCP validation behavior for nested Pydantic/TypedDict params; mitigated by extensive new tests but still user-visible for existing tools. > > **Overview** > Fixes **incomplete nested object JSON Schema** for tool inputs so OpenAI strict, Anthropic, and MCP paths stay aligned and OpenAI no longer 400s on invalid tool lists. > > **`arcade_core/catalog`:** Pydantic params now get **`required_keys`** and **`nullable`** like TypedDict; known shapes use **`({}, [])`** instead of collapsing to `None`, so empty/zero-field models differ from freeform **`dict`**. List and **`ValueSchema`** conversion propagate nested shapes when **`properties` / `inner_properties` is not `None`**. > > **OpenAI & Anthropic converters:** Shared **`_build_object_schema`** recursively expands **`json`** and **`list[object]`** items (no more bare **`{"type": "object"}`**). OpenAI strict closes objects, lists all keys in **`required`**, and uses **`null`** unions (including enums); Anthropic lists only real required keys and unions **`null`** on nullable nested fields. > > **MCP `convert`:** Known-shape objects and array-of-object items get **`additionalProperties: false`** at every depth; unknown **`dict`** shapes stay open. > > Bumps **`arcade-core` 4.7.3** and **`arcade-mcp-server` 1.22.2** with broad converter/catalog tests plus a cross-dialect consistency test. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 46dee1d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 959ad31 commit ffbed28

12 files changed

Lines changed: 1051 additions & 71 deletions

File tree

libs/arcade-core/arcade_core/catalog.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -775,12 +775,13 @@ def get_wire_type_info(_type: type) -> WireTypeInfo:
775775
inner_info = get_wire_type_info(inner_type)
776776
inner_wire_type = cast(InnerWireType, inner_info.wire_type)
777777

778-
# If inner type has properties (it's a complex object), propagate them
779-
if inner_info.properties:
778+
# If inner type has a known object shape (possibly empty), propagate it. A known-empty
779+
# shape ({}) is distinct from None (unknown shape) so converters can close it.
780+
if inner_info.properties is not None:
780781
inner_properties = inner_info.properties
781782
inner_required_keys = inner_info.required_keys
782783
# If inner type is array (nested arrays), propagate inner_properties
783-
elif inner_info.inner_properties:
784+
elif inner_info.inner_properties is not None:
784785
inner_properties = inner_info.inner_properties
785786
inner_required_keys = inner_info.inner_required_keys
786787
else:
@@ -860,30 +861,42 @@ def extract_properties(
860861
"""
861862
Extract properties from TypedDict, Pydantic models, or other structured types.
862863
863-
Returns (properties, required_keys). required_keys is a sorted list of required
864-
property names for TypedDict types, or None for other types.
864+
Returns (properties, required_keys). For a known structured type (TypedDict or
865+
Pydantic model) properties is a dict of its fields, empty when the type declares no
866+
fields, and required_keys is a sorted list of required property names, empty when every
867+
field is optional. A known-empty shape (no fields) therefore yields ``({}, [])``, which
868+
converters can distinguish from a type with no known shape (e.g. a freeform ``dict``),
869+
which yields ``(None, None)`` to signal that both shape and requiredness are unknown.
865870
"""
866871
properties = {}
867872

868873
# Handle Pydantic BaseModel
869874
if isinstance(type_to_check, type) and issubclass(type_to_check, BaseModel):
875+
pydantic_required_keys: list[str] = []
870876
for field_name, field_info in type_to_check.model_fields.items():
871877
# Get the field type
872878
field_type = field_info.annotation
873879
if field_type is None:
874880
continue
875881

876882
# Handle Optional types (Union[T, None])
877-
if is_strict_optional(field_type):
883+
is_nullable = is_strict_optional(field_type)
884+
if is_nullable:
878885
# Extract the non-None type from Optional
879886
field_type = next(arg for arg in get_args(field_type) if arg is not type(None))
880887

881888
# Get wire type info recursively
882889
# field_type cannot be None here due to the check above
883890
wire_info = get_wire_type_info(field_type)
891+
if is_nullable:
892+
wire_info.nullable = True
884893
properties[field_name] = wire_info
885894

886-
return (properties or None, None)
895+
# A Pydantic field is required when it has no default (default or factory).
896+
if field_info.is_required():
897+
pydantic_required_keys.append(field_name)
898+
899+
return (properties, sorted(pydantic_required_keys))
887900

888901
# Handle TypedDict
889902
elif is_typeddict(type_to_check):
@@ -909,9 +922,8 @@ def extract_properties(
909922

910923
properties[field_name] = wire_info
911924

912-
req = sorted(getattr(type_to_check, "__required_keys__", frozenset()))
913-
required_keys = req or None # normalize empty → None
914-
return (properties or None, required_keys)
925+
required_keys = sorted(getattr(type_to_check, "__required_keys__", frozenset()))
926+
return (properties, required_keys)
915927

916928
# Handle regular dict with type annotations (e.g., dict[str, Any])
917929
elif get_origin(type_to_check) is dict:
@@ -925,17 +937,18 @@ def wire_type_info_to_value_schema(wire_info: WireTypeInfo) -> ValueSchema:
925937
"""
926938
Convert WireTypeInfo to ValueSchema, including nested properties.
927939
"""
928-
# Convert nested properties if they exist
940+
# Convert nested properties if they exist. A known-empty object shape ({}) is preserved
941+
# as {} rather than collapsed to None so converters can emit it as a closed object.
929942
properties = None
930-
if wire_info.properties:
943+
if wire_info.properties is not None:
931944
properties = {
932945
name: wire_type_info_to_value_schema(nested_info)
933946
for name, nested_info in wire_info.properties.items()
934947
}
935948

936949
# Convert inner properties for array items
937950
inner_properties = None
938-
if wire_info.inner_properties:
951+
if wire_info.inner_properties is not None:
939952
inner_properties = {
940953
name: wire_type_info_to_value_schema(nested_info)
941954
for name, nested_info in wire_info.inner_properties.items()

libs/arcade-core/arcade_core/converters/anthropic.py

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,16 @@
1717
class AnthropicInputSchemaProperty(TypedDict, total=False):
1818
"""Type definition for a property within Anthropic input schema."""
1919

20-
type: str
21-
"""The JSON Schema type for this property."""
20+
type: str | list[str]
21+
"""The JSON Schema type(s) for this property. A list expresses a union (e.g. ["string", "null"])."""
2222

2323
description: str
2424
"""Description of the property."""
2525

2626
enum: list[Any]
2727
"""Allowed values for enum properties."""
2828

29-
items: dict[str, Any]
29+
items: "AnthropicInputSchemaProperty"
3030
"""Schema for array items when type is 'array'."""
3131

3232
properties: dict[str, "AnthropicInputSchemaProperty"]
@@ -112,6 +112,49 @@ def _create_tool_schema(
112112
return tool
113113

114114

115+
def _add_null_to_type(schema: AnthropicInputSchemaProperty) -> None:
116+
"""Union a field's type with "null" so a null value validates.
117+
118+
A nested field can be required yet nullable (``str | None`` with no default): it must
119+
stay required while still accepting null. When the field is an enum, ``None`` is
120+
appended to the enum too, since ``type`` and ``enum`` are independent assertions.
121+
"""
122+
field_type = schema.get("type")
123+
if isinstance(field_type, str):
124+
schema["type"] = [field_type, "null"]
125+
elif isinstance(field_type, list) and "null" not in field_type:
126+
schema["type"] = [*field_type, "null"]
127+
128+
enum_values = schema.get("enum")
129+
if isinstance(enum_values, list) and None not in enum_values:
130+
schema["enum"] = [*enum_values, None]
131+
132+
133+
def _build_object_schema(
134+
properties: dict[str, ValueSchema],
135+
required_keys: list[str] | None,
136+
) -> AnthropicInputSchemaProperty:
137+
"""Build an object schema from a structured type's fields.
138+
139+
Anthropic uses standard JSON Schema: only the actually-required fields appear in
140+
``required`` and there is no ``additionalProperties`` constraint. A nullable field
141+
unions ``null`` into its type so a null value validates even when the field is required.
142+
"""
143+
field_schemas: dict[str, AnthropicInputSchemaProperty] = {}
144+
for name, field_value_schema in properties.items():
145+
field_schema = _convert_value_schema_to_json_schema(field_value_schema)
146+
if field_value_schema.description:
147+
field_schema["description"] = field_value_schema.description
148+
if field_value_schema.nullable:
149+
_add_null_to_type(field_schema)
150+
field_schemas[name] = field_schema
151+
152+
schema: AnthropicInputSchemaProperty = {"type": "object", "properties": field_schemas}
153+
if required_keys:
154+
schema["required"] = list(required_keys)
155+
return schema
156+
157+
115158
def _convert_value_schema_to_json_schema(
116159
value_schema: ValueSchema,
117160
) -> AnthropicInputSchemaProperty:
@@ -125,28 +168,30 @@ def _convert_value_schema_to_json_schema(
125168
"array": "array",
126169
}
127170

128-
schema: AnthropicInputSchemaProperty = {"type": type_mapping[value_schema.val_type]}
171+
schema: AnthropicInputSchemaProperty
129172

130173
if value_schema.val_type == "array" and value_schema.inner_val_type:
131-
items_schema: dict[str, Any] = {"type": type_mapping[value_schema.inner_val_type]}
132-
133-
# For arrays, enum should be applied to the items, not the array itself
134-
if value_schema.enum:
135-
items_schema["enum"] = value_schema.enum
136-
137-
schema["items"] = items_schema
138-
else:
139-
# Handle enum for non-array types
140-
if value_schema.enum:
141-
schema["enum"] = value_schema.enum
142-
143-
# Handle object properties
144-
if value_schema.val_type == "json" and value_schema.properties:
145-
schema["properties"] = {
146-
name: _convert_value_schema_to_json_schema(nested_schema)
147-
for name, nested_schema in value_schema.properties.items()
148-
}
149-
174+
schema = {"type": "array"}
175+
if value_schema.inner_val_type == "json" and value_schema.inner_properties is not None:
176+
schema["items"] = _build_object_schema(
177+
value_schema.inner_properties, value_schema.inner_required_keys
178+
)
179+
else:
180+
items_schema: AnthropicInputSchemaProperty = {
181+
"type": type_mapping[value_schema.inner_val_type]
182+
}
183+
# For arrays of scalars, enum applies to the items, not the array itself
184+
if value_schema.enum:
185+
items_schema["enum"] = value_schema.enum
186+
schema["items"] = items_schema
187+
return schema
188+
189+
if value_schema.val_type == "json" and value_schema.properties is not None:
190+
return _build_object_schema(value_schema.properties, value_schema.required_keys)
191+
192+
schema = {"type": type_mapping[value_schema.val_type]}
193+
if value_schema.enum:
194+
schema["enum"] = value_schema.enum
150195
return schema
151196

152197

libs/arcade-core/arcade_core/converters/openai.py

Lines changed: 82 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class OpenAIFunctionParameterProperty(TypedDict, total=False):
2525
enum: list[Any]
2626
"""Allowed values for enum properties."""
2727

28-
items: dict[str, Any]
28+
items: "OpenAIFunctionParameterProperty"
2929
"""Schema for array items when type is 'array'."""
3030

3131
properties: dict[str, "OpenAIFunctionParameterProperty"]
@@ -136,6 +136,62 @@ def _create_tool_schema(
136136
return tool
137137

138138

139+
def _add_null_to_type(schema: OpenAIFunctionParameterProperty) -> None:
140+
"""Union a property's type with "null" so it may be omitted in strict mode.
141+
142+
Strict mode lists every property in ``required``; an optional property is then
143+
expressed by allowing ``null`` rather than by leaving it out of ``required``. When the
144+
property is an enum, ``None`` is appended to the enum too: ``type`` and ``enum`` are
145+
independent assertions, so a null value must satisfy both.
146+
"""
147+
param_type = schema.get("type")
148+
if isinstance(param_type, str):
149+
schema["type"] = [param_type, "null"]
150+
elif isinstance(param_type, list) and "null" not in param_type:
151+
schema["type"] = [*param_type, "null"]
152+
153+
enum_values = schema.get("enum")
154+
if isinstance(enum_values, list) and None not in enum_values:
155+
schema["enum"] = [*enum_values, None]
156+
157+
158+
def _build_object_schema(
159+
properties: dict[str, ValueSchema],
160+
required_keys: list[str] | None,
161+
) -> OpenAIFunctionParameterProperty:
162+
"""Build a strict-mode object schema from a structured type's fields.
163+
164+
Strict mode requires ``additionalProperties: false`` on every object and every
165+
property listed in ``required``. Fields that are not actually required are unioned
166+
with ``null`` so the model may omit them. When ``required_keys`` is a list (a known
167+
shape, possibly empty) a field is required iff it appears there; when it is ``None``
168+
(an unknown shape) the field's ``nullable`` flag decides.
169+
"""
170+
required_set = set(required_keys or [])
171+
field_schemas: dict[str, OpenAIFunctionParameterProperty] = {}
172+
173+
for name, field_value_schema in properties.items():
174+
field_schema = _convert_value_schema_to_json_schema(field_value_schema)
175+
if field_value_schema.description:
176+
field_schema["description"] = field_value_schema.description
177+
178+
if required_keys is None:
179+
is_required = not field_value_schema.nullable
180+
else:
181+
is_required = name in required_set
182+
if field_value_schema.nullable or not is_required:
183+
_add_null_to_type(field_schema)
184+
185+
field_schemas[name] = field_schema
186+
187+
return {
188+
"type": "object",
189+
"properties": field_schemas,
190+
"required": list(properties.keys()),
191+
"additionalProperties": False,
192+
}
193+
194+
139195
def _convert_value_schema_to_json_schema(
140196
value_schema: ValueSchema,
141197
) -> OpenAIFunctionParameterProperty:
@@ -149,28 +205,30 @@ def _convert_value_schema_to_json_schema(
149205
"array": "array",
150206
}
151207

152-
schema: OpenAIFunctionParameterProperty = {"type": type_mapping[value_schema.val_type]}
208+
schema: OpenAIFunctionParameterProperty
153209

154210
if value_schema.val_type == "array" and value_schema.inner_val_type:
155-
items_schema: dict[str, Any] = {"type": type_mapping[value_schema.inner_val_type]}
156-
157-
# For arrays, enum should be applied to the items, not the array itself
158-
if value_schema.enum:
159-
items_schema["enum"] = value_schema.enum
160-
161-
schema["items"] = items_schema
162-
else:
163-
# Handle enum for non-array types
164-
if value_schema.enum:
165-
schema["enum"] = value_schema.enum
166-
167-
# Handle object properties
168-
if value_schema.val_type == "json" and value_schema.properties:
169-
schema["properties"] = {
170-
name: _convert_value_schema_to_json_schema(nested_schema)
171-
for name, nested_schema in value_schema.properties.items()
172-
}
173-
211+
schema = {"type": "array"}
212+
if value_schema.inner_val_type == "json" and value_schema.inner_properties is not None:
213+
schema["items"] = _build_object_schema(
214+
value_schema.inner_properties, value_schema.inner_required_keys
215+
)
216+
else:
217+
items_schema: OpenAIFunctionParameterProperty = {
218+
"type": type_mapping[value_schema.inner_val_type]
219+
}
220+
# For arrays of scalars, enum applies to the items, not the array itself
221+
if value_schema.enum:
222+
items_schema["enum"] = value_schema.enum
223+
schema["items"] = items_schema
224+
return schema
225+
226+
if value_schema.val_type == "json" and value_schema.properties is not None:
227+
return _build_object_schema(value_schema.properties, value_schema.required_keys)
228+
229+
schema = {"type": type_mapping[value_schema.val_type]}
230+
if value_schema.enum:
231+
schema["enum"] = value_schema.enum
174232
return schema
175233

176234

@@ -192,21 +250,15 @@ def _convert_input_parameters_to_json_schema(
192250
for parameter in parameters:
193251
param_schema = _convert_value_schema_to_json_schema(parameter.value_schema)
194252

195-
# For optional parameters in strict mode, we need to add "null" as a type option
253+
# In strict mode every parameter is listed in `required`; optional ones are
254+
# expressed by allowing "null" rather than by omission.
196255
if not parameter.required:
197-
param_type = param_schema.get("type")
198-
if isinstance(param_type, str):
199-
# Convert single type to union with null
200-
param_schema["type"] = [param_type, "null"]
201-
elif isinstance(param_type, list) and "null" not in param_type:
202-
param_schema["type"] = [*param_type, "null"]
256+
_add_null_to_type(param_schema)
203257

204258
if parameter.description:
205259
param_schema["description"] = parameter.description
206260
properties[parameter.name] = param_schema
207261

208-
# In strict mode, all parameters (including optional ones) go in required array
209-
# Optional parameters are handled by adding "null" to their type
210262
required.append(parameter.name)
211263

212264
json_schema: OpenAIFunctionParameters = {

libs/arcade-core/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "arcade-core"
3-
version = "4.7.2"
3+
version = "4.7.3"
44
description = "Arcade Core - Core library for Arcade platform"
55
readme = "README.md"
66
license = { text = "MIT" }

0 commit comments

Comments
 (0)