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) }] }; } }); 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:
- Protocol / JSON-RPC Error: The protocol itself broke. The message was invalid JSON, the method doesn't exist, or the transport disconnected.
- 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 }], }; } 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."); } } }); 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). 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.
