back to posts
#07 Part 2 2025-07-18 15 min

Error Handling in MCP: Where Do Errors Actually Go?

When your MCP server crashes silently and Claude just says Something went wrong

Part 2 of the Journey: Discovering the Patterns - Learning Through Pain Previous: The Logger Revolution | Next: BigInt Testing Hell

Error Handling in MCP: Where Do Errors Actually Go?

When your MCP server crashes silently and Claude just says “Something went wrong”

Historical Context (November 2025): Built July 2025, during early MCP development. Error handling patterns were undocumented—we established these practices through production debugging across multiple blockchain servers.

Date: July 18, 2025 Author: Myron Koch & Claude Code Category: Debugging Mysteries

The Silent Failure

// Your beautiful error handling
export async function handleGetBalance(args: any, client: any) {
  try {
    if (!args.address) {
      throw new Error('Address is required!');
    }

    const balance = await client.getBalance(args.address);

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ balance }, null, 2)
      }]
    };
  } catch (error: any) {
    console.error('Balance check failed:', error);
    throw error;
  }
}

What Claude sees:

Something went wrong with the tool.

What you see:

[Nothing. Absolute silence.]

The MCP Error Protocol

MCP has specific rules for errors that nobody tells you:

Rule 1: Errors Must Be JSON-RPC Errors

// WRONG: Regular JavaScript errors
throw new Error('Invalid address');

// WRONG: HTTP-style errors
throw { status: 400, message: 'Invalid address' };

// RIGHT: MCP error format
return {
  content: [{
    type: 'text',
    text: JSON.stringify({
      error: 'Invalid address',
      code: 'INVALID_ADDRESS',
      details: 'Expected format: 0x followed by 40 hex characters'
    }, null, 2)
  }],
  isError: true
};

Rule 2: Console Output Corrupts Everything

// This BREAKS the entire protocol
console.log('Checking balance...');
console.error('Error occurred:', error);

// MCP uses stdio for JSON-RPC communication
// Any console output = corrupted JSON stream = silent death

Rule 3: Uncaught Errors Kill the Server

// Server dies silently
async function handleTool(args: any) {
  const result = await riskyOperation();  // Throws
  return formatResponse(result);
}

// Server survives
async function handleTool(args: any) {
  try {
    const result = await riskyOperation();
    return formatResponse(result);
  } catch (error: any) {
    return createErrorResponse(error);
  }
}

The Logging Nightmare

You can’t use console.log. But you need to see errors. Solution?

The Winston Logger Pattern

// src/utils/logger.ts
import winston from 'winston';

export const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    // ONLY write to stderr (never stdout!)
    new winston.transports.File({
      filename: 'error.log',
      level: 'error'
    }),
    new winston.transports.File({
      filename: 'combined.log'
    })
  ]
});

// In development, also log to stderr console
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple(),
    stderrLevels: ['error', 'warn', 'info', 'debug']  // All levels to stderr
  }));
}

Usage in Tools

import { logger } from '../utils/logger.js';

export async function handleGetBalance(args: any, client: any) {
  try {
    logger.info('Getting balance', { address: args.address });

    if (!args.address) {
      logger.error('Missing address parameter');
      return createError('MISSING_PARAMETER', 'Address is required');
    }

    const balance = await client.getBalance(args.address);
    logger.info('Balance retrieved', { address: args.address, balance });

    return createSuccess({ balance });
  } catch (error: any) {
    logger.error('Balance retrieval failed', {
      address: args.address,
      error: error.message,
      stack: error.stack
    });

    return createError('BALANCE_FAILED', error.message);
  }
}

The Error Response Factory

Create consistent error responses:

// src/utils/errors.ts

export function createError(
  code: string,
  message: string,
  details?: any
): { content: Array<{ type: string; text: string }>; isError: true } {
  return {
    content: [{
      type: 'text',
      text: JSON.stringify({
        error: message,
        code,
        details,
        timestamp: new Date().toISOString()
      }, null, 2)
    }],
    isError: true
  };
}

export function createSuccess(data: any) {
  return {
    content: [{
      type: 'text',
      text: JSON.stringify(data, null, 2)
    }]
  };
}

// Usage
return createError('INVALID_ADDRESS', 'Address must start with 0x');
return createSuccess({ balance: '1000000000000000000' });

