Mcp

Error Handling Patterns for MCP

Comprehensive guide to error handling patterns in Model Context Protocol servers for reliable AI tool integrations.

Error Handling Patterns for MCP

MCP servers sit between AI models and real-world systems — databases, APIs, file systems. When those systems fail (and they will), your MCP server needs to handle errors gracefully, return structured responses the model can understand, and avoid leaking sensitive information. This guide covers battle-tested error handling patterns I use in production MCP servers.

Prerequisites

  • Node.js 18+ installed
  • Basic familiarity with the Model Context Protocol (JSON-RPC 2.0 transport)
  • Experience building MCP servers with the @modelcontextprotocol/sdk package
  • Understanding of async/await and Promise error handling in Node.js

Understanding MCP Error Types

MCP uses JSON-RPC 2.0 as its message format. Errors fall into two categories: protocol-level errors (transport and JSON-RPC) and tool-level errors (your application logic).

JSON-RPC Standard Error Codes

The JSON-RPC 2.0 spec defines these error codes:

var ERROR_CODES = {
  PARSE_ERROR: -32700,       // Invalid JSON received
  INVALID_REQUEST: -32600,   // JSON is valid but not a proper request
  METHOD_NOT_FOUND: -32601,  // Tool or method doesn't exist
  INVALID_PARAMS: -32602,    // Wrong parameters for the tool
  INTERNAL_ERROR: -32603     // Catch-all server error
};

MCP extends these with additional codes:

var MCP_ERROR_CODES = {
  TOOL_EXECUTION_ERROR: -32000,  // Tool ran but failed
  RESOURCE_NOT_FOUND: -32001,    // Requested resource missing
  RATE_LIMITED: -32002,          // Too many requests
  TIMEOUT: -32003,               // Operation timed out
  UNAUTHORIZED: -32004           // Authentication failed
};

Protocol-Level vs Tool-Level Errors

This distinction matters. Protocol-level errors mean the MCP communication itself failed. Tool-level errors mean the tool executed but encountered a problem.

// Protocol-level error: The MCP framework handles this.
// Returned when the client calls a tool that doesn't exist.
// Response:
// { "jsonrpc": "2.0", "error": { "code": -32601, "message": "Method not found" }, "id": 1 }

// Tool-level error: YOUR code handles this.
// Returned inside a successful MCP response with isError: true.
// Response:
// { "jsonrpc": "2.0", "result": { "content": [{ "type": "text", "text": "Database connection failed" }], "isError": true }, "id": 1 }

The key insight: tool-level errors should return structured content with isError: true, not throw exceptions. Throwing causes a protocol-level error, which is harder for models to interpret.

Structured Error Responses

I use a consistent error response format across all my MCP tools:

function createErrorResponse(code, message, details) {
  var response = {
    content: [{
      type: "text",
      text: JSON.stringify({
        error: true,
        code: code,
        message: message,
        details: details || null,
        timestamp: new Date().toISOString()
      }, null, 2)
    }],
    isError: true
  };
  return response;
}

function createSuccessResponse(data) {
  return {
    content: [{
      type: "text",
      text: typeof data === "string" ? data : JSON.stringify(data, null, 2)
    }],
    isError: false
  };
}

Usage in a tool handler:

server.setRequestHandler("tools/call", function(request) {
  var toolName = request.params.name;
  var args = request.params.arguments;

  if (toolName === "query_database") {
    return handleQueryDatabase(args);
  }

  return createErrorResponse("UNKNOWN_TOOL", "Tool not found: " + toolName);
});

function handleQueryDatabase(args) {
  if (!args.query) {
    return createErrorResponse("INVALID_PARAMS", "Missing required parameter: query");
  }

  if (args.query.toLowerCase().indexOf("drop") !== -1) {
    return createErrorResponse("FORBIDDEN", "Destructive queries are not allowed");
  }

  return runQuery(args.query)
    .then(function(rows) {
      return createSuccessResponse({ rowCount: rows.length, rows: rows });
    })
    .catch(function(err) {
      return createErrorResponse("QUERY_FAILED", "Database query failed", {
        sqlState: err.code,
        hint: err.hint || null
      });
    });
}

Custom Error Classes

Define error classes that carry structured information:

