AI Agents & MCP

How does MCP work, message by message

MCP is JSON-RPC over stdio or HTTP. A client opens a session, asks what tools exist, then calls one. Here is each message, then a hosted server to point your agent at.

On this page
  1. The three roles you have to keep straight
  2. Step 1: the initialize handshake
  3. Step 2: tools/list is the discovery call
  4. Step 3: tools/call is where the work happens
  5. Step 4: connect a real server
  6. Step 5: send the messages by hand
  7. Where MCP stops

How does MCP work? A client opens a session with a server, asks it tools/list to find out what the server can do, then sends tools/call when the model decides to use one of them. Every message is JSON-RPC 2.0, carried over one of two transports: stdio for a local subprocess, or Streamable HTTP for a hosted server. That is the entire protocol. The rest of this post walks each step with the actual messages, then connects a real server so you can watch the sequence happen.

The three roles you have to keep straight

The Model Context Protocol names three parties, and mixing them up is where most of the confusion starts:

  • Host: the LLM application. Claude Code, Cursor, a chat UI you wrote yourself.
  • Client: the connector inside the host. One client per server connection.
  • Server: the thing exposing capabilities. A filesystem, a database, an email inbox.

Servers can offer three kinds of thing: tools (functions the model executes), resources (data pulled in for context) and prompts (templated workflows). Tools are the ones that do work, and they are what most servers ship. The spec takes its shape from the Language Server Protocol: write the integration once, and every host that speaks MCP gets it.

Step 1: the initialize handshake

A session starts with an initialize request. The client states the protocol version it wants and what it can do. The server replies with the version it will actually use, plus its own capabilities.

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "capabilities": {},
    "clientInfo": { "name": "my-client", "version": "1.0.0" }
  }
}

The client then sends a notifications/initialized notification. It has no id, so there is no response, and the session is live.

Over Streamable HTTP the server may return an Mcp-Session-Id header on that first response. If it does, every later request has to carry it back, along with an MCP-Protocol-Version header holding the negotiated version. Drop the session id and a stateful server answers 400. If it answers 404, the session expired and you start again from initialize.

Step 2: tools/list is the discovery call

The client hardcodes nothing. It asks:

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

Back comes an array in which every tool carries a name, a human readable description, and an inputSchema: JSON Schema describing the arguments. That schema and that description are what the model actually reads, so a vague description is a tool the model will misuse. Long lists paginate with a cursor. If the server declared the listChanged capability it can push notifications/tools/list_changed and the client refetches, which is how a server grows a new tool mid session.

Step 3: tools/call is where the work happens

When the model picks a tool, the client sends the name and the arguments:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "wait_for_email",
    "arguments": { "inboxId": "ibx_123", "waitFor": "otp" }
  }
}

The result is a content array (text, images, resource links, embedded resources) plus an optional structuredContent object when the tool declares an outputSchema.

Error handling has two layers, and getting this wrong in your own server is common. A protocol error, such as an unknown tool name or arguments that fail validation, comes back as a JSON-RPC error object. A tool execution error, such as the API behind the tool returning a 500, comes back as an ordinary result with isError: true. The difference matters: the second one reaches the model as text it can read and act on, the first one does not. Returning a protocol error for a business failure hides it from the one participant that could have recovered from it.

Those four messages, initialize, notifications/initialized, tools/list and tools/call, answer how does MCP work at runtime. Everything past them is a server deciding what to expose and a model deciding what to call.

Step 4: connect a real server

Concretely: InboxDome runs a hosted MCP server at https://inboxdome.com/mcp over Streamable HTTP, so there is nothing to install. In Claude Code:

claude mcp add --transport http inboxdome https://inboxdome.com/mcp \
  --header "Authorization: Bearer idk_live_..."

Any other MCP client takes the same thing as JSON config:

{
  "mcpServers": {
    "inboxdome": {
      "type": "http",
      "url": "https://inboxdome.com/mcp",
      "headers": { "Authorization": "Bearer idk_live_..." }
    }
  }
}

