Introduction
Moving an MCP-powered application from prototype to production requires addressing reliability, security, and observability. This lesson covers error handling and retry logic, graceful fallbacks, conversation context limits, user permission flows for destructive tools, and logging strategies for debugging tool interactions.
Key Concepts
- Retry Logic: Automatically retrying failed tool calls with backoff, distinguishing between transient errors (network timeouts) and permanent errors (invalid arguments).
- Graceful Fallback: Providing a useful response when a tool or server is unavailable, rather than failing the entire interaction.
- User Permission Flow: Requiring explicit user confirmation before executing tools that modify state (delete, write, deploy), preventing accidental destructive actions.
- Structured Logging: Recording tool calls, arguments, results, and timing in a structured format for debugging and auditing.
Real World Context
A production coding assistant processes hundreds of conversations daily. Occasionally, the database server times out due to heavy load. The retry logic catches the timeout, waits briefly, and retries the query. If the server remains unavailable, the fallback logic tells the user the database is temporarily unavailable and suggests trying again later. When a user asks the assistant to drop a database table, the permission flow pauses and asks for explicit confirmation before proceeding.
Deep Dive
Robust error handling wraps every tool call with retry logic that distinguishes between retryable and non-retryable errors.
Here is a tool call wrapper with retry logic:
typescriptasync function callToolWithRetry( client: Client, name: string, args: Record<string, unknown>, maxRetries = 3 ): Promise<any> { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await client.callTool({ name, arguments: args }); } catch (error) { const isRetryable = error.code === -32603 || error.message?.includes('timeout'); if (!isRetryable || attempt === maxRetries) { return { content: [{ type: 'text', text: `Tool ${name} failed: ${error.message}` }], isError: true }; } const delay = 1000 * Math.pow(2, attempt - 1); await new Promise(resolve => setTimeout(resolve, delay)); } } }
The wrapper retries on internal server errors and timeouts (transient failures) but immediately returns an error for invalid parameters or unknown tools (permanent failures).
Graceful fallbacks ensure the application remains useful even when servers are down.
Here is a fallback-aware tool router:
typescriptasync function routeWithFallback( registry: ServerRegistry, toolName: string, args: Record<string, unknown>, fallbackMessages: Map<string, string> ): Promise<any> { const client = registry.getClientForTool(toolName); if (!client) { const fallback = fallbackMessages.get(toolName) ?? `The tool '${toolName}' is currently unavailable. Please try again later.`; return { content: [{ type: 'text', text: fallback }], isError: true }; } return callToolWithRetry(client, toolName, args); } // Configure friendly fallback messages per tool const fallbacks = new Map([ ['query', 'The database is temporarily unavailable. I can help with questions that do not require database access.'], ['create_issue', 'GitHub is currently unreachable. Please save your issue details and try again shortly.'] ]);
Custom fallback messages give the LLM useful context about what is and is not available.
User permission flows prevent accidental execution of destructive tools. The application pauses and asks for confirmation before proceeding.
Here is a permission system for destructive tool calls:
typescriptconst DESTRUCTIVE_TOOLS = new Set([ 'delete_file', 'drop_table', 'force_push', 'restart_service', 'delete_branch' ]); async function executeWithPermission( toolName: string, args: Record<string, unknown>, askUser: (question: string) => Promise<boolean> ): Promise<any> { if (DESTRUCTIVE_TOOLS.has(toolName)) { const confirmed = await askUser( `The assistant wants to execute '${toolName}' with arguments:\n` + `${JSON.stringify(args, null, 2)}\n\nDo you want to proceed?` ); if (!confirmed) { return { content: [{ type: 'text', text: 'User declined the action.' }], isError: false }; } } return callToolWithRetry(registry.getClientForTool(toolName)!, toolName, args); }
The permission system checks if the tool is in the destructive set and prompts the user before execution. The LLM receives the user's decision as a tool result.
Structured logging records every tool interaction for debugging and auditing.
Here is a logging wrapper for tool calls:
typescriptinterface ToolLog { timestamp: string; toolName: string; arguments: Record<string, unknown>; durationMs: number; success: boolean; error?: string; } const toolLogs: ToolLog[] = []; async function callToolWithLogging( client: Client, name: string, args: Record<string, unknown> ): Promise<any> { const start = Date.now(); try { const result = await client.callTool({ name, arguments: args }); toolLogs.push({ timestamp: new Date().toISOString(), toolName: name, arguments: args, durationMs: Date.now() - start, success: true }); return result; } catch (error) { toolLogs.push({ timestamp: new Date().toISOString(), toolName: name, arguments: args, durationMs: Date.now() - start, success: false, error: error.message }); throw error; } }
Every tool call is logged with its name, arguments, duration, and success status. This data is invaluable for debugging issues in multi-step interactions.
Common Pitfalls
- Retrying non-retryable errors: Retrying an invalid parameters error wastes time and resources. Distinguish between transient and permanent failures.
- Blocking on permission for every tool call: Requiring confirmation for safe, read-only tools (like queries or file reads) creates friction. Only gate destructive operations.
- Logging sensitive arguments: Tool arguments may contain passwords, tokens, or personal data. Sanitize or redact sensitive fields before logging.
Best Practices
- Classify tools into safe (read-only) and destructive (write/delete) categories, and only require user confirmation for destructive tools.
- Use structured logging with consistent fields so you can query and aggregate tool usage data.
- Set reasonable retry limits (2-3 attempts) with exponential backoff to avoid cascading failures.
Summary
- Wrap tool calls with retry logic that distinguishes transient errors (timeout, server error) from permanent errors (invalid params).
- Provide graceful fallbacks with custom messages when servers or tools are unavailable.
- Implement user permission flows for destructive tools to prevent accidental data loss.
- Use structured logging for every tool interaction to enable debugging and auditing.
- Classify tools as safe or destructive to determine which require user confirmation.
Code Examples
async function callToolWithRetry(
client: Client, name: string, args: Record<string, unknown>, maxRetries = 3
): Promise<any> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await client.callTool({ name, arguments: args });
} catch (error) {
const isRetryable = error.code === -32603 || error.message?.includes('timeout');
if (!isRetryable || attempt === maxRetries) {
return { content: [{ type: 'text', text: `Tool ${name} failed: ${error.message}` }], isError: true };
}
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
}
}
}const DESTRUCTIVE_TOOLS = new Set(['delete_file', 'drop_table', 'force_push']);
async function executeWithPermission(toolName, args, askUser) {
if (DESTRUCTIVE_TOOLS.has(toolName)) {
const confirmed = await askUser(
`Execute '${toolName}' with ${JSON.stringify(args)}?`
);
if (!confirmed) {
return { content: [{ type: 'text', text: 'User declined.' }], isError: false };
}
}
return callToolWithRetry(client, toolName, args);
}