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:
- Clear error message (what went wrong)
- Error code (for programmatic handling)
- Helpful details (how to fix it)
- Timestamp (when it happened)
- Context (what was being attempted)
- Suggestion (what to try next)
The Golden Rules
- Never use console.log/error - Corrupts stdio
- Always catch errors - Uncaught = server death
- Return MCP format - Even for errors
- Log to stderr - Winston to files and stderr only
- Test error paths - Errors are features
- Provide context - Include what was attempted
- Suggest solutions - Help users fix it
- Use error codes - Make errors machine-readable
The Reality
Error handling in MCP is harder than in normal Node.js because:
- Can’t use console (corrupts protocol)
- Can’t see errors in Claude (silent failures)
- Must format errors as JSON (specific structure)
- No stack traces visible (unless logged)
But with proper logging, error factories, and MCP Inspector, you can debug anything.
Your error handling IS your developer experience.
References
- Error utilities:
/src/utils/errors.ts - Logger configuration:
/src/utils/logger.ts - Error tests:
/tests/errors.test.ts - Winston documentation: https://github.com/winstonjs/winston
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.
Related Reading
Prerequisites
- The Logger Revolution: Why console.log Will Kill Your MCP Server - Understanding why
console.logis forbidden is essential before learning how to handle errors properly.
Next Steps
- BigInt Testing Hell: Mocking Blockchain Numbers in Jest - Many errors come from mishandling large numbers. This post dives deep into that specific pain point.
Deep Dives
- The MCP Inspector Deep Dive: Your Only Debugging Friend - The primary tool for seeing the structured errors you’ve built.
- The AI Dream Team - See how structured errors enable AI agents to effectively debug and fix code.