teachyou.ai academy
← All posts
MCPerror handlingserver developmentJSON-RPCTypeScript

MCP Error Handling Patterns: Building Resilient Servers

Pramod Dutta · Jun 26, 2026 · 17 min read

Good MCP error handling means knowing which of two very different error channels a failure belongs to: protocol-level JSON-RPC errors that the client's transport layer catches, and tool-level execution errors that get handed back to the model as ordinary content. Get this split wrong and you end up with servers that either crash the client connection on a bad API call, or silently swallow failures the model needed to see. This guide walks through both channels, plus the timeout, retry, validation, and logging patterns that keep a Model Context Protocol server stable under real-world failure conditions: flaky upstream APIs, malformed tool arguments, rate limits, and slow network calls.

If you are building your first MCP server, skim the "Two error channels" section first. It is the one concept that changes how you write every tool handler after it.

The two error channels in MCP error handling

MCP runs on JSON-RPC 2.0 as its wire protocol, so it inherits JSON-RPC's error object format: a code, a message, and optional data. That format is what you use for protocol-level failures: a request that doesn't parse, a method the server doesn't implement, a resource URI that doesn't exist. The TypeScript SDK exposes this as McpError, and the Python SDK exposes it as McpError with an ErrorData payload. Throwing one of these aborts the current request and the client sees a JSON-RPC error response.

Tool calls work differently on purpose. When a tool's underlying logic fails, for example a downstream API returns a 500, or a file doesn't exist, the correct move is almost never to throw an McpError. Instead you return a normal CallToolResult with isError: true and a content array describing what went wrong in plain text. The model reads that content the same way it reads a successful result, and it can decide to retry with different arguments, apologize to the user, or try a different tool. This is the mechanism that lets an agent recover from a bad API call instead of the whole turn failing.

Here is the distinction in code, using the TypeScript SDK:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({ name: "weather-server", version: "1.0.0" });

server.registerTool(
  "get_forecast",
  {
    title: "Get weather forecast",
    description: "Fetch a forecast for a city",
    inputSchema: { city: z.string().min(1) },
  },
  async ({ city }) => {
    // Protocol-level failure: malformed input the schema didn't catch,
    // or a condition that means the request itself is invalid.
    // Zod already validates inputSchema, so this branch is rare in
    // practice, but it illustrates when McpError is the right tool.

    let response: Response;
    try {
      response = await fetch(`https://example-weather-api.test/v1/forecast?city=${encodeURIComponent(city)}`);
    } catch (networkError) {
      // Tool-level failure: the model should see this and can decide
      // to retry, ask the user for a different city, or give up gracefully.
      return {
        isError: true,
        content: [
          {
            type: "text",
            text: `Could not reach the weather service: ${(networkError as Error).message}`,
          },
        ],
      };
    }

    if (!response.ok) {
      return {
        isError: true,
        content: [
          {
            type: "text",
            text: `Weather service returned ${response.status} for city "${city}". The city name may be misspelled or unsupported.`,
          },
        ],
      };
    }

    const data = await response.json();
    return {
      content: [{ type: "text", text: JSON.stringify(data) }],
    };
  }
);

Notice there is no try/catch around the whole handler that rethrows as McpError. If you do that, every downstream failure becomes a connection-level error, the client's transport surfaces a generic failure, and the model never gets a chance to reason about what happened. That is the single most common MCP error handling mistake: routing tool failures through the protocol error channel instead of the result channel.

When McpError is actually the right choice

Reach for McpError when the failure means the request itself was invalid or the server cannot process it as a protocol operation, not when a tool's business logic failed. Typical cases:

  • A resources/read call for a URI the server doesn't recognize (ErrorCode.InvalidParams or a custom code).
  • A prompts/get call for a prompt name that doesn't exist.
  • Arguments that fail schema validation before your handler logic even runs (many SDKs do this for you automatically if you declare a Zod or Pydantic schema).
  • Internal server faults that mean the server itself is in a bad state, not just this one call, for example a database connection pool that failed to initialize at startup.
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";

server.registerResource(
  "config",
  "config://app/settings",
  { title: "App settings", mimeType: "application/json" },
  async (uri) => {
    const settings = loadSettings();
    if (!settings) {
      throw new McpError(
        ErrorCode.InternalError,
        "Settings store is unavailable"
      );
    }
    return {
      contents: [{ uri: uri.href, text: JSON.stringify(settings) }],
    };
  }
);

The standard JSON-RPC error codes worth knowing: -32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal error. MCP SDKs typically expose these as an ErrorCode enum rather than making you remember the numbers, and you can extend into the -32000 to -32099 range for server-defined errors.