Error Categories We Discovered

1. Validation Errors (User Input)

// Always validate before calling blockchain
if (!args.address?.match(/^0x[a-fA-F0-9]{40}$/)) {
  return createError(
    'INVALID_ADDRESS',
    'Invalid Ethereum address format',
    {
      received: args.address,
      expected: '0x followed by 40 hexadecimal characters',
      example: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb3'
    }
  );
}

2. Network Errors (RPC Failures)

try {
  const balance = await client.getBalance(args.address);
} catch (error: any) {
  // Network or RPC error
  if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
    return createError(
      'NETWORK_ERROR',
      'Unable to connect to blockchain network',
      {
        network: client.network,
        rpcUrl: client.rpcUrl,
        suggestion: 'Check RPC endpoint configuration'
      }
    );
  }

  // Re-throw unexpected errors
  throw error;
}

3. Blockchain Errors (Transaction Failures)

try {
  const tx = await client.sendTransaction(signedTx);
} catch (error: any) {
  // Parse blockchain error messages
  if (error.message?.includes('insufficient funds')) {
    return createError(
      'INSUFFICIENT_FUNDS',
      'Account does not have enough balance',
      {
        required: estimatedCost,
        available: currentBalance
      }
    );
  }

  if (error.message?.includes('nonce too low')) {
    return createError(
      'NONCE_ERROR',
      'Transaction nonce is incorrect',
      {
        suggestion: 'Retry with updated nonce'
      }
    );
  }

  // Unknown blockchain error
  return createError(
    'BLOCKCHAIN_ERROR',
    error.message,
    { originalError: error.toString() }
  );
}

4. Configuration Errors (Startup Failures)

// In index.ts startup
try {
  const client = new BlockchainClient({
    network: process.env.NETWORK || 'mainnet',
    rpcUrl: process.env.RPC_URL
  });

  // Verify connection
  await client.getBlockNumber();
  logger.info('Connected to blockchain', { network: client.network });
} catch (error: any) {
  logger.error('Failed to initialize blockchain client', {
    error: error.message,
    config: {
      network: process.env.NETWORK,
      hasRpcUrl: !!process.env.RPC_URL
    }
  });

  // Exit with clear error
  process.stderr.write(`Fatal: Cannot connect to blockchain\n${error.message}\n`);
  process.exit(1);
}

The Error Propagation Pattern

// Low-level: Blockchain client
class BlockchainClient {
  async getBalance(address: string): Promise<bigint> {
    try {
      return await this.web3.eth.getBalance(address);
    } catch (error: any) {
      // Add context and re-throw
      const enrichedError = new Error(
        `Failed to get balance for ${address}: ${error.message}`
      );
      enrichedError.cause = error;
      enrichedError.context = { address, network: this.network };
      throw enrichedError;
    }
  }
}

// Mid-level: Tool handler
export async function handleGetBalance(args: any, client: any) {
  try {
    const balance = await client.getBalance(args.address);
    return createSuccess({ balance: balance.toString() });
  } catch (error: any) {
    logger.error('Balance retrieval failed', {
      address: args.address,
      error: error.message,
      context: error.context
    });

    return createError(
      'BALANCE_FAILED',
      'Could not retrieve balance',
      {
        address: args.address,
        reason: error.message
      }
    );
  }
}

// Top-level: Request handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  try {
    const { name, arguments: args } = request.params;

    if (!toolHandlers[name]) {
      return createError('UNKNOWN_TOOL', `Tool not found: ${name}`);
    }

    return await toolHandlers[name](args, client);
  } catch (error: any) {
    // Last resort error handler
    logger.error('Unhandled error in tool handler', {
      tool: request.params.name,
      error: error.message,
      stack: error.stack
    });

    return createError(
      'INTERNAL_ERROR',
      'An unexpected error occurred',
      {
        tool: request.params.name,
        message: error.message
      }
    );
  }
});

Debugging Silent Failures

When your server just… stops working:

1. Check the Log Files

# View error logs
tail -f error.log

# View all logs
tail -f combined.log

# Search for specific errors
grep "BALANCE_FAILED" combined.log

2. Use MCP Inspector

# Inspector shows actual error responses
npx @modelcontextprotocol/inspector node dist/index.js

# Test the failing tool
# Inspector will show the exact error JSON returned

