Your MCP server works in development but crashes silently in production with cryptic -32700 errors you can’t reproduce. After implementing proper error boundaries, Anthropic’s reference MCP servers reduced unhandled exceptions significantly in production telemetry [VERIFY: 2025-11 MCP SDK release notes]. This guide shows you how to build bulletproof MCP servers that emit correct JSON-RPC error objects, recover from transport failures, and log diagnostically useful context without leaking sensitive data, mastering MCP server error handling in the process.
Table of Contents
- What is MCP server error handling in the JSON-RPC protocol?
- How do MCP error codes map to JSON-RPC standard codes?
- What are the stdio transport edge cases for MCP error handling?
- How do SSE transport errors differ in MCP servers?
- What causes MCP ‘Invalid params’ errors and how to fix them?
- How should MCP servers handle async tool execution errors?
- What is the correct MCP error response for unsupported protocol versions?
- How to implement MCP logging hooks for production debugging?
- What are the common MCP server error handling anti-patterns?
What is MCP server error handling in the JSON-RPC protocol?
MCP error handling implements JSON-RPC 2.0 error objects with protocol-specific extensions: standardized error codes (-32700 to -32000), optional data fields for debugging, and transport-aware recovery mechanisms for stdio and SSE connections. The Model Context Protocol layers its own constraints on top of the base JSON-RPC specification, requiring specific error semantics during initialization, capability negotiation, and tool execution.
JSON-RPC 2.0 error object specification
Every JSON-RPC error response must contain three fields: jsonrpc: "2.0", the id from the request, and an error object. The error object itself requires an integer code and a string message. A third field, data, is optional and contains additional context about the failure. Here is a valid error response:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32602,
"message": "Invalid params",
"data": {
"expected": "string",
"received": "number"
}
}
}
The JSON-RPC specification [jsonrpc.org] reserves the range -32768 to -32000 for protocol-defined errors. MCP servers must never return codes outside this range for protocol-level failures.
MCP protocol extensions and constraints
The Model Context Protocol adds semantic constraints beyond basic JSON-RPC. During initialization, servers must validate the protocolVersion field. MCP also introduces a distinction between tool execution errors and protocol errors. A tool that fails to find a file should return a successful JSON-RPC response with isError: true in the result, not a JSON-RPC error object.
This distinction often trips up developers moving from REST APIs. A 404 Not Found in REST is an error, but in MCP, a file-tool returning “file not found” is a successful protocol exchange. The tool ran, looked for the file, and reported the outcome.
How do MCP error codes map to JSON-RPC standard codes?
MCP servers must use the standard JSON-RPC error codes reserved for protocol-level issues. Correct code selection is critical for client libraries to handle errors programmatically. Use -32700 for parse errors, -32600 for invalid requests, -32601 for unknown methods, -32602 for invalid parameters, and -32603 for internal server errors.
Standard JSON-RPC error code reference
The following table lists the standard codes every MCP implementation must support.
| Code | Name | Meaning in MCP context |
|---|---|---|
| -32700 | Parse error | Invalid JSON received. Server could not parse the request text. |
| -32600 | Invalid Request | The JSON sent is not a valid Request object (missing method or jsonrpc field). |
| -32601 | Method not found | The method does not exist or is not available on this server. |
| -32602 | Invalid params | Invalid method parameter(s) provided. |
| -32603 | Internal error | Internal JSON-RPC error on the server side. |
For invalid JSON, the error code should be -32700. This code indicates malformed JSON was received before the server can determine if it is a valid JSON-RPC object. The server cannot even read the id field to correlate the error with a specific request, so the id in the response is typically null.
MCP-specific reserved code ranges
The range -32099 to -32000 is reserved for implementation-defined server errors. MCP implementations should use this range for custom errors that do not fit the standard codes. For example, a database connection failure might use -32001.
Avoid inventing error codes outside these ranges. Client libraries like the official @modelcontextprotocol/sdk [github.com/modelcontextprotocol] parse the code field to determine error type. A code of 404 will not be recognized as a standard error and may crash the client.
When to use custom vs standard codes
Reserve custom codes for application-level failures that the protocol cannot anticipate. A payment processing failure or an external API rate limit are good candidates for custom codes in the -32000 range. Never use custom codes for parameter validation, parse errors, or unknown methods. Those belong to the standard range.
If you find yourself reaching for a custom code, first ask: is this a tool execution failure? If the answer is yes, return CallToolResult with isError: true instead. Reserve JSON-RPC error codes for problems with the request itself, not the business logic.
What are the stdio transport edge cases for MCP error handling?
Stdio MCP servers face unique failures: unparseable lines before the handshake, partial JSON writes, newline delimiter corruption, and process exit before response flush. Each requires distinct recovery without breaking the transport stream. Stdio is the simplest transport, but its simplicity leaves no room for metadata or connection state management.
Handling malformed initial messages
During initialization, a stdio server reads from stdin line by line. If a line cannot be parsed as JSON, the server must respond with a -32700 error written to stdout. However, the server must not exit. It should continue reading the next line.
This behavior differs from HTTP servers that close connections on protocol errors. A stdio MCP server is a long-running process. Exiting on a single malformed message would allow a client to crash the server with garbage input.
import sys
import json
def read_loop():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
# process request
except json.JSONDecodeError as e:
error_response = {
"jsonrpc": "2.0",
"id": None,
"error": {
"code": -32700,
"message": "Parse error",
"data": str(e)
}
}
print(json.dumps(error_response), flush=True)
Note the flush=True argument. Stdio buffers output by default. Without flushing, the error response may sit in the buffer indefinitely, deadlocking the client.
Recovering from partial write failures
Writing to stdout can fail if the parent process closes the pipe unexpectedly. This usually happens when the client crashes or times out. Always wrap stdout writes in a try/catch block.
If stdout is closed, the server should exit gracefully. There is no way to communicate with a client whose input pipe is broken. Attempting to write error logs to stderr is still possible and recommended.
Process termination mid-response
A common failure mode is a timeout killing the server process while it is streaming a large response. This leaves the client with partial JSON. Clients should implement a read timeout and attempt to parse whatever data they have. If parsing fails, they can treat it as a connection reset.
From the server’s perspective, you cannot prevent abrupt termination. You can only ensure that your response generation logic is stateless. If the server restarts, it should be able to handle a retried request identically. This is a good reason to prefer idempotent operations in MCP tools.
How do SSE transport errors differ in MCP servers?
SSE MCP servers must handle HTTP upgrade failures, EventSource reconnection storms, and message boundary fragmentation. Unlike stdio, SSE supports server-to-client notifications requiring independent error channels per connection. SSE operates over HTTP, which adds status codes, headers, and connection lifecycle events.
HTTP 4xx/5xx response handling
SSE transport begins with an HTTP GET request. If the server cannot fulfill the request (e.g. invalid headers, authentication failure), it should return an appropriate HTTP status code. A 401 Unauthorized or 503 Service Unavailable is valid before the SSE stream starts.
Once the SSE stream begins (status 200 with Content-Type: text/event-stream), the server cannot use HTTP status codes for errors. All subsequent communication uses JSON-RPC error objects inside SSE data fields.
Reconnection backoff strategies
EventSource clients automatically reconnect when the connection drops. Without rate limiting, a server restart can trigger a reconnection storm from thousands of clients. Implement exponential backoff with jitter.
A robust pattern is to include a retry: field in the initial SSE response. This tells the client to wait N milliseconds before reconnecting. The client’s reconnection logic should also use exponential backoff retry strategies before declaring an outage, preventing a thundering herd.
SSE message boundary corruption
SSE messages are delimited by blank lines. An error in your server logic that writes a partial message could corrupt the stream. For example, writing data: {"jsonrpc": without a closing brace and newline. The client’s EventSource parser will stall waiting for the message to complete.
Always use an SSE library or helper that guarantees atomic message writes. Never write to the underlying socket directly. If your framework supports mcp server stdio transport with Python, the SSE patterns are similar but with different framing.
What causes MCP ‘Invalid params’ errors and how to fix them?
The -32602 error typically stems from JSON Schema validation mismatches, missing required tool parameters, type coercion failures, and enum value violations. MCP SDKs expose this via McpError with code: -32602. Catching this error early saves debugging time later.
Schema validation failure patterns
MCP uses JSON Schema for parameter validation. A common mismatch occurs when the tool definition specifies a field as an integer, but the client sends a string representation of a number. Unlike JavaScript, JSON Schema does not coerce types by default.
Here is a correct validation setup using Zod, which is used internally by many TypeScript MCP SDKs.
import { z } from 'zod';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
const ReadFileArgs = z.object({
path: z.string().min(1, "Path cannot be empty"),
encoding: z.enum(['utf-8', 'binary']).optional().default('utf-8')
});
const server = new McpServer({ name: 'file-server', version: '1.0.0' });
server.tool(
'read_file',
'Read a file from the filesystem',
{ path: ReadFileArgs.shape.path, encoding: ReadFileArgs.shape.encoding },
async (args) => {
// args is already validated by the SDK
const parsed = ReadFileArgs.parse(args);
// ... implementation
}
);
If validation fails, the SDK automatically returns a -32602 error. The data field will contain the specific schema violation.
Handling polymorphic tool arguments
Some tools accept different parameter shapes based on a discriminator field. For example, a search tool might accept either a query string or a filter object. Use a discriminated union schema.
const SearchArgs = z.discriminatedUnion('type', [
z.object({ type: z.literal('text'), query: z.string() }),
z.object({ type: z.literal('filter'), field: z.string(), value: z.string() })
]);
This ensures that the validation error is precise. Instead of a generic “invalid params”, the client sees exactly which union member failed validation.
Deserialization error recovery
Parameters sometimes arrive as JSON strings nested inside the main request. This happens when a tool proxies input from another system that serializes JSON twice. Attempting to use the value directly fails.
Detect double-encoded strings by checking the type at runtime. If a string is expected but the schema says object, try JSON.parse. If that succeeds, use the parsed value. If it fails, return -32602.
How should MCP servers handle async tool execution errors?
Long-running tools must return CallToolResult with isError: true rather than throwing JSON-RPC errors, preserving the connection. Uncaught exceptions in async handlers crash stdio transports but allow SSE error framing. This is a critical distinction for tool developers.
Structured error content in tool results
When a tool fails at its task (file not found, permission denied, external API error), it should return a result object with isError: true. The content array should contain human-readable error messages.
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
async function readFileTool(args: { path: string }): Promise<CallToolResult> {
try {
const content = await fs.readFile(args.path, 'utf-8');
return {
content: [{ type: 'text', text: content }]
};
} catch (err) {
return {
isError: true,
content: [{ type: 'text', text: `Error reading file: ${err.message}` }]
};
}
}
This returns a successful JSON-RPC response. The isError flag is part of the MCP protocol extension, allowing clients to distinguish between protocol failures and tool failures.
Distinguishing tool errors from protocol errors
The line between -32602 and isError: true is subtle. A missing required parameter is -32602. A file that does not exist is isError: true. The rule: if the request is structurally invalid, use JSON-RPC error codes. If the request is valid but the operation fails, use tool result errors.
This distinction matters for ai agent error handling feedback loops. Agents can retry tool errors with different parameters or request human intervention. Protocol errors indicate a bug in the client implementation.
Cancellation signal handling
Long-running tools should respect cancellation signals. MCP sends a $/cancelRequest notification when the user aborts an operation. The tool should stop work and return a result indicating cancellation.
The SDK provides an AbortSignal to tool handlers. Pass this to any async operation that supports it.
server.tool(
'slow_operation',
{ input: z.string() },
async (args, extra) => {
const result = await fetchExternalResource(args.input, {
signal: extra.signal // AbortSignal from MCP
});
return { content: [{ type: 'text', text: result }] };
}
);
If the operation is cancelled, the async function throws an AbortError. Catch this and return a cancellation result rather than letting it propagate as a generic -32603 error.
What is the correct MCP error response for unsupported protocol versions?
Protocol negotiation failures during initialization should return -32600 (Invalid Request) with data.protocolVersion indicating the mismatch. Never return -32700 (Parse error) for semantically valid but unsupported version strings.
MCP 2024-11-05 vs 2025-03-26 version handling
The MCP specification version is declared in the initialize request. If the server does not support the client’s version, it must return -32600, not proceed with the handshake. The response should clearly state the supported versions.
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32600,
"message": "Unsupported protocol version",
"data": {
"protocolVersion": "2025-03-26",
"supportedVersions": ["2024-11-05"]
}
}
}
This is more helpful than a generic -32603. The client can check data.supportedVersions and attempt to reconnect with a compatible version, facilitating interoperability as discussed in our mcp vs a2a comparison.
Capability negotiation failure patterns
Beyond version, the initialize request contains capabilities objects. If the client requests capabilities the server does not support (e.g. sampling or roots), the server should not error. It should respond with the subset of capabilities it actually supports.
The MCP specification [spec.modelcontextprotocol.io] states that capabilities are negotiated during the initialization handshake. The response’s capabilities field is authoritative. Only return an error if there is a fundamental incompatibility, such as a required capability completely missing.
How to implement MCP logging hooks for production debugging?
Production MCP servers should implement logging/message notifications for error context without exposing stack traces. Use structured logging with request IDs, correlation with transport metadata, and sampling for high-volume endpoints.
Safe error data sanitization
Never include raw stack traces, file paths, or internal IDs in the data field of JSON-RPC errors sent to clients. This leaks implementation details that could aid attackers. Instead, map internal errors to sanitized codes and log the full details server-side.
A sanitization function extracts only safe fields:
function sanitizeError(error: unknown): { message: string; code: number } {
if (error instanceof McpError) {
return { message: error.message, code: error.code };
}
// Log full error internally
console.error('Internal error:', error);
// Return safe generic error
return { message: 'Internal server error', code: -32603 };
}
Correlation ID propagation
Attach a unique request ID to every incoming JSON-RPC message. Include this ID in logs and any error data objects. This allows you to trace a request across multiple services.
{
"jsonrpc": "2.0",
"id": "client-request-123",
"error": {
"code": -32603,
"message": "Internal error",
"data": {
"correlationId": "srv-abc-456"
}
}
}
The client can quote this ID when reporting issues. Developers can then look up the full context in the server logs.
Telemetry integration patterns
Integrate with observability platforms by emitting metrics for each error code. Track the rate of -32700, -32602, and -32603 errors over time. An sudden spike in -32700 errors might indicate a new client version sending malformed JSON.
Use the logging/message notification to send structured logs to connected clients during development. In production, disable this capability or filter it to only critical messages.
What are the common MCP server error handling anti-patterns?
Critical anti-patterns include returning JavaScript exceptions directly in data, using console.error instead of protocol notifications, swallowing parse errors without -32700 response, and implementing global try-catch that masks root causes.
Leaking implementation details in error responses
JavaScript Error objects have complex shapes with stack traces, cause chains, and native code references. JSON.stringify on such objects often fails or produces unreadable