From 480e6b3b399bf6ab974329b838a91dc476c04c6c Mon Sep 17 00:00:00 2001 From: sun04222 <3242054475@qq.com> Date: Sat, 24 Oct 2026 08:09:52 +0800 Subject: [PATCH] fix(tools): omit oversized MCP output schemas from Gemini declarations MCP defines `outputSchema` for client-side validation of structured results, but the Gemini path forwarded it verbatim as `FunctionDeclaration.response_json_schema` with no size limit. Gemini prices a schema as if every `$ref` were inlined, so proto-derived schemas (e.g. Google Cloud's logging/monitoring MCP servers) can push a single declaration past the model's input limit. Mirror the LiteLLM path: omit `response_json_schema` when the serialized schema exceeds the same 1024-character budget used there (`_MAX_TOOL_DESCRIPTION_LENGTH`), and log a warning. Schemas within the budget are sent unchanged. Fixes #7690 --- src/google/adk/tools/mcp_tool/mcp_tool.py | 42 +++++++++++ .../unittests/tools/mcp_tool/test_mcp_tool.py | 74 +++++++++++++++++++ 2 files changed, 116 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..dfe18c307e3 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,32 @@ _UNSET = object() +# MCP defines `outputSchema` "for validation of structured results" on the +# client, but the Gemini path forwards it to the model inside +# `FunctionDeclaration.response_json_schema`. Gemini prices a schema as if +# every `$ref` were inlined, so server-generated schemas (proto-derived ones +# reach hundreds of thousands of characters) can push a single declaration +# past the model's input limit. Mirror the LiteLLM path, which only sends an +# output schema when it fits within `_MAX_TOOL_DESCRIPTION_LENGTH`: drop the +# ones that exceed the same budget here. +_MAX_RESPONSE_JSON_SCHEMA_LENGTH = 1024 + + +def _response_schema_serialized_size(output_schema: Any) -> int | None: + """Returns the JSON size of an MCP output schema. + + Args: + output_schema: The schema read from the MCP tool definition. + + Returns: + The serialized length in characters, or None when the value cannot be + serialized for measurement. + """ + try: + return len(json.dumps(output_schema)) + except (TypeError, ValueError): + return None + def _merge_auth_and_provider_headers( *, @@ -404,6 +431,21 @@ def _build_declaration(self, *, fenced: bool = False) -> FunctionDeclaration: if fenced and output_schema is not None else output_schema ) + if output_schema is not None: + serialized_size = _response_schema_serialized_size(output_schema) + if ( + serialized_size is not None + and serialized_size > _MAX_RESPONSE_JSON_SCHEMA_LENGTH + ): + logger.warning( + "Omitting the output schema of MCP tool %r from its function" + " declaration: %d serialized characters exceeds the" + " %d-character budget for response schemas sent to the model.", + self.name, + serialized_size, + _MAX_RESPONSE_JSON_SCHEMA_LENGTH, + ) + output_schema = None function_decl = FunctionDeclaration( name=self.name, description=description, diff --git a/tests/unittests/tools/mcp_tool/test_mcp_tool.py b/tests/unittests/tools/mcp_tool/test_mcp_tool.py index 77163d91026..09c26c3f8eb 100644 --- a/tests/unittests/tools/mcp_tool/test_mcp_tool.py +++ b/tests/unittests/tools/mcp_tool/test_mcp_tool.py @@ -667,6 +667,80 @@ 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_drops_oversized_output_schema(self): + """An output schema too large to send is omitted from the declaration.""" + output_schema = { + "type": "object", + "properties": { + "field_%d" + % i: { + "type": "string", + "description": "x" * 200, + } + for i in range(10) + }, + } + + 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 is None + assert declaration.parameters_json_schema is not None + + def test_get_declaration_logs_when_dropping_oversized_output_schema( + self, caplog + ): + """Dropping an oversized output schema is logged as a warning.""" + output_schema = { + "type": "object", + "properties": { + "field": {"type": "string", "description": "x" * 2000}, + }, + } + + 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 + ): + with caplog.at_level(logging.WARNING): + declaration = tool._get_declaration() + + assert declaration.response_json_schema is None + assert "Omitting the output schema" in caplog.text + + def test_get_declaration_keeps_output_schema_within_budget(self): + """An output schema that fits the budget is sent to the model.""" + output_schema = { + "type": "object", + "properties": { + "status": {"type": "string", "description": "x" * 500}, + }, + } + + 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 + class TestMCPTool: """Test suite for MCPTool class."""