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:
- Requests — “Please do this thing and tell me how it went”
- Responses — “Here’s how it went” (or “Here’s why it didn’t”)
- 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
| Method | Purpose |
|---|---|
initialize | Start a session, exchange capabilities |
ping | Health check |
tools/list | Discover available tools |
tools/call | Execute a tool |
resources/list | Discover available resources |
resources/read | Read a resource’s contents |
resources/templates/list | List resource URI templates |
resources/subscribe | Subscribe to resource changes |
resources/unsubscribe | Unsubscribe from resource changes |
prompts/list | Discover available prompts |
prompts/get | Retrieve a prompt with arguments |
logging/setLevel | Set the server’s log level |
completion/complete | Request argument autocompletion |
Server → Client Requests
| Method | Purpose |
|---|---|
sampling/createMessage | Ask the client’s LLM to generate a completion |
elicitation/create | Ask the user a question through the client |
roots/list | Ask the client for its workspace roots |
Client → Server Notifications
| Method | Purpose |
|---|---|
notifications/initialized | Signal that initialization is complete |
notifications/cancelled | Cancel a pending request |
notifications/roots/list_changed | Inform that the workspace roots have changed |
Server → Client Notifications
| Method | Purpose |
|---|---|
notifications/cancelled | Cancel a pending request |
notifications/tools/list_changed | Inform that the tool list has changed |
notifications/resources/list_changed | Inform that the resource list has changed |
notifications/resources/updated | Inform that a subscribed resource has changed |
notifications/prompts/list_changed | Inform that the prompt list has changed |
notifications/progress | Report progress on a long-running request |
notifications/message | Send 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
toolscapability, the client knows not to ask for tools. This isn’t an error—it’s by design. - The
initializerequest MUST be the first request — Sending any other request beforeinitializeis a protocol violation. - The
initializerequest 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
| Code | Name | Meaning |
|---|---|---|
-32700 | Parse Error | The server received invalid JSON |
-32600 | Invalid Request | The JSON is valid but not a valid JSON-RPC request |
-32601 | Method Not Found | The method doesn’t exist or isn’t available |
-32602 | Invalid Params | The parameters are wrong |
-32603 | Internal Error | Something broke inside the server |
MCP-Specific Errors
| Code | Name | Meaning |
|---|---|---|
-32000 to -32099 | Server Errors | Implementation-specific errors |
-32002 | Resource Not Found | The requested resource doesn’t exist |
-32800 | Request Cancelled | The request was cancelled via notification |
-32801 | Content Too Large | The 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
initializerequest 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.