Handle MCP tool errors so the user gets help
Return every tool failure as a result with isError: true, a stable error code, one plain sentence and the next step the user can take. Keep the last calls of each account on your server. When a person has to act, let the agent open a ticket and attach that context for the user. The user then writes one sentence and gets the answer back in the same conversation.
Why a plain error is a dead end
A user of your MCP server is inside Claude, ChatGPT or Cursor. They never see your web app or your help desk. When a tool fails, the agent shows them whatever text your server returned. If that text is a stack trace or "Internal error", the user has nowhere to go. They retry, then leave. You never hear about it.
Step by step
- 1. Return an error, never an empty answer
Set isError to true and write the text for the agent that will read it: a stable code, one plain sentence on what happened, the one next step the user can take. Do not return a stack trace or an empty string.
- 2. Say what to do next, in the error
An error that names the fix gets fixed. An error that names only the failure gets retried until the user gives up. Add a support line that tells the agent which tool to call if the fix does not work.
- 3. Keep the last calls per account
Record tool name, time, duration, ok or failed, and the error code for the last dozen calls of each account. This is the context a person needs, and the user will not remember it.
- 4. Attach it, do not ask the user to describe it
When the user opens a ticket, your server adds the account, the client name, the catalogue version and the recent calls. The user writes one sentence. Nobody asks them for logs.
- 5. Open a ticket when a person must act
A bug, a billing question, a permission that looks wrong, or the same error three times in a row. Answer from your own rules first for the rest: a missing connection, a rate limit, an empty credit balance.
The error shape
// One error shape for every tool. isError tells the client the call failed;
// the text is what the agent reads and repeats to the user.
return {
isError: true,
content: [{
type: "text",
text: JSON.stringify({
error: "FORBIDDEN", // a stable code, never a stack trace
message: "This account is managed: drafts can be read but not approved here.",
fix: "Approve the drafts in the web app.", // the one next step the user can take
support: "If this is wrong, call support_ask. support_open_ticket sends it to the team with this error attached.",
}),
}],
};What to attach to a ticket
- The account: user id and email, so the reply reaches the right person.
- The client: the user agent of Claude, ChatGPT or Cursor.
- The catalogue version: it shows when the user has an old tool list.
- The last calls: tool, time, duration, result and error code.
- Nothing secret: no tokens, no passwords, no message bodies.
// Keep the last calls per account in memory, newest first. Attach them to a ticket.
type Call = { tool: string; at: string; ms: number; ok: boolean; error?: string };
const recent = new Map<string, Call[]>();
function record(userId: string, c: Call) {
const l = recent.get(userId) ?? [];
l.unshift(c);
recent.set(userId, l.slice(0, 12));
}
// In the support_open_ticket tool:
// metadata.mcp = { account, client: { userAgent }, catalogueVersion, recentCalls: recent.get(userId) }When to open a ticket
- Open one for a bug, a billing question, a permission that looks wrong, or the same error three times in a row.
- Answer from your own rules, with no ticket, for a missing connection, a rate limit, an empty balance or a stale connector.
- If the question is not in your rules, say so and point at the ticket. Do not guess.
canhelpto gives you the ticket side: three tools your server mounts, an inbox for your team, and the reply sent back to the user by email and through the connector. The calls are in the docs for agents. Plans are on the pricing page.