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.
| Code | Meaning | When it appears in MCP |
|---|---|---|
-32700 | Parse error | Transport received bytes that are not valid JSON. Usually a framing bug — a broken \n delimiter in stdio, a truncated SSE event. |
-32600 | Invalid Request | The JSON-RPC envelope is malformed: missing jsonrpc field, wrong version string, or a request sent before initialized. |
-32601 | Method not found | The server does not implement the requested method. Calling tools/call with a tool name that doesn't exist returns this code. |
-32602 | Invalid params | The params object fails the server's validation (wrong type, missing required field). Return this from your input validation layer when Zod safeParse fails. |
-32603 | Internal error | Unhandled 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.
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | notifications/initialized | Confirms the initialize handshake is complete |
| Client → Server | notifications/cancelled | Client cancels a pending request by ID |
| Server → Client | notifications/progress | Progress update for a long-running tool call (tied to progressToken in the request) |
| Server → Client | notifications/tools/list_changed | Server's tool list changed — client should re-issue tools/list |
| Server → Client | notifications/resources/list_changed | Server's resource list changed |
| Server → Client | notifications/message | Log 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 abstraction | Underlying 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 handler | SDK sends a success response with result containing your return value |
| Throwing from a handler | SDK 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 initialize → initialized → tools/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
- MCP server stdio transport — newline-delimited JSON-RPC over stdin/stdout
- MCP server SSE transport — HTTP+SSE with JSON-RPC framing
- MCP server Streamable HTTP transport — modern single-endpoint design
- MCP server transport comparison — choosing the right transport
- MCP server error codes — when to use which JSON-RPC code
- MCP server error handling — isError vs protocol errors
- MCP server input validation — Zod and -32602 Invalid Params
- MCP Inspector — viewing raw JSON-RPC messages in real time
- MCP server structured logging — request/response tracing
- AliveMCP — probing the full JSON-RPC lifecycle for every registered MCP server