Handle
Connecting…
Back to Workshop
Module 05 ~30 min

Build the AI Agent

Build an AI agent that discovers tools dynamically, talks to an LLM, and orchestrates the tool-calling loop.

What You'll Build

LLM Integration

Connect to OpenAI with function calling

Tool Discovery

Load tools dynamically from MCP server

Agentic Loop

User → LLM → Tool → LLM → Response

Get Your API Key

The agent needs an LLM to think. Everything here speaks the OpenAI wire format, so the provider is a line in .env and nothing more.

Gemini — Free tier
  1. 1.Go to aistudio.google.com/apikey
  2. 2.Create an API key
  3. 3.Copy it — that is your OPENAI_API_KEY
  4. 4.We call it through the OpenAI-compatible endpoint, so the SDK never knows the difference
.env Bash
OPENAI_API_KEY=your-gemini-api-key-here
OPENAI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
OPENAI_MODEL=gemini-3.5-flash-lite
OpenAI — Pay-as-you-go
  1. 1.Sign up at platform.openai.com
  2. 2.Go to API Keys → Create new secret key
  3. 3.Add a small credit balance; a few USD goes a long way with a mini model
.env Bash
OPENAI_API_KEY=sk-...
# OPENAI_BASE_URL not needed for OpenAI
OPENAI_MODEL=gpt-5-mini

All paths — including Google ADK — use the same OPENAI_API_KEY from above. ADK connects to OpenAI-compatible endpoints via LiteLlm, so no separate Google key is needed.

Language:

Step by Step

Step 5a

Agent API Skeleton

The agent is an HTTP service that accepts user queries and returns AI-generated responses. Start with the API surface.

services/agent/app.py Python
from fastapi import FastAPI
from pydantic import BaseModel
import os

app = FastAPI(title="AI Agent")

class QueryRequest(BaseModel):
    query: str
    session_id: str = "default"

@app.get("/health")
async def health():
    return {"status": "ok", "service": "agent"}

@app.post("/query")
async def handle_query(request: QueryRequest):
    result = await process_query(request.query)
    return {"response": result}
Step 5b

Initialize the LLM Client

Set up the OpenAI client. The model name comes from an environment variable so you can easily switch between OpenAI and local models (Ollama) later.

services/agent/app.py Python
from openai import AsyncOpenAI

client = AsyncOpenAI(
    api_key=os.environ.get("OPENAI_API_KEY"),
    base_url=os.environ.get("OPENAI_BASE_URL"),  # Optional: Gemini, Ollama, or any OpenAI-compatible endpoint
)
MODEL = os.environ.get("OPENAI_MODEL", "gemini-3.5-flash-lite")
Step 5c

Dynamic Tool Discovery

On startup, the agent calls the MCP server's tools/list to discover available tools. It then converts the MCP tool format to OpenAI's function calling format.

services/agent/app.py Python
import httpx

MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL", "http://mcp-server:8000")

async def load_tools_from_mcp() -> list[dict]:
    """Discover tools from MCP server and convert to OpenAI format."""
    async with httpx.AsyncClient() as http:
        resp = await http.post(
            f"{MCP_SERVER_URL}/message",
            json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
        )
        mcp_tools = resp.json()["result"]["tools"]

    # Convert MCP format → OpenAI function calling format
    openai_tools = []
    for tool in mcp_tools:
        openai_tools.append({
            "type": "function",
            "function": {
                "name": tool["name"],
                "description": tool["description"],
                "parameters": tool["inputSchema"]
            }
        })
    return openai_tools

Key insight: The MCP format uses inputSchema while OpenAI uses parameters. The conversion is just a field rename — both use JSON Schema internally.

Step 5d

Call MCP Tools

When the LLM decides to use a tool, the agent sends a tools/call JSON-RPC request to the MCP server.

services/agent/app.py Python
async def call_mcp_tool(tool_name: str, arguments: dict) -> str:
    """Call a tool on the MCP server via JSON-RPC."""
    async with httpx.AsyncClient() as http:
        resp = await http.post(
            f"{MCP_SERVER_URL}/message",
            json={
                "jsonrpc": "2.0",
                "id": 1,
                "method": "tools/call",
                "params": {
                    "name": tool_name,
                    "arguments": arguments
                }
            }
        )
        result = resp.json()["result"]

    # Extract text content for the LLM
    if result.get("isError"):
        return f"Tool error: {result['content'][0]['text']}"
    return result["content"][0]["text"]
Step 5e

The Agentic Loop

This is the heart of the agent. Send the user's query to the LLM with tool definitions. If the LLM wants to call a tool, execute it and send the result back. Repeat until the LLM produces a final text response.

services/agent/app.py Python
import json

async def process_query(query: str) -> str:
    # 1. Load available tools from MCP
    tools = await load_tools_from_mcp()

    # 2. Start conversation with user's query
    messages = [{"role": "user", "content": query}]

    # 3. Agentic loop — keep going until no more tool calls
    while True:
        response = await client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=tools if tools else None,
        )

        choice = response.choices[0]
        message = choice.message

        # If no tool calls, we have our final answer
        if not message.tool_calls:
            return message.content

        # 4. Execute each tool call
        messages.append(message.model_dump())
        for tool_call in message.tool_calls:
            args = json.loads(tool_call.function.arguments)
            result = await call_mcp_tool(tool_call.function.name, args)

            # 5. Send tool result back to the LLM
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result
            })

        # Loop continues — LLM sees the tool results and responds

Important: The while True loop is essential. The LLM might need to call multiple tools before giving a final answer. Each iteration: (1) ask the LLM, (2) if it wants tools, execute them, (3) feed results back, (4) repeat.

Step 5f

Containerize the Agent

Package the agent into a Docker container. Same pattern as the MCP server.

services/agent/Dockerfile Python
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8001
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
services/agent/requirements.txt Python
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
httpx>=0.27.0
openai>=1.42.0
pydantic>=2.0.0

What You've Built

Dynamic tool discovery from the MCP server
MCP format → OpenAI function calling format conversion
Complete agentic loop: User → LLM → Tool → LLM → Response
Containerized and ready to connect to the MCP server

How was this module?

Your feedback helps us improve the workshop.

Submitting as anonymous

Your handle is sent with this feedback; leave it blank in the header and the submission stays anonymous. Please keep personal data out of the comment too — no name, e-mail address or employer, yours or anyone else's.

MCP Server Wire It Together