function McpToolError(code, message, details, retryable) {
  this.name = "McpToolError";
  this.code = code;
  this.message = message;
  this.details = details || {};
  this.retryable = retryable || false;
  this.timestamp = new Date().toISOString();
}
McpToolError.prototype = Object.create(Error.prototype);
McpToolError.prototype.constructor = McpToolError;

McpToolError.prototype.toResponse = function() {
  return {
    content: [{
      type: "text",
      text: JSON.stringify({
        error: true,
        code: this.code,
        message: this.message,
        details: this.details,
        retryable: this.retryable
      }, null, 2)
    }],
    isError: true
  };
};

// Specific error types
function ValidationError(message, field) {
  McpToolError.call(this, "VALIDATION_ERROR", message, { field: field }, false);
}
ValidationError.prototype = Object.create(McpToolError.prototype);

function ExternalApiError(message, statusCode, retryable) {
  McpToolError.call(this, "EXTERNAL_API_ERROR", message, { statusCode: statusCode }, retryable);
}
ExternalApiError.prototype = Object.create(McpToolError.prototype);

function TimeoutError(message, timeoutMs) {
  McpToolError.call(this, "TIMEOUT", message, { timeoutMs: timeoutMs }, true);
}
TimeoutError.prototype = Object.create(McpToolError.prototype);

Retry Strategies for Transient Failures

External API calls, database connections, and network requests fail intermittently. Implement retry with exponential backoff:

function retryWithBackoff(fn, options) {
  var maxRetries = (options && options.maxRetries) || 3;
  var baseDelay = (options && options.baseDelay) || 1000;
  var maxDelay = (options && options.maxDelay) || 10000;
  var retryOn = (options && options.retryOn) || function() { return true; };

  var attempt = 0;

  function execute() {
    attempt++;
    return fn().catch(function(err) {
      if (attempt >= maxRetries || !retryOn(err)) {
        throw err;
      }

      var delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
      var jitter = Math.random() * delay * 0.1;
      var totalDelay = delay + jitter;

      console.error(
        "[retry] Attempt " + attempt + "/" + maxRetries +
        " failed: " + err.message +
        ". Retrying in " + Math.round(totalDelay) + "ms"
      );

      return new Promise(function(resolve) {
        setTimeout(resolve, totalDelay);
      }).then(function() {
        return execute();
      });
    });
  }

  return execute();
}

Use it in tool handlers:

function handleFetchWeather(args) {
  var city = args.city;
  if (!city) {
    return Promise.resolve(createErrorResponse("INVALID_PARAMS", "City is required"));
  }

  return retryWithBackoff(
    function() {
      return callWeatherApi(city);
    },
    {
      maxRetries: 3,
      baseDelay: 1000,
      retryOn: function(err) {
        // Only retry on network errors and 5xx responses
        return err.code === "ECONNRESET" ||
               err.code === "ETIMEDOUT" ||
               (err.statusCode && err.statusCode >= 500);
      }
    }
  ).then(function(data) {
    return createSuccessResponse(data);
  }).catch(function(err) {
    return createErrorResponse(
      "WEATHER_API_FAILED",
      "Failed to fetch weather after retries",
      { city: city, lastError: err.message }
    );
  });
}

Timeout Handling

Every external call needs a timeout. I wrap operations with a timeout utility:

function withTimeout(promise, ms, label) {
  var timer;
  var timeoutPromise = new Promise(function(_, reject) {
    timer = setTimeout(function() {
      reject(new TimeoutError(
        (label || "Operation") + " timed out after " + ms + "ms",
        ms
      ));
    }, ms);
  });

  return Promise.race([promise, timeoutPromise]).finally(function() {
    clearTimeout(timer);
  });
}

// Usage
function handleDatabaseQuery(args) {
  return withTimeout(
    pool.query(args.query, args.params),
    5000,
    "Database query"
  ).then(function(result) {
    return createSuccessResponse(result.rows);
  }).catch(function(err) {
    if (err instanceof TimeoutError) {
      return err.toResponse();
    }
    return createErrorResponse("QUERY_FAILED", err.message);
  });
}

Graceful Degradation

When a dependency is down, return partial results instead of failing entirely:

