Error Handling Best Practices

Handle errors gracefully in MCP servers with proper error types, user-friendly messages, and recovery strategies.


title: "Error Handling Best Practices" description: "Handle errors gracefully in MCP servers with proper error types, user-friendly messages, and recovery strategies." order: 2 keywords: ["MCP error handling", "MCP errors", "MCP error types", "MCP graceful degradation"] date: "2026-04-01"

Quick Summary

MCP servers must handle errors in a way that helps AI models recover gracefully. This guide covers the MCP error model, how to choose between content-level and protocol-level errors, wrapping external service failures, building retry logic, implementing graceful degradation, and producing error messages that are useful to both AI models and human users. Examples cover both the official TypeScript SDK (@modelcontextprotocol/sdk) and mcp-framework.

2error levels in MCP: content-level (isError) for recoverable failures and protocol-level (McpError) for fatal ones

Understanding MCP Error Levels

Content-Level Error

A content-level error is a tool response with isError: true. The AI model receives the error message as text content and can decide how to proceed — retry, try a different tool, or inform the user. This is the recommended approach for most operational failures.

Protocol-Level Error

A protocol-level error is a JSON-RPC error response thrown via McpError. It signals that the request itself was invalid — bad parameters, authentication failure, or unsupported method. The AI model receives an error object, not a tool result.

Default to Content-Level Errors

Use content-level errors (isError: true) as your default. They give the AI model the most context to work with. Reserve protocol-level errors (throw McpError) for situations where the request itself is fundamentally invalid — wrong parameters, missing authentication, or calling a method the server does not support.

MCP Error Codes Reference

MCP inherits JSON-RPC 2.0 error codes and adds protocol-specific extensions:

CodeNameWhen to Use
-32700Parse ErrorMalformed JSON received by the server
-32600Invalid RequestValid JSON but not a proper JSON-RPC request
-32601Method Not FoundClient called a method the server does not implement
-32602Invalid ParamsMethod parameters fail validation
-32603Internal ErrorUnexpected server-side failure

Wrapping External Service Errors

External APIs, databases, and file systems return errors in their own formats. Always translate these into MCP-friendly responses.

Never Expose Raw External Errors

Raw error messages from databases, APIs, or libraries often contain internal details — connection strings, stack traces, or query text. Always wrap external errors in a sanitized message that describes what went wrong without leaking implementation details.

// With the official TypeScript SDK
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

function wrapExternalError(service: string, error: unknown): string {
  if (error instanceof Error) {
    const message = error.message.replace(/at\s+.*\n/g, "").trim();
    return `${service} error: ${message}`;
  }
  return `${service} error: An unexpected failure occurred.`;
}

server.tool(
  "fetch-github-issues",
  "Fetch open issues from a GitHub repository",
  {
    owner: z.string().describe("Repository owner"),
    repo: z.string().describe("Repository name"),
  },
  async ({ owner, repo }) => {
    try {
      const response = await fetch(
        `https://api.github.com/repos/${owner}/${repo}/issues?state=open`
      );

      if (response.status === 404) {
        return {
          content: [{ type: "text", text: `Repository ${owner}/${repo} not found. Check the owner and repo name.` }],
          isError: true,
        };
      }

      if (response.status === 403) {
        return {
          content: [{ type: "text", text: "GitHub API rate limit exceeded. Try again in a few minutes." }],
          isError: true,
        };
      }

      if (!response.ok) {
        return {
          content: [{ type: "text", text: `GitHub API returned status ${response.status}. Try again later.` }],
          isError: true,
        };
      }

      const issues = await response.json();
      return {
        content: [{ type: "text", text: JSON.stringify(issues, null, 2) }],
      };
    } catch (error) {
      return {
        content: [{ type: "text", text: wrapExternalError("GitHub", error) }],
        isError: true,
      };
    }
  }
);

Building User-Friendly Error Messages

Error messages from MCP tools are read by AI models, which then relay them to users. Write messages that serve both audiences.

Structure Error Messages for AI Consumption

Return error details as structured JSON, not just plain text. Include an error code, a human-readable message, whether the error is retryable, and a suggested next step. AI models can parse structured errors and make better recovery decisions.

interface StructuredError {
  code: string;
  message: string;
  retryable: boolean;
  suggestion?: string;
}

