Skip to content

Commit d8942df

Browse files
authored
Merge branch 'main' into skill-script-utf8-output
2 parents f0e7c1b + 1ac6875 commit d8942df

7 files changed

Lines changed: 354 additions & 34 deletions

File tree

‎docs/guides/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ This directory contains specific developer guides for the ADK Python implementat
1313
* [Event and NodeInfo](events/event/index.md) - Understanding Event and NodeInfo in workflows.
1414
* [RequestInput](events/request_input/index.md) - How to use RequestInput for human-in-the-loop interactions.
1515

16+
### Tools
17+
* [to_mcp_server](tools/mcp_tool/agent_to_mcp/index.md) - Expose an ADK agent as an MCP server so any MCP host can drive it as a single tool (the MCP counterpart of to_a2a).
18+
1619
### Workflows
1720
* [Workflow](workflow/workflow/index.md) - Graph-based orchestration of complex, multi-step agent interactions.
1821
* [Workflow Graphs](workflow/graph/index.md) - Understanding nodes, edges, and graph structures in workflows.
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# to_mcp_server
2+
3+
Exposes an ADK agent as an MCP server so any MCP host (Claude Code, OpenAI
4+
Codex, an IDE, or any MCP client) can drive it as a single tool. It is the MCP
5+
counterpart of `to_a2a`.
6+
7+
## Introduction
8+
9+
`to_mcp_server` turns a whole ADK agent into a standard
10+
[Model Context Protocol](https://modelcontextprotocol.io/) server. The agent —
11+
its model loop and all of its tools — is registered as a *single* MCP tool named
12+
after the agent. A host that speaks MCP sends a request string and receives the
13+
agent's final response; it never imports ADK and does not see the agent's
14+
individual tools.
15+
16+
This solves the problem of making an ADK agent consumable by harnesses that are
17+
not ADK. Where `to_a2a` publishes an agent over A2A, `to_mcp_server` publishes it
18+
over MCP, so coding agents and IDEs that already speak MCP can delegate a task to
19+
an ADK agent as if it were any other tool. It builds on `Runner` to execute the
20+
agent and returns a `FastMCP` server, leaving the choice of transport (stdio for
21+
local hosts, streamable-http for networked ones) to the caller.
22+
23+
## Get started
24+
25+
Define an agent and expose it. Running the file starts the MCP server on stdio;
26+
an MCP host can also launch it as a subprocess.
27+
28+
```python
29+
import random
30+
31+
from google.adk.agents import LlmAgent
32+
from google.adk.tools.mcp_tool import to_mcp_server
33+
34+
35+
def roll_die(sides: int) -> int:
36+
"""Roll a die with the given number of sides and return the result."""
37+
return random.randint(1, sides)
38+
39+
40+
dice_agent = LlmAgent(
41+
name="dice_agent",
42+
description="Rolls dice with any number of sides and reports the outcome.",
43+
instruction="Use the roll_die tool to roll the dice the user asks for.",
44+
tools=[roll_die],
45+
)
46+
47+
# The whole agent becomes one MCP tool named "dice_agent".
48+
server = to_mcp_server(dice_agent)
49+
50+
if __name__ == "__main__":
51+
server.run(transport="stdio")
52+
```
53+
54+
A host configured to launch this file sees one tool, `dice_agent`, and calls it
55+
with a `request` string; the ADK agent runs its own model and `roll_die` loop and
56+
returns the answer.
57+
58+
## How it works
59+
60+
`to_mcp_server` creates a `FastMCP` server and registers one tool whose handler
61+
runs the agent through a `Runner`. If no `runner` is supplied, one is built with
62+
in-memory session, artifact, memory, and credential services.
63+
64+
On each tool call the handler:
65+
66+
1. Resolves an ADK session (see below), then wraps the incoming `request` string
67+
as a user `Content`.
68+
2. Drives `Runner.run_async` and iterates the event stream.
69+
3. Forwards intermediate (non-final) text events to the host as MCP **progress
70+
notifications**, so the host can show the agent working in real time.
71+
4. Maps the parts of the final response to MCP content blocks and returns them:
72+
text becomes `TextContent`, inline image data becomes `ImageContent`, audio
73+
becomes `AudioContent`, and any other inline data becomes an
74+
`EmbeddedResource`. This is why a multimodal agent's output is preserved
75+
rather than flattened to text.
76+
77+
### Session continuity
78+
79+
`to_mcp_server` keeps one ADK session per MCP connection, so successive tool
80+
calls on the same connection form a single multi-turn conversation. The mapping
81+
from connection to session is held in a `weakref.WeakKeyDictionary`, so a
82+
session's entry is dropped when its connection is garbage-collected. Over stdio
83+
there is one connection per process, so all calls share one conversation; over
84+
streamable-http each client connection gets its own session.
85+
86+
`to_mcp_server` depends on `Runner`, the agent (`BaseAgent`/`LlmAgent`),
87+
`google.genai.types`, and `mcp.server.fastmcp.FastMCP`; it returns a `FastMCP`
88+
that the caller runs on a transport of their choice.
89+
90+
## Configuration options
91+
92+
| Option | Type | Default | Description |
93+
| --- | --- | --- | --- |
94+
| `agent` | `BaseAgent` | *required* | The agent to serve. Its model loop and all of its tools are exposed together as one MCP tool. |
95+
| `name` | `str \| None` | `None` | The MCP server and tool name. Defaults to the agent's name (or `"adk_agent"`). Set it when you want the tool to appear under a name other than the agent's. |
96+
| `instructions` | `str \| None` | `None` | Optional server instructions an MCP host may surface to its model to describe how to use the tool. |
97+
| `runner` | `Runner \| None` | `None` | A pre-built `Runner`. If omitted, one is created with in-memory services. Supply your own to use persistent or custom session, artifact, memory, or credential services — this is the recommended path for a long-lived networked server. |
98+
99+
## Advanced applications
100+
101+
### Serving over the network
102+
103+
* **Problem solved**: a host on another machine needs to reach the agent.
104+
* **Implementation**: run the same server with the networked transport:
105+
`server.run(transport="streamable-http")`. Nothing about the agent changes;
106+
only the transport differs.
107+
108+
### Bringing your own services
109+
110+
* **Problem solved**: the default in-memory services do not persist across
111+
process restarts and are not suited to multi-client production serving.
112+
* **Implementation**: build a `Runner` with your chosen services and pass it in:
113+
`to_mcp_server(agent, runner=my_runner)`. The tool then uses those services
114+
for every call.
115+
116+
### Multimodal responses
117+
118+
* **Problem solved**: the agent produces images or audio, not just text.
119+
* **Implementation**: no extra work — non-text parts of the final response are
120+
returned as `ImageContent`, `AudioContent`, or `EmbeddedResource`, so the
121+
host receives them alongside any text.
122+
123+
## Limitations
124+
125+
* **Text input only**: the tool accepts a single `request` string. Passing
126+
media *into* the agent is not supported through the tool call, because MCP
127+
tool arguments are JSON that the host's model fills in and hosts do not place
128+
media in tool arguments. For media input, use MCP resources or elicitation
129+
instead.
130+
* **Default services are in-memory**: for a long-lived streamable-http server,
131+
sessions accumulate with no eviction; inject a `runner` with a persistent or
132+
cleaning session service. Tool calls on a single connection are expected to
133+
be sequential, since they share one session.
134+
* **Experimental**: `to_mcp_server` is `@experimental` and lives behind the
135+
`mcp` extra; its behavior may change in future releases.
136+
137+
## Related samples
138+
139+
* [MCP: serve an ADK agent](../../../../../contributing/samples/mcp/mcp_serve_agent)

‎src/google/adk/apps/llm_event_summarizer.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@ class LlmEventSummarizer(BaseEventsSummarizer):
5050
' It may or may not start from a compacted history. Please identify and'
5151
' reiterate the user request, summarize the context so far, focusing on'
5252
' key decisions made and information obtained, as well as any unresolved'
53-
' questions or tasks. The summary should be concise and capture the'
53+
' questions or tasks. '
54+
'CRITICAL INSTRUCTIONS: '
55+
'1. Explicitly identify and state the primary language used by the user '
56+
'at the top of your summary (e.g., "Conversation Language: English"). '
57+
'2. If the agent called any tools, accurately list the exact tool names '
58+
'used to maintain tool grounding. '
59+
'The rest of the summary should be concise and capture the'
5460
' essence of the interaction.\n\n{conversation_history}'
5561
)
5662

