Agent loop
The agent loop is the cycle a host runs around a model: list the tools, choose one, call it over MCP, and turn the result into a reply.
Last updated: 29 Sep, 2026 · MCP 2.2
The video finishes client.py. A ChatGroq model and the tools from get_tools go into create_react_agent, and main awaits agent.ainvoke with a user message; main is async, so asyncio.run(main()) starts it. Run with python client.py, "what's (3 + 5) x 12?" came back as 96 with the two steps, 3 + 5 = 8 and 8 x 12 = 96. "what is the weather in California?" called get_weather, which returns "It's always raining in California", and the model's reply added that California has diverse climates, something the tool never said.
Three parts of the video's code do not run as written today. create_react_agent from langgraph.prebuilt is now create_agent in langchain.agents; MultiServerMCPClient needs an mcp below 2.0, and MCP tools with an LLM swapped in MCPAdapter; and Groq has retired qwen-qwq-32b. The same program with those swaps runs on openai/gpt-oss-120b. Two added lines print which tools each answer used.
import asyncio
from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from langchain.mcp import MCPAdapter
servers = {
"mcpServers": {
"math": {"command": "python", "args": ["mathserver.py"], "transport": "stdio"},
"weather": {"url": "http://localhost:8001/mcp", "transport": "streamable-http"},
}
}
async def main():
async with MCPAdapter(servers) as adapter:
tools = await adapter.list_tools()
model = init_chat_model("groq:openai/gpt-oss-120b")
agent = create_agent(model, tools)
math_response = await agent.ainvoke(
{"messages": [{"role": "user", "content": "what's (3 + 5) x 12?"}]}
)
print("Math response:", math_response["messages"][-1].text)
print("Tools used:", [m.name for m in math_response["messages"] if m.type == "tool"])
weather_response = await agent.ainvoke(
{"messages": [{"role": "user", "content": "what is the weather in California?"}]}
)
print("Weather response:", weather_response["messages"][-1].text)
print("Tools used:", [m.name for m in weather_response["messages"] if m.type == "tool"])
asyncio.run(main())
python weather.py > weather.log 2>&1 &
sleep 3
python agent_client.py 2> client.log
kill $!Math response: The expression evaluates to \( (3 + 5) \times 12 = 8 \times 12 = 96 \). Tools used: [] Weather response: California is a huge state, so the weather can be quite different depending on where you are: | Region | Typical Weather (April 2024) | Typical Temperatures (°F) | |--------|-----------------------------|---------------------------| | **Coastal (e.g., San Francisco, Los Angeles)** | Mild, often partly cloudy; occasional fog along the coast | 55‑68 (SF), 60‑75 (LA) | | **Central Valley (e.g., Fresno, Sacramento)** | Warm and dry; sunny skies dominate | 65‑85 | | **Sierra Nevada (e.g., Lake Tahoe, Mammoth Lakes)** | Cool to cold; snow still present at higher elevations | 30‑55 (often below freezing at night) | | **Desert (e.g., Palm Springs, Death Valley)** | Hot and dry; clear skies | 80‑100 (Desert) | | **Northern Coast (e.g., Eureka, Crescent City)** | Cool, damp; frequent drizzle or light rain | 50‑60 | Because “California” covers everything from sea level to high mountains, a single statewide forecast isn’t very useful. If you let me know a specific city or region you’re interested in, I can give you a more precise, up‑to‑date forecast. Tools used: ['weather_get_weather']
- Math response is 96, and
Tools usedis empty: the model did the arithmetic itself and never calledaddormultiple. - Weather response did call
weather_get_weather, which returnedIt's always raining in California. The reply ignored it and wrote a table of typical weather, with a date and temperatures no tool returned. - Nothing in the video's code keeps the model to the tools. It has no system message, so the model is free to skip a tool or add to its result. The video's run added one sentence about California's climate; this run went further.
create_agent runs a loop: call the model, run any tool it asks for, give it the result, and call it again until it answers in words. The shop's agent writes that loop by hand over the SDK client, with the grounded system message from Tool calling with a model, so every step is visible.
The model and its instructions
agent.py starts with the model and the system message from the tool choice lesson, plus ToolMessage, which carries a tool's result back to the model.
from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage, SystemMessage, ToolMessage
model = init_chat_model("groq:openai/gpt-oss-120b", temperature=0)
SYSTEM = (
"You are the support assistant for a small online shop. "
"Answer in one or two short sentences, using only what the tools returned."
)
The tool list for the model
Then to_model_tools from MCP tools with an LLM:
def to_model_tools(tools):
return [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.input_schema,
},
}
for tool in tools
]
Running one tool call
run_tool prints the call, runs it over MCP, and wraps the result in a ToolMessage tied to the call's id. A failed call is still passed on, marked error, so the model reads the message the tool-errors lesson wrote for it.
async def run_tool(client, call):
print(f" calling {call['name']} {call['args']}")
result = await client.call_tool(call["name"], call["args"])
status = "error" if result.is_error else "success"
return ToolMessage(result.content[0].text, tool_call_id=call["id"], status=status)
The loop
run_agent lists the tools on every message, because a server's tools can change while it runs. It asks the model, runs any tools it requests, and asks again with the results, until the model answers in words. Three rounds is a cap you choose, so a confused model cannot loop forever.
async def run_agent(client, message):
listed = await client.list_tools()
llm = model.bind_tools(to_model_tools(listed.tools))
messages = [SystemMessage(SYSTEM), HumanMessage(message)]
for _ in range(3):
reply = await llm.ainvoke(messages)
messages.append(reply)
if not reply.tool_calls:
return reply.text
for call in reply.tool_calls:
messages.append(await run_tool(client, call))
return "Stopped after three rounds of tool calls."
- written in Streamable HTTP transport
- written in MCPServer quickstart
- written in Streamable HTTP transport
View the code here
from mcp.server import MCPServer
mcp=MCPServer("Weather")
@mcp.tool()
async def get_weather(location:str)->str:
"""Get the weather location."""
return "It's always raining in California"
if __name__=="__main__":
mcp.run(transport="streamable-http", port=8001)
from mcp.server import MCPServer
mcp=MCPServer("Math")
@mcp.tool()
def add(a:int,b:int)->int:
"""_summary_
Add to numbers
"""
return a+b
@mcp.tool()
def multiple(a:int,b:int)-> int:
"""Multiply two numbers"""
return a*b
#The transport="stdio" argument tells the server to:
#Use standard input/output (stdin and stdout) to receive and respond to tool function calls.
if __name__=="__main__":
mcp.run(transport="stdio")
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage
from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError
from mcp.types import ToolAnnotations
mcp = MCPServer("Shop support", log_level="WARNING")
ORDERS = {
"A17": {"item": "blue mug", "status": "shipped", "total": 12.50},
"B42": {"item": "desk lamp", "status": "processing", "total": 48.00},
}
ARTICLES = {
"Where is my order?": "orders",
"Changing an order": "orders",
"How refunds work": "refunds",
"Refunds for damaged items": "refunds",
"Resetting your password": "account",
}
class Order(BaseModel):
id: str
item: str
status: Literal["processing", "shipped", "delivered"]
total: float
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def lookup_order(order_id: str) -> Order:
"""Look up an order by its id."""
if order_id not in ORDERS:
raise ToolError(f"No order with id {order_id!r}. Order ids look like A17.")
return Order(id=order_id, **ORDERS[order_id])
@mcp.tool()
def search_help(
query: Annotated[str, Field(description="Words to look for in the help articles.")],
topic: Literal["orders", "refunds", "account"] | None = None,
limit: Annotated[int, Field(ge=1, le=5)] = 3,
) -> str:
"""Search the help centre and return matching article titles."""
found = [title for title, t in ARTICLES.items() if query.lower() in title.lower() and topic in (None, t)]
return "; ".join(found[:limit]) or "No articles found."
@mcp.tool(
title="Refund an order",
annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False),
)
def refund_order(order_id: str, reason: str) -> str:
"""Refund the full total of an order to the customer's card."""
if order_id not in ORDERS:
raise ToolError(f"No order with id {order_id!r}.")
return f"Refunded {ORDERS[order_id]['total']:.2f} for order {order_id}: {reason}."
@mcp.resource("policy://refunds", mime_type="text/markdown")
def refund_policy() -> str:
"""The shop's refund policy."""
return "# Refunds\n\nFull refund within 30 days of delivery. Damaged items: refund or replacement."
@mcp.resource("orders://{order_id}", mime_type="application/json")
def order_record(order_id: str) -> Order:
"""The full record for one order."""
if order_id not in ORDERS:
raise ResourceNotFoundError(f"No order with id {order_id!r}.")
return Order(id=order_id, **ORDERS[order_id])
@mcp.prompt(title="Reply to a customer")
def reply_to_customer(ticket: str, tone: str = "friendly") -> list[Message]:
"""Draft a reply to a support ticket."""
return [
UserMessage(f"Write a {tone} reply to this support ticket:\n\n{ticket}"),
AssistantMessage("Hello, and thank you for getting in touch."),
]
if __name__ == "__main__":
mcp.run(transport="streamable-http", port=8200)
Three messages through the loop
import asyncio
from mcp import Client
from agent import run_agent
from shop import mcp
async def main():
async with Client(mcp) as client:
for message in ["Where is my order B42?", "Where is my order Z9?", "I need help with my refund"]:
print(message)
print(" ", await run_agent(client, message))
asyncio.run(main())Where is my order B42?
calling lookup_order {'order_id': 'B42'}
Your order B42 is currently being processed.
Where is my order Z9?
calling lookup_order {'order_id': 'Z9'}
I’m sorry, but I can’t find an order with the ID Z9. Please double‑check the order number and try again.
I need help with my refund
calling search_help {'limit': 3, 'query': 'refund', 'topic': 'refunds'}
Sure! You can read our “How refunds work” article for an overview, and check “Refunds for damaged items” if your purchase was faulty. Let me know if you’d like me to start a refund for you.- B42: the model called
lookup_order, read the order back, and replied in one sentence with its status. - Z9: the tool raised the ToolError from the tool-errors lesson.
run_toolpassed its text on, markederror, and the model told the customer it could not find that order. - The refund question went to
search_helpwith the topicrefunds, and the reply names the two articles the tool returned.
The refund goes through
import asyncio
from mcp import Client
from agent import run_agent
from shop import mcp
async def main():
async with Client(mcp) as client:
print(await run_agent(client, "Please refund order B42, it arrived broken"))
asyncio.run(main()) calling refund_order {'order_id': 'B42', 'reason': 'Arrived broken'}
Your order B42 has been refunded. Let us know if you need anything else.- The model called refund_order with a reason it wrote, and the tool refunded B42 at once.
- No one approved it. The
refund_orderinshop.pyis still the annotation-only version from the tool-annotations lesson, andrun_agentignores annotations.
create_agent vs the loop you wrote
| create_agent (the video's way) | run_agent (this lesson) | |
|---|---|---|
| Who writes the loop | LangChain | You, in about twenty lines |
| Where the tools come from | MCPAdapter, as LangChain tools | list_tools, reshaped by to_model_tools |
| Instructions | None in the video's code | A system message that keeps replies to the tool results |
| Round cap | Set by the library | Three, set by you |
Where the loop fits
- Any host that lets a model call your server's tools, from Claude Code to a support bot.
- An agent that has to log or check every call before it runs.
- A first agent you later swap for create_agent, once you know what it does for you.
shop.py still uses the annotation-only tool from the tool-annotations lesson and this loop ignores annotations. The project lesson enforces approval in the server, whatever the host does.Related
- Previous: Tool calling with a model
- Next: Testing MCP servers
- Swap Client(mcp) for the HTTP client from the Streamable HTTP lesson, with python shop.py running, and send the same messages.
- Make run_tool skip any tool whose annotations are not read-only, and return a ToolMessage that says so.
- Delete the system message and send "Where is my order B42?" again. Compare the reply.
Slow is fine. Stopping is the only problem.