What Actually Happens When an AI Agent Calls an MCP Tool

What Actually Happens When an AI Agent Calls an MCP Tool

Your agent "used a tool." You watched it check the weather, query a database, or open a file, and it felt like magic. It isn't. Underneath that one line in the transcript is a small, boring, completely knowable exchange of JSON messages. Once you've seen it, MCP stops being a buzzword and becomes something you can debug.

Let me walk you through exactly what happens between the moment the model decides to act and the moment it gets an answer back. I'll be honest about the parts that trip people up, because I got most of them wrong the first time.

The cast of characters

There are three players, and keeping them straight is half the battle:

  • The host is the app you're using (Claude Desktop, an IDE, your own agent loop). It contains the LLM.
  • The client lives inside the host and speaks MCP. There's one client per server.
  • The server exposes the actual tools. It might wrap a Postgres database, a filesystem, or a REST API.

The important thing, and the thing everyone gets wrong at first: the LLM never talks to the server. The model only ever emits text. The client is what turns that text into real network calls and feeds the results back. The model is the brain, the client is the hands.

Step 1: The handshake

Before anything useful happens, the client and server do an initialize exchange. They agree on a protocol version and swap capabilities, so each side knows what the other supports. A server that offers tools declares it up front:

{
  "capabilities": {
    "tools": { "listChanged": true }
  }
}

That listChanged flag is a promise: "if my tool list changes while we're connected, I'll tell you." Handy for servers whose tools depend on runtime state.

Step 2: Discovery

Now the client asks what's on offer with a tools/list request. This is plain JSON-RPC 2.0, the same envelope every MCP message uses:

{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }

The server answers with a list of tool definitions. Each one has a name, a human description, and an inputSchema, which is just JSON Schema describing the arguments:

{
  "name": "get_weather",
  "description": "Get current weather for a location",
  "inputSchema": {
    "type": "object",
    "properties": {
      "location": { "type": "string", "description": "City name or zip code" }
    },
    "required": ["location"]
  }
}

Here's the part that connects it to the LLM: the client takes these definitions and hands them to the model as available functions. The description and the schema are the only things the model knows about your tool. Vague description, bad results. This is why "prompt engineering" for tools is really just writing good descriptions.

Step 3: The model decides

The model reads the user's request, looks at the tool list, and emits a structured intent: call get_weather with {"location": "New York"}. It does not make a network call. It produces that decision as output, and the client catches it.

A good client validates those arguments against the inputSchema before going any further. If the model hallucinated a field or dropped a required one, you catch it here instead of sending garbage to your database.

Step 4: The actual call

The client sends a tools/call request over the wire:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": { "location": "New York" }
  }
}

The server runs whatever code sits behind that tool and returns a result made of content blocks:

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [
      { "type": "text", "text": "72 F, partly cloudy" }
    ],
    "isError": false
  }
}

Content can be text, images, audio, or links to resources. The client takes that result, drops it back into the model's context as the tool's output, and the loop continues. The model now "knows" the weather and can answer the user. That's the whole trick: tool result in, next reasoning step out.

The two kinds of failure

This distinction matters more than any other, and it's where flaky agents come from. MCP has two completely separate error channels:

Type How it shows up Who should handle it
Protocol error JSON-RPC error object (e.g. -32602, unknown tool) The client / your code
Tool execution error A normal result with "isError": true The model, so it can retry or explain

A rate limit or a bad API key should come back as a result with isError: true, so the model sees it and can react. An unknown tool name is a protocol error, because the model shouldn't be papering over a broken integration. Mixing these up is why some agents silently stall and others loop forever.

Where the messages travel

All of this rides over one of two transports. For local servers, the client launches the server as a subprocess and they exchange newline-delimited JSON over stdio. For remote servers, it's Streamable HTTP: a single endpoint taking POST and GET, with an Mcp-Session-Id header tracking the session and optional Server-Sent Events for streaming. Same JSON messages either way. The transport is just the pipe.

Why this is worth knowing

Once you can see the tools/list and tools/call traffic, MCP debugging becomes ordinary. Tool not firing? Check the description the model actually received. Weird arguments? Validate against the schema. Server flaking? Watch whether it's returning a protocol error or an isError result.

And because a server is just a process speaking JSON-RPC, you don't have to build one to feel this in practice. The Elestio MCP connector is exactly the flow you just read about, pointed at real infrastructure: it gives your agent 68 tools to deploy, manage, and scale 400+ open-source services across 9 cloud providers, straight from Claude or ChatGPT. Same tools/list, same tools/call, now wired to your own servers.

Thanks for reading ❤️ See you in the next one 👋