Learning note · AI automation
A small guided ADK and MCP example
One approved question, one read-only MCP tool, and one concise model answer. Enough structure to learn the pattern without turning the example into a full application.
Back to the MCP and ADK article
This tutorial mirrors the shape of the Crypto Market Explorer: an MCP service retrieves a narrow piece of public evidence, while a Google ADK agent turns that evidence into a short, factual response.
1 · The MCP side
Expose one small, read-only capability
An MCP server makes a capability available to a compatible client. The useful boundary is not “everything the provider can do”; it is one understandable operation with validated input and a predictable result. In the excerpt, MarketDataService owns validation and the provider call; the MCP tool only exposes its safe, read-only result.
# market_mcp/mcp_server.py
from collections.abc import Awaitable, Callable
from typing import Any
from mcp.server import MCPServer
from .market_data import MarketDataError, MarketDataService
def create_mcp_server(market_data: MarketDataService) -> MCPServer:
mcp = MCPServer(name="market-demo")
@mcp.tool()
async def get_market_snapshot(symbol: str) -> dict[str, Any]:
"""Return a read-only price snapshot for one supported symbol."""
return await safe_tool_call(lambda: market_data.market_snapshot(symbol))
return mcp
async def safe_tool_call(operation: Callable[[], Awaitable[dict[str, Any]]]) -> dict[str, Any]:
try:
return {"ok": True, "data": await operation()}
except (ValueError, MarketDataError) as error:
return {"ok": False, "error": {"message": str(error)}}The server owns the provider call. The agent never receives an exchange API key, account permission, or unrestricted HTTP client. It receives a named, read-only tool. The runnable MCP project also mounts this server on a Streamable HTTP endpoint.
2 · The guided choice
Turn a user choice into an approved request
Before an agent is involved, the application decides what is allowed. A small allowlist makes the experience clearer for the visitor and easier to test for the team.
from dataclasses import dataclass
@dataclass(frozen=True)
class GuidedQuestion:
title: str
tool_name: str
tool_arguments: dict[str, object]
SUPPORTED_MARKETS = {"BTC", "ETH", "SOL"}
def price_question(market: str) -> GuidedQuestion:
if market not in SUPPORTED_MARKETS:
raise ValueError("Choose a supported market.")
return GuidedQuestion(
title=f"{market} price",
tool_name="get_market_snapshot",
tool_arguments={"symbol": f"{market}USDT"},
)A real interface can present this as a selector rather than a text box. The important part is that the browser sends a known question and known market—not a free-form instruction.
3 · The ADK side
Let the model explain evidence, not invent it
The application’s MCP client retrieves the approved data before the model can see it. The agent gets one tool created for that guided question, can retrieve the evidence once, and then uses the returned values to write the answer.
# market_agent/adk_answerer.py
from collections.abc import Awaitable, Callable
from typing import Any
from uuid import uuid4
from google.adk.agents import LlmAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
async def answer_guided_question(
*, user_id: str, question: GuidedQuestion, retrieve: Callable[[], Awaitable[dict[str, Any]]]
) -> str:
tool_used = False
# Expose one zero-argument ADK tool for this question's evidence only.
async def market_tool() -> dict:
"""Retrieve the evidence required for this guided market question."""
nonlocal tool_used
if tool_used:
raise RuntimeError("This guided tool can be used once.")
tool_used = True
return await retrieve()
# ADK uses the function name as the tool name the model can call.
market_tool.__name__ = question.tool_name
agent = LlmAgent(
name="guided_market_agent",
model="gemini-2.5-flash",
instruction="Use the supplied market evidence. Be factual and concise.",
tools=[market_tool],
)
# Create a short-lived in-memory session for this one guided run.
sessions = InMemorySessionService()
session_id = str(uuid4())
await sessions.create_session(app_name="market_demo", user_id=user_id, session_id=session_id)
runner = Runner(agent=agent, app_name="market_demo", session_service=sessions)
message = types.Content(role="user", parts=[types.Part(text=question.title)])
# Return the final response from ADK's streamed events.
async for event in runner.run_async(user_id=user_id, session_id=session_id, new_message=message):
if event.is_final_response() and event.content and event.content.parts:
return "".join(part.text or "" for part in event.content.parts).strip()
raise RuntimeError("The agent did not return a response.")The one-use guard is intentional. It does not limit the MCP service’s internal work: a comparison tool may still retrieve several market snapshots. It prevents the model from repeatedly asking the same approved agent tool for another answer.
The MCP server
Returns evidence
It validates the request and retrieves the defined public data.
The ADK agent
Explains evidence
It turns returned facts into a concise answer within the set boundary.
What to add next
Keep production concerns separate from the lesson
This example deliberately stops before authentication, provider credentials, rate limits, caching, history, deployment, and monitoring. Those are important in a real service, but they obscure the first concept: a guided agent is useful because its job and evidence are deliberately narrow.
Once this small path makes sense, add those safeguards around it—without widening the original question or tool boundary by accident.