fix: refuse MCP tools that take a reserved ADK tool name

`McpTool` registered under the verbatim name the remote server advertised, with
no check against the names the framework itself puts on the wire. A server that
advertised `adk_request_credential`, `adk_request_confirmation`,
`adk_request_input` or `transfer_to_agent` therefore had its own tool dispatched
in place of the framework's, so it could harvest the credentials meant for an
auth callback or route the conversation to an agent of its choosing.

`McpToolset.get_tools` now drops any tool carrying one of those four names and
logs that it did, and `McpTool.__init__` refuses the name outright. The listing
skips rather than raises because a single reserved name would otherwise fail the
whole `list_tools` call and take the server's honest tools down with it; the
constructor check is the backstop for anything that builds an `McpTool`
directly. Only exact matches are refused, so `transfer_to_agent_v2` still
registers.

Co-authored-by: Kathy Wu <wukathy@google.com>
PiperOrigin-RevId: 963544587
This commit is contained in:
Kathy Wu
2026-08-12 10:57:21 -07:00
committed by Copybara-Service
parent 5418b73156
commit 77d4647c8e
4 changed files with 87 additions and 1 deletions
+21 -1
View File
@@ -42,6 +42,9 @@ from ...auth.auth_tool import AuthConfig
from ...events.ui_widget import UiWidget
from ...features import FeatureName
from ...features import is_feature_enabled
from ...flows.llm_flows.functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME
from ...flows.llm_flows.functions import REQUEST_EUC_FUNCTION_CALL_NAME
from ...flows.llm_flows.functions import REQUEST_INPUT_FUNCTION_CALL_NAME
from ...utils.context_utils import find_context_parameter
# `is_feature_enabled(FeatureName._MCP_GRACEFUL_ERROR_HANDLING)` gates the
# error-boundary and transport-crash-detection behavior added in this module.
@@ -52,6 +55,7 @@ from ...utils.context_utils import find_context_parameter
from .._gemini_schema_util import _to_gemini_schema
from ..base_authenticated_tool import BaseAuthenticatedTool
from ..tool_context import ToolContext
from ..transfer_to_agent_tool import transfer_to_agent
from .mcp_session_manager import _http_debug_var
from .mcp_session_manager import MCPSessionManager
from .mcp_session_manager import retry_on_errors
@@ -59,6 +63,16 @@ from .session_context import SessionContext
logger = logging.getLogger("google_adk." + __name__)
# Tool names the framework itself puts on the wire. A server advertising one of
# these would have its tool dispatched in place of the framework's own, so the
# name is refused at registration.
_RESERVED_TOOL_NAMES = frozenset({
REQUEST_EUC_FUNCTION_CALL_NAME,
REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
REQUEST_INPUT_FUNCTION_CALL_NAME,
transfer_to_agent.__name__,
})
@runtime_checkable
class ProgressCallbackFactory(Protocol):
@@ -176,8 +190,14 @@ class McpTool(BaseAuthenticatedTool):
and modify runtime context like session state.
Raises:
ValueError: If mcp_tool or mcp_session_manager is None.
ValueError: If the MCP tool name collides with a reserved ADK tool
name.
"""
if mcp_tool.name in _RESERVED_TOOL_NAMES:
raise ValueError(
f"MCP tool name '{mcp_tool.name}' collides with a reserved ADK tool"
" name."
)
super().__init__(
name=mcp_tool.name,
@@ -61,6 +61,7 @@ from .mcp_session_manager import retry_on_errors
from .mcp_session_manager import SseConnectionParams
from .mcp_session_manager import StdioConnectionParams
from .mcp_session_manager import StreamableHTTPConnectionParams
from .mcp_tool import _RESERVED_TOOL_NAMES
from .mcp_tool import MCPTool
from .mcp_tool import ProgressCallbackFactory
@@ -564,6 +565,16 @@ class McpToolset(BaseToolset):
# even on a cache hit, so only the round trip is skipped.
tools = []
for tool in mcp_tools:
# Skip rather than let McpTool raise: one reserved name would otherwise
# fail the whole listing and take the server's honest tools down with it.
if tool.name in _RESERVED_TOOL_NAMES:
logger.warning(
"Skipping MCP tool '%s' because it collides with a reserved ADK"
" framework tool name.",
tool.name,
)
continue
mcp_tool = MCPTool(
mcp_tool=tool,
mcp_session_manager=self._mcp_session_manager,
@@ -243,6 +243,40 @@ class TestMCPTool:
assert tool.description == ""
@pytest.mark.parametrize(
"reserved_name",
[
"adk_request_credential",
"adk_request_confirmation",
"adk_request_input",
"transfer_to_agent",
],
)
def test_init_reserved_name(self, reserved_name):
"""A tool named after a framework function call is refused."""
mock_tool = MockMCPTool(name=reserved_name)
with pytest.raises(
ValueError,
match=(
f"MCP tool name '{reserved_name}' collides with a reserved ADK tool"
" name."
),
):
MCPTool(
mcp_tool=mock_tool,
mcp_session_manager=self.mock_session_manager,
)
def test_init_reserved_name_prefix_allowed(self):
"""Only exact collisions are refused, not names that merely look alike."""
mock_tool = MockMCPTool(name="transfer_to_agent_v2")
tool = MCPTool(
mcp_tool=mock_tool,
mcp_session_manager=self.mock_session_manager,
)
assert tool.name == "transfer_to_agent_v2"
@pytest.mark.asyncio
async def test_run_async_impl_no_auth(self):
"""Test running tool without authentication."""
@@ -397,6 +397,27 @@ class TestMcpToolset:
assert [tool.name for tool in tools] == ["alpha", "bravo", "charlie"]
@pytest.mark.asyncio
async def test_get_tools_skips_reserved_names(self):
"""A server advertising reserved names loses those, not the whole list."""
mock_tools = [
MockMCPTool("valid_tool"),
MockMCPTool("transfer_to_agent"),
MockMCPTool("adk_request_credential"),
MockMCPTool("adk_request_confirmation"),
MockMCPTool("adk_request_input"),
]
self.mock_session.list_tools = AsyncMock(
return_value=MockListToolsResult(mock_tools)
)
toolset = McpToolset(connection_params=self.mock_stdio_params)
toolset._mcp_session_manager = self.mock_session_manager
tools = await toolset.get_tools()
assert [tool.name for tool in tools] == ["valid_tool"]
@pytest.mark.asyncio
async def test_get_tools_with_list_filter(self):
"""Test getting tools with list-based filtering."""