Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 3: The Wire Protocol

JSON-RPC 2.0: An Old Friend

MCP doesn’t invent its own message format. It uses JSON-RPC 2.0, a lightweight remote procedure call protocol that’s been around since 2010. If you’ve used the Language Server Protocol (LSP) in VS Code, you’ve already met JSON-RPC. MCP is, in many ways, LSP’s cooler younger sibling who went into AI instead of syntax highlighting.

JSON-RPC defines three types of messages:

  1. Requests — “Please do this thing and tell me how it went”
  2. Responses — “Here’s how it went” (or “Here’s why it didn’t”)
  3. Notifications — “FYI, this happened” (no response expected)

That’s it. Three message types. The entire MCP protocol is built from these three building blocks.

Requests

A request is a message that expects a response. It always includes an id field that the response will reference.

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

The fields:

  • jsonrpc — Always "2.0". Non-negotiable.
  • id — A unique identifier for this request. Can be a string or number. Must be unique among outstanding requests.
  • method — The method to invoke. MCP defines a fixed set of methods.
  • params — Optional parameters for the method. Can be omitted if the method takes no parameters.

The id is how you match responses to requests. When the server responds, it includes the same id. In a world of asynchronous communication, this is how you know which answer goes with which question.

Responses

A successful response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      {
        "name": "get_weather",
        "description": "Get current weather for a location",
        "inputSchema": {
          "type": "object",
          "properties": {
            "location": { "type": "string" }
          },
          "required": ["location"]
        }
      }
    ]
  }
}

An error response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32601,
    "message": "Method not found",
    "data": "The method 'tools/execute' does not exist. Did you mean 'tools/call'?"
  }
}

A response always includes the id from the request. It contains either a result (success) or an error (failure), never both.

Notifications

Notifications are fire-and-forget messages. No id, no response expected.

{
  "jsonrpc": "2.0",
  "method": "notifications/tools/list_changed"
}

Notifications are used for events: “my tools changed,” “this request was cancelled,” “here’s a log message.” The sender doesn’t wait for acknowledgment—they just shout into the void and trust the receiver is listening.

MCP’s Method Catalog

MCP defines a specific set of methods, organized by who sends them. Here’s the complete catalog:

Client → Server Requests

MethodPurpose
initializeStart a session, exchange capabilities
pingHealth check
tools/listDiscover available tools
tools/callExecute a tool
resources/listDiscover available resources
resources/readRead a resource’s contents
resources/templates/listList resource URI templates
resources/subscribeSubscribe to resource changes
resources/unsubscribeUnsubscribe from resource changes
prompts/listDiscover available prompts
prompts/getRetrieve a prompt with arguments
logging/setLevelSet the server’s log level
completion/completeRequest argument autocompletion

Server → Client Requests

MethodPurpose
sampling/createMessageAsk the client’s LLM to generate a completion
elicitation/createAsk the user a question through the client
roots/listAsk the client for its workspace roots

Client → Server Notifications

MethodPurpose
notifications/initializedSignal that initialization is complete
notifications/cancelledCancel a pending request
notifications/roots/list_changedInform that the workspace roots have changed

Server → Client Notifications

MethodPurpose
notifications/cancelledCancel a pending request
notifications/tools/list_changedInform that the tool list has changed
notifications/resources/list_changedInform that the resource list has changed
notifications/resources/updatedInform that a subscribed resource has changed
notifications/prompts/list_changedInform that the prompt list has changed
notifications/progressReport progress on a long-running request
notifications/messageSend a log message

The Initialization Handshake in Detail

The initialization handshake deserves special attention because it sets the rules for the entire session. Here’s what happens, step by step:

Phase 1: The Client Proposes

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "roots": {
        "listChanged": true
      },
      "sampling": {}
    },
    "clientInfo": {
      "name": "claude-desktop",
      "version": "1.5.0"
    }
  }
}

The client is saying:

  • “I speak protocol version 2025-06-18”
  • “I can provide workspace roots, and I’ll notify you when they change”
  • “I support sampling (you can ask me to generate LLM completions)”
  • “I’m Claude Desktop version 1.5.0”

Phase 2: The Server Responds

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "tools": {
        "listChanged": true
      },
      "resources": {
        "subscribe": true,
        "listChanged": true
      },
      "logging": {}
    },
    "serverInfo": {
      "name": "my-awesome-server",
      "version": "2.0.0"
    }
  }
}

The server is saying:

  • “I also speak 2025-06-18, we’re compatible”
  • “I provide tools, and I’ll notify you when the list changes”
  • “I provide resources, support subscriptions, and will notify on list changes”
  • “I support logging”
  • “I don’t provide prompts” (absence means no support)

Phase 3: The Client Confirms

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

The client says “We’re good. Let’s go.”

What Can Go Wrong

  • Version mismatch — If the client and server can’t agree on a protocol version, the connection fails. The server MUST respond with a version it supports; the client can then decide whether to proceed.
  • Missing capabilities — If the client needs tools but the server doesn’t declare tools capability, the client knows not to ask for tools. This isn’t an error—it’s by design.
  • The initialize request MUST be the first request — Sending any other request before initialize is a protocol violation.
  • The initialize request MUST NOT be cancelled — It’s the one request that’s exempt from cancellation.

Error Codes

MCP uses standard JSON-RPC error codes plus a few of its own:

Standard JSON-RPC Errors

