Back to Blog

How MCP Actually Works: The Protocol Behind AI Tool Use

A clear, technical explanation of how the Model Context Protocol actually works — the JSON-RPC messages, the initialize handshake, tool discovery, tool calls, and the transports underneath. No hand-waving.

Most explanations of the Model Context Protocol stop at the analogy: "MCP is like a USB-C port for AI." That's fine for a first pass, but it doesn't tell you what actually happens on the wire when Claude runs a query against your database. If you've ever wondered how an AI model "discovers" and "calls" a tool — or you're trying to debug why yours won't — you need the layer underneath the analogy.

This post walks through what MCP actually is at the protocol level: the messages, the handshake, and the transports. It's grounded in a real, working MCP server (Synra's database gateway), so the examples are the real shapes, not idealized ones. (If you want the plain-English overview first, read what is an MCP server, then come back here.)

MCP is just JSON-RPC 2.0

Here's the thing nobody says plainly: MCP is JSON-RPC 2.0 with an agreed-upon set of method names. That's most of it.

JSON-RPC is a decades-old, dead-simple convention for calling a function on another process by sending it JSON. Every message is one of two shapes. A request:

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

And a response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": { "tools": [] }
}

The id ties a response back to its request. method is the name of the thing you're calling. params carries the arguments. If something goes wrong, result is replaced by an error object with a numeric code:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": { "code": -32601, "message": "Method not found" }
}

That's the entire envelope. MCP's contribution on top of JSON-RPC is a specification of which methods exist (initialize, tools/list, tools/call, and a handful of others), what their params and results look like, and how the connection is set up and torn down. Once you internalize "it's just JSON-RPC with standard method names," the whole protocol stops being mysterious.

The lifecycle: four messages that matter

A working MCP session is a short, ordered conversation between the client (Claude Desktop, Cursor, ChatGPT) and the server (the thing exposing tools). Four messages do almost all the work.

1. initialize — the handshake

The client's first message. It's a version negotiation and capability exchange: "here's the protocol version I speak, here's what I can do — what about you?"

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-03-26",
    "capabilities": {},
    "clientInfo": { "name": "claude", "version": "1.0.0" }
  }
}

The server replies with the version it will actually use and what it offers. A well-behaved server echoes back the client's requested version if it supports it, and otherwise falls back to a version it does — this is exactly why the same server can talk to an older Zapier client and a newer Claude build at the same time:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-03-26",
    "capabilities": { "tools": { "listChanged": false } },
    "serverInfo": { "name": "Synra MCP Gateway", "version": "1.0.0" }
  }
}

The capabilities field is the server declaring what parts of the spec it implements. tools means "I expose callable tools." listChanged: false means "my tool list is static — don't expect me to push you updates when it changes." Servers that expose other primitives (resources, prompts) advertise those here too.

2. notifications/initialized — the acknowledgment

After the client digests the server's capabilities, it sends a notification. A notification is a JSON-RPC message with no id — which signals "I don't expect a response":

{
  "jsonrpc": "2.0",
  "method": "notifications/initialized"
}

This small detail trips up a lot of self-hosted servers. Per the MCP Streamable HTTP transport, a POST body that contains only notifications or responses must get back an HTTP 202 Accepted with no body — not a JSON-RPC result. If your server tries to answer a notification like a normal request, strict clients get confused. Handshake complete, the session is live.

3. tools/list — discovery

Now the client asks: "what can you do?" This is the message whose failure produces the single most common MCP complaint — "it connected but there are no tools."

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

The server returns an array of tool definitions. Each one is a name, a human-and-model-readable description, and a JSON Schema describing its arguments. Here's a real one from Synra's Postgres server:

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "describe_table",
        "description": "Get the schema/columns of a specific table",
        "inputSchema": {
          "type": "object",
          "properties": {
            "table_name": {
              "type": "string",
              "description": "Name of the table to describe"
            }
          },
          "required": ["table_name"]
        }
      }
    ]
  }
}

That inputSchema is the important part. It's standard JSON Schema, and it's the entire contract the model gets. The model never sees your code — it sees this schema and the descriptions, and from that alone it decides which tool to call and how to fill in the arguments. This is why good tool descriptions matter so much: the description text is quite literally the model's documentation. A vague description ("query stuff") produces a model that calls the tool badly; a precise one ("Query data from a table with optional filters. Read-only SELECT queries only.") produces one that uses it correctly.

4. tools/call — execution

When the model decides to use a tool, the client sends a tools/call with the tool's name and the arguments it filled in from the schema:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "describe_table",
    "arguments": { "table_name": "users" }
  }
}

The server runs whatever that tool means — for a database server, it connects, runs the query, and formats the output — then returns the result as content blocks:

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      { "type": "text", "text": "id: uuid\nemail: text\ncreated_at: timestamptz" }
    ]
  }
}

