Skip to main content
RapidDev - Software Development Agency
mcp-tutorial

How to handle errors in an MCP server

Handle errors in MCP servers by returning isError: true in tool results instead of throwing exceptions. Wrap handler logic in try/catch blocks, map known failure modes to descriptive error messages, and use JSON-RPC error codes for protocol-level errors. This lets AI clients retry intelligently instead of crashing.

What you'll learn

  • How to return isError: true for graceful tool failures
  • How to structure try/catch patterns in MCP tool handlers
  • How to use JSON-RPC error codes for protocol-level errors
  • How to handle errors in Python FastMCP servers
  • Best practices for error messages that help the AI self-correct
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate9 min read20-30 minMCP TypeScript SDK 1.x, MCP Python SDK 1.xMarch 2026RapidDev Engineering Team
TL;DR

Handle errors in MCP servers by returning isError: true in tool results instead of throwing exceptions. Wrap handler logic in try/catch blocks, map known failure modes to descriptive error messages, and use JSON-RPC error codes for protocol-level errors. This lets AI clients retry intelligently instead of crashing.

Error Handling in MCP Servers

MCP servers face two categories of errors: tool-level errors (a tool ran but the operation failed) and protocol-level errors (the request itself is invalid). Understanding the difference is critical because they use different mechanisms.

Tool errors should return { isError: true } with a descriptive message — this tells the AI the tool executed but the operation did not succeed, and the AI can adjust and retry. Protocol errors use standard JSON-RPC error codes and indicate problems like invalid method calls, missing parameters, or internal server crashes. This tutorial covers both patterns with practical examples.

Prerequisites

  • A working MCP server with registered tools
  • Understanding of MCP tool result format (content array)
  • Basic familiarity with try/catch error handling
  • Knowledge of JSON-RPC 2.0 error format (helpful but not required)

Step-by-step guide

1

Return isError for tool-level failures

When a tool's operation fails (API down, record not found, permission denied), return the result with isError: true and a clear error message. Do not throw exceptions — thrown errors become protocol-level errors that may crash the connection. The isError flag tells the AI client that the tool ran but could not complete the requested action.

typescript
1// TypeScript — correct error handling
2server.registerTool("get-user", {
3 description: "Get user profile by ID",
4 inputSchema: {
5 userId: z.string().describe("User ID"),
6 },
7}, async ({ userId }) => {
8 const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
9
10 if (!user) {
11 return {
12 content: [{ type: "text", text: `Error: No user found with ID '${userId}'` }],
13 isError: true,
14 };
15 }
16
17 return {
18 content: [{ type: "text", text: JSON.stringify(user, null, 2) }],
19 isError: false,
20 };
21});

Expected result: The AI receives the error message and can ask the user for a different ID or try an alternative approach.

2

Wrap handlers in try/catch for unexpected errors

Even with input validation, handlers can fail due to network issues, database outages, or unexpected data. Wrap the entire handler body in try/catch and convert caught exceptions into isError responses. Log the full error to stderr for debugging while returning a user-friendly message to the AI.

typescript
1// TypeScript
2server.registerTool("query-api", {
3 description: "Query an external API endpoint",
4 inputSchema: {
5 endpoint: z.string().url().describe("API endpoint URL"),
6 method: z.enum(["GET", "POST"]).default("GET").describe("HTTP method"),
7 },
8}, async ({ endpoint, method }) => {
9 try {
10 const response = await fetch(endpoint, { method });
11
12 if (!response.ok) {
13 return {
14 content: [{
15 type: "text",
16 text: `Error: API returned HTTP ${response.status} ${response.statusText}. Check the endpoint URL and try again.`,
17 }],
18 isError: true,
19 };
20 }
21
22 const data = await response.json();
23 return {
24 content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
25 };
26 } catch (error) {
27 // Log full error for debugging
28 console.error("query-api error:", error);
29
30 return {
31 content: [{
32 type: "text",
33 text: `Error: Failed to reach ${endpoint}. ${(error as Error).message}`,
34 }],
35 isError: true,
36 };
37 }
38});

Expected result: Network failures, HTTP errors, and JSON parse errors are all caught and returned as structured error responses.

3

Map common failure modes to specific error messages

Instead of generic 'something went wrong' messages, map known error types to specific, actionable messages. This helps the AI understand what happened and adjust its next request. Check for common patterns like timeout errors, authentication failures, not-found errors, and rate limits.

