aiml
SearchMy learning
Menu

Lesson 4 of 17Module 1

Connect an agent to MCP servers with McpToolset

Attach a typed MCP server to a Google ADK 2.0 agent with McpToolset, expose only the tools it needs, read what reaches the model and catch a failed start.

Prerequisites

Python, a Gemini API key, and the preceding lessons in Agents and tools.

By the end, you can

  • Connect a stdio MCP server to an ADK agent with McpToolset and StdioConnectionParams.
  • Limit the tools an agent receives with tool_filter, and read the declarations ADK sends for MCP tools.
  • Detect an MCP server that fails to start, which ADK reports only in its log.
Runtime
Python 3.12+ and a Gemini API key
Dependencies
google-adk[mcp] 2.12.0, litellm and python-dotenv

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

Progress is saved in this browser.

When you need this. Another team already maintains a task tracker as an MCP server. Your docuctl assistant needs to find and add documentation tasks, but should not duplicate the server’s tools or expose its task-completion operation. We will connect that server through ADK’s McpToolset and select the two tools this assistant needs.

MCP is the protocol between the agent application and the tool server. McpToolset is the client adapter: it discovers the server’s tools and makes their declarations available to the model. The server still owns their implementation.

data flowreturnfailuregemini-3.8-flashagent processRunner + Agenttools: read_page,McpToolsetcall or textdeclarationsMcpToolsettool_filter:add_task, list_taskscallresultserver cannot startERROR log, no tracker toolscomplete_tasknever offered to the modelserver process (stdio child)tasks_server.pyMCPServertools/callresultTASKSin memoryreads, writesRecorded discoverytools/list returnedadd_taskcomplete_tasklist_tasksoffered to the model: add_task, list_tasks (shaded: filtered out)each description starts with<<<BEGIN_UNTRUSTED_TOOL_DESCRIPTION>>>data flowreturnfailureGeminigemini-3.8-flashagent processcalldeclarationsRunner + Agenttool callresultMcpToolsettool_filter:add_task, list_tasksno startERROR log onlyfilteredcomplete_taskcallresultserver processtasks_server.pyreads, writesTASKSin memoryRecorded discoveryadd_taskcomplete_tasklist_tasksshaded: never offereddescriptions are fencedas untrusted
McpToolset is the MCP client inside the agent process. It starts tasks_server.py as a child, lists its tools over stdio and wraps the ones tool_filter allows; the filtered tool never reaches the model's declarations. Each call becomes tools/call, and the server's CallToolResult returns unchanged as the function response. If the server cannot start, ADK logs an error and the agent runs without the tracker tools, so the failure shows only in the log and in the answers.

Set up the environment

Download the lab using Download code and open adk-mcp-toolset-lesson. It contains one Python client, tasks_server.py, documentation and offline tests. The tracker holds sample tasks in memory; it is not connected to an external issue service.

Prepare the local MCP client and serverTerminal · bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
cp .env.example .env

Set your Gemini API key in the private .env. agent.py loads it before constructing the agent. The MCP extra supplies the server SDK; both parent and child use the activated Python environment.

First inspect the documentation that the assistant can read:

docs/rollback.mdSource · markdownDownload
# Roll back a release

`docuctl rollback` moves an environment back to an earlier release. It does not rebuild anything; it points traffic at the release you name.

    docuctl rollback --env production --to r41

Without `--to`, docuctl rolls back to the previous release. A rollback is itself recorded as a new release, so `docuctl releases` shows it.

Database migrations are not rolled back. If the release you leave ran a migration, write a new migration that reverses it.

docuctl 1.x used `docuctl release revert <release>`. That command was removed in docuctl 2.0.

The server keeps three sample tasks in TASKS. Its list_tasks, add_task and complete_task functions have typed arguments and results:

tasks_server.py — task records and server stateSource · pythonDownload
class Task(BaseModel):
    """One task in the tracker."""
    id: str = Field(description="Task ID such as T-1.")
    title: str
    priority: Priority
    status: Status


class TaskList(BaseModel):
    """The tasks that match a filter."""
    tasks: list[Task]
    count: int


server = MCPServer("tasks", version="1.0.0", instructions="Read and update the task tracker of one software project.")

TASKS: dict[str, Task] = {task.id: task for task in [
    Task(id="T-1", title="Write the release notes", priority="normal", status="open"),
    Task(id="T-2", title="Fix the login redirect bug", priority="high", status="done"),
    Task(id="T-3", title="Update the API docs", priority="low", status="open"),
]}

Before making a model request, check discovery:

Discover tools without calling GeminiTerminal · bash
python agent.py --tools

The filtered discovery in the supplied example lists:

Recorded example. The archived “Connect an agent to MCP servers with McpToolset” demonstration retains the earlier shipctl scenario; the current lab uses docuctl and does not claim a new model result.

Saved tool-discovery resultRecorded output · text
add_task
list_tasks

