← Back to DevBytes

Error Recovery Patterns with MCP (Model Context Protocol): Complete Guide

Error Recovery Patterns with MCP (Model Context Protocol): Complete Guide

The Model Context Protocol (MCP) has rapidly become the standard for connecting AI assistants to external tools, data sources, and services. But any production system that talks to databases, APIs, file systems, or remote servers will eventually fail. Network connections drop, tools throw unexpected exceptions, schemas drift, and LLMs occasionally send malformed arguments. A robust MCP integration must not only work when everything goes right — it must recover gracefully when things go wrong.

This guide walks through the most important error recovery patterns you can apply when building MCP servers and clients. Each pattern includes a discussion of when to use it, why it matters, and a practical code example you can adapt to your own projects.

What Is MCP?

MCP is an open protocol that standardizes how AI models communicate with external context providers. It defines a JSON-RPC 2.0 based message format for exposing tools, resources, and prompts to LLM-powered applications. An MCP server exposes capabilities, and an MCP client (often embedded inside an AI application) consumes them on behalf of a model.

Because MCP sits between an LLM and the real world, it inherits all the failure modes of both: unpredictable model outputs and unreliable external systems. Error recovery is therefore a first-class concern.

Why Error Recovery Matters

Common Error Categories in MCP

Before diving into patterns, it helps to classify the errors you will encounter:

Each category calls for a different recovery strategy. The patterns below address them in combination.

Pattern 1: Retry with Exponential Backoff and Jitter

The most fundamental recovery pattern. Use it for transient, idempotent failures: network blips, rate limits, temporary unavailability. Avoid it for validation errors or non-idempotent mutations unless you can guarantee safety.

import { Client } from "@modelcontextprotocol/sdk/client/index.js";

const MAX_RETRIES = 4;
const BASE_DELAY_MS = 500;
const MAX_DELAY_MS = 8000;

function isRetryable(error: unknown): boolean {
  const code = (error as any)?.code ?? (error as any)?.status;
  // Retry on transport errors, 429, 503, 502, 504
  return code === -32000 || code === 429 ||
         code === 502 || code === 503 || code === 504;
}

function backoffWithJitter(attempt: number): number {
  const exp = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
  return Math.random() * exp; // full jitter
}

async function callToolWithRetry(
  client: Client,
  name: string,
  args: Record<string, unknown>
) {
  let lastError: unknown;
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
    try {
      return await client.callTool({ name, arguments: args });
    } catch (err) {
      lastError = err;
      if (!isRetryable(err) || attempt === MAX_RETRIES - 1) break;
      const delay = backoffWithJitter(attempt);
      console.warn(`Tool "${name}" failed (attempt ${attempt + 1}), retrying in ${Math.round(delay)}ms`);
      await new Promise((r) => setTimeout(r, delay));
    }
  }
  throw lastError;
}

Key points: jitter prevents thundering herds when many clients retry simultaneously, and the isRetryable guard ensures you don't waste attempts on errors that will never succeed.

Pattern 2: Circuit Breaker

When a downstream dependency is hard down, retrying every call just adds latency and load. A circuit breaker tracks recent failures and short-circuits calls once a threshold is crossed, giving the dependency time to recover.

type State = "closed" | "open" | "half-open";

class CircuitBreaker {
  private state: State = "closed";
  private failureCount = 0;
  private lastFailureAt = 0;

  constructor(
    private readonly failureThreshold: number = 5,
    private readonly cooldownMs: number = 30_000
  ) {}

  private isOpen(): boolean {
    if (this.state === "open") {
      if (Date.now() - this.lastFailureAt > this.cooldownMs) {
        this.state = "half-open";
        return false;
      }
      return true;
    }
    return false;
  }

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.isOpen()) {
      throw new Error("CircuitBreakerOpen: downstream unavailable");
    }
    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (err) {
      this.onFailure();
      throw err;
    }
  }

  private onSuccess() {
    this.failureCount = 0;
    this.state = "closed";
  }

  private onFailure() {
    this.failureCount++;
    this.lastFailureAt = Date.now();
    if (this.failureCount >= this.failureThreshold) {
      this.state = "open";
      console.error("CircuitBreaker tripped open");
    }
  }
}

// Usage inside an MCP tool handler
const dbBreaker = new CircuitBreaker(5, 30_000);

server.tool("query_db", { sql: z.string() }, async ({ sql }) => {
  const rows = await dbBreaker.execute(() => db.query(sql));
  return { content: [{ type: "text", text: JSON.stringify(rows) }] };
});