Input validation as the first line of defense

Most tool failures never need to reach your handler body if you validate aggressively at the schema level. Both major SDKs support this natively.

TypeScript, with Zod:

server.registerTool(
  "create_order",
  {
    title: "Create order",
    inputSchema: {
      sku: z.string().regex(/^[A-Z0-9-]{4,32}$/, "sku must be uppercase alphanumeric"),
      quantity: z.number().int().positive().max(1000),
      customerEmail: z.string().email(),
    },
  },
  async ({ sku, quantity, customerEmail }) => {
    // By the time this runs, sku, quantity, and customerEmail are
    // guaranteed to match the schema. No manual type or range checks needed.
    const order = await createOrder({ sku, quantity, customerEmail });
    return { content: [{ type: "text", text: `Order ${order.id} created` }] };
  }
);

Python, with Pydantic through the official SDK's FastMCP:

from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field, EmailStr

mcp = FastMCP("orders-server")

class CreateOrderArgs(BaseModel):
    sku: str = Field(pattern=r"^[A-Z0-9-]{4,32}$")
    quantity: int = Field(gt=0, le=1000)
    customer_email: EmailStr

@mcp.tool()
async def create_order(args: CreateOrderArgs) -> str:
    order = await create_order_in_db(args.sku, args.quantity, args.customer_email)
    return f"Order {order.id} created"

When the schema rejects the input, the SDK returns a proper JSON-RPC invalid-params error before your function body runs at all, so you don't need to hand-write that boilerplate. Where validation gets more interesting is business-rule validation that a type schema can't express: "this SKU exists but is discontinued," "this customer's account is suspended." Those checks belong inside the handler, and their failures should go back as isError: true results, not thrown exceptions, because the model can act on "SKU DISCONTINUED-4471 was retired last quarter, try SKU-4802 instead" in a way it cannot act on a raw stack trace.

Timeouts and retries for downstream calls

MCP servers are frequently thin wrappers around some other API, and that API will occasionally be slow or flaky. Two separate concerns need separate handling: bounding how long a tool call can take, and deciding whether to retry a failed call automatically before reporting failure to the model.

A wrapped fetch with a timeout and bounded retry, using exponential backoff with jitter:

async function fetchWithRetry(
  url: string,
  options: RequestInit = {},
  { maxAttempts = 3, timeoutMs = 8000 } = {}
): Promise<Response> {
  let lastError: unknown;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);

    try {
      const response = await fetch(url, { ...options, signal: controller.signal });
      clearTimeout(timer);

      // Retry on 429 and 5xx, not on 4xx client errors, since retrying
      // a bad request just wastes time and produces the same result.
      if (response.status === 429 || response.status >= 500) {
        throw new Error(`Retryable status ${response.status}`);
      }
      return response;
    } catch (err) {
      clearTimeout(timer);
      lastError = err;
      if (attempt === maxAttempts) break;

      const backoffMs = 2 ** attempt * 250 + Math.random() * 200;
      await new Promise((resolve) => setTimeout(resolve, backoffMs));
    }
  }

  throw lastError instanceof Error ? lastError : new Error(String(lastError));
}

Call it from a tool handler and translate the final failure into a tool-level error, not a protocol one:

async ({ city }) => {
  try {
    const response = await fetchWithRetry(
      `https://example-weather-api.test/v1/forecast?city=${encodeURIComponent(city)}`
    );
    const data = await response.json();
    return { content: [{ type: "text", text: JSON.stringify(data) }] };
  } catch (err) {
    return {
      isError: true,
      content: [{ type: "text", text: `Forecast lookup failed after retries: ${(err as Error).message}` }],
    };
  }
}

Keep retry counts low, two or three attempts at most, because MCP tool calls sit inside a larger agent loop that has its own timeout expectations. A tool that silently retries for thirty seconds before failing makes the whole agent feel stuck, and the model has no visibility into why. If a call is genuinely slow by nature (a report generation job, a long-running query), consider whether that operation should be modeled as a resource the client polls rather than a single blocking tool call.

Timeouts at the transport level

Separate from per-request retry logic, MCP transports (stdio, Streamable HTTP) have their own connection lifecycle. If your server process hangs, for example a database call that never returns because a connection pool is exhausted, the client-side timeout eventually fires and the user sees a generic "request timed out" rather than your carefully worded error message. Guard against this by wrapping any I/O that has no native timeout in your own deadline:

function withDeadline<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
  return Promise.race([
    promise,
    new Promise<T>((_, reject) =>
      setTimeout(() => reject(new Error(`${label} exceeded ${ms}ms deadline`)), ms)
    ),
  ]);
}