function createErrorResponse(error: StructuredError) {
  return {
    content: [{
      type: "text" as const,
      text: JSON.stringify(error, null, 2),
    }],
    isError: true,
  };
}

// Usage
return createErrorResponse({
  code: "RATE_LIMITED",
  message: "GitHub API rate limit exceeded. Resets at 2026-04-01T16:00:00Z.",
  retryable: true,
  suggestion: "Wait 2 minutes or provide a GitHub token with higher rate limits.",
});

Retry Logic

Not all errors are permanent. Network timeouts and 5xx server errors are often transient and succeed on retry.

Retry Only Transient Failures

Never retry 400 (bad request), 401 (unauthorized), 403 (forbidden), or 404 (not found) errors. These are permanent and will fail every time. Only retry on network errors (connection reset, DNS failure), timeouts, and 5xx server errors. Use exponential backoff with jitter to avoid thundering herd problems.

async function withRetry<T>(
  fn: () => Promise<T>,
  opts: { maxAttempts?: number; baseMs?: number; isRetryable?: (err: unknown) => boolean } = {}
): Promise<T> {
  const { maxAttempts = 3, baseMs = 500, isRetryable = () => true } = opts;
  let lastError: unknown;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;
      if (attempt === maxAttempts || !isRetryable(err)) throw err;
      const delay = baseMs * Math.pow(2, attempt - 1) + Math.random() * 200;
      await new Promise(r => setTimeout(r, delay));
    }
  }
  throw lastError;
}

function isTransient(err: unknown): boolean {
  if (err instanceof TypeError) return true; // Network error
  if (err instanceof Error && err.message.match(/5\d{2}/)) return true;
  return false;
}

Graceful Degradation

When a dependency fails, serve what you can rather than failing entirely.

Return Partial Results Over No Results

If a tool aggregates data from multiple sources and one source fails, return the data you have with a warning — do not discard everything. AI models can work with partial information far better than with a generic error message.

server.tool(
  "project-summary",
  "Get a summary combining data from multiple sources",
  { projectId: z.string() },
  async ({ projectId }) => {
    const results: Record<string, unknown> = {};
    const warnings: string[] = [];

    try {
      results.details = await db.getProject(projectId);
    } catch {
      warnings.push("Project details unavailable (database error).");
    }

    try {
      results.metrics = await metricsApi.getProjectMetrics(projectId);
    } catch {
      warnings.push("Metrics unavailable (external API error).");
    }

    if (Object.keys(results).length === 0) {
      return {
        content: [{ type: "text", text: "All data sources failed. Please try again later." }],
        isError: true,
      };
    }

    const output = warnings.length > 0 ? { ...results, warnings } : results;
    return {
      content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
    };
  }
);

Error Handling in mcp-framework

With mcp-framework's class-based approach, use the execute method's natural try/catch and let the framework convert thrown errors into MCP responses:

import { MCPTool } from "mcp-framework";
import { z } from "zod";

class FetchDataTool extends MCPTool<{ url: string }> {
  name = "fetch-data";
  description = "Fetch data from a URL with error handling";

  schema = {
    url: { type: z.string().url(), description: "URL to fetch" },
  };

  async execute({ url }: { url: string }) {
    const response = await fetch(url, {
      signal: AbortSignal.timeout(10000),
    });

    if (!response.ok) {
      throw new Error(
        `Request failed with status ${response.status}. ` +
        (response.status >= 500 ? "The remote server may be down." : "Check the URL.")
      );
    }

    return await response.text();
  }
}

Error Logging

Log to stderr, Never stdout

In MCP servers using stdio transport, stdout is reserved for JSON-RPC protocol messages. All logging, including error logging, must go to stderr (console.error). Writing anything else to stdout will corrupt the protocol stream and crash the connection.

function logError(toolName: string, error: unknown, context?: Record<string, unknown>) {
  const entry = {
    level: "error",
    tool: toolName,
    message: error instanceof Error ? error.message : String(error),
    stack: error instanceof Error ? error.stack : undefined,
    context,
    timestamp: new Date().toISOString(),
  };
  // Always stderr — stdout is for MCP protocol messages
  console.error(JSON.stringify(entry));
}
Never Expose Stack Traces to Clients

Log full stack traces on the server side for debugging, but never include them in error responses returned to the AI model. Stack traces leak file paths, dependency versions, and internal architecture details that could be security-sensitive.

Frequently Asked Questions