aiml
SearchMy learning
Menu

Lesson 11 of 20Module 3

Expose the docs assistant as an MCP server

Serve the docs assistant as one MCP tool with to_mcp_server, call it from the MCP SDK and another ADK agent, and fix key and timeout failures.

Prerequisites

An ADK agent with function tools, and an MCP client connected to a server with McpToolset.

By the end, you can

  • Wrap an ADK agent in a FastMCP server with to_mcp_server and run it over stdio or Streamable HTTP.
  • Call the agent tool from the MCP Python SDK and from another ADK agent through McpToolset, and explain how sessions map to MCP connections.
  • Pass the server process its environment and give the client a timeout that fits an agent run.
Runtime
Python 3.12+, a Gemini API key (or Ollama with gemma4:e2b)
Dependencies
google-adk[mcp] 2.12.0 with mcp 1.30.0; litellm 1.102.4 for the Ollama alternative

Lab code, tests and fixtures (ZIP, 243 KB)Quiz check your understanding

Progress is saved in this browser.

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.

Recorded: two calls on one MCP connectionRecorded output (excerpt) · textFull output
# 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)
server.py · release_bot.py · to_mcp_server · recorded in recorded-runs.json
data flowreturnartifact hand-offfailurerelease_botMcpToolset,prefix docs_ClientSessionMCP Python SDKrecord.pyserver.py process, started over stdioFastMCP servertool docuctl_docs(request)from to_mcp_servertools/calltextcallRunnerone session per connectionuser mcp_userrequestin-memory sessionsdocuctl_docsAgent + read_pagerunGeminiGoogle-hosted APIrequestisError: No API key was providedthe stdio client passed only its default environmentWaited 5.0 secondsMcpToolset default stdio timeout; the model retrieddata flowreturnartifact hand-offfailurerelease_botMcpToolsetClientSessionMCP SDKcallcallserver.pyFastMCP serverdocuctl_docs(request)requestRunnersession per connectionsessionsrundocuctl_docsAgent + read_pagerequestGeminiGoogle-hosted APIisError: no API keydefault env onlyWaited 5.0 secondsdefault timeout
to_mcp_server wraps the whole agent in one MCP tool that takes a request string. Each MCP connection gets its own ADK session, so follow-up calls on one connection continue the conversation, while every connection runs as the same ADK user. The server is a separate process: it sees only the environment its client passes, and its client waits only as long as its timeout. Gemini is an external Google-hosted API; the Python process does not host the model. The current agent and MCP tool are named docuctl_docs. The preserved recording names the tool shipctl_docs; its argument and the timeout message come from that recording. Both failures were recorded through the real stdio transport.

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:

Prepare the lab environmentTerminal · bash
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt

1. Wrap the agent in an MCP server

server.pySource · pythonDownload
"""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.

Terminal commandsTerminal · bash
python record.py
Recorded: the advertised tool, and the follow-up on a new connectionRecorded output (excerpt) · textFull output
server 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:

recorded-runs.json: two failuresRecorded output · textDownload
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_.

release_bot.pySource · pythonDownload
"""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:

Ask through the MCP transportTerminal · bash
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).

Recorded: release_bot asks the docs assistantRecorded output (excerpt) · textFull output
# 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:

recorded-runs.json: the advertised toolRecorded output · jsonDownload
{
  "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.

Terminal commandsTerminal · bash
python -m unittest -v test_server.py
Recorded test outputRecorded output · textDownload
test_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

OK

All 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_server.pySource · pythonDownload
"""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.pySource · pythonDownload
"""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())

Check your understanding

Two MCP clients connect to this wrapper. Each connection gets its own ADK session. What should you check before adding user: state?

Choose one answer, then check your reasoning.

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.

Written by Arjun Prabhulal.

Module 3

Connect APIs and tools