# How to fix MCP tool execution errors

- Tool: MCP
- Difficulty: Intermediate
- Time required: 15-20 min
- Compatibility: MCP SDK 1.0+, TypeScript/Python
- Last updated: March 2026

## TL;DR

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.

## Before you start

- 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

### 1. 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.

```
// Successful tool response
{
  content: [{ type: "text", text: "Result data here" }]
}

// Error tool response — the correct way to report tool failures
{
  content: [{ type: "text", text: "Error: File not found at /path/to/file" }],
  isError: true
}

// What happens if you throw instead:
// The SDK catches it and returns a generic error:
// { content: [{ type: "text", text: "Internal error" }], isError: true }
// This is less helpful to the AI
```

**Expected result:** You understand the difference between tool errors (isError: true) and protocol errors, and why explicit error responses are better than unhandled throws.

### 2. 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.

```
server.tool(
  "fetch-data",
  "Fetch data from an API",
  { url: z.string().url() },
  async ({ url }) => {
    try {
      const response = await fetch(url);

      if (!response.ok) {
        return {
          content: [{
            type: "text",
            text: `API returned HTTP ${response.status}: ${response.statusText}. URL: ${url}`,
          }],
          isError: true,
        };
      }

      const data = await response.json();
      return {
        content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
      };
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      return {
        content: [{
          type: "text",
          text: `Failed to fetch ${url}: ${message}`,
        }],
        isError: true,
      };
    }
  }
);
```

**Expected result:** Tool failures return descriptive error messages instead of crashing the server or showing generic errors.

### 3. 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.

```
import { z } from "zod";

// WEAK validation — accepts any string, errors happen inside handler
server.tool("search", "Search", { query: z.string() }, async ({ query }) => {
  // If query is empty, the API might return confusing results
});

// STRONG validation — catches problems before handler runs
server.tool(
  "search",
  "Search for items",
  {
    query: z.string()
      .min(1, "Query cannot be empty")
      .max(500, "Query too long, max 500 characters"),
    category: z.enum(["docs", "code", "issues"]).optional(),
    limit: z.number()
      .int()
      .min(1, "Limit must be at least 1")
      .max(100, "Limit cannot exceed 100")
      .optional()
      .default(10),
  },
  async ({ query, category, limit }) => {
    // Inputs are guaranteed to be valid at this point
    console.error(`Searching: query=${query}, category=${category}, limit=${limit}`);
    // ... handler logic
  }
);
```

**Expected result:** Invalid inputs are rejected with clear validation errors before the tool handler code runs.

### 4. 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.

```
server.tool(
  "get-repo-info",
  "Get GitHub repository information",
  { owner: z.string(), repo: z.string() },
  async ({ owner, repo }) => {
    try {
      const response = await fetch(
        `https://api.github.com/repos/${owner}/${repo}`,
        { headers: { Authorization: `token ${process.env.GITHUB_TOKEN}` } }
      );

      if (response.status === 404) {
        return {
          content: [{
            type: "text",
            text: `Repository ${owner}/${repo} not found. Check the owner and repo name are correct.`,
          }],
          isError: true,
        };
      }

      if (response.status === 401) {
        return {
          content: [{
            type: "text",
            text: "GitHub authentication failed. The GITHUB_TOKEN may be expired or invalid.",
          }],
          isError: true,
        };
      }

      if (response.status === 403) {
        return {
          content: [{
            type: "text",
            text: "GitHub rate limit exceeded. Wait a minute and try again.",
          }],
          isError: true,
        };
      }

      const data = await response.json();
      return {
        content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
      };
    } catch (error) {
      return {
        content: [{
          type: "text",
          text: `Network error: ${(error as Error).message}. Check your internet connection.`,
        }],
        isError: true,
      };
    }
  }
);
```

**Expected result:** Different error types produce specific, actionable error messages that help the AI adjust its approach.

### 5. 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.

```
server.tool(
  "complex-operation",
  "Perform a complex operation",
  { input: z.string() },
  async ({ input }) => {
    try {
      const result = await performComplexOperation(input);
      return { content: [{ type: "text", text: result }] };
    } catch (error) {
      // Log full details to stderr (for developer debugging)
      console.error("Tool 'complex-operation' failed:");
      console.error(`  Input: ${input}`);
      console.error(`  Error: ${(error as Error).stack}`);

      // Return clean message to AI (for user experience)
      return {
        content: [{
          type: "text",
          text: `The operation failed: ${(error as Error).message}. Try with different input or check the server logs for details.`,
        }],
        isError: true,
      };
    }
  }
);
```

**Expected result:** The AI receives a clean, helpful error message while full debugging details are available in the server logs.

## Complete code example

File: `src/robust-tools-server.ts`

```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({
  name: "robust-tools-server",
  version: "1.0.0",
});

// Helper: create a tool error response
function toolError(message: string) {
  return {
    content: [{ type: "text" as const, text: message }],
    isError: true,
  };
}

// Helper: create a tool success response
function toolSuccess(text: string) {
  return {
    content: [{ type: "text" as const, text }],
  };
}

// Tool with comprehensive error handling
server.tool(
  "fetch-url",
  "Fetch content from a URL",
  {
    url: z.string().url("Must be a valid URL"),
    format: z.enum(["json", "text"]).optional().default("json"),
  },
  async ({ url, format }) => {
    try {
      console.error(`Fetching: ${url} (format: ${format})`);
      const start = Date.now();

      const controller = new AbortController();
      const timeout = setTimeout(() => controller.abort(), 30000);

      const response = await fetch(url, { signal: controller.signal });
      clearTimeout(timeout);

      if (!response.ok) {
        return toolError(
          `HTTP ${response.status} from ${url}: ${response.statusText}`
        );
      }

      const data = format === "json"
        ? JSON.stringify(await response.json(), null, 2)
        : await response.text();

      console.error(`Fetched in ${Date.now() - start}ms`);
      return toolSuccess(data);
    } catch (error) {
      const msg = (error as Error).message;
      console.error(`Fetch failed: ${msg}`);

      if ((error as Error).name === "AbortError") {
        return toolError(`Request timed out after 30s: ${url}`);
      }
      return toolError(`Failed to fetch ${url}: ${msg}`);
    }
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Robust tools server ready.");
```

## Common mistakes

- **Throwing exceptions from tool handlers instead of returning isError responses** — undefined Fix: Wrap all handler code in try/catch and return { content: [...], isError: true } instead of throwing. This gives the AI a useful error message.
- **Using generic z.string() when more specific validation exists** — undefined Fix: Use z.string().url(), z.string().min(1), z.number().int().positive(), z.enum([...]) to catch invalid inputs before they reach your handler logic.
- **Returning error details that expose sensitive information** — undefined Fix: 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.
- **Not handling specific HTTP status codes differently** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/mcp-tutorial/how-to-fix-mcp-tool-execution-errors
© RapidDev — https://www.rapidevelopers.com/mcp-tutorial/how-to-fix-mcp-tool-execution-errors
