From 1edde710416170283e02ada4f59243c64a6ab3fc Mon Sep 17 00:00:00 2001 From: danieldutu Date: Fri, 23 Oct 2026 22:42:11 +0300 Subject: [PATCH] fix(tools): bound the MCP output schema sent to Gemini McpTool._build_declaration set response_json_schema from the MCP tool's outputSchema with no size limit whenever JSON_SCHEMA_FOR_FUNC_DECL (the default) is on. Google Cloud's remote MCP servers serialize proto-derived output schemas to over 70,000 characters, and Gemini expands each $ref when tokenizing, so 18 declarations from those servers pushed the request past the 1,048,576-token input limit on every call (#7690). Bound the serialized output schema at 1024 characters, the same ceiling _append_response_schema_to_description applies to the same schema on the OpenAI-compatible path (lite_llm.py). Larger schemas are dropped with one warning per tool instance naming the tool and the serialized length. The length is measured on the unfenced schema with ensure_ascii=False, so the plain and fenced declarations decide the same way and non-ASCII text counts one character each. Fixes #7690 --- src/google/adk/tools/mcp_tool/mcp_tool.py | 29 ++++ .../unittests/tools/mcp_tool/test_mcp_tool.py | 152 ++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/src/google/adk/tools/mcp_tool/mcp_tool.py b/src/google/adk/tools/mcp_tool/mcp_tool.py index 5a52e4eb6c6..9cbfaf2b510 100644 --- a/src/google/adk/tools/mcp_tool/mcp_tool.py +++ b/src/google/adk/tools/mcp_tool/mcp_tool.py @@ -17,6 +17,7 @@ import base64 from collections.abc import Awaitable import inspect +import json import logging from typing import Any from typing import Callable @@ -90,6 +91,15 @@ _UNSET = object() +# Serialized ceiling for an MCP output schema sent to Gemini as +# FunctionDeclaration.response_json_schema. Gemini declares no ceiling for the +# field, so keep parity with the 1024-character bound that +# _append_response_schema_to_description applies to the same schema on the +# OpenAI-compatible path (models/lite_llm.py). Servers generate outputSchema +# mechanically; Google Cloud's MCP servers serialize proto-derived schemas to +# over 70,000 characters and Gemini expands each $ref when tokenizing. +_MAX_RESPONSE_JSON_SCHEMA_LENGTH = 1024 + def _merge_auth_and_provider_headers( *, @@ -371,6 +381,7 @@ def __init__( self._header_provider = header_provider self._progress_callback = progress_callback self.propagate_grounding_metadata = propagate_grounding_metadata + self._logged_output_schema_drop = False @override def _get_declaration(self) -> FunctionDeclaration: @@ -399,6 +410,24 @@ def _build_declaration(self, *, fenced: bool = False) -> FunctionDeclaration: output_schema = _read_field( self._mcp_tool, "outputSchema", "output_schema" ) + # Measure the server's schema before fencing so the plain and fenced + # declarations make the same decision. + if output_schema: + serialized_length = len( + json.dumps(output_schema, separators=(",", ":"), ensure_ascii=False) + ) + if serialized_length > _MAX_RESPONSE_JSON_SCHEMA_LENGTH: + if not self._logged_output_schema_drop: + self._logged_output_schema_drop = True + logger.warning( + "Dropping output_schema for MCP tool %s: serialized length %d" + " exceeds %d characters; sending it as response_json_schema" + " can push the request past the model's input token limit.", + self.name, + serialized_length, + _MAX_RESPONSE_JSON_SCHEMA_LENGTH, + ) + output_schema = None output_schema = ( fence_schema_descriptions(output_schema) if fenced and output_schema is not None diff --git a/tests/unittests/tools/mcp_tool/test_mcp_tool.py b/tests/unittests/tools/mcp_tool/test_mcp_tool.py index 77163d91026..cc87c8d51ad 100644 --- a/tests/unittests/tools/mcp_tool/test_mcp_tool.py +++ b/tests/unittests/tools/mcp_tool/test_mcp_tool.py @@ -14,6 +14,7 @@ import asyncio import inspect +import json import logging import time from types import SimpleNamespace @@ -667,6 +668,157 @@ def test_get_declaration_with_empty_output_schema_and_json_schema_for_func_decl_ assert declaration.response is None assert not declaration.response_json_schema + def test_get_declaration_with_output_schema_under_bound_sent(self): + """Test a serialized output schema within the size bound is sent.""" + output_schema = { + "type": "object", + "properties": { + "result": { + "type": "string", + "description": "The result of the operation", + }, + }, + } + tool = MCPTool( + mcp_tool=MockMCPTool(outputSchema=output_schema), + mcp_session_manager=self.mock_session_manager, + ) + + with temporary_feature_override( + FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True + ): + declaration = tool._get_declaration() + + assert declaration.response is None + assert declaration.response_json_schema == output_schema + + def test_get_declaration_with_oversized_output_schema_dropped_with_warning( + self, caplog + ): + """Test an output schema over the size bound is dropped with one warning.""" + output_schema = { + "type": "object", + "properties": { + "result": {"type": "string", "description": "x" * 1100}, + }, + } + tool = MCPTool( + mcp_tool=MockMCPTool(outputSchema=output_schema, name="get_dashboard"), + mcp_session_manager=self.mock_session_manager, + ) + + with ( + caplog.at_level(logging.WARNING), + temporary_feature_override(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True), + ): + declaration = tool._get_declaration() + + assert declaration.response is None + assert declaration.response_json_schema is None + warning_records = [ + record for record in caplog.records if record.levelno == logging.WARNING + ] + assert len(warning_records) == 1 + message = warning_records[0].getMessage() + assert "get_dashboard" in message + serialized_length = len(json.dumps(output_schema, separators=(",", ":"))) + assert str(serialized_length) in message + + def test_fenced_declaration_keeps_output_schema_under_bound_before_fencing( + self, + ): + """The bound is measured before fencing, so both declarations agree.""" + output_schema = { + "type": "object", + "properties": { + f"f{i}": {"type": "string", "description": "d" * 20} + for i in range(12) + }, + } + assert len(json.dumps(output_schema, separators=(",", ":"))) < 1024 + tool = MCPTool( + mcp_tool=MockMCPTool(outputSchema=output_schema), + mcp_session_manager=self.mock_session_manager, + ) + + with temporary_feature_override( + FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True + ): + plain = tool._get_declaration() + fenced = tool._build_fenced_declaration() + + assert plain.response_json_schema == output_schema + assert fenced.response_json_schema is not None + assert fenced.response_json_schema != output_schema + + def test_get_declaration_measures_non_ascii_output_schema_by_characters( + self, + ): + """Non-ASCII characters count once toward the bound, not as escapes.""" + output_schema = { + "type": "object", + "properties": {"result": {"type": "string", "description": "説" * 256}}, + } + assert len(json.dumps(output_schema, separators=(",", ":"))) > 1024 + assert ( + len( + json.dumps(output_schema, separators=(",", ":"), ensure_ascii=False) + ) + < 1024 + ) + tool = MCPTool( + mcp_tool=MockMCPTool(outputSchema=output_schema), + mcp_session_manager=self.mock_session_manager, + ) + + with temporary_feature_override( + FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True + ): + declaration = tool._get_declaration() + + assert declaration.response_json_schema == output_schema + + def test_oversized_output_schema_warns_once_per_tool(self, caplog): + """Repeated declaration builds log the drop once for the tool.""" + output_schema = { + "type": "object", + "properties": {"result": {"type": "string", "description": "x" * 1100}}, + } + tool = MCPTool( + mcp_tool=MockMCPTool(outputSchema=output_schema), + mcp_session_manager=self.mock_session_manager, + ) + + with ( + caplog.at_level(logging.WARNING), + temporary_feature_override(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True), + ): + tool._get_declaration() + tool._build_fenced_declaration() + tool._get_declaration() + + warning_records = [ + record for record in caplog.records if record.levelno == logging.WARNING + ] + assert len(warning_records) == 1 + + def test_get_declaration_without_output_schema_and_json_schema_for_func_decl_enabled( + self, + ): + """Test function declaration generation without any output schema.""" + tool = MCPTool( + mcp_tool=MockMCPTool(outputSchema=None), + mcp_session_manager=self.mock_session_manager, + ) + + with temporary_feature_override( + FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True + ): + declaration = tool._get_declaration() + + assert declaration.response is None + assert declaration.response_json_schema is None + class TestMCPTool: """Test suite for MCPTool class."""