MCP tool execution errors occur when a tool handler throws an unhandled exception, returns an invalid format, or fails input validation. The tool returns isError: true in the response, and the host displays an error message to the user. Fix this by wrapping all tool handlers in try/catch blocks, validating inputs with Zod schemas, and returning proper content arrays with descriptive error messages instead of throwing exceptions.
Fixing MCP Tool Execution Errors
When an MCP tool runs but fails, the server returns a response with isError: true and a content array describing the error. If the tool throws an unhandled exception, the SDK catches it and returns a generic error. Both situations degrade the AI's ability to use your tools effectively. This tutorial shows you how to build robust tool handlers that fail gracefully with helpful error messages.
Prerequisites
- An MCP server with tools that are returning errors
- Basic understanding of MCP tool handlers
- Familiarity with TypeScript or Python error handling
Step-by-step guide
Understand the MCP tool error response format
Understand the MCP tool error response format
When a tool fails, the MCP server should return a response with isError: true and a content array containing the error description. This is different from a JSON-RPC error (which indicates a protocol failure). A tool error means the tool ran but could not complete its task — like an API returning 404 or a file not being found. The AI host sees this error and can try a different approach or ask the user for help.
1// Successful tool response2{3 content: [{ type: "text", text: "Result data here" }]4}56// Error tool response — the correct way to report tool failures7{8 content: [{ type: "text", text: "Error: File not found at /path/to/file" }],9 isError: true10}1112// What happens if you throw instead:13// The SDK catches it and returns a generic error:14// { content: [{ type: "text", text: "Internal error" }], isError: true }15// This is less helpful to the AIExpected result: You understand the difference between tool errors (isError: true) and protocol errors, and why explicit error responses are better than unhandled throws.
Wrap all tool handlers in try/catch
Wrap all tool handlers in try/catch
Every tool handler should be wrapped in a try/catch block that catches all errors and returns a descriptive error response. This prevents unhandled exceptions from crashing the server or producing generic error messages. Include the error type and message in the response so the AI can understand what went wrong and potentially try a different approach.
1server.tool(2 "fetch-data",3 "Fetch data from an API",4 { url: z.string().url() },5 async ({ url }) => {6 try {7 const response = await fetch(url);89 if (!response.ok) {10 return {11 content: [{12 type: "text",13 text: `API returned HTTP ${response.status}: ${response.statusText}. URL: ${url}`,14 }],15 isError: true,16 };17 }1819 const data = await response.json();20 return {21 content: [{ type: "text", text: JSON.stringify(data, null, 2) }],22 };23 } catch (error) {24 const message = error instanceof Error ? error.message : String(error);25 return {26 content: [{27 type: "text",28 text: `Failed to fetch ${url}: ${message}`,29 }],30 isError: true,31 };32 }33 }34);Expected result: Tool failures return descriptive error messages instead of crashing the server or showing generic errors.
Validate inputs with Zod schemas
Validate inputs with Zod schemas
The MCP SDK uses Zod for input validation. Define precise schemas for your tool inputs to catch invalid data before your handler runs. Zod automatically rejects malformed inputs with clear error messages. Use specific types (z.string().url(), z.number().min(1).max(100), z.enum([...])) instead of generic z.string() to catch problems early.
1import { z } from "zod";23// WEAK validation — accepts any string, errors happen inside handler4server.tool("search", "Search", { query: z.string() }, async ({ query }) => {5 // If query is empty, the API might return confusing results6});78// STRONG validation — catches problems before handler runs9server.tool(10 "search",11 "Search for items",12 {13 query: z.string()14 .min(1, "Query cannot be empty")15 .max(500, "Query too long, max 500 characters"),16 category: z.enum(["docs", "code", "issues"]).optional(),17 limit: z.number()18 .int()19 .min(1, "Limit must be at least 1")20 .max(100, "Limit cannot exceed 100")21 .optional()22 .default(10),23 },24 async ({ query, category, limit }) => {25 // Inputs are guaranteed to be valid at this point26 console.error(`Searching: query=${query}, category=${category}, limit=${limit}`);27 // ... handler logic28 }29);Expected result: Invalid inputs are rejected with clear validation errors before the tool handler code runs.
Handle specific error types differently
Handle specific error types differently
Different errors warrant different responses. Network errors might suggest retrying. Authentication errors indicate a configuration problem. Not-found errors might mean the AI should try different parameters. Categorize errors so the AI receives actionable guidance, not just a generic failure message.
1server.tool(2 "get-repo-info",3 "Get GitHub repository information",4 { owner: z.string(), repo: z.string() },5 async ({ owner, repo }) => {6 try {7 const response = await fetch(8 `https://api.github.com/repos/${owner}/${repo}`,9 { headers: { Authorization: `token ${process.env.GITHUB_TOKEN}` } }10 );1112 if (response.status === 404) {13 return {14 content: [{15 type: "text",16 text: `Repository ${owner}/${repo} not found. Check the owner and repo name are correct.`,17 }],18 isError: true,19 };20 }2122 if (response.status === 401) {23 return {24 content: [{25 type: "text",26 text: "GitHub authentication failed. The GITHUB_TOKEN may be expired or invalid.",27 }],28 isError: true,29 };30 }3132 if (response.status === 403) {33 return {34 content: [{35 type: "text",36 text: "GitHub rate limit exceeded. Wait a minute and try again.",37 }],38 isError: true,39 };40 }4142 const data = await response.json();43 return {44 content: [{ type: "text", text: JSON.stringify(data, null, 2) }],45 };46 } catch (error) {47 return {48 content: [{49 type: "text",50 text: `Network error: ${(error as Error).message}. Check your internet connection.`,51 }],52 isError: true,53 };54 }55 }56);Expected result: Different error types produce specific, actionable error messages that help the AI adjust its approach.
Log errors for debugging while returning clean messages
Log errors for debugging while returning clean messages
Log the full error details (stack trace, raw response) to stderr for your debugging needs, but return a clean, user-friendly message in the tool response. The AI sees the clean message, and you can inspect the detailed logs when investigating issues. If you are building production MCP servers and need help implementing comprehensive error handling and monitoring, the RapidDev team can help design robust error strategies.
1server.tool(2 "complex-operation",3 "Perform a complex operation",4 { input: z.string() },5 async ({ input }) => {6 try {7 const result = await performComplexOperation(input);8 return { content: [{ type: "text", text: result }] };9 } catch (error) {10 // Log full details to stderr (for developer debugging)11 console.error("Tool 'complex-operation' failed:");12 console.error(` Input: ${input}`);13 console.error(` Error: ${(error as Error).stack}`);1415 // Return clean message to AI (for user experience)16 return {17 content: [{18 type: "text",19 text: `The operation failed: ${(error as Error).message}. Try with different input or check the server logs for details.`,20 }],21 isError: true,22 };23 }24 }25);Expected result: The AI receives a clean, helpful error message while full debugging details are available in the server logs.
Complete working example
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";3import { z } from "zod";45const server = new McpServer({6 name: "robust-tools-server",7 version: "1.0.0",8});910// Helper: create a tool error response11function toolError(message: string) {12 return {13 content: [{ type: "text" as const, text: message }],14 isError: true,15 };16}1718// Helper: create a tool success response19function toolSuccess(text: string) {20 return {21 content: [{ type: "text" as const, text }],22 };23}2425// Tool with comprehensive error handling26server.tool(27 "fetch-url",28 "Fetch content from a URL",29 {30 url: z.string().url("Must be a valid URL"),31 format: z.enum(["json", "text"]).optional().default("json"),32 },33 async ({ url, format }) => {34 try {35 console.error(`Fetching: ${url} (format: ${format})`);36 const start = Date.now();3738 const controller = new AbortController();39 const timeout = setTimeout(() => controller.abort(), 30000);4041 const response = await fetch(url, { signal: controller.signal });42 clearTimeout(timeout);4344 if (!response.ok) {45 return toolError(46 `HTTP ${response.status} from ${url}: ${response.statusText}`47 );48 }4950 const data = format === "json"51 ? JSON.stringify(await response.json(), null, 2)52 : await response.text();5354 console.error(`Fetched in ${Date.now() - start}ms`);55 return toolSuccess(data);56 } catch (error) {57 const msg = (error as Error).message;58 console.error(`Fetch failed: ${msg}`);5960 if ((error as Error).name === "AbortError") {61 return toolError(`Request timed out after 30s: ${url}`);62 }63 return toolError(`Failed to fetch ${url}: ${msg}`);64 }65 }66);6768const transport = new StdioServerTransport();69await server.connect(transport);70console.error("Robust tools server ready.");Common mistakes when fixing MCP tool execution errors
Why it's a problem: Throwing exceptions from tool handlers instead of returning isError responses
How to avoid: Wrap all handler code in try/catch and return { content: [...], isError: true } instead of throwing. This gives the AI a useful error message.
Why it's a problem: Using generic z.string() when more specific validation exists
How to avoid: Use z.string().url(), z.string().min(1), z.number().int().positive(), z.enum([...]) to catch invalid inputs before they reach your handler logic.
Why it's a problem: Returning error details that expose sensitive information
How to avoid: Log full error details to stderr. Return only a clean, user-appropriate message in the tool response. Never expose stack traces, API keys, or internal paths in tool responses.
Why it's a problem: Not handling specific HTTP status codes differently
How to avoid: Map 401 to auth errors, 404 to not-found, 429 to rate limiting, etc. Each error type should suggest a specific corrective action to the AI.
Best practices
- Wrap every tool handler in try/catch — no exceptions
- Return descriptive error messages that help the AI understand what went wrong
- Use Zod schemas with specific validators for precise input validation
- Log full error details to stderr for debugging, return clean messages to the AI
- Create reusable helper functions for common error and success response patterns
- Handle specific HTTP error codes with targeted error messages
- Include the input parameters in error messages for context
- Set timeouts on all external network calls to prevent indefinite hangs
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
My MCP tool keeps returning errors. Here is my tool handler code: [paste code]. Help me add proper error handling with try/catch, input validation with Zod, and descriptive error responses for different failure modes.
Add comprehensive error handling to my MCP server's tool handlers. Wrap each tool in try/catch, add Zod input validation, and return specific error messages for common failure modes like network errors, 401/403/404 HTTP responses, and timeouts.
Frequently asked questions
What is the difference between isError: true and a JSON-RPC error?
isError: true means the tool ran but failed at its task (like an API returning 404). A JSON-RPC error (codes like -32700, -32001) means the protocol itself failed (parse error, timeout, method not found). Tool errors are handled gracefully by the AI; protocol errors often terminate the connection.
Can the AI retry a tool that returned isError: true?
Yes, the AI can see the error message and decide to retry with different parameters, try a different tool, or ask the user for help. Descriptive error messages help the AI make better retry decisions.
Should I return the full stack trace in the error message?
No. Stack traces are for developers, not the AI. Log the full stack trace to stderr and return a clean, actionable message in the tool response.
How do I test tool error handling?
Use MCP Inspector to manually invoke tools with invalid inputs, missing parameters, and edge cases. Verify that each scenario returns a helpful isError response instead of crashing the server.
What happens if a tool returns neither isError nor valid content?
The SDK may reject the response as malformed. Always return a content array with at least one item. For errors, include isError: true. For success, omit isError or set it to false.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation