Skip to content
Merged
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
15 changes: 14 additions & 1 deletion contributing/samples/tool_mcp_stdio_notion_config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,20 @@ env:
1. Click "Edit access"
1. Add pages or databases as needed

### 4. Run the Agent
### 4. Opt In to Stdio MCP Servers

This sample declares a stdio MCP server in `root_agent.yaml`, which means
loading the config launches `npx` as a local process. ADK rejects that by
default, because an agent config obtained from someone else would then be able
to run arbitrary commands. Opt in before running the sample:

```bash
export ADK_ALLOW_CONFIG_STDIO_MCP_SERVERS=1
```

Only set this when you trust every agent config the process will load.

### 5. Run the Agent

Use the `adk web` to run the agent and interact with your Notion workspace.

Expand Down
16 changes: 16 additions & 0 deletions contributing/samples/tool_mcp_stdio_notion_config/root_agent.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# yaml-language-server: $schema=https://raw.githubusercontent.com/google/adk-python/refs/heads/main/src/google/adk/agents/config_schemas/AgentConfig.json
name: notion_agent
model: gemini-2.5-flash
instruction: |
You are my workspace assistant. Use the provided tools to read, search, comment on, or create
Notion pages. Ask clarifying questions when unsure.
# Declaring a stdio MCP server launches `command` as a local process when this
# config loads, so it requires ADK_ALLOW_CONFIG_STDIO_MCP_SERVERS=1. See README.
tools:
- name: MCPToolset
args:
Expand Down
23 changes: 22 additions & 1 deletion src/google/adk/tools/mcp_tool/mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
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.
Expand All @@ -56,12 +59,23 @@
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 MCPSessionManager
from .mcp_session_manager import retry_on_errors
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):
Expand Down Expand Up @@ -177,7 +191,8 @@ def __init__(
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.
"""

# --- BEGIN BOUND TOKEN PATCH ---
Expand All @@ -189,6 +204,12 @@ def __init__(
)
# --- END BOUND TOKEN PATCH ---

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,
description=mcp_tool.description if mcp_tool.description else "",
Expand Down
34 changes: 34 additions & 0 deletions src/google/adk/tools/mcp_tool/mcp_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from ...auth.auth_credential import AuthCredential
from ...auth.auth_schemes import AuthScheme
from ...auth.auth_tool import AuthConfig
from ...utils.env_utils import is_env_enabled
from ..base_tool import BaseTool
from ..base_toolset import BaseToolset
from ..base_toolset import ToolPredicate
Expand All @@ -54,11 +55,19 @@
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

logger = logging.getLogger("google_adk." + __name__)

ALLOW_CONFIG_STDIO_SERVERS_ENV_VAR = "ADK_ALLOW_CONFIG_STDIO_MCP_SERVERS"


def _allow_config_stdio_servers_enabled() -> bool:
"""Returns whether agent configs may declare stdio MCP servers."""
return is_env_enabled(ALLOW_CONFIG_STDIO_SERVERS_ENV_VAR)


T = TypeVar("T")

Expand Down Expand Up @@ -354,6 +363,16 @@ async def get_tools(
# Apply filtering based on context and tool_filter
tools = []
for tool in tools_response.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,
Expand Down Expand Up @@ -456,6 +475,21 @@ def from_config(
"""Creates an McpToolset from a configuration object."""
mcp_toolset_config = McpToolsetConfig.model_validate(config.model_dump())

if (
mcp_toolset_config.stdio_server_params
or mcp_toolset_config.stdio_connection_params
) and not _allow_config_stdio_servers_enabled():
raise ValueError(
"Stdio MCP servers are not allowed in agent configs: the"
" config-supplied 'command' is launched as a local process when the"
" agent starts, so an untrusted config would be able to run"
" arbitrary code. Construct the McpToolset in Python code instead,"
" use a remote transport (sse_connection_params or"
" streamable_http_connection_params), or set"
f" {ALLOW_CONFIG_STDIO_SERVERS_ENV_VAR}=1 if this application only"
" loads agent configs it trusts."
)

if mcp_toolset_config.stdio_server_params:
connection_params = mcp_toolset_config.stdio_server_params
elif mcp_toolset_config.stdio_connection_params:
Expand Down
34 changes: 34 additions & 0 deletions tests/unittests/tools/mcp_tool/test_mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,40 @@ def test_init_with_empty_description(self):

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."""
Expand Down
75 changes: 74 additions & 1 deletion tests/unittests/tools/mcp_tool/test_mcp_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from google.adk.auth.auth_credential import OAuth2Auth
from google.adk.auth.auth_tool import AuthConfig
from google.adk.tools.load_mcp_resource_tool import LoadMcpResourceTool
from google.adk.tools.mcp_tool import mcp_toolset as mcp_toolset_module
from google.adk.tools.mcp_tool.mcp_session_manager import MCPSessionManager
from google.adk.tools.mcp_tool.mcp_session_manager import SseConnectionParams
from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams
Expand Down Expand Up @@ -176,8 +177,11 @@ def test_init_with_auth_and_credential_key(self):
assert toolset._auth_credential == auth_credential
assert toolset._auth_config.credential_key == "my_custom_key"

def test_from_config_with_credential_key(self):
def test_from_config_with_credential_key(self, monkeypatch):
"""Test that from_config correctly parses credential_key."""
monkeypatch.setenv(
mcp_toolset_module.ALLOW_CONFIG_STDIO_SERVERS_ENV_VAR, "1"
)

auth_scheme = OAuth2(flows={})

Expand All @@ -191,6 +195,54 @@ def test_from_config_with_credential_key(self):
assert isinstance(toolset._auth_scheme, OAuth2)
assert toolset._auth_config.credential_key == "my_custom_key"

def test_from_config_rejects_stdio_server_params(self):
"""Config-supplied stdio servers are rejected by default."""
config = ToolArgsConfig(stdio_server_params=self.mock_stdio_params)

with pytest.raises(ValueError, match="not allowed in agent configs"):
McpToolset.from_config(config, "")

def test_from_config_rejects_stdio_connection_params(self):
"""The stdio_connection_params spelling is rejected the same way."""
config = ToolArgsConfig(
stdio_connection_params=StdioConnectionParams(
server_params=self.mock_stdio_params
)
)

with pytest.raises(ValueError, match="not allowed in agent configs"):
McpToolset.from_config(config, "")

def test_from_config_rejection_names_the_env_var(self):
"""The error tells the operator how to opt in."""
config = ToolArgsConfig(stdio_server_params=self.mock_stdio_params)

with pytest.raises(
ValueError, match=mcp_toolset_module.ALLOW_CONFIG_STDIO_SERVERS_ENV_VAR
):
McpToolset.from_config(config, "")

def test_from_config_allows_stdio_when_env_var_set(self, monkeypatch):
"""The environment variable opts a whole process in."""
monkeypatch.setenv(
mcp_toolset_module.ALLOW_CONFIG_STDIO_SERVERS_ENV_VAR, "1"
)
config = ToolArgsConfig(stdio_server_params=self.mock_stdio_params)

toolset = McpToolset.from_config(config, "")

assert isinstance(toolset, McpToolset)

def test_from_config_allows_remote_connection_params(self):
"""Remote MCP servers are unaffected: they launch no local process."""
config = ToolArgsConfig(
sse_connection_params=SseConnectionParams(url="https://example.com/sse")
)

toolset = McpToolset.from_config(config, "")

assert isinstance(toolset, McpToolset)

def test_init_missing_connection_params(self):
"""Test initialization with missing connection params raises error."""
with pytest.raises(ValueError, match="Missing connection params"):
Expand Down Expand Up @@ -225,6 +277,27 @@ async def test_get_tools_basic(self):
assert tools[2].name == "tool3"
assert tools[3].name == "load_mcp_resource"

@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."""
Expand Down
Loading