typescript
1// TypeScript — specific error mapping
2async function handleApiError(error: unknown, context: string): {
3 content: Array<{ type: "text"; text: string }>;
4 isError: true;
5} {
6 const err = error as Error & { code?: string; status?: number };
7
8 if (err.code === "ETIMEDOUT" || err.code === "ECONNREFUSED") {
9 return {
10 content: [{ type: "text", text: `Error: ${context} — service unreachable. The API may be down. Try again in a moment.` }],
11 isError: true,
12 };
13 }
14 if (err.status === 401 || err.status === 403) {
15 return {
16 content: [{ type: "text", text: `Error: ${context} — authentication failed. Check API credentials.` }],
17 isError: true,
18 };
19 }
20 if (err.status === 404) {
21 return {
22 content: [{ type: "text", text: `Error: ${context} — resource not found. Verify the ID or path is correct.` }],
23 isError: true,
24 };
25 }
26 if (err.status === 429) {
27 return {
28 content: [{ type: "text", text: `Error: ${context} — rate limited. Wait before retrying.` }],
29 isError: true,
30 };
31 }
32
33 return {
34 content: [{ type: "text", text: `Error: ${context} — ${err.message}` }],
35 isError: true,
36 };
37}

Expected result: Each error type produces a specific message that guides the AI toward the correct next action.

4

Handle errors in Python FastMCP servers

In Python, FastMCP tools that raise exceptions automatically return error responses. However, for better control, catch exceptions explicitly and return error strings or raise specific MCP exceptions. Use Python's exception hierarchy to handle different error types.

typescript
1# Python
2from mcp.server.fastmcp import FastMCP
3import httpx
4
5mcp = FastMCP("error-handling-server")
6
7@mcp.tool()
8async def fetch_data(url: str) -> str:
9 """Fetch data from a URL.
10
11 Args:
12 url: The URL to fetch data from
13 """
14 try:
15 async with httpx.AsyncClient(timeout=10.0) as client:
16 response = await client.get(url)
17 response.raise_for_status()
18 return response.text
19 except httpx.TimeoutException:
20 raise ValueError(f"Error: Request to {url} timed out after 10 seconds")
21 except httpx.HTTPStatusError as e:
22 raise ValueError(f"Error: HTTP {e.response.status_code} from {url}")
23 except httpx.ConnectError:
24 raise ValueError(f"Error: Could not connect to {url}")

Expected result: Each failure scenario returns a specific error message to the AI client.

5

Use JSON-RPC error codes for protocol-level errors

Protocol-level errors occur when the request itself is malformed — wrong method name, invalid JSON, or server internal errors. The MCP SDK handles most of these automatically, but you can raise McpError with standard JSON-RPC codes when building custom protocol extensions or middleware. For teams building production MCP infrastructure with custom error handling, RapidDev can assist with error monitoring and alerting setup.

typescript
1// TypeScript — JSON-RPC error codes reference
2// -32700 Parse error (invalid JSON)
3// -32600 Invalid request (missing required fields)
4// -32601 Method not found
5// -32602 Invalid params (schema validation failure)
6// -32603 Internal error
7
8// The SDK handles these automatically. For custom errors:
9import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
10
11// Inside a tool handler or custom method:
12throw new McpError(
13 ErrorCode.InvalidParams,
14 "The 'format' parameter must be 'json' or 'csv'"
15);

Expected result: Protocol errors return proper JSON-RPC error responses with standard error codes.

Complete working example