Combine the circuit breaker with retries: retry inside the execute callback for transient errors, and let the breaker handle sustained outages.

Pattern 3: Fallback Tools and Degraded Responses

Sometimes the best recovery is to return a useful but reduced result instead of failing. For example, if a live API is down, fall back to a cached response or a simpler tool.

async function getWeather(city: string): Promise<string> {
  try {
    const live = await callLiveWeatherApi(city);
    await cache.set(`weather:${city}`, live, { ttl: 600 });
    return live;
  } catch (err) {
    console.warn("Live weather failed, trying cache", err);
    const cached = await cache.get(`weather:${city}`);
    if (cached) return `${cached} (cached, live source unavailable)`;

    // Final fallback: a deterministic stub so the agent can still reason
    return `Weather data temporarily unavailable for ${city}. ` +
           `Suggest asking the user to retry later.`;
  }
}

server.tool("get_weather", { city: z.string() }, async ({ city }) => ({
  content: [{ type: "text", text: await getWeather(city) }]
}));

The degraded response is still valid MCP output — the model receives a coherent message and can decide how to proceed (apologize to the user, suggest alternatives, etc.).

Pattern 4: Argument Validation and Self-Correction

LLMs frequently produce tool arguments that almost match the schema: wrong enum values, strings where numbers are expected, missing required fields. Instead of rejecting outright, you can normalize, coerce, or ask the model to retry with feedback.

import { z } from "zod";

const SearchArgs = z.object({
  query: z.string().min(1),
  limit: z.number().int().positive().max(100).default(10),
  sort: z.enum(["relevance", "date", "popularity"]).default("relevance"),
});

server.tool("search", SearchArgs.shape, async (rawArgs, extra) => {
  const parsed = SearchArgs.safeParse(rawArgs);
  if (!parsed.success) {
    // Surface a structured, model-readable error
    const issues = parsed.error.issues
      .map((i) => `Field "${i.path.join(".")}" ${i.message}`)
      .join("; ");
    return {
      isError: true,
      content: [{
        type: "text",
        text: `Invalid arguments: ${issues}. Please correct and call search again.`
      }],
    };
  }

  const { query, limit, sort } = parsed.data;
  const results = await searchIndex(query, limit, sort);
  return { content: [{ type: "text", text: JSON.stringify(results) }] };
});

Returning isError: true with a clear, actionable message is the MCP-idiomatic way to let the model self-correct. The model sees the error text, adjusts its arguments, and retries — no human intervention required.

Pattern 5: Timeout Enforcement

Long-running tools can hang an entire agent loop. Always wrap external calls in timeouts, and prefer MCP's built-in cancellation when supported.

function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(
      () => reject(new Error(`Timeout: ${label} exceeded ${ms}ms`)),
      ms
    );
    promise.then(
      (v) => { clearTimeout(timer); resolve(v); },
      (e) => { clearTimeout(timer); reject(e); }
    );
  });
}

server.tool("run_query", { sql: z.string() }, async ({ sql }, extra) => {
  // Respect client-sent cancellation via the AbortSignal if available
  const signal = extra?.signal ?? new AbortController().signal;
  const result = await withTimeout(
    db.query(sql, { signal }),
    5000,
    "run_query"
  );
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
});

For operations that support cancellation (HTTP fetch, Node streams, Postgres queries), pass the MCP AbortSignal through so a client-initiated cancel actually stops the work rather than just ignoring the result.

Pattern 6: Compensating Transactions for Partial Failures

When a tool performs multiple side effects, a failure midway leaves the system in an inconsistent state. Compensating transactions undo completed steps.

async function transferFunds(from: string, to: string, amount: number) {
  const operations: Array<() => Promise<void>> = [];

  try {
    await debit(from, amount);
    operations.push(() => credit(from, amount)); // compensate: refund

    await credit(to, amount);
    operations.push(() => debit(to, amount)); // compensate: claw back

    await logTransaction(from, to, amount);
    return { status: "completed" };
  } catch (err) {
    console.error("Transfer failed, rolling back", err);
    // Run compensations in reverse order
    for (const compensate of operations.reverse()) {
      try { await compensate(); }
      catch (c) { console.error("Compensation failed", c); }
    }
    throw err;
  }
}

server.tool("transfer", TransferSchema.shape, async (args) => {
  try {
    const result = await transferFunds(args.from, args.to, args.amount);
    return { content: [{ type: "text", text: JSON.stringify(result) }] };
  } catch (err) {
    return {
      isError: true,
      content: [{ type: "text", text: `Transfer failed and was rolled back: ${(err as Error).message}` }],
    };
  }
});

