A release bot needs answers from the docuctl docs assistant without importing its Python code. Google ADK’s to_mcp_server wraps an agent in a FastMCP server with a single tool. A client sends a question as text, the server runs the whole agent, with its own tools and model, and returns the final answer. Calls on one MCP connection share an ADK session, so a follow-up keeps the context. In one recorded run on Gemini gemini-3.8-flash, the second call on a connection still answered for shipctl 1.x, which only the first call had mentioned:
When you need this. When an agent’s knowledge or tools should be reusable without sharing its code: a docs assistant, a runbook agent or a code-search agent that several teams call from their own agents and hosts. MCP is the common interface those clients already speak. This lab does not bind MCP clients to authenticated users; add that identity boundary before sharing it between users.
Recorded example. The archived “Expose the docs assistant as an MCP server” demonstration retains the earlier shipctl scenario; the current lab uses docuctl and does not claim a new model result.
# connection 1
> I'm on shipctl 1.x. How do I roll back production to r41?
answer In shipctl 1.x, use:
```bash
shipctl release revert r41
```
This reverts traffic to release `r41`. Note that in shipctl 2.x, this was replaced by `shipctl rollback --env production --to r41`. (6.0s)
> And how do I list the releases of production?
answer In shipctl 1.x, use:
```bash
shipctl release list
```
(In shipctl 2.x, this was replaced by `shipctl releases --env production`.) (5.9s)Lab setup. Download the complete lab ZIP, extract it and open a terminal in adk-agent-mcp-server/. Keep the bundled code, tests and fixtures together. Create the pinned environment before running the commands below:
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt1. Wrap the agent in an MCP server
"""The docs assistant as an MCP server: any MCP client can ask it docuctl questions.
Run over stdio (a local client starts this process): python server.py
Run over Streamable HTTP (a networked client connects): python server.py http
to_mcp_server registers one MCP tool that runs the whole agent and returns its final answer.
"""
import os
import sys
from pathlib import Path
from google.adk import Agent
from google.adk.models.lite_llm import LiteLlm
from google.adk.tools.mcp_tool import to_mcp_server
from google.genai import types
HERE = Path(__file__).resolve().parent
# Gemini through ADK's native client; set DOCS_MODEL=ollama_chat/gemma4:e2b to run Gemma locally.
MODEL = os.environ.get("DOCS_MODEL", "gemini-3.8-flash")
PAGES = sorted(p.stem for p in (HERE / "docs").glob("*.md"))
def read_page(page: str) -> dict:
"""Read one page of the docuctl manual, such as rollback, hooks, secrets or upgrade-2x."""
if page not in PAGES:
return {"error": f"No page {page!r}. Pages: {', '.join(PAGES)}."}
return {"page": page, "text": (HERE / "docs" / f"{page}.md").read_text(encoding="utf-8")}
root_agent = Agent(
name="docuctl_docs",
# The description becomes the MCP tool description: it is how a client's model decides to call it.
description="Answer a question about docuctl, the team's deploy CLI, from its manual. "
"Pass the full question; follow-up questions on the same connection keep the context.",
model=LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL,
instruction=f"""You answer questions about docuctl from its manual. Pages: {', '.join(PAGES)}.
Read the relevant page with read_page before you answer, and name the exact command.
If the developer said which docuctl version they use, answer for that version. Keep answers under 80 words.""",
tools=[read_page],
generate_content_config=types.GenerateContentConfig(
temperature=0.2, thinking_config=types.ThinkingConfig(thinking_level="low")),
)
mcp_server = to_mcp_server(root_agent, instructions="Ask docuctl questions in plain English.")
if __name__ == "__main__":
# stdout carries the protocol on stdio: never print to it; log to stderr instead.
mcp_server.run(transport="streamable-http" if sys.argv[1:] == ["http"] else "stdio")to_mcp_server(root_agent, instructions=...) returns a FastMCP server. It registers one tool named after the agent, with one string argument, and uses the agent’s description as the tool description. That description is what a client’s model reads when it decides whether to call the tool, so write it for that model: what the tool answers and how to phrase a request. Without one, ADK falls back to a generic “Run the docuctl_docs agent.”
The transport is the caller’s choice. stdio suits a client that starts the server as a child process on the same machine, as both clients here do. streamable-http serves many clients over the network, and then the server needs the authentication an HTTP service needs.
There is a second pattern: expose single ADK tools, without the agent loop, by converting each one with adk_to_mcp_tool_type in a low-level MCP server. Choose it when clients should call read_page directly and do their own reasoning. Choose to_mcp_server when the answer needs the agent’s instruction, tools and model.
The server has no API key. The call returned an error result: the stdio client started the server with only its default environment variables, so the Gemini client had no key. Pass env={"GOOGLE_API_KEY": ...} in StdioServerParameters, or load secrets inside the server process.
2. Call it from an MCP client
record.py connects with the MCP Python SDK’s stdio_client and ClientSession, which is what any MCP host does underneath. It makes two calls on one connection and one on a new connection.
python record.pyserver shipctl_docs: Ask shipctl questions in plain English.
tool shipctl_docs(request): Answer a question about shipctl, the team's deploy CLI, from its manual. Pass the full question; follow-up questions on the same connection keep the context.
# connection 2
> And how do I list the releases of production?
answer To list releases in production, run:
```bash
shipctl releases --env production
```
This lists the releases that the environment has run, newest first, along with their status (`live`, `previous`, `degraded`, `failed`, or `pending`). (3.8s)The new connection got the 2.x command, shipctl releases --env production: it started a new ADK session, with no 1.x context. Each call took several seconds, 6 s and 5.9 s on the first connection, because each one is a complete agent run with a tool call and a model call.
The client times out first. Through McpToolset with the default stdio timeout, the first call failed; the bot’s model retried, and the second call returned in time. Set StdioConnectionParams(timeout=...) above the agent’s slowest run; the bot uses 60 seconds.
The recorded messages:
Error executing tool shipctl_docs: No API key was provided. Please pass a valid API key. Learn how to create an API key at https://ai.google.dev/gemini-api/docs/api-key.
MCP tool execution failed: Timed out while waiting for response to ClientRequest. Waited 5.0 seconds.In the recorded timeout case, the client retried and received a response. A timeout result alone does not show whether remote model work stopped. Check server logs for cancellation and repeated calls before retrying an operation with side effects.
3. Call it from another ADK agent
release_bot.py is a second agent that helps with failed releases. It reaches the docs assistant through McpToolset, which starts server.py over stdio and offers its tool with the prefix docs_.
"""A release bot, a second ADK agent, that asks the docs assistant through its MCP server.
McpToolset starts server.py over stdio and offers its one tool, prefixed docs_, to the bot's model.
Run with GOOGLE_API_KEY set: python release_bot.py "<question>"
"""
import asyncio
import json
import os
import sys
from pathlib import Path
from google.adk import Agent, Runner
from google.adk.models.lite_llm import LiteLlm
from google.adk.sessions import InMemorySessionService
from google.adk.tools.mcp_tool import McpToolset, StdioConnectionParams
from google.genai import types
from mcp import StdioServerParameters
HERE = Path(__file__).resolve().parent
MODEL = os.environ.get("DOCS_MODEL", "gemini-3.8-flash")
QUESTION = "Staging deploy r42 failed in pre_deploy. Ask the docs whether rolling staging back undoes its migration."
def docs_toolset(timeout: float | None = 60) -> McpToolset:
server = StdioServerParameters(
command=sys.executable, args=[str(HERE / "server.py")], cwd=str(HERE),
# The stdio client passes only HOME, PATH, SHELL, TERM, USER and LOGNAME to the server by default.
env={"GOOGLE_API_KEY": os.environ.get("GOOGLE_API_KEY", ""), "DOCS_MODEL": MODEL},
)
connection = StdioConnectionParams(server_params=server, **({"timeout": timeout} if timeout else {}))
return McpToolset(connection_params=connection, tool_name_prefix="docs")
def make_bot(toolset: McpToolset) -> Agent:
return Agent(
name="release_bot",
model=LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL,
instruction="You help engineers with failed releases. For any question about how docuctl behaves, "
"call the docs tool with the full question, then answer in under 60 words.",
tools=[toolset],
generate_content_config=types.GenerateContentConfig(
temperature=0.2, thinking_config=types.ThinkingConfig(thinking_level="low")),
)
async def ask(question: str, timeout: float | None = 60) -> list:
toolset = docs_toolset(timeout)
runner = Runner(app_name="release", agent=make_bot(toolset), session_service=InMemorySessionService(),
auto_create_session=True)
message = types.Content(role="user", parts=[types.Part(text=question)])
try:
return [e async for e in runner.run_async(user_id="ops", session_id="s1", new_message=message)]
finally:
try:
await runner.close()
finally:
await toolset.close()
async def record(timeout: float | None, question: str = QUESTION) -> dict:
events = await ask(question, timeout)
answer = next(("".join(p.text or "" for p in e.content.parts if not p.thought).strip()
for e in reversed(events) if e.is_final_response() and e.content), "")
return {"question": question, "timeout": timeout,
"calls": [{"name": c.name, "args": c.args} for e in events for c in e.get_function_calls()],
"results": [r.response for e in events for r in e.get_function_responses()], "answer": answer}
if __name__ == "__main__":
print(json.dumps(asyncio.run(record(60, " ".join(sys.argv[1:]) or QUESTION)), indent=2))From the extracted adk-agent-mcp-server directory, activate the environment with the pinned dependencies and set GOOGLE_API_KEY. Run release_bot.py to ask a question through the MCP server:
python release_bot.py "Does rolling staging back undo its database migration?"The client starts the stdio server as a child process, so do not start another server terminal for this command (python server.py alone waits for MCP protocol messages on stdin/stdout).
# release_bot through McpToolset, timeout=60
> Staging deploy r42 failed in pre_deploy. Ask the docs whether rolling staging back undoes its migration.
call docs_shipctl_docs {"request": "Does rolling staging back undo its migration?"}
result {"content": [{"type": "text", "text": "No. Running `shipctl rollback` (for example, `shipctl rollback --env staging`) moves traffic back to an earlier release, but database migrations are not rolled back. If the release
answer No, rolling back staging does not undo its database migration. `shipctl rollback` only reverts traffic to an earlier release. To undo the database changes, you must write and deploy a new migration that reverses them.The bot’s model rewrote the question in its own words before it passed it on; the docs assistant only sees what the bot sends. tool_name_prefix gives the remote tool a distinct name. The client closes its Runner and toolset in finally, including when a transport or model call raises; the cleanup regression covers that error path.
How an MCP call becomes an ADK invocation
The server advertises one tool. Its schema, as tools/list returned it:
{
"name": "shipctl_docs",
"description": "Answer a question about shipctl, the team's deploy CLI, from its manual. Pass the full question; follow-up questions on the same connection keep the context.",
"inputSchema": {
"properties": {
"request": {
"title": "Request",
"type": "string"
}
},
"required": [
"request"
],
"title": "call_agentArguments",
"type": "object"
}
}On each tools/call, the server looks up the ADK session for the calling MCP connection, creating one on the first call, and runs the agent with the request as a new user message. Text from non-final events goes out as MCP progress notifications, if the client asked for progress. The final response’s parts become MCP content blocks: text as TextContent, images and audio as their own block types. An exception in the agent run becomes a result with isError set, so the client gets an error result, not a broken connection.
Two details of that mapping matter in production. Every connection runs as the same ADK user id, mcp_user in the 2.12.0 source, so user: state and memory are shared by all clients; this agent keeps none, and an agent that does should not be served this way without its own user mapping. And sessions live in the server’s in-memory session service unless you pass runner= with a persistent one, so a server restart starts every conversation over.
Test the server in memory
test_server.py connects to the server through the MCP SDK’s in-memory transport, so the requests go through the real protocol without a subprocess. A counting model replaces Gemini: it answers with how many developer messages it can see, which makes session reuse visible.
python -m unittest -v test_server.pytest_a_slow_agent_exceeds_the_client_read_timeout (test_server.AgentAsMcpServer.test_a_slow_agent_exceeds_the_client_read_timeout) ... ok
test_an_agent_error_comes_back_as_an_error_result (test_server.AgentAsMcpServer.test_an_agent_error_comes_back_as_an_error_result) ... ok
test_calls_on_one_connection_share_a_session (test_server.AgentAsMcpServer.test_calls_on_one_connection_share_a_session) ... ok
test_stdio_connections_default_to_a_five_second_timeout (test_server.AgentAsMcpServer.test_stdio_connections_default_to_a_five_second_timeout) ... ok
test_the_server_offers_one_tool_that_takes_a_request (test_server.AgentAsMcpServer.test_the_server_offers_one_tool_that_takes_a_request) ... ok
test_a_failed_run_closes_the_runner_and_toolset (test_server.ClientCleanup.test_a_failed_run_closes_the_runner_and_toolset) ... ok
test_the_follow_up_on_the_same_connection_answered_for_1x (test_server.RecordedRun.test_the_follow_up_on_the_same_connection_answered_for_1x) ... ok
test_the_server_without_the_key_failed_every_call (test_server.RecordedRun.test_the_server_without_the_key_failed_every_call) ... ok
----------------------------------------------------------------------
Ran 8 tests in 0.243s
OKAll 8 tests pass. The no-key test removes the key from the environment and uses the real Gemini model class, which fails before it sends anything.
Read the test and recording scripts
"""Test the MCP server in memory: the real MCP protocol, with a counting model instead of Gemini.
Run: python -m unittest -v test_server.py
"""
import asyncio
import json
import logging
import os
import unittest
import warnings
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import timedelta
from pathlib import Path
warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)
from google.adk.models.base_llm import BaseLlm # noqa: E402
from google.adk.models.llm_response import LlmResponse # noqa: E402
from google.adk.tools.mcp_tool import StdioConnectionParams, to_mcp_server # noqa: E402
from google.genai import types # noqa: E402
from mcp import McpError, StdioServerParameters # noqa: E402
from mcp.shared.memory import create_connected_server_and_client_session as connect # noqa: E402
import server # noqa: E402
import release_bot # noqa: E402
RECORD = json.loads(Path(__file__).with_name("recorded-runs.json").read_text(encoding="utf-8"))
class CountingLlm(BaseLlm):
"""Answers with the number of developer messages in the request, after an optional delay."""
delay: float = 0.0
async def generate_content_async(self, llm_request, stream=False):
await asyncio.sleep(self.delay)
seen = sum(1 for c in llm_request.contents if c.role == "user" and any(p.text for p in c.parts))
yield LlmResponse(content=types.Content(role="model", parts=[types.Part(text=f"messages seen: {seen}")]))
def serve(model) -> object:
return to_mcp_server(server.root_agent.clone(update={"model": model}))
def run(coro):
return asyncio.run(coro)
class AgentAsMcpServer(unittest.TestCase):
def test_the_server_offers_one_tool_that_takes_a_request(self):
async def go():
async with connect(serve(CountingLlm(model="count"))) as client:
return (await client.list_tools()).tools
tools = run(go())
self.assertEqual([t.name for t in tools], ["docuctl_docs"])
self.assertEqual(tools[0].inputSchema["required"], ["request"])
self.assertEqual(tools[0].description, server.root_agent.description)
def test_calls_on_one_connection_share_a_session(self):
mcp_server = serve(CountingLlm(model="count"))
async def go():
async with connect(mcp_server) as first:
a = await first.call_tool("docuctl_docs", {"request": "one"})
b = await first.call_tool("docuctl_docs", {"request": "two"})
async with connect(mcp_server) as second:
c = await second.call_tool("docuctl_docs", {"request": "three"})
return [r.content[0].text for r in (a, b, c)]
self.assertEqual(run(go()), ["messages seen: 1", "messages seen: 2", "messages seen: 1"])
def test_an_agent_error_comes_back_as_an_error_result(self):
saved = {k: os.environ.pop(k) for k in ("GOOGLE_API_KEY", "GEMINI_API_KEY") if k in os.environ}
try:
async def go():
async with connect(serve("gemini-3.8-flash")) as client:
return await client.call_tool("docuctl_docs", {"request": "How do I roll back?"})
result = run(go())
finally:
os.environ.update(saved)
self.assertTrue(result.isError)
self.assertIn("No API key was provided", result.content[0].text)
def test_a_slow_agent_exceeds_the_client_read_timeout(self):
async def go():
async with connect(serve(CountingLlm(model="count", delay=1.0)),
read_timeout_seconds=timedelta(seconds=0.2)) as client:
try:
await client.call_tool("docuctl_docs", {"request": "slow"})
except McpError as error:
return str(error)
self.assertIn("Timed out while waiting for response", run(go()))
def test_stdio_connections_default_to_a_five_second_timeout(self):
params = StdioConnectionParams(server_params=StdioServerParameters(command="python"))
self.assertEqual(params.timeout, 5.0)
errors = [r for r in RECORD["failures"]["default_timeout"]["results"] if "error" in r]
self.assertIn("Waited 5.0 seconds", errors[0]["error"])
class ClientCleanup(unittest.TestCase):
def test_a_failed_run_closes_the_runner_and_toolset(self):
async def failing_run(**kwargs):
raise RuntimeError("transport failed")
yield # keep the Runner.run_async interface
runner = MagicMock()
runner.run_async = failing_run
runner.close = AsyncMock()
toolset = MagicMock()
toolset.close = AsyncMock()
with patch.object(release_bot, "docs_toolset", return_value=toolset), \
patch.object(release_bot, "make_bot"), patch.object(release_bot, "Runner", return_value=runner):
with self.assertRaisesRegex(RuntimeError, "transport failed"):
run(release_bot.ask("Read the rollback page."))
runner.close.assert_awaited_once()
toolset.close.assert_awaited_once()
class RecordedRun(unittest.TestCase):
"""Checks on the recorded Gemini run: these read recorded-runs.json and make no model call."""
def test_the_follow_up_on_the_same_connection_answered_for_1x(self):
same, fresh = RECORD["same_connection"]["calls"][1]["text"], RECORD["new_connection"]["calls"][0]["text"]
self.assertIn("shipctl release list", same)
self.assertNotIn("1.x", fresh)
def test_the_server_without_the_key_failed_every_call(self):
self.assertTrue(all(c["is_error"] for c in RECORD["failures"]["no_key"]["calls"]))
if __name__ == "__main__":
unittest.main()"""Record MCP clients talking to the docs assistant's MCP server over stdio, on Gemini.
Run from this folder with GOOGLE_API_KEY set: python record.py
Writes recorded-runs.json and run-output.txt. The key is passed to the server process, never recorded.
"""
import asyncio
import json
import os
import sys
import time
import warnings
from pathlib import Path
warnings.filterwarnings("ignore")
from mcp import ClientSession, StdioServerParameters # noqa: E402
from mcp.client.stdio import stdio_client # noqa: E402
import release_bot # noqa: E402
HERE = Path(__file__).resolve().parent
FIRST = "I'm on docuctl 1.x. How do I roll back production to r41?"
FOLLOW_UP = "And how do I list the releases of production?"
def params(with_key: bool = True) -> StdioServerParameters:
env = {"GOOGLE_API_KEY": os.environ["GOOGLE_API_KEY"]} if with_key else None
return StdioServerParameters(command=sys.executable, args=[str(HERE / "server.py")], env=env, cwd=str(HERE))
def text_of(result) -> str:
return "".join(getattr(block, "text", "") for block in result.content)
async def connection(questions: list[str], with_key: bool = True) -> dict:
"""One MCP connection: initialize, list tools, then call the agent tool once per question."""
async with stdio_client(params(with_key)) as (read, write), ClientSession(read, write) as session:
init = await session.initialize()
tools = (await session.list_tools()).tools
calls = []
for question in questions:
start = time.perf_counter()
result = await session.call_tool(tools[0].name, {"request": question})
calls.append({"request": question, "is_error": result.isError, "text": text_of(result),
"seconds": round(time.perf_counter() - start, 1)})
return {"server": {"name": init.serverInfo.name, "instructions": init.instructions,
"capabilities": sorted(k for k, v in init.capabilities.model_dump().items() if v)},
"tools": [t.model_dump(mode="json", exclude_none=True) for t in tools], "calls": calls}
async def main() -> None:
first = await connection([FIRST, FOLLOW_UP])
fresh = await connection([FOLLOW_UP])
no_key = await connection([FIRST], with_key=False)
bot = await release_bot.record(timeout=60)
bot_default_timeout = await release_bot.record(timeout=None)
record = {"model": release_bot.MODEL, "same_connection": first, "new_connection": fresh,
"failures": {"no_key": no_key, "default_timeout": bot_default_timeout}, "release_bot": bot}
text = json.dumps(record, indent=2)
assert os.environ["GOOGLE_API_KEY"] not in text
(HERE / "recorded-runs.json").write_text(text + "\n", encoding="utf-8")
tool = first["tools"][0]
lines = [f"server {first['server']['name']}: {first['server']['instructions']}",
f"tool {tool['name']}({', '.join(tool['inputSchema']['properties'])}): {tool['description']}", ""]
for label, run in [("connection 1", first), ("connection 2", fresh), ("no GOOGLE_API_KEY in the server env", no_key)]:
lines.append(f"# {label}")
for c in run["calls"]:
lines += [f"> {c['request']}", f"{'error ' if c['is_error'] else 'answer'} {c['text'][:260]} ({c['seconds']}s)"]
lines.append("")
for label, run in [("release_bot through McpToolset, timeout=60", bot),
("release_bot through McpToolset, default timeout", bot_default_timeout)]:
lines += [f"# {label}", f"> {run['question']}"]
lines += [f"call {c['name']} {json.dumps(c['args'])[:160]}" for c in run["calls"]]
lines += [f"result {json.dumps(r)[:220]}" for r in run["results"]]
lines += ["answer " + run["answer"].replace("\n", " ")[:300], ""]
(HERE / "run-output.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
print("\n".join(lines))
if __name__ == "__main__":
asyncio.run(main())- Trace and measure agent runs with OpenTelemetry: inspect model and tool activity as spans.
Check your understanding
Read the explanation
The wrapper uses the same ADK user ID for connections in the pinned version. Separate sessions do not isolate user-scoped state.
References
Official sources checked October 21, 2026, against google-adk 2.12.0.