Wiring MCP tools into an agent loop

Model Context Protocol (MCP) · Advanced · 15 min

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_hint can 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

Check yourself

  1. Two connected servers both expose a tool named search. What should your host do?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: modelcontextprotocol.io/docs/2026-07-28/develop/build-client