Skip to main content
Learn how to handle errors gracefully in your MCP services with proper error types, validation, and user-friendly messages.

Error Types

LeanMCP handles several types of errors:
  • Validation errors - Input doesn’t match schema constraints
  • Authentication errors - Missing or invalid auth tokens
  • Runtime errors - Errors thrown during tool execution
  • Tool errors - Business logic errors with custom messages

Validation Errors

Validation errors occur when input doesn’t match the schema defined with @SchemaConstraint:

Automatic Validation

LeanMCP automatically validates all input before calling your tool:
class DivideInput {
  @SchemaConstraint({
    description: 'Numerator',
    minimum: -1000000,
    maximum: 1000000
  })
  a!: number;
  
  @SchemaConstraint({
    description: 'Denominator (cannot be zero)',
    minimum: -1000000,
    maximum: 1000000
  })
  b!: number;
}

@Tool({ 
  description: 'Divide two numbers',
  inputClass: DivideInput
})
async divide(input: DivideInput) {
  // Input is already validated here
  return { result: input.a / input.b };
}

Common Validation Errors

Missing required field:
{
  "error": "Validation failed",
  "details": [
    {
      "field": "email",
      "message": "Required field is missing"
    }
  ]
}
Invalid format:
{
  "error": "Validation failed",
  "details": [
    {
      "field": "email",
      "message": "Must be a valid email address"
    }
  ]
}
Out of range:
{
  "error": "Validation failed",
  "details": [
    {
      "field": "age",
      "message": "Must be between 18 and 120"
    }
  ]
}

Runtime Errors

Throwing Errors

Throw standard JavaScript errors for business logic failures:
@Tool({ 
  description: 'Divide two numbers',
  inputClass: DivideInput
})
async divide(input: DivideInput) {
  if (input.b === 0) {
    throw new Error('Division by zero is not allowed');
  }
  
  if (!Number.isFinite(input.a) || !Number.isFinite(input.b)) {
    throw new Error('Invalid input: numbers must be finite');
  }
  
  return { result: input.a / input.b };
}

Error Response Format

Errors are automatically formatted as MCP responses:
{
  "content": [
    {
      "type": "text",
      "text": "Error: Division by zero is not allowed"
    }
  ],
  "isError": true
}

Custom Error Classes

Create custom error classes for better error handling:
class ValidationError extends Error {
  constructor(message: string, public field: string) {
    super(message);
    this.name = 'ValidationError';
  }
}

class NotFoundError extends Error {
  constructor(message: string, public resourceId: string) {
    super(message);
    this.name = 'NotFoundError';
  }
}

class PermissionError extends Error {
  constructor(message: string, public action: string) {
    super(message);
    this.name = 'PermissionError';
  }
}

@Tool({ description: 'Get user by ID' })
async getUser(args: { userId: string }) {
  const user = await db.users.findById(args.userId);
  
  if (!user) {
    throw new NotFoundError(
      `User not found: ${args.userId}`,
      args.userId
    );
  }
  
  return user;
}

Authentication Errors

Handle authentication errors from @Authenticated decorator:
import { AuthenticationError } from '@leanmcp/auth';

try {
  await service.protectedMethod({ data: 'test' });
} catch (error) {
  if (error instanceof AuthenticationError) {
    switch (error.code) {
      case 'MISSING_TOKEN':
        console.log('No token provided');
        // Redirect to login
        break;
      
      case 'INVALID_TOKEN':
        console.log('Token is invalid or expired');
        // Refresh token or re-authenticate
        break;
      
      case 'VERIFICATION_FAILED':
        console.log('Token verification failed:', error.message);
        // Log error and show user-friendly message
        break;
    }
  }
}

Error Handling Patterns

Try-Catch in Tools

Handle external API failures gracefully:
@Tool({ 
  description: 'Fetch GitHub user profile',
  inputClass: GitHubUserInput
})
async fetchGitHubUser(args: GitHubUserInput) {
  try {
    const response = await fetch(
      `https://api.github.com/users/${args.username}`
    );
    
    if (!response.ok) {
      if (response.status === 404) {
        throw new Error(`GitHub user '${args.username}' not found`);
      }
      throw new Error(`GitHub API error: ${response.status}`);
    }
    
    return await response.json();
  } catch (error) {
    if (error instanceof Error) {
      throw new Error(`Failed to fetch user: ${error.message}`);
    }
    throw new Error('Unknown error occurred');
  }
}