src/index.ts
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3import { z } from "zod";
4
5const server = new McpServer({
6 name: "error-handling-server",
7 version: "1.0.0",
8});
9
10function errorResult(message: string) {
11 return {
12 content: [{ type: "text" as const, text: message }],
13 isError: true as const,
14 };
15}
16
17function successResult(text: string) {
18 return {
19 content: [{ type: "text" as const, text }],
20 };
21}
22
23server.registerTool("fetch-url", {
24 description: "Fetch content from a URL",
25 inputSchema: {
26 url: z.string().url().describe("URL to fetch"),
27 timeout: z.number().min(1).max(30).default(10).describe("Timeout in seconds"),
28 },
29}, async ({ url, timeout }) => {
30 try {
31 const controller = new AbortController();
32 const timer = setTimeout(() => controller.abort(), timeout * 1000);
33
34 const response = await fetch(url, { signal: controller.signal });
35 clearTimeout(timer);
36
37 if (!response.ok) {
38 return errorResult(`Error: HTTP ${response.status} from ${url}`);
39 }
40
41 const text = await response.text();
42 return successResult(text.slice(0, 5000));
43 } catch (error) {
44 const err = error as Error;
45 console.error("fetch-url error:", err);
46
47 if (err.name === "AbortError") {
48 return errorResult(`Error: Request to ${url} timed out after ${timeout}s`);
49 }
50 return errorResult(`Error: Failed to fetch ${url} — ${err.message}`);
51 }
52});
53
54server.registerTool("read-file", {
55 description: "Read a file from the project directory",
56 inputSchema: {
57 path: z.string().min(1).describe("Relative file path"),
58 },
59}, async ({ path: filePath }) => {
60 try {
61 const { readFile } = await import("fs/promises");
62 const { resolve, normalize } = await import("path");
63
64 const root = process.cwd();
65 const resolved = resolve(root, normalize(filePath));
66
67 if (!resolved.startsWith(root)) {
68 return errorResult("Error: Access denied — path is outside project directory");
69 }
70
71 const content = await readFile(resolved, "utf-8");
72 return successResult(content);
73 } catch (error) {
74 const err = error as NodeJS.ErrnoException;
75 if (err.code === "ENOENT") {
76 return errorResult(`Error: File not found — ${filePath}`);
77 }
78 if (err.code === "EACCES") {
79 return errorResult(`Error: Permission denied — ${filePath}`);
80 }
81 return errorResult(`Error: Could not read ${filePath} — ${err.message}`);
82 }
83});
84
85const transport = new StdioServerTransport();
86await server.connect(transport);
87console.error("Error handling server running on stdio");

Common mistakes when handling errors in an MCP server

Why it's a problem: Throwing exceptions instead of returning isError

How to avoid: Thrown errors become protocol-level errors that may disconnect the client. Use return { isError: true, content: [...] } for tool failures.

Why it's a problem: Returning generic error messages

How to avoid: Map specific error types (404, 401, timeout, network) to specific messages that tell the AI what to do differently.

Why it's a problem: Not logging errors to stderr

How to avoid: Always console.error() the full error for debugging. The isError message to the AI should be concise; the full stack trace goes to stderr.

Why it's a problem: Exposing internal details in error messages

How to avoid: Do not return database connection strings, internal IPs, or stack traces to the AI. Return actionable summaries only.

Why it's a problem: Ignoring the isError field entirely

How to avoid: Always set isError: true on error results. Without it, the AI treats the error message as a successful result.

Best practices

  • Always wrap tool handlers in try/catch and convert exceptions to isError responses
  • Start error messages with 'Error:' followed by what went wrong and what to try instead
  • Map known error codes (ENOENT, 404, 429, ETIMEDOUT) to specific actionable messages
  • Log full error details to stderr for debugging; return concise messages to the AI
  • Never expose internal infrastructure details (IPs, connection strings) in error messages
  • Create reusable errorResult() and successResult() helper functions for consistency
  • Use McpError with JSON-RPC codes only for protocol-level issues, not business logic
  • Test error paths with the MCP Inspector by sending intentionally invalid parameters

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I'm building an MCP server in TypeScript. Show me a tool handler that wraps all operations in try/catch, returns isError: true with specific error messages for different failure types (not found, timeout, auth failure), and logs full errors to stderr.

MCP Prompt

Improve error handling in my MCP tool [tool-name]. Currently it throws exceptions on failure. Refactor to catch all errors and return { content: [{ type: 'text', text: 'Error: ...' }], isError: true } with specific messages for each failure mode.

Frequently asked questions

What is the difference between isError and throwing an exception?

isError: true tells the AI the tool ran but the operation failed — the AI can adjust and retry. Throwing an exception creates a protocol-level error that may disconnect the client. Always prefer isError for tool failures.

Does the AI automatically retry when it sees isError: true?

It depends on the AI client. Claude and most clients will read the error message and may adjust parameters or try a different approach. The key is writing error messages that tell the AI what went wrong and what to try instead.

Should I ever throw from a tool handler?

Only if the error is truly unrecoverable at the protocol level. For all business logic errors (not found, unauthorized, validation failure, API errors), return isError: true. Reserve exceptions for catastrophic failures.

How do I handle errors in resource handlers?

Resource handlers should throw exceptions for errors (unlike tools). The SDK converts thrown errors into proper JSON-RPC error responses for resource read failures.

Can I include structured data in error responses?

Yes. The content array can contain multiple text items or even JSON. For example, return the error message plus a JSON object with details: { field: 'userId', received: 'abc', expected: 'UUID format' }.

How do I monitor errors across all my MCP tools?

Log all errors to stderr with context (tool name, parameters, error type). Use structured logging (JSON format) and pipe stderr to a log aggregator. For production MCP servers, RapidDev can help set up error monitoring dashboards with alerting.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.