function handleEnrichedSearch(args) {
  var query = args.query;

  var dbPromise = withTimeout(searchDatabase(query), 3000, "DB search")
    .catch(function(err) {
      console.error("[search] Database search failed:", err.message);
      return { source: "database", results: [], error: err.message };
    });

  var apiPromise = withTimeout(searchExternalApi(query), 5000, "API search")
    .catch(function(err) {
      console.error("[search] External API failed:", err.message);
      return { source: "api", results: [], error: err.message };
    });

  return Promise.all([dbPromise, apiPromise]).then(function(responses) {
    var allResults = [];
    var errors = [];

    responses.forEach(function(r) {
      if (r.error) {
        errors.push(r.source + ": " + r.error);
      }
      if (r.results) {
        allResults = allResults.concat(r.results);
      }
    });

    var response = {
      results: allResults,
      totalSources: 2,
      failedSources: errors.length
    };

    if (errors.length > 0) {
      response.warnings = errors;
      response.partial = true;
    }

    return createSuccessResponse(response);
  });
}

Error Propagation Across MCP Boundaries

When your MCP server calls another MCP server (chaining), propagate errors cleanly:

function callDownstreamMcp(client, toolName, args) {
  return client.callTool({ name: toolName, arguments: args })
    .then(function(result) {
      if (result.isError) {
        var errorText = result.content[0] && result.content[0].text;
        var parsed;
        try {
          parsed = JSON.parse(errorText);
        } catch (e) {
          parsed = { message: errorText };
        }

        throw new ExternalApiError(
          "Downstream MCP tool '" + toolName + "' failed: " + (parsed.message || "Unknown error"),
          parsed.code || "DOWNSTREAM_ERROR",
          parsed.retryable || false
        );
      }
      return result;
    });
}

Logging and Observability

Structured logging is essential for debugging MCP errors in production:

var LOG_LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
var currentLevel = LOG_LEVELS[process.env.LOG_LEVEL || "info"];

function log(level, message, context) {
  if (LOG_LEVELS[level] > currentLevel) return;

  var entry = {
    timestamp: new Date().toISOString(),
    level: level,
    message: message,
    service: "mcp-server"
  };

  if (context) {
    Object.keys(context).forEach(function(key) {
      entry[key] = context[key];
    });
  }

  var output = JSON.stringify(entry);
  if (level === "error" || level === "warn") {
    console.error(output);
  } else {
    // MCP uses stdout for protocol, so log to stderr
    process.stderr.write(output + "\n");
  }
}

// Middleware wrapper for tool handlers
function withLogging(toolName, handler) {
  return function(args) {
    var startTime = Date.now();
    var requestId = Math.random().toString(36).substring(2, 10);

    log("info", "Tool invoked", { tool: toolName, requestId: requestId });

    return Promise.resolve()
      .then(function() { return handler(args); })
      .then(function(result) {
        var duration = Date.now() - startTime;
        log("info", "Tool completed", {
          tool: toolName,
          requestId: requestId,
          duration: duration,
          isError: result.isError || false
        });
        return result;
      })
      .catch(function(err) {
        var duration = Date.now() - startTime;
        log("error", "Tool threw exception", {
          tool: toolName,
          requestId: requestId,
          duration: duration,
          error: err.message,
          stack: err.stack
        });
        return createErrorResponse("INTERNAL_ERROR", "An unexpected error occurred");
      });
  };
}

Complete Working Example

Here is a full MCP server with comprehensive error handling:

var { Server } = require("@modelcontextprotocol/sdk/server/index.js");
var { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
var http = require("http");

// --- Error infrastructure ---

function McpToolError(code, message, details, retryable) {
  this.name = "McpToolError";
  this.code = code;
  this.message = message;
  this.details = details || {};
  this.retryable = retryable || false;
}
McpToolError.prototype = Object.create(Error.prototype);

function createErrorResponse(code, message, details) {
  return {
    content: [{ type: "text", text: JSON.stringify({ error: true, code: code, message: message, details: details || null }, null, 2) }],
    isError: true
  };
}

function createSuccessResponse(data) {
  return {
    content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }],
    isError: false
  };
}

function withTimeout(promise, ms, label) {
  var timer;
  var tp = new Promise(function(_, reject) {
    timer = setTimeout(function() {
      reject(new McpToolError("TIMEOUT", (label || "Operation") + " timed out after " + ms + "ms", { timeoutMs: ms }, true));
    }, ms);
  });
  return Promise.race([promise, tp]).finally(function() { clearTimeout(timer); });
}