Validation in Tools

Add custom business logic validation:
@Tool({ 
  description: 'Create user account',
  inputClass: CreateUserInput
})
async createUser(args: CreateUserInput) {
  // Check if user already exists
  const existing = await db.users.findByEmail(args.email);
  if (existing) {
    throw new Error(`User with email ${args.email} already exists`);
  }
  
  // Validate password strength beyond schema constraints
  if (!this.isPasswordStrong(args.password)) {
    throw new Error(
      'Password must contain at least one uppercase letter, ' +
      'one lowercase letter, one number, and one special character'
    );
  }
  
  // Create user
  const user = await db.users.create(args);
  return user;
}

private isPasswordStrong(password: string): boolean {
  return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/.test(password);
}

Retry Logic

Implement retry logic for transient failures:
@Tool({ 
  description: 'Send email notification',
  inputClass: EmailInput
})
async sendEmail(args: EmailInput) {
  const maxRetries = 3;
  let lastError: Error | null = null;
  
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      await this.emailService.send(args);
      return { 
        success: true,
        message: 'Email sent successfully'
      };
    } catch (error) {
      lastError = error instanceof Error ? error : new Error(String(error));
      console.log(`Email send attempt ${attempt} failed:`, lastError.message);
      
      if (attempt < maxRetries) {
        // Exponential backoff
        await new Promise(resolve => 
          setTimeout(resolve, Math.pow(2, attempt) * 1000)
        );
      }
    }
  }
  
  throw new Error(
    `Failed to send email after ${maxRetries} attempts: ${lastError?.message}`
  );
}

Fallback Values

Provide fallback values for non-critical failures:
@Tool({ 
  description: 'Get weather with forecast',
  inputClass: WeatherInput
})
async getWeather(args: WeatherInput) {
  // Get current weather (critical)
  const current = await this.weatherAPI.getCurrent(args.city);
  
  // Get forecast (non-critical - use fallback)
  let forecast;
  try {
    forecast = await this.weatherAPI.getForecast(args.city);
  } catch (error) {
    console.warn('Failed to fetch forecast:', error);
    forecast = {
      message: 'Forecast temporarily unavailable',
      days: []
    };
  }
  
  return {
    current,
    forecast
  };
}

User-Friendly Error Messages

Provide Context

// Bad - vague error
throw new Error('Invalid input');

// Good - specific error with context
throw new Error(
  `Invalid city name: '${args.city}'. ` +
  `City must be alphanumeric and 2-50 characters long.`
);

Suggest Solutions

// Bad - just states the problem
throw new Error('Rate limit exceeded');

// Good - suggests solution
throw new Error(
  'Rate limit exceeded. Please wait 60 seconds before trying again. ' +
  'Current limit: 100 requests per minute.'
);

Include Actionable Information

@Tool({ description: 'Delete user account' })
async deleteUser(args: { userId: string }) {
  const user = await db.users.findById(args.userId);
  
  if (!user) {
    throw new Error(
      `Cannot delete user: User ID '${args.userId}' not found. ` +
      `Please verify the user ID and try again.`
    );
  }
  
  if (user.role === 'admin') {
    throw new Error(
      `Cannot delete admin user: '${user.email}'. ` +
      `Admin users must be downgraded to regular users before deletion. ` +
      `Use the 'updateUserRole' tool first.`
    );
  }
  
  await db.users.delete(args.userId);
  return { success: true };
}

Error Logging

Structured Logging

import { Logger } from './logger';

export class PaymentService {
  private logger = new Logger('PaymentService');
  
  @Tool({ 
    description: 'Process payment',
    inputClass: PaymentInput
  })
  async processPayment(args: PaymentInput) {
    this.logger.info('Processing payment', {
      amount: args.amount,
      currency: args.currency
    });
    
    try {
      const result = await this.paymentProvider.charge(args);
      
      this.logger.info('Payment successful', {
        transactionId: result.id,
        amount: args.amount
      });
      
      return result;
    } catch (error) {
      this.logger.error('Payment failed', {
        error: error instanceof Error ? error.message : String(error),
        amount: args.amount,
        currency: args.currency
      });
      
      throw new Error(
        `Payment processing failed. Please try again or contact support. ` +
        `Reference: ${Date.now()}`
      );
    }
  }
}

