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: true tool 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:

typescript
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
    };
  }
});

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:

typescript
server.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

  1. Throwing on expected failures — If a file does not exist or input is invalid, do not throw. Return isError: true so the LLM can recover. Throwing escalates the error to the protocol level and may kill the session.
  2. 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.
  3. 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

  1. 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.
  2. 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.
  3. 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: true when 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

typescript
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
    };
  }
});
typescript
// 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');
✓ Completed