Guide · Protocol

MCP server JSON-RPC 2.0

The Model Context Protocol uses JSON-RPC 2.0 as its wire protocol — the format for every message exchanged between a host (Claude Desktop, Cursor, an LLM agent) and your server. The MCP SDK handles serialization and routing automatically, but understanding the underlying message shapes is essential for debugging protocol errors, implementing custom transports, and reading the raw logs that tools like MCP Inspector expose. This guide covers the three message types, the session lifecycle, and how error codes map to tool-call failures.

TL;DR

JSON-RPC 2.0 has three message types: request (has an id, expects a response), response (matches a request id with either result or error), and notification (no id, no response expected). MCP sessions always start with initialize (request) → initialized (notification). Tool calls use tools/call (request) → response with the tool result. Application-level errors (your tool failed) return isError: true inside the result — NOT in the error field. The error field is for protocol-level failures.

The three JSON-RPC message types

Every MCP message is one of three shapes. Knowing which shape you're looking at is the first step in debugging protocol issues.

Request

A message from one party expecting a matching response. Always has a non-null id.

{
  "jsonrpc": "2.0",
  "id": "req-1",          // any string or integer — matched to response
  "method": "tools/call",
  "params": {
    "name": "search_documents",
    "arguments": { "query": "MCP transport" }
  }
}

Response (success)

Matches a request by id. Has a result field with the method's return value.

{
  "jsonrpc": "2.0",
  "id": "req-1",          // matches the request id
  "result": {
    "content": [
      { "type": "text", "text": "Found 3 documents about MCP transport." }
    ],
    "isError": false
  }
}

Response (error)

Protocol-level failure. The error object has a numeric code and a human-readable message.

{
  "jsonrpc": "2.0",
  "id": "req-1",
  "error": {
    "code": -32602,
    "message": "Invalid params",
    "data": { "field": "query", "issue": "must be a non-empty string" }
  }
}

Notification

A one-way message with no id — the sender does not expect a response. Used for events the receiver should know about but doesn't need to acknowledge.

{
  "jsonrpc": "2.0",
  "method": "notifications/progress",
  "params": {
    "progressToken": "task-1",
    "progress": 42,
    "total": 100
  }
  // no "id" field
}

Session lifecycle: initialize and initialized

Every MCP session starts with a two-message handshake before any tools can be called. The MCP SDK handles this automatically, but you will see it in protocol logs.

Step 1 — Client sends initialize (request)

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

Step 2 — Server responds with its capabilities

{
  "jsonrpc": "2.0",
  "id": "init-1",
  "result": {
    "protocolVersion": "2025-03-26",
    "serverInfo": {
      "name": "my-server",
      "version": "1.0.0"
    },
    "capabilities": {
      "tools": { "listChanged": false },
      "resources": {},
      "prompts": {}
    }
  }
}

Step 3 — Client sends initialized (notification)

{
  "jsonrpc": "2.0",
  "method": "notifications/initialized"
  // no id — notification, no response expected
}

After this three-message sequence, the session is active and the client may send tools/list, tools/call, resources/read, and other requests. Any message sent before the initialized notification should return a -32600 Invalid Request error.

Tools/list and tools/call flows

The most common session flow after initialization:

1. Discover available tools

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

// Server response
{
  "jsonrpc": "2.0",
  "id": "list-1",
  "result": {
    "tools": [
      {
        "name": "search_documents",
        "description": "Search documents by keyword",
        "inputSchema": {
          "type": "object",
          "properties": {
            "query": { "type": "string", "description": "Search query" }
          },
          "required": ["query"]
        }
      }
    ]
  }
}

2. Call a tool

// Client request
{
  "jsonrpc": "2.0",
  "id": "call-1",
  "method": "tools/call",
  "params": {
    "name": "search_documents",
    "arguments": { "query": "MCP protocol" }
  }
}

// Server response — success
{
  "jsonrpc": "2.0",
  "id": "call-1",
  "result": {
    "content": [
      { "type": "text", "text": "Found 5 documents matching 'MCP protocol'." }
    ],
    "isError": false
  }
}

// Server response — tool-level error (not a protocol error)
{
  "jsonrpc": "2.0",
  "id": "call-1",
  "result": {
    "content": [
      { "type": "text", "text": "Search failed: upstream API rate limit exceeded." }
    ],
    "isError": true
  }
}

The isError: true pattern is key: it delivers the error to the LLM as readable content so it can reason about the failure and retry differently. A -32603 Internal Error in the error field gives the LLM no useful information to act on.

JSON-RPC error codes in MCP

The JSON-RPC 2.0 spec defines five standard codes. MCP uses all of them and adds application-level errors via isError.

CodeMeaningWhen it appears in MCP
-32700Parse errorTransport received bytes that are not valid JSON. Usually a framing bug — a broken \n delimiter in stdio, a truncated SSE event.
-32600Invalid RequestThe JSON-RPC envelope is malformed: missing jsonrpc field, wrong version string, or a request sent before initialized.
-32601Method not foundThe server does not implement the requested method. Calling tools/call with a tool name that doesn't exist returns this code.
-32602Invalid paramsThe params object fails the server's validation (wrong type, missing required field). Return this from your input validation layer when Zod safeParse fails.
-32603Internal errorUnhandled exception inside the server. This code is opaque to the LLM — use isError: true in the result for application errors instead.

In the MCP SDK, returning a -32602 from an input validation failure looks like this:

server.tool(
  'search_documents',
  'Search documents by keyword',
  { query: z.string().min(1) },
  async ({ query }) => {
    // The SDK validates 'query' against the Zod schema before calling this handler.
    // If validation fails, the SDK returns -32602 automatically.
    // Inside the handler, query is already validated — proceed safely.
    const docs = await searchDocs(query);
    return { content: [{ type: 'text', text: docs.join('\n') }] };
  }
);