This command starts and closes the real local MCP child. Do not start a second server terminal: ADK owns the child’s stdin and stdout. Server diagnostics belong on stderr so they do not corrupt the protocol.

Attach the server with McpToolset

The client factory defines the process command, timeout and allowed tool names:

agent.py — connect and filter MCP toolsSource · pythonDownload
def tracker(tool_filter: list[str] | None = ("list_tasks", "add_task"), python: str | None = None) -> McpToolset:
    """The task tracker MCP server as a child process; only the tools in tool_filter reach the model."""
    command = python or os.environ.get("TASKS_SERVER_PYTHON", sys.executable)
    return McpToolset(
        connection_params=StdioConnectionParams(
            server_params=StdioServerParameters(command=command, args=[str(HERE / "tasks_server.py")]),
            timeout=10,  # setup and default MCP request-response timeout in this SDK
        ),
        tool_filter=list(tool_filter) if tool_filter else None,
    )


def build_agent(model=None, toolset: McpToolset | None = None) -> Agent:
    return Agent(
        name="docs_assistant",
        model=model or (LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL),
        instruction="""You help developers keep the docuctl documentation correct.
Read pages with read_page. Track docs work in the task tracker: list the tasks before you add one,
and do not add a task that is already open. Say plainly when you cannot do something with your tools.""",
        tools=[read_page, toolset or tracker()],
        generate_content_config=types.GenerateContentConfig(
            temperature=0.2, max_output_tokens=1024, thinking_config=types.ThinkingConfig(thinking_level="low")),
    )

StdioServerParameters launches tasks_server.py with this environment’s Python. StdioConnectionParams adds a ten-second setup and request-response timeout. The process starts when the toolset first needs to discover tools. TASKS_SERVER_PYTHON is an optional override if your child interpreter must differ.

The agent combines its local read_page function with the remote tracker tools. Model responses request LOW thinking and at most 1,024 output tokens.

A server that cannot start. Tool discovery inside the agent can log an error and continue without the MCP tools. Check get_tools() explicitly at application startup when the toolset is required, and stop startup if discovery raises. The --tools command provides that check for this lab.

A tool excluded by tool_filter. complete_task is absent from this agent’s declarations. This is a client filter, not server authorization: another client with server access could still call that operation. Enforce shared permissions on the server.

A hostile tool description. ADK fences server-provided descriptions as untrusted data before presenting them to the model. That helps separate tool metadata from instructions, but does not make an arbitrary server trustworthy. Select the tools and servers deliberately.

Run the agent

Ask for a task with an explicit title and priority:

Use the tracker through the Python RunnerTerminal · bash
python agent.py "List the open tasks. If none already covers it, add a normal-priority task titled Clarify rollback migration warning."

Look for list_tasks, followed by add_task when no matching task exists. The output distinguishes model tool calls from server results. The new task belongs to this child process; a new command starts a fresh tracker with the original sample tasks.

Try the excluded operation in a separate run:

Check the excluded operationTerminal · bash
python agent.py "Mark task T-1 as done."

The assistant should explain that it has no completion tool. Compare that explanation with the discovered tool list rather than assuming the server rejected the call. This lesson exposes a local write without the previous lesson’s confirmation gate; add such a gate before adapting it to a tracker where writes need approval.

Adapt MCP tools into ADK calls

The model proposes a function call. ADK’s MCP adapter sends a tools/call request to the child, then returns the server’s CallToolResult as the function response. The result can contain text, typed structuredContent and an isError flag. The next model turn uses that result to explain what happened.

The Runner’s lifecycle also owns the connection:

agent.py — close the MCP child with the RunnerSource · pythonDownload
async def ask(text: str, agent: Agent | None = None) -> list:
    """Run one question in a new session; closing the runner also stops the MCP server process."""
    sessions = InMemorySessionService()
    runner = Runner(app_name="docs", agent=agent or build_agent(), session_service=sessions)
    try:
        session = await sessions.create_session(app_name="docs", user_id="dev")
        message = types.Content(role="user", parts=[types.Part(text=text)])
        return [e async for e in runner.run_async(user_id="dev", session_id=session.id, new_message=message)]
    finally:
        await runner.close()

Closing the runner in finally releases the toolset and its child process on both success and failure. For a shared server, ADK also supports Streamable HTTP; the server then owns its own process lifecycle.

Test the agent against the real server

Run the MCP contract and failure testsTerminal · bash
python -m unittest -v test_agent.py output_cap_check.py

The tests substitute saved model turns while running the real MCP client, stdio child and server. They check discovery, filtered declarations, result forwarding, failed startup and description fencing without a provider request.

What does tool_filter do when complete_task is excluded?

Choose one answer, then check your reasoning.

Read the explanation

tool_filter is a client-side selection boundary. Enforce permissions shared by all clients on the server.

Next, persist session state and separate it from memory so a returning developer can retain preferences between conversations.

References

Written by Arjun Prabhulal.

Module 1

Agents and tools