Introduction
When an MCP tool fails, the way you report that failure determines whether the LLM can recover gracefully or gets stuck in a loop. MCP defines two distinct error channels, and choosing the wrong one confuses both the model and the end user.
Key Concepts
- Tool-level error: An expected failure reported inside the tool result by setting
isError: true. The LLM sees the message and can decide what to do next. - Protocol error: A JSON-RPC error object with a numeric code. These indicate that the request itself was malformed or the server crashed. The host application handles them, not the LLM.
- JSON-RPC error codes: Standardized codes like -32600 (InvalidRequest), -32601 (MethodNotFound), -32602 (InvalidParams), and -32603 (InternalError).
- Schema vs business validation: The SDK auto-rejects with -32602 for schema-level type mismatches (wrong type, missing field). Business logic failures (valid type but invalid value, like a city name that does not exist) should use
isError: truetool execution errors so the LLM can self-correct.
Real World Context
Imagine an LLM asks your file-reading tool to open a path that does not exist. That is not a server bug — it is an expected scenario. If you throw an exception, the host might terminate the connection. If you return isError: true with a helpful message, the LLM can ask the user for the correct path or try a different approach.
Deep Dive
MCP splits errors into two categories because they serve different audiences.
Tool-Level Errors with isError
When a tool encounters a predictable failure — a file not found, invalid user input, a third-party API returning 404 — you should return a normal result with the isError flag set to true. The following example shows how to handle a missing file:
typescriptserver.registerTool('read-file', { description: 'Read a file', inputSchema: z.object({ path: z.string() }) }, async ({ path }): Promise<CallToolResult> => { try { const content = await fs.readFile(path, 'utf-8'); return { content: [{ type: 'text', text: content }] }; } catch (err) { return { content: [{ type: 'text', text: `Error: File not found at ${path}` }], isError: true }; } });
The LLM receives the error message as part of the conversation and can reason about it. Notice that the error text is descriptive — it includes the path so the LLM knows exactly what failed.
Protocol Errors with JSON-RPC Codes
Protocol errors happen at a lower level. If the client sends a request for a tool that does not exist, the SDK automatically responds with a -32601 MethodNotFound error. If your handler throws an unhandled exception, the SDK wraps it in a -32603 InternalError. You generally do not create these manually — they are for situations where the protocol itself breaks down.
Here is the complete list of standard MCP error codes:
typescript// Standard JSON-RPC error codes used by MCP const MCP_ERROR_CODES = { InvalidRequest: -32600, // Malformed JSON-RPC request MethodNotFound: -32601, // Unknown tool or method InvalidParams: -32602, // Parameters failed schema validation InternalError: -32603 // Unhandled server exception };
These codes follow the JSON-RPC 2.0 specification and are recognized by all MCP hosts.
Choosing Between isError and throw
The rule of thumb is straightforward: if the LLM can do something useful with the error information, use isError: true. If the server is genuinely broken, let the exception propagate.
Consider this timeout handling pattern:
typescriptserver.registerTool('fetch-data', { description: 'Fetch data from an API', inputSchema: z.object({ url: z.string() }) }, async ({ url }): Promise<CallToolResult> => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); try { const response = await fetch(url, { signal: controller.signal }); const data = await response.text(); return { content: [{ type: 'text', text: data }] }; } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') { return { content: [{ type: 'text', text: `Request to ${url} timed out after 10 seconds. Try again or use a different endpoint.` }], isError: true }; } throw err; // Unexpected error — let it become a protocol error } finally { clearTimeout(timeout); } });
The timeout is an expected failure that the LLM can act on, so it gets isError: true. An unexpected network error is a genuine crash, so it propagates as a protocol error.
Structured Error Messages
When returning tool-level errors, structure the message so the LLM can parse it. Include what went wrong, why, and what the LLM could do differently.
typescript// Good: structured, actionable return { content: [{ type: 'text', text: 'Error: User "admin" not found. Available users: alice, bob, charlie.' }], isError: true }; // Bad: vague, unhelpful return { content: [{ type: 'text', text: 'Something went wrong.' }], isError: true };
The first message gives the LLM enough context to retry with a valid username. The second leaves it guessing.
Common Pitfalls
- Throwing on expected failures — If a file does not exist or input is invalid, do not throw. Return
isError: trueso the LLM can recover. Throwing escalates the error to the protocol level and may kill the session. - Swallowing unexpected errors — Do not catch every exception and return
isError: true. Genuine bugs should propagate so they appear in logs and trigger proper error handling. - Vague error messages — The LLM cannot debug "Error occurred". Always include the specific failure reason and, when possible, a suggestion for what to try next.
Best Practices
- Use isError for all recoverable failures — File not found, validation failure, API rate limits, and timeouts are all recoverable. Return a descriptive message with
isError: true. - Include context in error messages — Mention the input that caused the failure, the expected format, and any alternatives. This turns errors into learning opportunities for the LLM.
- Set timeouts on all external calls — Network requests, database queries, and file operations should all have timeouts to prevent your server from hanging indefinitely.
Summary
- MCP has two error channels: tool-level (
isError: true) for expected failures and protocol-level (JSON-RPC codes) for crashes. - Use
isError: truewhen the LLM can act on the error information. - Let unexpected exceptions propagate as protocol errors.
- Standard error codes: -32600 (InvalidRequest), -32601 (MethodNotFound), -32602 (InvalidParams), -32603 (InternalError).
- Always write structured, descriptive error messages that help the LLM recover.
Code Examples
server.registerTool('read-file', {
description: 'Read a file',
inputSchema: z.object({ path: z.string() })
}, async ({ path }): Promise<CallToolResult> => {
try {
const content = await fs.readFile(path, 'utf-8');
return { content: [{ type: 'text', text: content }] };
} catch (err) {
return {
content: [{ type: 'text', text: `Error: File not found at ${path}` }],
isError: true
};
}
});// Standard JSON-RPC error codes in MCP
// -32600 InvalidRequest — Malformed request
// -32601 MethodNotFound — Unknown tool name
// -32602 InvalidParams — Schema validation failed
// -32603 InternalError — Unhandled server exception
// Tool-level: expected failure, LLM can recover
return {
content: [{ type: 'text', text: 'User not found. Try: alice, bob.' }],
isError: true
};
// Protocol-level: unexpected crash, let it propagate
throw new Error('Database connection lost');