CodeNameMeaning
-32700Parse ErrorThe server received invalid JSON
-32600Invalid RequestThe JSON is valid but not a valid JSON-RPC request
-32601Method Not FoundThe method doesn’t exist or isn’t available
-32602Invalid ParamsThe parameters are wrong
-32603Internal ErrorSomething broke inside the server

MCP-Specific Errors

CodeNameMeaning
-32000 to -32099Server ErrorsImplementation-specific errors
-32002Resource Not FoundThe requested resource doesn’t exist
-32800Request CancelledThe request was cancelled via notification
-32801Content Too LargeThe content exceeds size limits

Tool Errors vs. Protocol Errors

MCP makes an important distinction between two kinds of errors:

Protocol errors use the JSON-RPC error format:

{
  "jsonrpc": "2.0",
  "id": 3,
  "error": {
    "code": -32601,
    "message": "Unknown tool: nonexistent_tool"
  }
}

These indicate something went wrong at the protocol level—unknown methods, malformed requests, server crashes. Models can’t usually fix these.

Tool execution errors use a successful response with isError: true:

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "content": [{
      "type": "text",
      "text": "Error: File not found: /nonexistent/path.txt"
    }],
    "isError": true
  }
}

These indicate the tool ran but couldn’t complete its work—file not found, invalid input, API failure. Models can often fix these (try a different path, adjust parameters, retry with different arguments).

This distinction matters because hosts should feed tool execution errors back to the LLM (so it can self-correct), while protocol errors are typically shown to the user or logged.

Pagination

Some MCP methods return potentially large lists (tools, resources, prompts). MCP supports cursor-based pagination for these:

Request the first page:

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

Response with a cursor for the next page:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [ /* ... first batch ... */ ],
    "nextCursor": "eyJwYWdlIjogMn0="
  }
}

Request the next page:

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

When nextCursor is absent or null, you’ve reached the end. Cursors are opaque strings—don’t try to decode or construct them. Just pass them back.

Cancellation

Either side can cancel an in-progress request:

{
  "jsonrpc": "2.0",
  "method": "notifications/cancelled",
  "params": {
    "requestId": "42",
    "reason": "User clicked cancel"
  }
}

Important rules:

  • You can only cancel requests you’ve sent that haven’t been answered yet
  • The initialize request cannot be cancelled
  • The receiver SHOULD stop processing, but MAY have already finished
  • The sender SHOULD ignore any response that arrives after cancellation
  • Due to network latency, race conditions are expected and both sides must handle them gracefully

Cancellation is a notification (not a request), so there’s no response. You fire it and move on. This is the right design—you don’t want cancellation to block on a response from a server that might be too busy to respond (that’s why you’re cancelling in the first place).

Progress Reporting

For long-running operations, servers can report progress:

{
  "jsonrpc": "2.0",
  "method": "notifications/progress",
  "params": {
    "progressToken": "op-42",
    "progress": 50,
    "total": 100,
    "message": "Processing file 50 of 100..."
  }
}

Progress tokens are established in the original request via a _meta.progressToken field. The client includes the token when making a request, and the server uses it to send progress updates.

Request with progress token:

{
  "jsonrpc": "2.0",
  "id": 5,
  "method": "tools/call",
  "params": {
    "name": "bulk_import",
    "arguments": { "file": "big_data.csv" },
    "_meta": {
      "progressToken": "import-progress-1"
    }
  }
}

The total field is optional. If omitted, the client knows work is happening but doesn’t know how much is left—like a progress bar that just spins.

Logging

Servers can send log messages to the client:

{
  "jsonrpc": "2.0",
  "method": "notifications/message",
  "params": {
    "level": "warning",
    "logger": "database",
    "data": "Connection pool running low: 2/50 available"
  }
}

Log levels follow the standard syslog hierarchy: debug, info, notice, warning, error, critical, alert, emergency.

Clients can control the verbosity:

{
  "jsonrpc": "2.0",
  "id": 10,
  "method": "logging/setLevel",
  "params": {
    "level": "warning"
  }
}

After this, the server should only send warning and above.

Putting It All Together

Here’s a complete interaction showing the full lifecycle from initialization through tool discovery and execution:

Client                              Server
  │                                    │
  │──── initialize ──────────────────→│
  │                                    │
  │←─── initialize result ───────────│
  │                                    │
  │──── notifications/initialized ──→│
  │                                    │
  │──── tools/list ─────────────────→│
  │                                    │
  │←─── tools list result ──────────│
  │                                    │
  │──── tools/call ─────────────────→│
  │                                    │
  │←─── notifications/progress ─────│
  │←─── notifications/progress ─────│
  │←─── notifications/progress ─────│
  │                                    │
  │←─── tools/call result ──────────│
  │                                    │
  │←─── notifications/tools/        │
  │     list_changed ───────────────│
  │                                    │
  │──── tools/list ─────────────────→│
  │                                    │
  │←─── updated tools list ─────────│
  │                                    │

The protocol is request-response at its core, but with notifications flowing in both directions to handle asynchronous events. It’s simple enough to implement in an afternoon, yet expressive enough to handle complex real-world scenarios.

Summary

MCP’s wire protocol is JSON-RPC 2.0 with a defined set of methods, a capability negotiation handshake, and sensible error handling that distinguishes protocol failures from tool execution failures. Pagination, cancellation, progress reporting, and logging round out the feature set.

The protocol is deliberately boring. There are no novel encoding schemes, no binary formats, no clever compression tricks. It’s JSON over a transport. This boringness is a feature—it means any language that can read and write JSON can implement MCP, and any developer who can read JSON can debug MCP.

Now let’s look at what travels over this protocol: the three primitives that make MCP useful.