Skip to content

Commit 2e2ec09

Browse files
haranrkcopybara-github
authored andcommitted
feat(agents): support remote MCP servers for ManagedAgent with runtime header callbacks
Add `RemoteMcpServer`, a server-side remote MCP tool for `ManagedAgent`. The Managed Agents / Interactions API runs the MCP server itself, so ADK only forwards the server URL and headers as an `MCPServerParam` and never opens an MCP session. A `header_provider` callback (the same contract as the `LlmAgent` `McpToolset.header_provider`) mints auth headers at request time, driven by the runner, and is merged over any static headers so a fresh token can be generated per turn. Only remote (HTTP/streamable) MCP servers are supported; raw `types.Tool.mcp_servers` remains rejected. Includes a live integration test against Maps Grounding Lite, scoped to the Gemini Developer API backend; the Vertex Interactions endpoint does not yet accept the `mcp_server` tool param (consistent with google-genai documenting `types.Tool.mcp_servers` as unsupported on Vertex AI). Co-authored-by: Haran Rajkumar <haranrk@google.com> PiperOrigin-RevId: 945306712
1 parent 283e92e commit 2e2ec09

8 files changed

Lines changed: 445 additions & 16 deletions

File tree

scripts/compliance_checks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
'src/google/adk/tools/openapi_tool/auth/credential_exchangers/service_account_exchanger.py',
6464
'src/google/adk/tools/pubsub/pubsub_credentials.py',
6565
'src/google/adk/tools/spanner/spanner_credentials.py',
66+
'tests/integration/test_managed_agent.py',
6667
'tests/unittests/auth/test_credential_manager.py',
6768
'tests/unittests/cli/utils/test_gcp_utils.py',
6869
'tests/unittests/flows/llm_flows/test_functions_request_euc.py',

src/google/adk/agents/_managed_agent.py

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import inspect
1718
import logging
1819
from typing import Any
1920
from typing import AsyncGenerator
@@ -32,19 +33,22 @@
3233

3334
from ..events.event import Event
3435
from ..flows.llm_flows.interactions_processor import _find_previous_interaction_state
36+
from ..models.interactions_utils import _build_mcp_server_param
3537
from ..models.interactions_utils import _convert_content_to_step
3638
from ..models.interactions_utils import _create_interactions
3739
from ..models.interactions_utils import build_interactions_request_log
3840
from ..models.interactions_utils import convert_tools_config_to_interactions_format
3941
from ..models.llm_request import LlmRequest
4042
from ..models.llm_response import LlmResponse
4143
from ..telemetry import tracer
44+
from ..tools._remote_mcp_server import RemoteMcpServer
4245
from ..tools.base_tool import BaseTool
4346
from ..tools.tool_context import ToolContext
4447
from ..utils.context_utils import Aclosing
4548
from ..utils.env_utils import is_enterprise_mode_enabled
4649
from .base_agent import BaseAgent
4750
from .invocation_context import InvocationContext
51+
from .readonly_context import ReadonlyContext
4852
from .run_config import StreamingMode
4953

