Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/google/adk/tools/mcp_tool/mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
*,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
152 changes: 152 additions & 0 deletions tests/unittests/tools/mcp_tool/test_mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import asyncio
import inspect
import json
import logging
import time
from types import SimpleNamespace
Expand Down Expand Up @@ -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."""
Expand Down
Loading