Free lab Real Python 3. Zero installs. Your code stays in this browser. Open the playground

AI Agent Tool Loop (MCP Client)

Build the other side of MCP: the client loop that hands tools to a language model, runs the calls it asks for, feeds the results back and stops safely. This is the core of every AI agent.

Advanced 4 steps 200 XP ~70 min

How it works in the real world

An AI agent is a loop, not magic. The client (Claude Desktop, Claude Code, your own script) does this until the model is finished:

  1. Offer tools: fetch them from MCP servers with tools/list and pass them to the model in its tool format.
  2. Ask the model: it replies with text, or with tool_use blocks naming a tool and its input.
  3. Run the calls: execute each one (through MCP's tools/call) and send back tool_result blocks with the same ids.
  4. Repeat or stop: when the model's stop_reason is no longer tool_use, its text is the answer. A good client also enforces limits so a confused model cannot loop forever.

Here the model is scripted, so every run is identical and gradable. The take-it-further section swaps in the real Claude API.

Build progress0 / 4 steps
1

Translate MCP tools for the model

MCP describes tools with inputSchema; the Claude Messages API expects input_schema. Convert each MCP tool into {"name", "description", "input_schema"} (use an empty string when a description is missing) and print each as JSON.

Blank · autosaved
PYstep_1.py
Run your code to check this step…
2

Route the model's tool calls

The model answered with a mix of text and tool_use blocks. Run every tool call against LOCAL_TOOLS and build a tool_result block for each, carrying the same tool_use_id. Print each result as JSON.

Blank · autosaved
PYstep_2.py
Run your code to check this step…
3

The agent loop

Wire it together. fake_model(messages) plays the model. Loop: call it, append its reply as an assistant message, and if stop_reason is tool_use run the tools and append a user message of tool results; otherwise print the final text. Log each call as -> name(input) and each result as <- value.

Blank · autosaved
PYstep_3.py
Run your code to check this step…
4

Guardrails

Real agents need limits. Run this misbehaving script with two rules: a tool that is not in ALLOWED returns a tool_result with is_error: True and the text Tool not allowed: NAME (the model sees it and adapts), and after MAX_CALLS tool calls the loop stops with Stopped: tool budget of 3 calls reached.

Blank · autosaved
PYstep_4.py
Run your code to check this step…

Take it further

Swap the scripted model for Claude and the local tools for a real MCP server: the loop you just wrote stays almost the same. Nothing below is graded: it runs on your own machine, not in this tab.

Stretch goals

  1. Connect two MCP servers at once and merge their tool lists, prefixing names so they cannot collide.
  2. Ask the user for confirmation before any tool whose name starts with delete_ or send_.
  3. Log every tool call with its duration to a JSON file, then build a report of the slowest tools.
  4. Stop the loop when the conversation exceeds a token budget, not just a call budget.

agent.py: Claude + your MCP server (needs an Anthropic API key)

# pip install anthropic "mcp[cli]"
# export ANTHROPIC_API_KEY=...
import asyncio

import anthropic
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client


async def main():
    server = StdioServerParameters(command="python", args=["server.py"])
    async with stdio_client(server) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            listed = await session.list_tools()
            tools = [{"name": t.name, "description": t.description or "",
                      "input_schema": t.inputSchema} for t in listed.tools]

            client = anthropic.Anthropic()
            messages = [{"role": "user", "content": "What is 19 + 23, and what is 21.5C in F?"}]
            for _ in range(10):  # the guardrail from step 4
                reply = client.messages.create(model="claude-sonnet-5", max_tokens=1024,
                                               tools=tools, messages=messages)
                messages.append({"role": "assistant", "content": reply.content})
                if reply.stop_reason != "tool_use":
                    print("".join(b.text for b in reply.content if b.type == "text"))
                    break
                results = []
                for block in reply.content:
                    if block.type == "tool_use":
                        out = await session.call_tool(block.name, block.input)
                        text = "".join(c.text for c in out.content if c.type == "text")
                        results.append({"type": "tool_result", "tool_use_id": block.id,
                                        "content": text, "is_error": out.isError})
                messages.append({"role": "user", "content": results})


asyncio.run(main())

Ask an AI to review your build

I wrote an AI agent loop in Python: it converts MCP tools to the Claude tool format, runs tool_use blocks, sends tool_result blocks back and enforces an allowlist and a call budget. Here is my code: [paste it]. What failure modes am I not handling (parallel tool calls, tools that hang, the model repeating the same call)? Suggest the single most valuable guardrail to add next and explain why.

Your next ten minutes

Write Python that does something useful.

Start free. No install, no card, no passive video marathon.

Start learning free → Explore the path