This is what every host does internally, and what you build when your own agent uses MCP servers.
An agent that uses MCP is the usual tool-calling loop, with MCP providing the tools:
1. Connect a Client and call list_tools(). 2. Convert each MCP tool to your model provider's tool format. The fields map directly: name, description and the input schema (a JSON Schema). 3. Send the conversation plus tool definitions to the model. 4. For each tool call in the reply, run client.call_tool(name, arguments) and send the result back as a tool result, marking it as an error when is_error is true. 5. Repeat until the model answers without calling tools.
Things real hosts add:
- Several servers. Tool names can clash, so prefix them with the server name (
notes__get_note) and route calls back to the right client. - Approval. Ask the user before running tools that change things. A server's
destructive_hintcan inform this, but you decide. - Limits. Cap the number of loop turns and truncate huge results so they don't flood the context window.
- Caching. Tool lists rarely change. Current servers send cache hints (
ttlMs), and a stable tool order also keeps the model provider's prompt cache effective.
You rarely need to write the conversion yourself: agent frameworks ship adapters. For example, LangChain 1.4 added a built-in (beta) langchain.mcp module whose MCPAdapter turns MCP tools into LangChain tools. The example below shows the loop with the Anthropic SDK so you can see every step; model names change, so check your provider's current list.
Example
import anyio
from anthropic import AsyncAnthropic
from mcp import Client
llm = AsyncAnthropic() # reads ANTHROPIC_API_KEY
async def run(question: str) -> str:
async with Client("http://localhost:8000/mcp") as mcp:
tools = [{"name": t.name, "description": t.description or "", "input_schema": t.input_schema}
for t in (await mcp.list_tools()).tools]
messages = [{"role": "user", "content": question}]
for _ in range(8): # hard cap on turns
reply = await llm.messages.create(model="claude-sonnet-4-6", max_tokens=1024,
tools=tools, messages=messages)
messages.append({"role": "assistant", "content": reply.content})
calls = [b for b in reply.content if b.type == "tool_use"]
if not calls:
return "".join(b.text for b in reply.content if b.type == "text")
results = []
for c in calls:
r = await mcp.call_tool(c.name, c.input)
text = "\n".join(getattr(x, "text", "") for x in r.content)
results.append({"type": "tool_result", "tool_use_id": c.id,
"content": text, "is_error": r.is_error})
messages.append({"role": "user", "content": results})
return "Stopped: too many steps"
print(anyio.run(run, "What notes do I have?"))Remember
- MCP tool name/description/input_schema map straight onto model tool definitions
- Execute model tool calls with client.call_tool and return results (with error flag)
- Namespace tools from multiple servers and gate risky ones behind approval
- Cap turns and result sizes; frameworks provide adapters