The client feeds that text back into the model's context, and the model uses it to answer you — or to decide on its next tool call. Ask "how many users signed up this week?" and you'll often see the model chain calls: list_tables to orient itself, describe_table to learn the columns, then query_table to get the count. Each one is a separate tools/call round trip. That chaining is the model reasoning, not the protocol doing anything clever — the protocol is just relaying JSON back and forth.

The transport: how the JSON actually travels

The messages above are the same regardless of how they're delivered. That "how" is the transport, and it's where a lot of real-world confusion lives. There are two you'll encounter.

stdio (local servers)

The original transport. The client launches the server as a child process on your machine and talks to it over standard input/output — it literally writes JSON-RPC to the process's stdin and reads responses from its stdout. This is what's happening when your Claude Desktop or Cursor config looks like:

{
  "mcpServers": {
    "my-server": {
      "command": "npx",
      "args": ["-y", "some-mcp-server"]
    }
  }
}

command + args is the client saying "here's the program to spawn." stdio is simple and needs no network, but the server has to live on the same machine as the client — which is why it doesn't scale to teams or remote databases.

Streamable HTTP + SSE (remote servers)

The transport that makes hosted MCP servers possible. Instead of spawning a process, the client sends JSON-RPC over HTTPS POST to a URL. This is what's happening when your config has a url instead of a command, or when you paste an endpoint into ChatGPT's connector settings.

There's one wrinkle that causes a disproportionate share of "no tools" bugs: content negotiation. Some clients send an Accept: text/event-stream header and expect the response framed as a Server-Sent Event (SSE) rather than plain JSON. The same JSON-RPC payload, wrapped differently:

event: message
data: {"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}

A correct server looks at the Accept header and responds in whichever format the client asked for — SSE when requested, plain JSON otherwise. A server that only ever returns JSON will appear to "connect" to a strict SSE client and then show zero tools, because the client couldn't parse the tools/list response. (This is the exact bug that broke Zapier's MCP client against JSON-only gateways.) If you're self-hosting and seeing empty tool lists in one client but not another, transport framing is the first thing to check — there's a full symptom guide in MCP troubleshooting.

The HTTP transport also uses a GET on the same URL to open a long-lived SSE channel for server-initiated messages (a server pushing "my tools changed" to the client). Many servers — including request/response-only ones like a database gateway — have nothing to push, so they just hold the stream open with periodic keepalive comments until it times out. Clients reconnect transparently.

Error codes: the standard ones plus custom ones

When something fails, the error.code tells you what kind of failure it is. JSON-RPC reserves a range of standard codes, and MCP servers add their own in the application-defined range. You'll see these in logs and inspector output:

| Code | Meaning | |---|---| | -32700 | Parse error — the request wasn't valid JSON | | -32600 | Invalid request — not well-formed JSON-RPC | | -32601 | Method (or tool) not found | | -32602 | Invalid params — e.g. a tools/call missing a required argument | | -32000 and below | Application-defined — e.g. "endpoint inactive", "rate limit exceeded", "credential not found" |

When you're debugging, the code narrows it down fast: a -32601 means the client asked for something the server doesn't have (often a version/spelling mismatch), while a custom code in the -32000 range means the request was fine but the server refused it for a business reason (auth, limits, inactive resource).

Why the design is shaped this way

Step back and the elegance is in the decoupling. The model never knows how a tool works — only its name, description, and argument schema. The server never knows which model is calling it. The transport doesn't care what the messages mean. Each layer is independent:

  • Swap Claude for ChatGPT: the server code doesn't change. Same tools/list, same tools/call.
  • Swap a Postgres backend for MySQL: the client doesn't change. It still just sees tools with schemas.
  • Swap stdio for HTTP: the message shapes don't change. Only the delivery does.

That's why one Synra endpoint URL works identically in Claude, Cursor, and ChatGPT. They're all just JSON-RPC clients speaking the same four methods to the same server.

Seeing it for yourself

You don't have to take any of this on faith. The MCP Inspector is a tool that speaks raw MCP and shows you every message:

npx @modelcontextprotocol/inspector

Point it at any MCP server URL and you'll watch the initialize handshake happen, see the exact tools/list response, and be able to fire a tools/call by hand and inspect the result. It's the fastest way to build an intuition for the protocol — and, not coincidentally, the best way to tell whether a bug is in your server or your client.


Once you see MCP as "JSON-RPC 2.0 with four standard methods and two transports," it stops being magic and starts being something you can reason about, debug, and build on.

Want the protocol handled for you? Synra is a managed MCP server that implements all of this correctly — version negotiation, transport content-negotiation, the notification 202 rule, the lot — so you get a single URL that just works. Try it free at mcpserver.design.

Related guides: