From 2b115b66bc2dd1b8b5e2f191de1020583cf891de Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 9 Aug 2026 10:39:32 +0900 Subject: [PATCH] fix: preserve free-form MCP object schemas (#4310) Co-authored-by: abhay-codes07 --- src/agents/mcp/util.py | 6 +- src/agents/strict_schema.py | 38 +++++- tests/mcp/test_mcp_util.py | 241 ++++++++++++++++++++++++++++++++++++ tests/test_strict_schema.py | 34 +++++ 4 files changed, 313 insertions(+), 6 deletions(-) diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index e05f05c9..d1edfd55 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -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: diff --git a/src/agents/strict_schema.py b/src/agents/strict_schema.py index 1bab745c..6c9ce4bf 100644 --- a/src/agents/strict_schema.py +++ b/src/agents/strict_schema.py @@ -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 diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index 04004905..607d8df1 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -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": [ diff --git a/tests/test_strict_schema.py b/tests/test_strict_schema.py index 43b6f574..04093ea0 100644 --- a/tests/test_strict_schema.py +++ b/tests/test_strict_schema.py @@ -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"}}})