Guide · Protocol
MCP server error codes
MCP is built on JSON-RPC 2.0, which defines a fixed set of error codes. When your MCP server or a tool handler fails, the type of error determines whether the LLM client can recover, whether it can retry, and what the user or developer sees in logs. There are two fundamentally different failure modes: a JSON-RPC protocol error (the server returns a JSON-RPC error object instead of a result) and a tool-level content error (the server returns a successful JSON-RPC result, but the result has isError: true). The distinction determines whether the LLM can read the error message and act on it.
TL;DR
JSON-RPC errors (-32700 to -32603) are protocol-level — the LLM client cannot read the message, so these are unrecoverable from the LLM's perspective. Tool-level isError: true responses are application-level — the LLM receives the content array and can read the error and retry. In your tool handlers: return { content: [...], isError: true } for user-facing and LLM-recoverable failures; throw (producing -32603) only for unexpected server bugs you want logged as protocol errors. Never let input validation failures throw — use safeParse and return isError.
JSON-RPC 2.0 standard error codes
These five codes are defined by the JSON-RPC 2.0 specification and used by the MCP SDK. When any of these appear, the client receives a JSON object with an error field instead of a result.
| Code | Name | When it appears in MCP |
|---|---|---|
-32700 | Parse error | Malformed JSON received — the message could not be parsed at all. Indicates a transport-level corruption or a bug in the client's serialisation code. Extremely rare in practice. |
-32600 | Invalid Request | The JSON was valid but is not a valid JSON-RPC request — missing jsonrpc, method, or id. Usually a client SDK version mismatch or direct API testing with malformed payloads. |
-32601 | Method not found | The method in the request is not implemented by the server (e.g., calling resources/list on a server with no resources capability). The MCP SDK returns this automatically for unregistered methods. |
-32602 | Invalid params | The params object doesn't match the method's expected structure. The MCP SDK may return this if the CallToolRequest structure is malformed — distinct from a tool-level validation failure, which should be isError: true. |
-32603 | Internal error | The request was valid but the server threw an unhandled exception while processing it. This is what your tool handler produces when it throws instead of returning isError: true. Unrecoverable — the LLM client cannot read the error message. |
MCP-specific error codes
The JSON-RPC spec reserves the range -32099 to -32000 for implementation-defined server errors. The MCP SDK uses some values in this range for MCP-specific protocol failures.
| Code | Meaning in MCP |
|---|---|
-32001 | Request timeout — the server took too long to respond to a request. Configured via transport-level options. |
-32002 | Resource not found — a specific resources/read request named a resource URI that doesn't exist. |
-32003 | Tool not found — a tools/call request named a tool that isn't registered on the server. |
Note: the exact codes used by the MCP SDK may change between SDK versions. Check your SDK version's source for the current ErrorCode enum values.
The two-tier error model
MCP's error model has two distinct tiers. Understanding which tier applies to a given failure determines how to handle it.
| Protocol error (JSON-RPC error) | Tool error (isError: true) | |
|---|---|---|
| JSON-RPC response | { "error": { "code": -32603, "message": "..." } } | { "result": { "content": [...], "isError": true } } |
| Cause | Server throws, transport failure, protocol mismatch | Tool handler returns explicitly with isError flag |
| LLM sees | Protocol error in client log — no content | Content array with error description |
| LLM can recover? | No — cannot read the message | Yes — reads message, can retry with corrected args |
| Inspector display | Red badge in protocol log, no tool result panel | Yellow/orange badge in tool result panel with content |
| Use for | Unexpected server bugs, unhandled exceptions | Validation failures, not-found, permission denied, upstream errors |
How to produce each error type
Tool-level isError: true (correct for most failures)
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'get_user') {
const parsed = GetUserSchema.safeParse(request.params.arguments);
// Validation failure → isError: true, LLM can retry
if (!parsed.success) {
return {
content: [{ type: 'text', text: `Invalid arguments: ${parsed.error.message}` }],
isError: true,
};
}
const user = await db.getUser(parsed.data.userId);
// Not-found → isError: true, LLM can use a different ID
if (!user) {
return {
content: [{ type: 'text', text: `User ${parsed.data.userId} not found.` }],
isError: true,
};
}
return { content: [{ type: 'text', text: JSON.stringify(user) }] };
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
Protocol error -32603 (from unhandled throw)
// This throw produces a -32603 Internal error
// The LLM client cannot read the message
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'get_user') {
const user = await db.getUser(request.params.arguments?.userId);
// If db.getUser throws (connection error, etc.) the exception propagates
// → JSON-RPC -32603, logged as protocol error, LLM cannot recover
return { content: [{ type: 'text', text: JSON.stringify(user) }] };
}
});
The corrected version wraps the database call in a try/catch and returns isError: true for expected failures (connection unavailable, query error), while letting truly unexpected failures propagate as protocol errors.
How MCP Inspector displays error codes
MCP Inspector displays protocol errors and tool errors differently. Knowing which you're looking at is essential for debugging.
| Error type | Inspector shows | Diagnostic action |
|---|---|---|
| -32700 Parse error | Red protocol log entry with code -32700, no result panel | Inspect the raw message the client sent — likely a serialisation bug |
| -32601 Method not found | Red protocol log with code -32601; method name visible in the request | Check the server's registered capabilities — is the requested method implemented? |
| -32602 Invalid params | Red protocol log with code -32602 | Compare the request params structure with the SDK's expected schema |
| -32603 Internal error | Red protocol log with code -32603; message from the thrown exception | Check server logs for the full stack trace — the message is truncated in Inspector |
| isError: true | Yellow/orange badge in the tool result panel; full content array visible | Read the content text — it should describe the validation or application failure |
| Connection failure | No protocol log entries at all; Inspector shows "disconnected" | Check that the server process is running and listening on the expected port/stdio |
Error handling in upstream service calls
MCP tool handlers frequently call external APIs, databases, or internal microservices. These calls can fail for reasons outside the tool's control. The correct pattern is to catch these failures and return isError: true with a message describing the upstream failure, so the LLM can decide whether to retry, use a different tool, or report the issue.
case 'send_email': {
const parsed = SendEmailSchema.safeParse(request.params.arguments);
if (!parsed.success) return validationError(parsed.error);
try {
await emailClient.send({
to: parsed.data.to,
subject: parsed.data.subject,
body: parsed.data.body,
});
return { content: [{ type: 'text', text: 'Email sent successfully.' }] };
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
// Categorise by retry-safety:
if (message.includes('rate limit')) {
return {
content: [{ type: 'text', text: `Email rate limit reached. Try again in 60 seconds.` }],
isError: true,
};
}
if (message.includes('invalid recipient')) {
return {
content: [{ type: 'text', text: `Invalid email address: ${parsed.data.to}` }],
isError: true,
};
}
// Unexpected failure — still return isError so LLM can report it
return {
content: [{ type: 'text', text: `Email service error: ${message}` }],
isError: true,
};
}
}
See MCP server error handling for the full decision tree on when to use each error path and retry-safe vs non-retry-safe classification.
Logging error codes in production
In production, log every error with its type (protocol vs tool-level), the tool name, and enough context to diagnose without a full stack trace. Structured logging makes this queryable.
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const toolName = request.params.name;
const startMs = Date.now();
try {
const result = await dispatchTool(request);
// Log tool errors as warnings (LLM can recover)
if (result.isError) {
logger.warn('tool_error', {
tool: toolName,
durationMs: Date.now() - startMs,
message: (result.content[0] as { text: string }).text,
});
}
return result;
} catch (err) {
// Log protocol errors as errors (cannot recover)
logger.error('tool_exception', {
tool: toolName,
durationMs: Date.now() - startMs,
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
throw; // re-throw to produce -32603
}
});
Tracking the ratio of isError: true responses to successful responses by tool name reveals which tools have the highest LLM validation failure rates — a signal that either the tool's inputSchema descriptions need improvement or the LLM needs different system-prompt guidance.
Related questions
Can I define custom error codes beyond -32603?
The JSON-RPC spec reserves -32099 to -32000 for server-defined errors. The MCP SDK uses some values in this range. You can return a custom code by throwing an object with code and message properties that the MCP SDK recognises as a structured JSON-RPC error. However, most MCP clients treat all non-standard codes as unrecoverable protocol errors. For application-level errors, prefer isError: true with a descriptive message over custom error codes.
What does the LLM client actually do with a -32603 error?
Behaviour varies by client. Claude typically logs the error in the tool-use block and continues the conversation, reporting to the user that the tool failed. It cannot retry because it has no error details to act on. Some clients may raise a user-visible error dialog. In all cases, the LLM's ability to recover is zero — it knows the tool failed but not why. This is why catching all expected failures in the handler and returning isError: true is so important for agent reliability.
How do I test that my handler returns the right error type?
In unit tests with InMemoryTransport, client.callTool() returns a CallToolResult for both success and isError: true responses. For protocol errors (-32603), the SDK throws at the client side. Write one test that asserts result.isError === true for expected failures, and one test that asserts await expect(client.callTool(...)).rejects.toThrow() for cases that should produce protocol errors (though in practice you should have very few of those).
Should I include error codes in the isError message text?
Including a short code or category in the isError message is useful for logging and debugging: "ERROR:RATE_LIMIT — Email service rate limit exceeded. Retry after 60s.". The LLM can read the human-readable part; your log parsers can extract the structured code. Avoid JSON in the message text — if you need structured error data, use multiple content items: a TextContent with the human-readable message followed by a TextContent with a JSON-formatted details object.
Further reading
- MCP server error handling — complete decision tree for isError vs throw
- MCP Inspector — how to read protocol logs and distinguish error types
- MCP server debugging — tracing errors back through the protocol
- MCP server logging — structured logging for tool errors and protocol events
- MCP server structured logging — queryable production error logs
- MCP server input validation — turn validation failures into isError responses
- MCP server unit testing — testing error code paths with InMemoryTransport
- MCP server retry logic — retry-safe vs non-retry-safe error classification
- AliveMCP — monitor protocol-level health of your deployed MCP server