fix: preserve free-form MCP object schemas (#4310)
Co-authored-by: abhay-codes07 <abhaysingh0293@gmail.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"}}})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user