// usage inside a handler
const rows = await withDeadline(db.query("SELECT * FROM orders WHERE id = $1", [orderId]), 5000, "order lookup");

This turns an invisible hang into a tool-level error the model can see and report, instead of a silent timeout the client attributes to the transport.

Structuring error messages the model can act on

An isError: true result is only useful if its text content gives the model something to work with. Compare two versions of the same failure:

Bad, too vague to act on:

return { isError: true, content: [{ type: "text", text: "Request failed" }] };

Better, gives the model a next step:

return {
  isError: true,
  content: [
    {
      type: "text",
      text: `Rate limited by the invoicing API (429). Retry after 30 seconds, or reduce the batch size below 50 invoices per call.`,
    },
  ],
};

The second version tells the model three things: what failed, why, and what to try differently. That is the difference between an agent that recovers gracefully and one that repeats the exact same failing call in a loop. If your tool wraps an API that returns structured error bodies (an error code, a field-level validation message), surface that structure in the text rather than a generic "the API returned an error." A useful pattern is to build a small formatter every tool handler shares:

function formatUpstreamError(status: number, body: unknown): string {
  const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
  const hint =
    status === 401
      ? "Check that the API key is valid and not expired."
      : status === 429
      ? "Back off and retry with a smaller request."
      : status >= 500
      ? "The upstream service is having issues, this is likely transient."
      : "Review the request parameters against the API docs.";
  return `Upstream API returned ${status}: ${bodyStr}. ${hint}`;
}

Logging without breaking stdio transport

MCP servers that run over stdio use stdout exclusively for JSON-RPC messages. Any console.log call in a Node.js server, or a stray print() in a Python server, corrupts the message stream and the client will fail to parse the next response. Route all diagnostic logging through stderr, or through the SDK's logging notification mechanism if you want the client to see it.

TypeScript, safe logging:

function logToStderr(level: "debug" | "info" | "warn" | "error", message: string, extra?: unknown) {
  process.stderr.write(
    JSON.stringify({ level, message, extra, ts: new Date().toISOString() }) + "\n"
  );
}

// inside a handler, on failure
logToStderr("error", "forecast lookup failed", { city, attempt: 3 });

Python, safe logging:

import logging
import sys

logging.basicConfig(stream=sys.stderr, level=logging.INFO)
logger = logging.getLogger("weather-server")

logger.error("forecast lookup failed for city=%s", city)

If your server runs over Streamable HTTP instead of stdio, stdout is not shared with the protocol channel, so ordinary console logging is safe there. The rule is specific to stdio transport, not universal, but it is easy to forget when you copy a snippet from an HTTP-based tutorial into a stdio server.

For structured observability beyond a log line, the MCP spec also defines a notifications/message mechanism that lets a server push log-level notifications to the client during a long-running operation. This is worth using for tools that take several seconds, since it gives the user visible progress instead of a silent wait followed by either a result or a failure.

Handling partial failures in batch tools

A tool that processes a list of items, say "send these five emails" or "fetch these ten records," needs a policy for partial failure. Two reasonable approaches:

Fail the whole call and report which items failed, letting the model retry only the failed subset:

async ({ recordIds }) => {
  const results = await Promise.allSettled(recordIds.map((id: string) => fetchRecord(id)));

  const failures = results
    .map((r, i) => ({ r, id: recordIds[i] }))
    .filter(({ r }) => r.status === "rejected");

  if (failures.length > 0) {
    const failedIds = failures.map((f) => f.id).join(", ");
    return {
      isError: true,
      content: [
        {
          type: "text",
          text: `${failures.length} of ${recordIds.length} records failed to fetch: ${failedIds}. Retry with just these IDs.`,
        },
      ],
    };
  }

  const records = results.map((r) => (r as PromiseFulfilledResult<unknown>).value);
  return { content: [{ type: "text", text: JSON.stringify(records) }] };
}

Or return partial success with an inline summary of what failed, letting the model decide whether that's good enough:

const succeeded = results.filter((r) => r.status === "fulfilled").length;
const summaryText =
  failures.length > 0
    ? `Fetched ${succeeded}/${recordIds.length} records. Failed: ${failures.map((f) => f.id).join(", ")}.`
    : `Fetched all ${recordIds.length} records.`;

return {
  content: [
    { type: "text", text: summaryText },
    { type: "text", text: JSON.stringify(results.filter((r) => r.status === "fulfilled").map((r: any) => r.value)) },
  ],
};