Always log compensation failures separately — they indicate a truly broken state that needs human attention.

Pattern 7: Transport Reconnection and Session Resumption

MCP clients using streamable HTTP or WebSocket transports will occasionally lose the connection. A robust client reconnects automatically and, where the server supports it, resumes the session.

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

async function createResilientClient(url: string) {
  let client: Client | null = null;
  let sessionId: string | undefined;

  async function connect() {
    const transport = new StreamableHTTPClientTransport(new URL(url), {
      sessionIdProvider: () => sessionId,
      reconnectionOptions: {
        maxReconnectionDelay: 30_000,
        initialReconnectionDelay: 1_000,
        reconnectionDelayGrowFactor: 2,
        maxRetries: 10,
      },
    });

    client = new Client({ name: "resilient-client", version: "1.0.0" });
    await client.connect(transport);
    sessionId = transport.sessionId;
    console.log("Connected, session:", sessionId);

    transport.onclose = async () => {
      console.warn("Transport closed, attempting reconnect...");
      // SDK handles reconnection automatically; this is a fallback
      await connect().catch((e) => console.error("Reconnect failed", e));
    };
  }

  await connect();
  return {
    call: (name: string, args: Record<string, unknown>) =>
      client!.callTool({ name, arguments: args }),
  };
}

For stdio-based servers, the equivalent recovery is to respawn the child process and re-initialize the protocol handshake. Always re-issue initialize and re-discover tools after reconnecting, since the server may have changed.

Pattern 8: Structured Error Reporting to the Model

When a tool fails, the message you return shapes how the model recovers. Vague errors like "Something went wrong" produce poor retries. Structured, specific errors let the model make intelligent decisions.

function toolError(code: string, message: string, details?: unknown) {
  return {
    isError: true,
    content: [{
      type: "text",
      text: JSON.stringify({ error: code, message, details }, null, 2),
    }],
  };
}

server.tool("create_issue", IssueSchema.shape, async (args) => {
  try {
    const issue = await issueTracker.create(args);
    return { content: [{ type: "text", text: JSON.stringify(issue) }] };
  } catch (err: any) {
    if (err.status === 401) {
      return toolError("AUTH_REQUIRED", "Authentication expired. Ask the user to re-authenticate.");
    }
    if (err.status === 422) {
      return toolError("VALIDATION_FAILED", err.message, err.body);
    }
    return toolError("UNEXPECTED", err.message ?? "Unknown error");
  }
});

Notice how AUTH_REQUIRED tells the model to involve the user, while VALIDATION_FAILED with details tells it to fix the arguments. Different error codes drive different recovery behaviors.

Best Practices

Putting It All Together

A production-grade MCP tool handler often combines several patterns at once. Here is a compact example that uses validation, timeout, retry, circuit breaking, and structured error reporting in a single handler:

const apiBreaker = new CircuitBreaker(5, 30_000);

server.tool("lookup_user", LookupSchema.shape, async (rawArgs, extra) => {
  const parsed = LookupSchema.safeParse(rawArgs);
  if (!parsed.success) {
    return toolError("INVALID_ARGS", parsed.error.message);
  }

  const signal = extra?.signal ?? new AbortController().signal;
  const op = () => withTimeout(
    apiBreaker.execute(() => fetchUser(parsed.data.userId, { signal })),
    4000,
    "lookup_user"
  );

  try {
    const user = await callToolWithRetryLogic(op, { retries: 3 });
    return { content: [{ type: "text", text: JSON.stringify(user) }] };
  } catch (err: any) {
    if (err.message.startsWith("CircuitBreakerOpen")) {
      return toolError("SERVICE_DOWN", "User service is temporarily unavailable.");
    }
    if (err.message.startsWith("Timeout")) {
      return toolError("TIMEOUT", "User lookup took too long, try again.");
    }
    return toolError("UNEXPECTED", err.message);
  }
});

Conclusion

Error recovery is what separates a demo MCP integration from a production one. By combining retries with backoff and jitter, circuit breakers, fallbacks, timeouts, compensating transactions, reconnection logic, and structured error reporting, you build a system that degrades gracefully under real-world conditions. The goal is not to eliminate failures — that is impossible when external systems and probabilistic models are involved — but to ensure that every failure is detected, contained, communicated clearly to the model, and recovered from in the safest way possible. Start with retries and structured errors, add circuit breakers and timeouts as your traffic grows, and treat your error-handling code with the same rigor as your happy-path code. Your users, your operators, and the models themselves will all benefit.

— Ad —

Google AdSense will appear here after approval

← Back to all articles