function retryWithBackoff(fn, opts) {
  var maxRetries = (opts && opts.maxRetries) || 3;
  var baseDelay = (opts && opts.baseDelay) || 1000;
  var attempt = 0;

  function run() {
    attempt++;
    return fn().catch(function(err) {
      if (attempt >= maxRetries) throw err;
      var delay = baseDelay * Math.pow(2, attempt - 1) + Math.random() * 200;
      process.stderr.write("[retry] Attempt " + attempt + " failed: " + err.message + "\n");
      return new Promise(function(r) { setTimeout(r, delay); }).then(run);
    });
  }
  return run();
}

// --- Tool handlers ---

function fetchUrl(url) {
  return new Promise(function(resolve, reject) {
    var req = http.get(url, function(res) {
      var data = "";
      res.on("data", function(chunk) { data += chunk; });
      res.on("end", function() {
        if (res.statusCode >= 400) {
          var err = new McpToolError("HTTP_ERROR", "HTTP " + res.statusCode, { statusCode: res.statusCode }, res.statusCode >= 500);
          return reject(err);
        }
        resolve(data);
      });
    });
    req.on("error", reject);
  });
}

function handleFetchData(args) {
  if (!args.url) {
    return Promise.resolve(createErrorResponse("INVALID_PARAMS", "url is required"));
  }

  return retryWithBackoff(
    function() { return withTimeout(fetchUrl(args.url), 8000, "HTTP fetch"); },
    { maxRetries: 3, baseDelay: 1000 }
  ).then(function(body) {
    return createSuccessResponse({ url: args.url, length: body.length, body: body.substring(0, 2000) });
  }).catch(function(err) {
    return createErrorResponse(err.code || "FETCH_FAILED", err.message, err.details);
  });
}

function handleCalculate(args) {
  if (typeof args.a !== "number" || typeof args.b !== "number") {
    return Promise.resolve(createErrorResponse("INVALID_PARAMS", "a and b must be numbers"));
  }
  if (args.operation === "divide" && args.b === 0) {
    return Promise.resolve(createErrorResponse("DIVISION_BY_ZERO", "Cannot divide by zero"));
  }

  var operations = {
    add: function(a, b) { return a + b; },
    subtract: function(a, b) { return a - b; },
    multiply: function(a, b) { return a * b; },
    divide: function(a, b) { return a / b; }
  };

  var fn = operations[args.operation];
  if (!fn) {
    return Promise.resolve(createErrorResponse("INVALID_PARAMS", "Unknown operation: " + args.operation));
  }

  return Promise.resolve(createSuccessResponse({ result: fn(args.a, args.b) }));
}

// --- Server setup ---

var server = new Server({ name: "example-server", version: "1.0.0" }, {
  capabilities: { tools: {} }
});

