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.
Top comments (0)