Notifications in MCP

Notifications are one-way messages — sent without an id, no response expected. Both client and server can send notifications at any point after the session is initialized.

DirectionMethodPurpose
Client → Servernotifications/initializedConfirms the initialize handshake is complete
Client → Servernotifications/cancelledClient cancels a pending request by ID
Server → Clientnotifications/progressProgress update for a long-running tool call (tied to progressToken in the request)
Server → Clientnotifications/tools/list_changedServer's tool list changed — client should re-issue tools/list
Server → Clientnotifications/resources/list_changedServer's resource list changed
Server → Clientnotifications/messageLog message from server to client (for display, not for tool results)

Sending a progress notification from within a tool handler:

server.tool(
  'export_data',
  'Export data to CSV',
  { format: z.enum(['csv', 'json']) },
  async ({ format }, { sendNotification, meta }) => {
    const token = meta?.progressToken;

    if (token !== undefined) {
      await sendNotification({
        method: 'notifications/progress',
        params: { progressToken: token, progress: 0, total: 3 },
      });
    }

    const data = await fetchAllRecords();
    if (token !== undefined) {
      await sendNotification({
        method: 'notifications/progress',
        params: { progressToken: token, progress: 1, total: 3 },
      });
    }

    const output = formatData(data, format);
    if (token !== undefined) {
      await sendNotification({
        method: 'notifications/progress',
        params: { progressToken: token, progress: 3, total: 3 },
      });
    }

    return { content: [{ type: 'text', text: output }] };
  }
);

How the MCP SDK abstracts JSON-RPC

You rarely write raw JSON-RPC when using the MCP SDK — the abstractions handle serialization, routing, and the initialize handshake. Understanding the mapping helps when debugging:

SDK abstractionUnderlying JSON-RPC
server.tool('name', desc, schema, handler)Registers a tools/call dispatcher; contributes to tools/list response
server.connect(transport)Starts the initialize / initialized handshake
client.callTool({ name, arguments })Sends a tools/call request; returns the result object
client.listTools()Sends a tools/list request; returns the tools array
Returning from a handlerSDK sends a success response with result containing your return value
Throwing from a handlerSDK sends an error response with -32603 (unless you throw a typed McpError)
sendNotification({ method, params })Sends a JSON-RPC notification (no id, no response)

To throw a typed protocol error (instead of a generic -32603), use McpError:

import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';

// Inside a tool handler:
if (!userHasPermission(userId, resource)) {
  throw new McpError(
    ErrorCode.InvalidRequest,  // -32600
    `User ${userId} does not have permission to access ${resource}`
  );
}

Debugging raw protocol messages

To see the raw JSON-RPC messages flowing between client and server, use the MCP Inspector — it acts as a proxy that logs every message in human-readable form. Alternatively, add a transport-level message interceptor:

// Intercept messages for debugging (stdio transport)
const transport = new StdioServerTransport();

// Wrap the transport's send method to log outgoing messages
const originalSend = transport.send.bind(transport);
transport.send = async (message) => {
  process.stderr.write(`OUT: ${JSON.stringify(message)}\n`);
  return originalSend(message);
};

// Log incoming messages via the onmessage callback
transport.onmessage = (message) => {
  process.stderr.write(`IN: ${JSON.stringify(message)}\n`);
};

In production, use structured logging at the transport boundary to record request IDs, method names, and response latency. This is the same pattern as HTTP access logs — one log line per JSON-RPC request, with timing.

When AliveMCP probes your server, it sends the full initializeinitializedtools/list sequence and validates each response. If any step fails, the probe records the exact JSON-RPC error code and message, giving you a precise starting point for debugging.

Related questions

Does MCP support JSON-RPC batch requests?

The JSON-RPC 2.0 spec allows sending an array of requests in a single message (batch mode). The MCP spec does not require or commonly use batching — the official SDK does not expose a batch API. Most MCP clients send individual requests. If you receive a batch request (an array at the top level), the MCP SDK handles it by routing each message individually. Do not rely on batch behavior in your server design.

Can request IDs be integers instead of strings?

Yes — the JSON-RPC spec allows IDs to be strings, integers, or null (for notifications). The MCP SDK accepts both string and integer IDs from clients. When you call client.callTool(), the SDK generates string UUIDs as IDs. If you write a custom client, any non-null JSON value works as an ID, but strings are the safest choice for interoperability.

What is the difference between isError: true and the error field?

These serve different audiences. The error field (with a numeric code) signals a protocol-level failure — the message was malformed, the method doesn't exist, the server crashed. The LLM client typically cannot recover from these. isError: true inside the result is an application-level error — the tool ran but the operation failed (API rate limit, record not found, validation failed). The LLM receives the error message as readable content and can decide to retry, inform the user, or try a different approach. Always prefer isError: true for tool failures; reserve the error field for protocol problems.

How does the server handle a request for an unknown tool name?

The SDK checks whether the tool name in a tools/call request matches any registered tool. If not, it returns a -32601 Method not found error response. You do not need to handle this case in your tool handlers — it never reaches them. If you want to return a more descriptive error message, check the tool name before the SDK does by implementing a middleware at the transport level, though this is rarely necessary.

Can the server send requests to the client?

Yes — in JSON-RPC, either party can be the requester. The MCP spec defines some server-to-client requests: sampling/createMessage (request the client to invoke an LLM on the server's behalf) and roots/list (request the list of filesystem roots the client exposes). These are used by advanced servers that need to "call back" into the host's capabilities. The MCP SDK exposes these via server.server.request() on the underlying low-level server instance.

Further reading