That tools/list now returns 7 tools: create_inbox, wait_for_email, list_messages, get_message, run_assertions, delete_inbox and list_domains. Connecting with no key at all still initializes and lists the tools, so an agent can inspect the server before it has credentials. If you are still picking one, this breakdown of which mail servers expose the most useful tools puts the options side by side. The InboxDome MCP server setup guide covers the rest, including where to get your key.

Step 5: send the messages by hand

Nothing about MCP needs a special client. Over HTTP it is a POST with a JSON body, and the detail people trip on is the Accept header: the spec requires the client to list both application/json and text/event-stream, because the server is allowed to answer with a single JSON object or to open an SSE stream instead.

curl -X POST https://inboxdome.com/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'

Read Mcp-Session-Id off the response headers, put it on a second POST carrying "method":"tools/list", and you have done by hand exactly what your editor does at startup. When a server misbehaves, this is the quickest way to learn whether the bug is in the server or in your client wiring.

Where MCP stops

The protocol standardises discovery and invocation. It does not standardise auth, beyond saying that HTTP servers should authenticate their connections. It does not decide when the model calls a tool. It gives you no sandbox. And it treats tool descriptions and annotations as untrusted unless the server itself is trusted, which is why the spec tells hosts to keep a human able to deny a call.

For an agent testing email, the sharp edge is therefore not the protocol, it is the mailbox you point it at. A receive-only address that expires on its own limits what a runaway loop can do with it, which is the model InboxDome uses. For the practical version, see how an agent drives a full signup and OTP flow, or read what the Email Testing API does and the API reference if you would rather call it straight from a test suite.

Disclosure: InboxDome publishes this blog and makes the products it mentions. We describe what they do rather than promise results.

Key takeaways

  • MCP is JSON-RPC 2.0 carried over one of two transports: stdio for a local subprocess, or Streamable HTTP for a hosted server.
  • A session runs in a fixed order: initialize, notifications/initialized, tools/list, then tools/call.
  • Nothing is hardcoded in the client. Tools are discovered at runtime and the inputSchema is the whole contract the model reads.
  • Protocol errors come back as JSON-RPC error objects. Tool failures come back as normal results with isError set to true, so the model can see them and react.
  • MCP standardises discovery and invocation. It does not standardise auth, it does not sandbox anything, and it does not decide when a tool gets called.

Sources

  1. Model Context Protocol specification (2025-06-18) — Roles, the JSON-RPC base protocol, and the server and client feature set.
  2. MCP transports (stdio and Streamable HTTP) — Session ids, the Accept header rule, and the protocol version header.
  3. MCP tools specification — tools/list, tools/call, inputSchema, and the two error mechanisms.
  4. JSON-RPC 2.0 specification — The request, response, and notification shapes MCP builds on.

Links last checked August 31, 2026.

Frequently asked questions

Is MCP the same thing as function calling?

No. Function calling is how a model asks for a tool inside one vendor's API. MCP is the wire protocol that lets any host discover and invoke tools from a separate process or server, so one integration works across every client that speaks it.

How does MCP work over HTTP instead of stdio?

Over stdio the client launches the server as a subprocess and exchanges newline delimited JSON on stdin and stdout. Over Streamable HTTP the server is just a URL: the client POSTs each message to a single endpoint, and the server answers with one JSON object or opens an SSE stream. In the HTTP case there is nothing to install locally.

What happens when a tool call fails?

There are two layers. An unknown tool name or malformed arguments returns a JSON-RPC error object. A failure inside the tool, such as an upstream API returning a 500, returns a normal result with isError set to true, so the model reads the failure text and can retry or change course.

Which clients can connect to an MCP server?

Claude Code, Cursor, and any other client that implements the specification. The InboxDome server uses Streamable HTTP, so any client that supports an HTTP transport plus an Authorization header can connect to it.

InboxDome TeamEmail infrastructure builders

The InboxDome team runs the temporary-email service and email-testing API this blog belongs to. We write about disposable inboxes, email privacy and testing transactional email, based on operating the infrastructure ourselves.