Skip to content

Commit 50ff37f

Browse files
GWealecopybara-github
authored andcommitted
feat: add to_mcp_server to serve an ADK agent over MCP
Exposes an ADK agent as an MCP server, the MCP counterpart of to_a2a, so MCP hosts such as Antigravity, Claude Code, and Codex can drive an ADK agent. The agent is registered as a single MCP tool backed by a Runner, with intermediate events surfaced as MCP progress notifications. Ships @experimental behind the existing mcp extra. Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 944100870
1 parent 49c0a36 commit 50ff37f

4 files changed

Lines changed: 420 additions & 0 deletions

File tree

src/google/adk/features/_feature_registry.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class FeatureName(str, Enum):
4646
GOOGLE_CREDENTIALS_CONFIG = "GOOGLE_CREDENTIALS_CONFIG"
4747
GOOGLE_TOOL = "GOOGLE_TOOL"
4848
JSON_SCHEMA_FOR_FUNC_DECL = "JSON_SCHEMA_FOR_FUNC_DECL"
49+
MCP_AGENT_SERVER = "MCP_AGENT_SERVER"
4950
# Private (leading underscore): not part of the public API surface.
5051
# GE flips this on by setting the env var
5152
# `ADK_ENABLE_MCP_GRACEFUL_ERROR_HANDLING=1`; nothing should import this
@@ -157,6 +158,9 @@ class FeatureConfig:
157158
FeatureName.JSON_SCHEMA_FOR_FUNC_DECL: FeatureConfig(
158159
FeatureStage.EXPERIMENTAL, default_on=True
159160
),
161+
FeatureName.MCP_AGENT_SERVER: FeatureConfig(
162+
FeatureStage.EXPERIMENTAL, default_on=True
163+
),
160164
FeatureName._MCP_GRACEFUL_ERROR_HANDLING: FeatureConfig(
161165
FeatureStage.EXPERIMENTAL, default_on=True
162166
),

src/google/adk/tools/mcp_tool/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
__all__ = []
1616

1717
try:
18+
from ._agent_to_mcp import to_mcp_server
1819
from .conversion_utils import adk_to_mcp_tool_type
1920
from .conversion_utils import gemini_to_json_schema
2021
from .mcp_session_manager import SseConnectionParams
@@ -35,6 +36,7 @@
3536
'SseConnectionParams',
3637
'StdioConnectionParams',
3738
'StreamableHTTPConnectionParams',
39+
'to_mcp_server',
3840
])
3941

