fix: preserve free-form MCP object schemas (#4310)

Co-authored-by: abhay-codes07 <abhaysingh0293@gmail.com>
This commit is contained in:
Kazuhiro Sera
2026-08-09 10:39:32 +09:00
committed by GitHub
parent dcd1705190
commit 2b115b66bc
4 changed files with 313 additions and 6 deletions
+5 -1
View File
@@ -536,6 +536,7 @@ class MCPUtil:
failure_error_function
)
schema, is_strict = copy.deepcopy(tool_input_schema(tool)), False
input_schema_is_empty = schema == {}
# MCP spec doesn't require the inputSchema to have `properties`, but OpenAI spec does.
if "properties" not in schema:
@@ -548,7 +549,10 @@ class MCPUtil:
# non-strict. Convert a separate copy so the non-strict fallback keeps
# the original schema intact.
try:
schema = ensure_strict_json_schema(copy.deepcopy(schema))
schema = ensure_strict_json_schema(
copy.deepcopy(schema),
_reject_open_objects=not input_schema_is_empty,
)
is_strict = True
except Exception as e:
if _debug.DONT_LOG_TOOL_DATA:
+33 -5
View File
@@ -27,12 +27,22 @@ _ADDITIONAL_PROPERTIES_ERROR = (
"to not use a strict schema."
)
_OPEN_OBJECT_ERROR = (
"JSON schema contains an object that permits undeclared properties and cannot be converted "
"to a strict schema without changing its accepted values."
)
_UNVALIDATED_REF_ERROR = (
"JSON schema contains a reference whose target was not validated for strict mode."
)
class _NodeBudget:
"""Tracks the remaining schema-node expansion budget across the recursion."""
"""Tracks conversion state across the recursion."""
def __init__(self, limit: int) -> None:
def __init__(self, limit: int, *, reject_open_objects: bool = False) -> None:
self.remaining = limit
self.reject_open_objects = reject_open_objects
def spend(self) -> None:
self.remaining -= 1
@@ -46,16 +56,23 @@ class _NodeBudget:
def ensure_strict_json_schema(
schema: dict[str, Any],
*,
_reject_open_objects: bool = False,
) -> dict[str, Any]:
"""Mutates the given JSON schema to ensure it conforms to the `strict` standard
that the OpenAI API expects.
"""
if schema == {}:
return copy.deepcopy(_EMPTY_SCHEMA)
converted = _ensure_strict_json_schema(
schema, path=(), root=schema, budget=_NodeBudget(_MAX_SCHEMA_NODES)
budget = _NodeBudget(_MAX_SCHEMA_NODES, reject_open_objects=_reject_open_objects)
return _ensure_strict_root(
_ensure_strict_json_schema(
schema,
path=(),
root=schema,
budget=budget,
)
)
return _ensure_strict_root(converted)
def _ensure_strict_root(schema: dict[str, Any]) -> dict[str, Any]:
@@ -116,6 +133,14 @@ def _ensure_strict_json_schema(
elif typ is None and json_schema.get("additionalProperties", False) is not False:
raise UserError(_ADDITIONAL_PROPERTIES_ERROR)
is_object = typ == "object" or (is_list(typ) and "object" in typ)
has_no_declared_properties = "properties" not in json_schema or properties == {}
if (
budget.reject_open_objects
and is_object
and has_no_declared_properties
and json_schema.get("additionalProperties") is not False
):
raise UserError(_OPEN_OBJECT_ERROR)
if is_object and "additionalProperties" not in json_schema:
json_schema["additionalProperties"] = False
elif (
@@ -223,6 +248,9 @@ def _ensure_strict_json_schema(
# we call `_ensure_strict_json_schema` again to fix the inlined schema and ensure it's valid
return _ensure_strict_json_schema(json_schema, path=path, root=root, budget=budget)
if budget.reject_open_objects and "$ref" in json_schema:
raise UserError(_UNVALIDATED_REF_ERROR)
return json_schema
+241
View File
@@ -1,4 +1,5 @@
import asyncio
import copy
import dataclasses
import json
import logging
@@ -1867,6 +1868,246 @@ def test_to_function_tool_failed_strict_conversion_keeps_original_schema():
}
@pytest.mark.parametrize(
"free_form_schema",
[
{"type": "object", "description": "key/value pairs"},
{"type": "object", "properties": {}},
{"type": "object", "properties": {}, "required": []},
{
"type": "object",
"properties": {},
"$schema": "https://json-schema.org/draft/2020-12/schema",
},
{"type": "object", "properties": {}, "$comment": "Arbitrary values."},
],
ids=[
"properties-omitted",
"properties-empty",
"required-empty",
"schema-metadata",
"comment-metadata",
],
)
def test_to_function_tool_free_form_object_arg_falls_back_to_non_strict(free_form_schema):
schema = {
"type": "object",
"properties": {
"target": {"type": "string"},
"keysAndValues": free_form_schema,
},
"required": ["target", "keysAndValues"],
}
tool = MCPTool(name="set_properties", inputSchema=schema)
function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True)
assert function_tool.strict_json_schema is False
assert function_tool.params_json_schema == schema
@pytest.mark.parametrize(
"schema",
[
{"type": "object", "description": "Arbitrary key/value pairs"},
{"type": "object", "properties": {}},
{"type": "object", "properties": {}, "required": []},
{
"type": "object",
"properties": {},
"$schema": "https://json-schema.org/draft/2020-12/schema",
},
{"type": "object", "properties": {}, "$comment": "Arbitrary values."},
],
ids=[
"properties-omitted",
"properties-empty",
"required-empty",
"schema-metadata",
"comment-metadata",
],
)
def test_to_function_tool_free_form_root_falls_back_to_non_strict(schema):
tool = MCPTool(name="set_properties", inputSchema=schema)
function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True)
assert function_tool.strict_json_schema is False
assert function_tool.params_json_schema == {**schema, "properties": {}}
def test_to_function_tool_finds_free_form_object_in_array_items():
schema = {
"type": "object",
"properties": {
"entries": {
"type": "array",
"items": {
"type": "object",
"properties": {},
"$comment": "Arbitrary values.",
},
},
},
}
tool = MCPTool(name="set_properties", inputSchema=schema)
function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True)
assert function_tool.strict_json_schema is False
assert function_tool.params_json_schema == schema
def test_to_function_tool_does_not_convert_schema_when_conversion_is_disabled():
schema = {"type": "object", "properties": {"value": {"type": "string"}}}
tool = MCPTool(name="non_strict", inputSchema=schema)
with patch(
"agents.mcp.util.ensure_strict_json_schema",
side_effect=AssertionError("Strict conversion should not run."),
):
function_tool = MCPUtil.to_function_tool(
tool, FakeMCPServer(), convert_schemas_to_strict=False
)
assert function_tool.strict_json_schema is False
assert function_tool.params_json_schema == schema
@pytest.mark.parametrize(
"schema",
[
{},
{"type": "object", "additionalProperties": False},
{
"type": "object",
"properties": {"value": {"type": "string"}},
},
],
ids=["empty-schema", "explicitly-closed", "declared-property"],
)
def test_to_function_tool_strictable_closed_and_shaped_objects_stay_strict(schema):
tool = MCPTool(name="strictable", inputSchema=schema)
function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True)
assert function_tool.strict_json_schema is True
assert function_tool.params_json_schema["additionalProperties"] is False
def test_to_function_tool_advanced_open_root_falls_back_without_schema_evaluation():
schema = {
"type": "object",
"allOf": [{"type": "object", "properties": {"value": {"type": "string"}}}],
}
tool = MCPTool(name="advanced", inputSchema=schema)
function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True)
assert function_tool.strict_json_schema is False
assert function_tool.params_json_schema == {**schema, "properties": {}}
@pytest.mark.parametrize(
"schema",
[
{
"type": "object",
"properties": {
"value": {
"anyOf": [
{"type": "object", "properties": {}},
{"type": "null"},
]
}
},
},
{
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "object", "properties": {}},
{"type": "null"},
]
}
},
},
{
"type": "object",
"properties": {
"value": {"allOf": [{"type": "object", "properties": {}}]},
},
},
{
"type": "object",
"properties": {"value": {"$ref": "#/$defs/value"}},
"$defs": {"value": {"type": "object", "properties": {}}},
},
{
"type": "object",
"properties": {"value": {"$ref": "#/definitions/value"}},
"definitions": {"value": {"type": "object", "properties": {}}},
},
{
"type": "object",
"properties": {"value": {"$ref": "#/components/schemas/value"}},
"components": {"schemas": {"value": {"type": "object", "properties": {}}}},
},
{
"type": "object",
"properties": {
"value": {
"$ref": "#/components/schemas/value",
"description": "Arbitrary values.",
}
},
"components": {"schemas": {"value": {"type": "object", "properties": {}}}},
},
],
ids=[
"any-of",
"one-of",
"all-of",
"defs",
"definitions",
"pure-ref-unvisited-target",
"ref-reentry",
],
)
def test_to_function_tool_finds_free_form_objects_in_supported_schema_nodes(schema):
tool = MCPTool(name="nested", inputSchema=schema)
function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True)
assert function_tool.strict_json_schema is False
assert function_tool.params_json_schema == schema
@pytest.mark.parametrize(
("ref", "definitions"),
[
("#/$defs/value", {"value": {"type": "string"}}),
("#/$defs/a%20b", {"a b": {"type": "string"}}),
],
ids=["ordinary", "percent-encoded"],
)
def test_to_function_tool_preserved_pure_refs_fall_back_to_non_strict(ref, definitions):
schema = {
"$defs": definitions,
"type": "object",
"properties": {"payload": {"$ref": ref}},
}
original_schema = copy.deepcopy(schema)
tool = MCPTool(name="pure_ref", inputSchema=schema)
function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True)
assert function_tool.strict_json_schema is False
assert function_tool.params_json_schema == original_schema
assert schema == original_schema
def test_to_function_tool_nullable_root_falls_back_to_non_strict():
schema = {
"anyOf": [
+34
View File
@@ -1,3 +1,5 @@
import copy
import pytest
from agents.exceptions import UserError
@@ -45,6 +47,38 @@ def test_object_without_additional_properties():
assert result["properties"]["a"] == {"type": "string"}
def test_open_object_rejection_is_opt_in():
schema = {"type": "object", "properties": {}}
result = ensure_strict_json_schema(schema.copy())
assert result["additionalProperties"] is False
with pytest.raises(UserError, match="permits undeclared properties"):
ensure_strict_json_schema(schema.copy(), _reject_open_objects=True)
@pytest.mark.parametrize(
("ref", "definitions"),
[
("#/$defs/value", {"value": {"type": "string"}}),
("#/$defs/a%20b", {"a b": {"type": "string"}}),
],
ids=["ordinary", "percent-encoded"],
)
def test_open_object_rejection_rejects_preserved_pure_refs(ref, definitions):
schema = {
"$defs": definitions,
"type": "object",
"properties": {"value": {"$ref": ref}},
}
default_result = ensure_strict_json_schema(copy.deepcopy(schema))
assert default_result["properties"]["value"] == {"$ref": ref}
with pytest.raises(UserError, match="reference whose target was not validated"):
ensure_strict_json_schema(copy.deepcopy(schema), _reject_open_objects=True)
def test_typeless_root_is_normalized_to_object():
result = ensure_strict_json_schema({"properties": {"a": {"type": "string"}}})