Error Tracking

Integrate with error tracking services:
import * as Sentry from '@sentry/node';

@Tool({ description: 'Complex operation' })
async complexOperation(args: OperationInput) {
  try {
    return await this.performOperation(args);
  } catch (error) {
    // Log to error tracking service
    Sentry.captureException(error, {
      tags: {
        tool: 'complexOperation',
        service: 'OperationService'
      },
      extra: {
        input: args
      }
    });
    
    // Re-throw with user-friendly message
    throw new Error(
      'Operation failed. Our team has been notified. ' +
      'Please try again in a few minutes.'
    );
  }
}

Testing Error Scenarios

Unit Tests for Error Handling

import { describe, it, expect } from '@jest/globals';
import { CalculatorService } from './calculator';

describe('CalculatorService', () => {
  const service = new CalculatorService();
  
  it('should throw error for division by zero', async () => {
    await expect(
      service.divide({ a: 10, b: 0 })
    ).rejects.toThrow('Division by zero is not allowed');
  });
  
  it('should throw error for infinite numbers', async () => {
    await expect(
      service.divide({ a: Infinity, b: 1 })
    ).rejects.toThrow('Invalid input: numbers must be finite');
  });
  
  it('should handle valid division', async () => {
    const result = await service.divide({ a: 10, b: 2 });
    expect(result).toEqual({ result: 5 });
  });
});

Best Practices

1. Fail Fast

Validate input early and throw errors immediately:
@Tool({ description: 'Create order' })
async createOrder(args: CreateOrderInput) {
  // Validate early
  if (args.items.length === 0) {
    throw new Error('Order must contain at least one item');
  }
  
  if (args.totalAmount <= 0) {
    throw new Error('Order amount must be greater than zero');
  }
  
  // Process order
  return await this.processOrder(args);
}

2. Never Swallow Errors

// Bad - silently ignores error
try {
  await saveToDatabase(data);
} catch (error) {
  // Empty catch block
}

// Good - logs and handles
try {
  await saveToDatabase(data);
} catch (error) {
  console.error('Database save failed:', error);
  throw new Error('Failed to save data');
}

3. Use Specific Error Messages

// Bad - generic
throw new Error('Error');

// Good - specific and helpful
throw new Error(
  `Failed to fetch user profile for '${username}': User does not exist`
);

4. Clean Up Resources

Always clean up resources in finally blocks:
@Tool({ description: 'Process file' })
async processFile(args: { filePath: string }) {
  const file = await fs.open(args.filePath);
  
  try {
    const data = await file.read();
    return this.process(data);
  } catch (error) {
    throw new Error(`File processing failed: ${error}`);
  } finally {
    await file.close();
  }
}

5. Document Error Conditions

/**
 * Process payment using payment provider
 * 
 * @throws {Error} If amount is negative or zero
 * @throws {Error} If payment provider is unavailable
 * @throws {Error} If card is declined
 * @throws {AuthenticationError} If user is not authenticated
 */
@Tool({ description: 'Process payment' })
@Authenticated(authProvider)
async processPayment(args: PaymentInput) {
  // Implementation
}

Troubleshooting

Error Not Caught

Problem: Errors are not being caught properly. Solutions:
  • Ensure you’re using async/await correctly
  • Check for unhandled promise rejections
  • Verify try-catch blocks are around async calls
  • Use .catch() for promise chains

Validation Not Working

Problem: Schema validation isn’t catching invalid input. Solutions:
  • Verify @SchemaConstraint decorators are applied
  • Check inputClass is specified in @Tool decorator
  • Ensure TypeScript decorators are enabled
  • Test with obviously invalid data first

Stack Traces in Production

Problem: Exposing internal stack traces to users. Solutions:
  • Catch errors and throw new user-friendly errors
  • Use error tracking service for internal logging
  • Strip stack traces in production environment
  • Log detailed errors server-side only

Next Steps

Deployment

Deploy error-resilient services to production

Schema Design

Design schemas to prevent validation errors

Creating Services

Build robust services with error handling

Examples

See error handling in real examples

Build docs developers (and LLMs) love