3. Add Debug Logging

export async function handleGetBalance(args: any, client: any) {
  logger.debug('handleGetBalance called', { args });

  try {
    logger.debug('Validating address');
    if (!args.address) {
      logger.debug('Validation failed: no address');
      return createError('MISSING_ADDRESS', 'Address required');
    }

    logger.debug('Calling client.getBalance');
    const balance = await client.getBalance(args.address);
    logger.debug('Balance retrieved', { balance: balance.toString() });

    return createSuccess({ balance: balance.toString() });
  } catch (error: any) {
    logger.debug('Error caught', {
      message: error.message,
      stack: error.stack
    });

    return createError('BALANCE_FAILED', error.message);
  }
}

4. Enable Development Mode

// Add to index.ts
if (process.env.NODE_ENV === 'development') {
  // More verbose logging
  logger.level = 'debug';

  // Log all requests
  server.onrequest = (request) => {
    logger.debug('Incoming request', { request });
  };

  // Log all responses
  server.onresponse = (response) => {
    logger.debug('Outgoing response', { response });
  };
}

The Error Testing Pattern

Test error cases explicitly:

// tests/errors.test.ts
describe('Error Handling', () => {
  it('should return error for invalid address', async () => {
    const result = await handleGetBalance(
      { address: 'invalid' },
      mockClient
    );

    const response = JSON.parse(result.content[0].text);
    expect(response.error).toBeDefined();
    expect(response.code).toBe('INVALID_ADDRESS');
    expect(result.isError).toBe(true);
  });

  it('should handle network failures', async () => {
    mockClient.getBalance.mockRejectedValue(
      new Error('ECONNREFUSED')
    );

    const result = await handleGetBalance(
      { address: '0x123...' },
      mockClient
    );

    const response = JSON.parse(result.content[0].text);
    expect(response.code).toBe('NETWORK_ERROR');
  });

  it('should never throw uncaught errors', async () => {
    mockClient.getBalance.mockImplementation(() => {
      throw new Error('Unexpected explosion');
    });

    // Should NOT throw
    const result = await handleGetBalance(
      { address: '0x123...' },
      mockClient
    );

    expect(result.isError).toBe(true);
  });
});

Common Error Scenarios

Scenario 1: Tool Doesn’t Appear

Claude: I don't have a tool called 'eth_get_balance'

Cause: Server crashed during startup, but Claude doesn’t know.

Debug:

# Check if server actually started
ps aux | grep "ethereum-mcp-server"

# Check error logs
cat error.log

# Common causes:
# - Missing dependencies
# - Invalid configuration
# - Port already in use
# - Syntax error in index.ts

Scenario 2: Tool Returns Nothing

Claude: The tool ran but didn't return any data.

Cause: Return value doesn’t match MCP response format.

Debug:

// Check your response structure
return {
  content: [{
    type: 'text',  // Must be 'text'
    text: '...'    // Must be string
  }]
  // Missing isError field for errors!
};

Scenario 3: Intermittent Failures

Claude: Sometimes it works, sometimes it doesn't.

Cause: Race conditions, network timeouts, or rate limiting.

Debug:

// Add request tracking
const requestId = Date.now();
logger.info('Request started', { requestId, tool: name });

try {
  const result = await toolHandler(args);
  logger.info('Request succeeded', { requestId });
  return result;
} catch (error: any) {
  logger.error('Request failed', {
    requestId,
    error: error.message,
    duration: Date.now() - requestId
  });
  throw error;
}

The Error Response Checklist

Every error response should include:

The Golden Rules

  1. Never use console.log/error - Corrupts stdio
  2. Always catch errors - Uncaught = server death
  3. Return MCP format - Even for errors
  4. Log to stderr - Winston to files and stderr only
  5. Test error paths - Errors are features
  6. Provide context - Include what was attempted
  7. Suggest solutions - Help users fix it
  8. Use error codes - Make errors machine-readable

The Reality

Error handling in MCP is harder than in normal Node.js because:

But with proper logging, error factories, and MCP Inspector, you can debug anything.

Your error handling IS your developer experience.

References


This is part of our ongoing series documenting architectural patterns and insights from building the Blockchain MCP Server Ecosystem. When errors are silent, good logging is everything.


Prerequisites

Next Steps

Deep Dives