DEV Community

Dan Kestrel
Dan Kestrel

Posted on Edited on

Proper Error Handshakes in MCP: Don't Let Tool Exceptions Crash Your LLM Context

When building Model Context Protocol (MCP) servers, one of the most common early mistakes is writing code like this:

// ❌ THE ANTI-PATTERN: Throwing unhandled errors
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "fetch_user") {
    const user = await db.users.findUnique({ where: { id: request.params.arguments.id } });
    if (!user) {
      throw new Error("User with that ID does not exist in the database!");
    }
    return { content: [{ type: "text", text: JSON.stringify(user) }] };
  }
});
Enter fullscreen mode Exit fullscreen mode

It looks standard in normal Node/Express applications. But in an MCP architecture, throwing an unhandled exception inside a tool handler is an anti-pattern.

Here’s why it hurts your AI workflows, and how to write clean, self-healing MCP error handshakes.


Protocol Exception vs. Tool Execution Failure

In the Model Context Protocol specification, there is a fundamental distinction:

  1. Protocol / JSON-RPC Error: The protocol itself broke. The message was invalid JSON, the method doesn't exist, or the transport disconnected.
  2. Tool Execution Result (isError: true): The tool executed correctly as an RPC call, but the domain operation encountered a normal real-world condition (e.g., record not found, rate limit hit, invalid argument).

When you throw new Error(...), you force the MCP server to emit a top-level JSON-RPC error frame. Many AI host clients (like Claude or Cursor) interpret this as a transport fault, which can break the tool stream or wipe context.

When you return { isError: true, content: [...] }, the LLM receives the error message directly into its conversational context window. It understands the tool ran, reads your error text, and can self-heal or correct its query!


The Clean Pattern: Structured Tool Error Handshakes

Here is a robust helper function you can drop into any TypeScript MCP server:

import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";

/**
 * Standardized MCP tool response helpers
 */
export function toolSuccess(data: unknown): CallToolResult {
  const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
  return {
    content: [{ type: "text", text }],
  };
}

export function toolError(errorMessage: string, remediationHint?: string): CallToolResult {
  const fullText = remediationHint
    ? `Error: ${errorMessage}\nRemediation Hint: ${remediationHint}`
    : `Error: ${errorMessage}`;

  return {
    isError: true,
    content: [{ type: "text", text: fullText }],
  };
}
Enter fullscreen mode Exit fullscreen mode

Putting It Into Practice

Now inspect how clean and self-healing your tool handler becomes:

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "fetch_customer_order") {
    try {
      const args = request.params.arguments as { orderId?: string };

      if (!args.orderId) {
        return toolError(
          "Missing required parameter 'orderId'",
          "Provide an order ID formatted as ORD-XXXXX (e.g., ORD-99214)"
        );
      }

      const order = await lookupOrder(args.orderId);
      if (!order) {
        return toolError(
          `Order '${args.orderId}' was not found in the order registry.`,
          "Verify the order number or search by customer email using the 'search_orders' tool."
        );
      }

      return toolSuccess(order);
    } catch (unexpectedError: unknown) {
      const message = unexpectedError instanceof Error ? unexpectedError.message : String(unexpectedError);
      return toolError(`Internal tool failure: ${message}`, "Check if the database service is reachable.");
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Why Remediation Hints Matter

When an LLM sees:

Error: Order '99214' was not found.
Remediation Hint: Provide an order ID formatted as ORD-XXXXX (e.g., ORD-99214).
Enter fullscreen mode Exit fullscreen mode

The model immediately understands its formatting mistake. Instead of giving up or repeating the query, it prefixes ORD- and successfully calls the tool on its second attempt without bothering the user.

Clean error handling turns brittle agents into resilient, self-healing developer assistants!


πŸ’‘ Developer Infrastructure Note: If you're building autonomous AI agents in Cursor, Claude Code, or LangChain that need to send external emails safely, check out SadaSend. It provides a hosted MCP server, hard per-key recipient allowlists, and pre-send bounce defense so runaway agent loops never damage your domain reputation.

Top comments (0)