4042
except ImportError as e:
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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+
"""Expose an ADK agent as an MCP server."""
16+
17+
from __future__ import annotations
18+
19+
import base64
20+
from typing import MutableMapping
21+
from typing import Optional
22+
import weakref
23+
24+
from google.genai import types
25+
from mcp import types as mcp_types
26+
from mcp.server.fastmcp import Context
27+
from mcp.server.fastmcp import FastMCP
28+
29+
from ...agents.base_agent import BaseAgent
30+
from ...artifacts.in_memory_artifact_service import InMemoryArtifactService
31+
from ...auth.credential_service.in_memory_credential_service import InMemoryCredentialService
32+
from ...features import experimental
33+
from ...features import FeatureName
34+
from ...memory.in_memory_memory_service import InMemoryMemoryService
35+
from ...runners import Runner
36+
from ...sessions.in_memory_session_service import InMemorySessionService
37+
38+
_MCP_USER_ID = "mcp_user"
39+
_INLINE_RESOURCE_URI = "resource://adk-agent/inline-data"
40+
41+
42+
def _build_runner(agent: BaseAgent) -> Runner:
43+
"""Builds a Runner for the agent using in-memory services."""
44+
return Runner(
45+
app_name=agent.name or "adk_agent",
46+
agent=agent,
47+
artifact_service=InMemoryArtifactService(),
48+
session_service=InMemorySessionService(),
49+
memory_service=InMemoryMemoryService(),
50+
credential_service=InMemoryCredentialService(),
51+
)
52+
53+
54+
def _part_to_content(part: types.Part) -> Optional[mcp_types.ContentBlock]:
55+
"""Maps one ADK content part to an MCP content block.
56+
57+
Args:
58+
part: An ADK content part from the agent's response.
59+
60+
Returns:
61+
The matching MCP content block (text, image, audio, or embedded resource),
62+
or None for a part with no renderable content (e.g. a function call).
63+
"""
64+
if part.text:
65+
return mcp_types.TextContent(type="text", text=part.text)
66+
blob = part.inline_data
67+
if blob is not None and blob.data is not None:
68+
data = base64.b64encode(blob.data).decode("ascii")
69+
mime = blob.mime_type or "application/octet-stream"
70+
if mime.startswith("image/"):
71+
return mcp_types.ImageContent(type="image", data=data, mimeType=mime)
72+
if mime.startswith("audio/"):
73+
return mcp_types.AudioContent(type="audio", data=data, mimeType=mime)
74+
return mcp_types.EmbeddedResource(
75+
type="resource",
76+
resource=mcp_types.BlobResourceContents(
77+
uri=_INLINE_RESOURCE_URI, blob=data, mimeType=mime
78+
),
79+
)
80+
return None
81+
82+
83+
async def _run_agent(
84+
runner: Runner,
85+
request: str,
86+
ctx: Optional[Context] = None,
87+
sessions: Optional[MutableMapping[object, str]] = None,
88+
) -> list[mcp_types.ContentBlock]:
89+
"""Runs the agent for one request and returns its final response content.
90+
91+
When ``ctx`` and ``sessions`` are supplied, one ADK session is reused per MCP
92+
connection, so successive calls form a single conversation; otherwise a fresh
93+
session is created. Intermediate (non-final) text events are forwarded as MCP
94+
progress notifications when ``ctx`` is supplied; progress is a no-op unless
95+
the host requested it.
96+
97+
Args:
98+
runner: The Runner that executes the agent.
99+
request: The user request text for this call.
100+
ctx: The MCP tool call context, used for progress and session reuse.
101+
sessions: Per-connection map from MCP connection to ADK session id.
102+
103+
Returns:
104+
The agent's final response as a list of MCP content blocks (text plus any
105+
images, audio, or other data the agent produced).
106+
"""
107+
session_id: Optional[str] = None
108+
if ctx is not None and sessions is not None:
109+
session_id = sessions.get(ctx.session)
110+
if session_id is None:
111+
session = await runner.session_service.create_session(
112+
app_name=runner.app_name, user_id=_MCP_USER_ID
113+
)
114+
session_id = session.id
115+
if ctx is not None and sessions is not None:
116+
sessions[ctx.session] = session_id
117+
new_message = types.Content(role="user", parts=[types.Part(text=request)])
118+
final_content: list[mcp_types.ContentBlock] = []
119+
async for event in runner.run_async(
120+
user_id=_MCP_USER_ID,
121+
session_id=session_id,
122+
new_message=new_message,
123+
):
124+
if not (event.content and event.content.parts):
125+
continue
126+
if event.is_final_response():
127+
for part in event.content.parts:
128+
block = _part_to_content(part)
129+
if block is not None:
130+
final_content.append(block)
131+
elif ctx is not None:
132+
text = "".join(part.text or "" for part in event.content.parts)
133+
if text:
134+
await ctx.report_progress(progress=0.0, message=text)
135+
return final_content
136+
137+
138+
@experimental(FeatureName.MCP_AGENT_SERVER)
139+
def to_mcp_server(
140+
agent: BaseAgent,
141+
*,
142+
name: Optional[str] = None,
143+
instructions: Optional[str] = None,
144+
runner: Optional[Runner] = None,
145+
) -> FastMCP:
146+
"""Exposes an ADK agent as an MCP server.
147+
148+
The returned server registers a single MCP tool that runs the agent: an MCP
149+
host (e.g. Claude Code, OpenAI Codex, an IDE, or any MCP client) sends a
150+
request string and receives the agent's final response, including any images
151+
or audio the agent produced. This is the MCP counterpart of ``to_a2a``; it
152+
lets harnesses that speak MCP drive an ADK agent.
153+
154+
One ADK session is kept per MCP connection, so successive tool calls on the
155+
same connection form a single multi-turn conversation.
156+
157+
The caller chooses the transport, e.g. ``server.run(transport="stdio")`` for
158+
a local host or ``server.run(transport="streamable-http")`` for a networked
159+
one.
160+
161+
Args:
162+
agent: The ADK agent to serve.
163+
name: The MCP server and tool name. Defaults to the agent's name.
164+
instructions: Optional instructions the MCP host may show to its model.
165+
runner: A pre-built Runner. If omitted, one is created with in-memory
166+
services.
167+
168+
Returns:
169+
A ``FastMCP`` server exposing the agent as a single tool.
170+
171+
Example::
172+
173+
agent = LlmAgent(name="assistant", model="gemini-2.0-flash", ...)
174+
server = to_mcp_server(agent)
175+
server.run(transport="stdio")
176+
"""
177+
tool_name = name or agent.name or "adk_agent"
178+
server = FastMCP(name=tool_name, instructions=instructions)
179+
agent_runner = runner if runner is not None else _build_runner(agent)
180+
# Maps each MCP connection to its ADK session; WeakKeyDictionary drops the
181+
# entry when the connection is garbage-collected. pylint wrongly flags the
182+
# WeakKeyDictionary() instantiation below as abstract-class-instantiated.
183+
# pylint: disable-next=abstract-class-instantiated
184+
sessions: MutableMapping[object, str] = weakref.WeakKeyDictionary()
185+
186+
async def call_agent(
187+
request: str, ctx: Context
188+
) -> list[mcp_types.ContentBlock]:
189+
return await _run_agent(agent_runner, request, ctx, sessions)
190+
191+
server.add_tool(
192+
call_agent,
193+
name=tool_name,
194+
description=agent.description or f"Run the {tool_name} agent.",
195+
structured_output=False,
196+
)
197+
return server

0 commit comments

Comments
 (0)