5054
if TYPE_CHECKING:
@@ -109,10 +113,12 @@ class ManagedAgent(BaseAgent):
109113
"""An agent backed by the Managed Agents API (interactions.create).
110114
111115
This agent calls the Managed Agents API directly from its execution loop.
112-
In this version only server-side tools are supported: ADK built-in tools and
113-
raw ``google.genai.types.Tool`` configs (the kinds the interactions converter
114-
understands). Client-executed tools (FunctionTool/callables) and MCP are not
115-
yet supported.
116+
Only server-side tools are supported: ADK built-in tools, raw
117+
``google.genai.types.Tool`` configs (the kinds the interactions converter
118+
understands), and server-side remote MCP servers declared as
119+
``RemoteMcpServer`` specs (forwarded to the backend as an ``MCPServerParam``).
120+
Client-executed tools (FunctionTool/callables) and raw
121+
``types.Tool.mcp_servers`` configs are not supported and are rejected.
116122
117123
ManagedAgent supports streaming interactions only. Interactions are always
118124
created with ``background=True`` (required by the Managed Agents workflow) and
@@ -132,10 +138,11 @@ class ManagedAgent(BaseAgent):
132138
agent_config: Optional[CreateAgentInteractionAgentConfigParam] = None
133139
"""Runtime configuration passed to interactions.create."""
134140

135-
tools: list[Union[types.Tool, BaseTool, Callable[..., Any]]] = Field(
136-
default_factory=list
137-
)
138-
"""Server-side tools: ADK built-in tools or raw types.Tool configs."""
141+
tools: list[
142+
Union[types.Tool, BaseTool, Callable[..., Any], RemoteMcpServer]
143+
] = Field(default_factory=list)
144+
"""Server-side tools: ADK built-in tools, raw types.Tool configs, or
145+
RemoteMcpServer specs for server-side remote MCP."""
139146

140147
_api_client: Optional[Client] = PrivateAttr(default=None)
141148

@@ -176,8 +183,10 @@ async def _resolve_backend_tools(
176183
"""Resolve self.tools into interaction ToolParams (server-side only).
177184
178185
Raw types.Tool configs are passed through; ADK built-in tools are processed
179-
into native tool configs. Client-executed tools (FunctionTool/callables) and
180-
MCP tools are rejected.
186+
into native tool configs. ``RemoteMcpServer`` specs are resolved to an
187+
``MCPServerParam`` (headers minted at request time via ``header_provider``).
188+
Client-executed tools (FunctionTool/callables) and raw
189+
``types.Tool.mcp_servers`` configs are rejected.
181190
"""
182191
# Built-in tools are resolved in "managed agent" mode: the request carries
183192
# the internal _is_managed_agent flag (and no model), so tools that normally
@@ -186,8 +195,20 @@ async def _resolve_backend_tools(
186195
llm_request = LlmRequest(config=types.GenerateContentConfig())
187196
llm_request._is_managed_agent = True
188197
tool_context = ToolContext(ctx)
198+
mcp_params: list[ToolParam] = []
189199

190200
for tool in self.tools:
201+
if isinstance(tool, RemoteMcpServer):
202+
resolved_headers = dict(tool.headers or {})
203+
if tool.header_provider is not None:
204+
dynamic = tool.header_provider(ReadonlyContext(ctx))
205+
if inspect.isawaitable(dynamic):
206+
dynamic = await dynamic
207+
if dynamic:
208+
resolved_headers.update(dynamic) # dynamic wins on key conflict
209+
mcp_params.append(_build_mcp_server_param(tool, resolved_headers))
210+
continue
211+
191212
if isinstance(tool, types.Tool):
192213
if tool.mcp_servers:
193214
raise NotImplementedError(
@@ -233,7 +254,10 @@ async def _resolve_backend_tools(
233254
f'{tool.name}'
234255
)
235256

236-
return convert_tools_config_to_interactions_format(llm_request.config)
257+
return (
258+
convert_tools_config_to_interactions_format(llm_request.config)
259+
+ mcp_params
260+
)
237261

238262
def _response_to_event(
239263
self, ctx: InvocationContext, llm_response: LlmResponse

src/google/adk/models/interactions_utils.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
from google.genai.interactions import InteractionCreatedEvent
6161
from google.genai.interactions import InteractionSSEEvent
6262
from google.genai.interactions import InteractionStatusUpdate
63+
from google.genai.interactions import MCPServerParam
6364
from google.genai.interactions import ModelOutputStep
6465
from google.genai.interactions import ModelOutputStepParam
6566
from google.genai.interactions import Step
@@ -81,6 +82,8 @@
8182
if TYPE_CHECKING:
8283
from google.genai import Client
8384

85+
from ..tools._remote_mcp_server import RemoteMcpServer
86+
8487
from .llm_request import LlmRequest
8588
from .llm_response import LlmResponse
8689

@@ -251,12 +254,12 @@ def convert_part_to_interaction_content(part: types.Part) -> dict | None:
251254
elif part.thought:
252255
# part.thought is a boolean indicating this is a thought part
253256
# ThoughtContentParam expects 'signature' (base64 encoded bytes)
254-
result: dict[str, Any] = {'type': 'thought'}
257+
thought_result: dict[str, Any] = {'type': 'thought'}
255258
if part.thought_signature is not None:
256-
result['signature'] = base64.b64encode(part.thought_signature).decode(
257-
'utf-8'
258-
)
259-
return result
259+
thought_result['signature'] = base64.b64encode(
260+
part.thought_signature
261+
).decode('utf-8')
262+
return thought_result
260263
elif part.code_execution_result is not None:
261264
is_error = part.code_execution_result.outcome in (
262265
types.Outcome.OUTCOME_FAILED,
@@ -521,6 +524,27 @@ def convert_tools_config_to_interactions_format(
521524
return interaction_tools
522525

523526

527+
def _build_mcp_server_param(
528+
server: RemoteMcpServer,
529+
resolved_headers: dict[str, str],
530+
) -> MCPServerParam:
531+
"""Map a RemoteMcpServer + resolved headers to an interactions MCPServerParam.
532+
533+
Built directly (not via ``types.McpServer``) so ``allowed_tools`` can be
534+
carried and the "not supported in Vertex AI" restriction on
535+
``types.Tool.mcp_servers`` is avoided. ``resolved_headers`` is the static
536+
headers already merged with any ``header_provider`` output by the caller.
537+
"""
538+
param: MCPServerParam = {'type': 'mcp_server', 'url': server.url}
539+
if server.name is not None:
540+
param['name'] = server.name
541+
if resolved_headers:
542+
param['headers'] = resolved_headers
543+
if server.allowed_tools is not None:
544+
param['allowed_tools'] = [{'tools': list(server.allowed_tools)}]
545+
return param
546+
547+
524548
def _function_result_to_response(
525549
result: BaseModel | dict[str, Any] | list[Any] | str,
526550
) -> dict[str, Any]:

src/google/adk/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
# The TYPE_CHECKING block is needed for autocomplete to work.
2121
if TYPE_CHECKING:
2222
from ..auth.auth_tool import AuthToolArguments
23+
from ._remote_mcp_server import RemoteMcpServer
2324
from ._request_input_tool import request_input
2425
from .agent_tool import AgentTool
2526
from .api_registry import ApiRegistry
@@ -83,6 +84,7 @@
8384
),
8485
'preload_memory': ('.preload_memory_tool', 'preload_memory_tool'),
8586
'request_input': ('._request_input_tool', 'request_input'),
87+
'RemoteMcpServer': ('._remote_mcp_server', 'RemoteMcpServer'),
8688
'ToolContext': ('.tool_context', 'ToolContext'),
8789
'transfer_to_agent': ('.transfer_to_agent_tool', 'transfer_to_agent'),
8890
'TransferToAgentTool': (
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
from typing import Awaitable
18+
from typing import Callable
19+
from typing import TYPE_CHECKING
20+
21+
from pydantic import BaseModel
22+
from pydantic import ConfigDict
23+
24+
if TYPE_CHECKING:
25+
from ..agents.readonly_context import ReadonlyContext
26+
27+
HeaderProvider = Callable[
28+
[ReadonlyContext], dict[str, str] | Awaitable[dict[str, str]]
29+
]
30+
else:
31+
HeaderProvider = Callable[..., dict[str, str] | Awaitable[dict[str, str]]]
32+
33+
34+
class RemoteMcpServer(BaseModel):
35+
"""A remote MCP server executed server-side by the Managed Agents API.
36+
37+
``ManagedAgent`` forwards the server's URL and headers to
38+
``interactions.create``; the Interactions backend opens the MCP session and
39+
runs the tools. Only remote (HTTP/streamable) MCP servers are supported.
40+
41+
This is server-side MCP: unlike ``LlmAgent``'s ``McpToolset`` (which opens the
42+
session and executes tools client-side), ADK never connects to the MCP server
43+
here. The reused concept is the ``header_provider`` callback contract.
44+
"""
45+
46+
model_config = ConfigDict(arbitrary_types_allowed=True, extra='forbid')
47+
48+
url: str
49+
"""Full URL of the remote MCP server endpoint (e.g.
50+
'https://api.example.com/mcp'). Maps to ``MCPServerParam.url``."""
51+
52+
name: str | None = None
53+
"""Optional server label. Maps to ``MCPServerParam.name``."""
54+
55+
headers: dict[str, str] | None = None
56+
"""Static headers sent on every turn (e.g. a fixed API key). Merged with
57+
``header_provider`` output; ``header_provider`` wins on key conflict."""
58+
59+
allowed_tools: list[str] | None = None
60+
"""Restrict which of the server's tools are exposed. Maps to
61+
``MCPServerParam.allowed_tools``."""
62+
63+
header_provider: HeaderProvider | None = None
64+
"""Runtime callback that mints headers (e.g. a fresh bearer token) at request
65+
time. Invoked by ``ManagedAgent`` during resolution (runner-driven), once per
66+
turn. Receives a ``ReadonlyContext`` and returns a headers dict (or an
67+
awaitable of one). Same contract as ``LlmAgent``'s
68+
``McpToolset.header_provider``."""

tests/integration/test_managed_agent.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,13 @@
2222

2323
from __future__ import annotations
2424

25+
import os
26+
2527
from google.adk.agents import ManagedAgent
2628
from google.adk.runners import Runner
2729
from google.adk.sessions.in_memory_session_service import InMemorySessionService
2830
from google.adk.tools import google_search
31+
from google.adk.tools import RemoteMcpServer
2932
from google.adk.utils.context_utils import Aclosing
3033
from google.genai import types
3134
import pytest
@@ -121,3 +124,49 @@ async def test_code_execution_prime_sum():
121124
assert (
122125
'5117' in normalized
123126
), f'expected the code-executed sum 5117; got: {answer!r}'
127+
128+
129+
@pytest.mark.asyncio
130+
# Server-side remote MCP (mcp_server tool param) is currently only accepted by
131+
# the Gemini Developer API Interactions endpoint. The Vertex Interactions
132+
# endpoint returns 400 invalid_request for it (google-genai likewise documents
133+
# types.Tool.mcp_servers as unsupported on Vertex AI), so this live test is
134+
# scoped to GOOGLE_AI.
135+
@pytest.mark.parametrize('llm_backend', ['GOOGLE_AI'], indirect=True)
136+
@pytest.mark.skipif(
137+
not os.environ.get('GOOGLE_MAPS_API_KEY'),
138+
reason='GOOGLE_MAPS_API_KEY not set',
139+
)
140+
async def test_remote_mcp_maps_grounding_lite():
141+
agent = ManagedAgent(
142+
name='managed_maps_agent',
143+
agent_id=_AGENT_ID,
144+
environment={'type': 'remote'},
145+
tools=[
146+
RemoteMcpServer(
147+
name='maps_grounding_lite',
148+
url='https://mapstools.googleapis.com/mcp',
149+
header_provider=lambda ctx: {
150+
'X-Goog-Api-Key': os.environ['GOOGLE_MAPS_API_KEY']
151+
},
152+
)
153+
],
154+
)
155+
session_service = InMemorySessionService()
156+
runner = Runner(
157+
app_name='managed_agent_it',
158+
agent=agent,
159+
session_service=session_service,
160+
)
161+
session = await session_service.create_session(
162+
app_name='managed_agent_it', user_id='test_user'
163+
)
164+
165+
events = await _run_turn(
166+
runner, session, 'Find a few coffee shops near Golden Gate Park.'
167+
)
168+
169+
# Non-deterministic content; assert a non-empty grounded answer came back and
170+
# no terminal error event was emitted.
171+
assert _joined_text(events).strip()
172+
assert not any(e.error_code for e in events)

0 commit comments

Comments
 (0)