Which one to use depends on whether partial results are useful to the caller. For a batch of independent lookups, partial success with a clear summary is usually more useful than an all-or-nothing failure. For a batch of writes that need to be atomic, for example a multi-row database transaction, an all-or-nothing failure with a rollback is the correct behavior, and the tool should say explicitly that nothing was written.

Testing error paths, not just the happy path

MCP server error handling is easy to get wrong silently, because a tool that works during manual testing with good inputs can still crash a client the first time a real user gives it a malformed argument or the upstream API times out. Build a short checklist into your test suite:

  • Call every tool with missing required arguments and confirm you get a schema validation error, not an unhandled exception.
  • Call every tool with a type-mismatched argument (a string where a number is expected) and confirm the same.
  • Simulate the downstream dependency being unavailable (mock a network failure or point the client at a closed port) and confirm the tool returns isError: true with a readable message, not a thrown exception that kills the request.
  • Simulate a slow downstream dependency and confirm your timeout logic fires before the client's own timeout does.
  • If you support batch operations, test the partial-failure case specifically, not just all-succeed and all-fail.

The @modelcontextprotocol/inspector tool is useful here: it lets you call tools directly against a running server and see the raw JSON-RPC response, so you can confirm an error response has the shape you intended before an agent ever exercises that path in production.

Common mistakes worth naming directly

Throwing a generic Error from inside a tool handler and letting the SDK convert it into a protocol-level error is the most common one. It technically "works," in that the client doesn't hang, but the model gets a terse, often unhelpful error string instead of the rich, structured explanation you could have written into an isError result.

A second common mistake is validating input twice in incompatible ways: a Zod or Pydantic schema at the tool boundary, and then a second set of manual if checks inside the handler that throw differently shaped errors. Pick one validation layer per concern. Structural validation (types, ranges, formats) belongs in the schema. Business-rule validation (does this record exist, is this account active) belongs in the handler and should return isError: true.

A third is forgetting that stdio transport shares its stdout channel with the protocol. A single stray print statement from a debugging session left in a production server will intermittently break every client that connects to it, and the failure mode looks like a parsing bug on the client side, which makes it hard to trace back to the actual cause.

A fourth is retrying too aggressively without a cap. An MCP tool call is not the place to implement unlimited retry with no upper bound. If a downstream API is down, the model needs to know that within a few seconds, not after two minutes of silent backoff.

FAQ

What is the difference between a JSON-RPC error and a tool error in MCP? A JSON-RPC error, thrown as McpError, aborts the request at the protocol level and the client sees a connection-level failure. A tool error is a normal CallToolResult with isError: true, which the model reads as content and can reason about or retry against. Use McpError for invalid requests, missing resources, or server faults. Use isError: true for anything a tool's own logic failed to do, like a downstream API call.

Should I catch every exception in a tool handler? Yes, at the outer boundary of the handler. Wrap the handler body in a try/catch and convert any unexpected exception into an isError: true result with as much detail as you can safely include, rather than letting it propagate up and become a generic protocol error.

How many times should a tool retry a failed downstream call? Two or three attempts with exponential backoff is a reasonable default for transient failures like 429s and 5xxs. Don't retry 4xx client errors, since the request itself is malformed and retrying produces the same failure. Cap total retry time to a few seconds so the tool call doesn't stall the agent loop.

Why did my MCP server stop responding after I added a console.log statement? If the server runs over stdio transport, stdout is reserved exclusively for JSON-RPC messages. Any extra text written to stdout, including console.log in Node.js or print in Python, corrupts the message stream and the client can no longer parse subsequent responses. Route all logging to stderr instead, or use the SDK's log notification mechanism.

Can I return both an error and partial data from a tool call? Yes. A CallToolResult can include multiple content items, so you can return a text summary describing what failed alongside a text or JSON block containing whatever data did succeed. Whether to set isError: true in that case depends on whether the overall call should be considered a failure; for batch operations with partial success, many servers leave isError unset and let the summary text explain the shortfall.

Does input validation with Zod or Pydantic replace the need for try/catch in handlers? It replaces the need to manually check types and ranges, but not the need to handle runtime failures like network errors, database errors, or business-rule violations that a static schema can't express. Schema validation and runtime error handling are complementary layers, not substitutes for each other.

What error code should I use for custom server-defined errors? The JSON-RPC spec reserves -32000 through -32099 for implementation-defined server errors. Standard MCP SDKs expose an ErrorCode enum with the reserved codes (parse error, invalid request, method not found, invalid params, internal error) and typically let you pass a custom numeric code in that reserved range along with a descriptive message for anything server-specific.

MCP Error Handling Patterns: Building Resilient Servers · TeachYou Academy