‎src/google/adk/tools/environment/_read_file_tool.py‎

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from __future__ import annotations
1818

1919
import logging
20-
import shlex
2120
from typing import Any
2221
from typing import Optional
2322
from typing import TYPE_CHECKING
@@ -103,31 +102,12 @@ async def run_async(
103102
start_line = args.get('start_line')
104103
end_line = args.get('end_line')
105104

106-
# Use `sed` to read the file if start_line or end_line are specified.
107-
if (start_line and start_line > 1) or end_line:
108-
start = start_line or 1
109-
if end_line:
110-
sed_range = f'{start},{end_line}'
111-
else:
112-
sed_range = f'{start},$'
113-
path_arg = shlex.quote(path)
114-
sed_arg = shlex.quote(f'{sed_range}p')
115-
cmd = f'cat -n {path_arg} | sed -n {sed_arg}'
116-
res = await self._environment.execute(cmd)
117-
if res.exit_code == 0:
118-
return {
119-
'status': 'ok',
120-
'content': _truncate(
121-
res.stdout,
122-
limit=self._max_output_chars,
123-
),
124-
}
125-
126105
try:
106+
# TODO: Avoid loading the entire file into memory to prevent OOM on large files.
127107
data_bytes = await self._environment.read_file(path)
128-
text = data_bytes.decode('utf-8', errors='replace')
129-
lines = text.splitlines(True)
130-
total = len(lines)
108+
# Slice data_bytes by line boundaries before decoding.
109+
lines_bytes = data_bytes.splitlines(keepends=True)
110+
total = len(lines_bytes)
131111
start = max(1, start_line or 1)
132112
end = min(total, end_line or total)
133113
if start > total:
@@ -144,9 +124,13 @@ async def run_async(
144124
'error': f'`start_line` ({start}) is after `end_line` ({end}).',
145125
'total_lines': total,
146126
}
147-
selected = lines[start - 1 : end]
127+
selected_bytes = lines_bytes[start - 1 : end]
128+
lines = [
129+
line_bytes.decode('utf-8', errors='replace')
130+
for line_bytes in selected_bytes
131+
]
148132
numbered = ''.join(
149-
f'{start + i:6d}\t{line}' for i, line in enumerate(selected)
133+
f'{start + i:6d}\t{line}' for i, line in enumerate(lines)
150134
)
151135
result = {
152136
'status': 'ok',

‎src/google/adk/workflow/_workflow.py‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ def get_recovered_sequence_index(t):
351351

352352
sorted_done = sorted(ordered_done, key=get_recovered_sequence_index)
353353

354+
error_to_raise = None
354355
for task in sorted_done:
355356
name = self._pop_completed_task(loop_state, task)
356357

@@ -365,14 +366,17 @@ def get_recovered_sequence_index(t):
365366
node_state = loop_state.nodes[name]
366367
node_state.status = NodeStatus.FAILED
367368

368-
ctx._error = child_ctx.error
369-
ctx._error_node_path = child_ctx.error_node_path
370-
371-
loop_state.error_shut_down = True
372-
logger.debug('node %s execute loop end.', ctx.node_path)
373-
return
369+
if not error_to_raise:
370+
ctx._error = child_ctx.error
371+
ctx._error_node_path = child_ctx.error_node_path
372+
error_to_raise = child_ctx.error
373+
else:
374+
self._handle_completion(loop_state, name, node, child_ctx)
374375

375-
self._handle_completion(loop_state, name, node, child_ctx)
376+
if error_to_raise:
377+
loop_state.error_shut_down = True
378+
logger.debug('node %s execute loop end.', ctx.node_path)
379+
return
376380

377381
# Await fire-and-forget dynamic tasks.
378382
# TODO: Handle dynamic task failures and interrupts here.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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 pathlib import Path
16+
from typing import Optional
17+
18+
from google.adk.environment._base_environment import BaseEnvironment
19+
from google.adk.environment._base_environment import ExecutionResult
20+
from google.adk.tools.environment._tools import ReadFileTool
21+
import pytest
22+
23+
24+
class _StubEnvironment(BaseEnvironment):
25+
"""Minimal environment double for ReadFileTool tests."""
26+
27+
def __init__(self, files: dict[str, bytes]):
28+
self._files = files
29+
self.execute_calls: list[str] = []
30+
31+
@property
32+
def working_dir(self) -> Path:
33+
return Path('/tmp/adk-test')
34+
35+
async def execute(
36+
self,
37+
command: str,
38+
*,
39+
timeout: Optional[float] = None,
40+
) -> ExecutionResult:
41+
del timeout
42+
self.execute_calls.append(command)
43+
raise AssertionError('ReadFileTool should not invoke execute().')
44+
45+
async def read_file(self, path: Path) -> bytes:
46+
key = str(path)
47+
if key not in self._files:
48+
raise FileNotFoundError(key)
49+
return self._files[key]
50+
51+
async def write_file(self, path: Path, content: str | bytes) -> None:
52+
del path, content
53+
raise NotImplementedError
54+
55+
56+
@pytest.mark.asyncio
57+
async def test_read_file_with_line_range_uses_direct_file_read():
58+
"""ReadFileTool slices lines without shelling out."""
59+
environment = _StubEnvironment({
60+
'notes.txt': b'alpha\nbeta\ngamma\ndelta\n',
61+
})
62+
63+
result = await ReadFileTool(environment).run_async(
64+
args={'path': 'notes.txt', 'start_line': 2, 'end_line': 3},
65+
tool_context=None,
66+
)
67+
68+
assert result == {
69+
'status': 'ok',
70+
'content': ' 2\tbeta\n 3\tgamma\n',
71+
'total_lines': 4,
72+
}
73+
assert environment.execute_calls == []
74+
75+
76+
@pytest.mark.asyncio
77+
async def test_read_file_with_line_range_treats_shell_payload_as_literal_path():
78+
"""Shell metacharacters in the path do not trigger command execution."""
79+
environment = _StubEnvironment({
80+
'safe.txt': b'line1\nline2\n',
81+
})
82+
payload = (
83+
'\'; python3 -c "from pathlib import Path;'
84+
" Path('pwned.txt').write_text('owned')\"; echo '"
85+
)
86+
87+
result = await ReadFileTool(environment).run_async(
88+
args={'path': payload, 'start_line': 1, 'end_line': 2},
89+
tool_context=None,
90+
)
91+
92+
assert result == {
93+
'status': 'error',
94+
'error': f'File not found: {payload}',
95+
}
96+
assert environment.execute_calls == []

0 commit comments

Comments
 (0)