server.setRequestHandler("tools/list", function() {
  return {
    tools: [
      { name: "fetch_data", description: "Fetch data from a URL", inputSchema: { type: "object", properties: { url: { type: "string" } }, required: ["url"] } },
      { name: "calculate", description: "Perform arithmetic", inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" }, operation: { type: "string", enum: ["add", "subtract", "multiply", "divide"] } }, required: ["a", "b", "operation"] } }
    ]
  };
});

var toolHandlers = {
  fetch_data: handleFetchData,
  calculate: handleCalculate
};

server.setRequestHandler("tools/call", function(request) {
  var name = request.params.name;
  var args = request.params.arguments || {};
  var handler = toolHandlers[name];

  if (!handler) {
    return Promise.resolve(createErrorResponse("UNKNOWN_TOOL", "No tool named: " + name));
  }

  var startTime = Date.now();
  return Promise.resolve()
    .then(function() { return handler(args); })
    .then(function(result) {
      process.stderr.write("[" + name + "] completed in " + (Date.now() - startTime) + "ms\n");
      return result;
    })
    .catch(function(err) {
      process.stderr.write("[" + name + "] ERROR: " + err.stack + "\n");
      return createErrorResponse("INTERNAL_ERROR", "Unexpected server error");
    });
});

// --- Global error handling ---

process.on("uncaughtException", function(err) {
  process.stderr.write("[FATAL] Uncaught exception: " + err.stack + "\n");
  process.exit(1);
});

process.on("unhandledRejection", function(reason) {
  process.stderr.write("[FATAL] Unhandled rejection: " + reason + "\n");
});

// --- Start ---

var transport = new StdioServerTransport();
server.connect(transport).then(function() {
  process.stderr.write("MCP server running on stdio\n");
});

Common Issues and Troubleshooting

1. Errors Swallowed Silently

// No error output, tool just hangs
[2024-01-15T10:30:00Z] Tool invoked: query_database
// ... nothing after this

Cause: An unhandled Promise rejection inside a tool handler. Without .catch(), the error vanishes and the MCP response never sends.

Fix: Always wrap tool handlers in a catch-all:

Promise.resolve().then(function() { return handler(args); }).catch(function(err) {
  return createErrorResponse("INTERNAL_ERROR", err.message);
});

2. stdout Corruption from console.log

Error: Parse error: Unexpected token 'D' at position 0

Cause: MCP uses stdout for JSON-RPC. If you console.log() debug output, it corrupts the protocol stream.

Fix: Always use process.stderr.write() or console.error() for logging in MCP servers. Never use console.log().

3. Timeout Not Firing Because Promise Never Resolves

[timeout] Database query timed out after 5000ms
Error: Cannot read property 'rows' of undefined

Cause: The timeout fires but the original database Promise eventually resolves too, causing a double-response.

Fix: Use a flag or Promise.race() properly and ensure the original operation is cancelled:

function withTimeout(promise, ms, label) {
  var done = false;
  var timer;
  var tp = new Promise(function(_, reject) {
    timer = setTimeout(function() {
      if (!done) {
        done = true;
        reject(new McpToolError("TIMEOUT", label + " timed out", {}, true));
      }
    }, ms);
  });
  return Promise.race([
    promise.then(function(r) { done = true; return r; }),
    tp
  ]).finally(function() { clearTimeout(timer); });
}

4. Retry Loop Overloading a Failing Service

[retry] Attempt 1/3 failed: ECONNREFUSED
[retry] Attempt 2/3 failed: ECONNREFUSED
[retry] Attempt 3/3 failed: ECONNREFUSED
// Multiplied across 50 concurrent tool calls = 150 requests in seconds

Cause: Multiple concurrent tool calls all retry simultaneously, creating a thundering herd against an already-struggling service.

Fix: Implement a circuit breaker:

function CircuitBreaker(options) {
  this.failureThreshold = options.failureThreshold || 5;
  this.resetTimeout = options.resetTimeout || 30000;
  this.failures = 0;
  this.lastFailure = null;
  this.state = "closed"; // closed = normal, open = failing, half-open = testing
}

CircuitBreaker.prototype.call = function(fn) {
  var self = this;
  if (self.state === "open") {
    if (Date.now() - self.lastFailure > self.resetTimeout) {
      self.state = "half-open";
    } else {
      return Promise.reject(new McpToolError("CIRCUIT_OPEN", "Service unavailable, circuit breaker open", {}, true));
    }
  }

  return fn().then(function(result) {
    self.failures = 0;
    self.state = "closed";
    return result;
  }).catch(function(err) {
    self.failures++;
    self.lastFailure = Date.now();
    if (self.failures >= self.failureThreshold) {
      self.state = "open";
      process.stderr.write("[circuit-breaker] OPEN after " + self.failures + " failures\n");
    }
    throw err;
  });
};

Best Practices

  • Always return isError: true for tool failures instead of throwing exceptions. Thrown errors become protocol-level errors that models handle poorly.
  • Never use console.log() in MCP servers. stdout is the protocol channel. Use process.stderr.write() or console.error() for all diagnostic output.
  • Set timeouts on every external call. A missing timeout means a hung connection blocks the tool forever. Default to 5-10 seconds for APIs, 30 seconds maximum.
  • Include error codes, not just messages. Structured codes like VALIDATION_ERROR and TIMEOUT let clients programmatically handle different failure types.
  • Implement circuit breakers for unreliable dependencies. Retries alone are not enough — they amplify load on failing services.
  • Sanitize error details before returning. Strip stack traces, connection strings, and internal paths from error responses sent to the model.
  • Log every tool invocation with timing. When something goes wrong in production, you need to know which tool, what arguments, and how long it took.
  • Test error paths explicitly. Write tests that force timeouts, connection failures, and invalid input. Happy-path tests catch maybe 30% of production issues.

References

Powered by Contentful