/**
* Centralized Error Handler
* Converts errors into structured, actionable responses for AI clients
*/
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { ErrorType, type ErrorDetails, type ToolError } from './errors.js';
/**
* Parse Attio API errors into structured error details
*/
function parseAttioError(error: any): ErrorDetails {
const status = error.statusCode || 500;
const message = error.message || 'Unknown error';
const response = error.response;
// VALIDATION ERRORS (400 with validation_errors array)
if (status === 400 && response?.validation_errors) {
const validationErrors = response.validation_errors.map((ve: any) => ({
field: Array.isArray(ve.path) ? ve.path.join('.') : 'unknown',
message: ve.message || 'Invalid value',
expected: ve.expected,
received: ve.received,
code: ve.code,
}));
return {
type: ErrorType.VALIDATION,
message: response.message || 'Validation failed',
http_status: status,
attio_code: response.code,
validation_errors: validationErrors,
suggestions: generateValidationSuggestions(validationErrors),
retryable: false,
};
}
// UNIQUENESS CONSTRAINT CONFLICTS
if (message.includes('uniqueness constraint')) {
const field = extractFieldFromError(message);
const value = extractValueFromError(message);
return {
type: ErrorType.CONFLICT,
message: 'A record with this value already exists',
http_status: 409,
field,
suggestions: [
'Search for the existing record',
'Update the existing record instead of creating',
field && value
? `A record with ${field}="${value}" already exists`
: 'Use a different value',
],
retryable: false,
};
}
// NOT FOUND (404)
if (status === 404) {
return {
type: ErrorType.NOT_FOUND,
message: response?.message || 'Record not found',
http_status: status,
suggestions: [
'Verify the record_id is correct',
'Search for the record first',
'The record may have been deleted',
],
retryable: false,
};
}
// RATE LIMITING (429)
if (status === 429) {
const retryAfter = extractRetryAfter(response);
return {
type: ErrorType.RATE_LIMIT,
message: 'Rate limit exceeded',
http_status: status,
retry_after: retryAfter,
suggestions: [
`Wait ${retryAfter} seconds before retrying`,
'Reduce request frequency',
'Consider batching requests',
],
retryable: true,
};
}
// AUTHENTICATION (401)
if (status === 401) {
return {
type: ErrorType.AUTHENTICATION,
message: 'Authentication failed',
http_status: status,
suggestions: [
'Verify ATTIO_API_KEY is correct',
'Check if API key has expired',
'Ensure API key is properly configured',
],
retryable: false,
};
}
// PERMISSION (403)
if (status === 403) {
return {
type: ErrorType.PERMISSION,
message: 'Permission denied',
http_status: status,
suggestions: [
'API key lacks required permissions',
'Contact administrator for access',
'Verify workspace access',
],
retryable: false,
};
}
// SERVER ERRORS (5xx)
if (status >= 500) {
return {
type: ErrorType.SERVER_ERROR,
message: 'Attio server error',
http_status: status,
suggestions: [
'Retry after a short delay',
'Check Attio status page',
'Contact Attio support if persists',
],
retryable: true,
};
}
// GENERIC 400 BAD REQUEST
if (status === 400) {
return {
type: ErrorType.VALIDATION,
message: response?.message || message,
http_status: status,
attio_code: response?.code,
suggestions: ['Check input parameters', 'Review API documentation'],
retryable: false,
};
}
// UNKNOWN/OTHER
return {
type: ErrorType.UNKNOWN,
message,
http_status: status,
retryable: false,
original_error:
typeof response === 'string' ? response : JSON.stringify(response),
};
}
/**
* Main error handler - converts any error into a structured tool error response
*/
export function handleToolError(
error: unknown,
_toolName: string
): CallToolResult {
let errorDetails: ErrorDetails;
// Parse Attio API errors (errors from attio-client.ts have statusCode)
if (error && typeof error === 'object' && 'statusCode' in error) {
errorDetails = parseAttioError(error);
}
// Parse custom error classes
else if (error && typeof error === 'object' && 'errorType' in error) {
const customError = error as any;
errorDetails = {
type: customError.errorType,
message: customError.message || 'Unknown error',
field: customError.field,
suggestions: generateSuggestionsForCustomError(customError),
retryable: false,
};
}
// Parse standard errors
else if (error instanceof Error) {
errorDetails = {
type: ErrorType.UNKNOWN,
message: error.message,
retryable: false,
};
}
// Unknown error type
else {
errorDetails = {
type: ErrorType.UNKNOWN,
message: String(error),
retryable: false,
};
}
const toolError: ToolError = {
success: false,
error: errorDetails,
timestamp: new Date().toISOString(),
};
return {
content: [
{
type: 'text',
text: JSON.stringify(toolError, null, 2),
},
],
// NOTE: We intentionally do NOT set isError: true here
// These are successful tool executions that return structured error details
// isError: true should be reserved for tool execution failures (crashes, timeouts)
// not operational errors (404, validation, etc.)
};
}
/**
* Helper: Create success response
*/
export function createSuccessResponse<T>(data: T): CallToolResult {
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: true,
data,
},
null,
2
),
},
],
};
}
/**
* Helper: Generate actionable suggestions from validation errors
*/
function generateValidationSuggestions(errors: any[]): string[] {
const suggestions: string[] = [];
for (const error of errors) {
if (error.message === 'Required') {
suggestions.push(`Provide a value for ${error.field}`);
} else if (error.expected && error.received) {
suggestions.push(
`${error.field} should be ${error.expected}, got ${error.received}`
);
} else {
suggestions.push(`Fix ${error.field}: ${error.message}`);
}
}
return suggestions.length > 0 ? suggestions : ['Check input parameters'];
}
/**
* Helper: Extract field name from error message
* Handles formats like:
* - "attribute with slug \"name\""
* - "attribute with ID \"12345\""
*/
function extractFieldFromError(message: string): string | undefined {
const slugMatch = message.match(/attribute with slug "([^"]+)"/);
if (slugMatch) return slugMatch[1];
const idMatch = message.match(/attribute with ID "([^"]+)"/);
if (idMatch) return idMatch[1];
return undefined;
}
/**
* Helper: Extract value from uniqueness constraint error
* Handles format: "The value \"xyz\" provided for attribute..."
*/
function extractValueFromError(message: string): string | undefined {
const match = message.match(/The value "([^"]+)"/);
return match ? match[1] : undefined;
}
/**
* Helper: Extract retry-after from rate limit response
*/
function extractRetryAfter(response: any): number {
if (response?.retry_after) {
return typeof response.retry_after === 'number'
? response.retry_after
: parseInt(response.retry_after, 10) || 60;
}
return 60; // Default 60 seconds
}
/**
* Helper: Generate suggestions for custom error classes
*/
function generateSuggestionsForCustomError(error: any): string[] {
if (error.name === 'ConfigurationError') {
return ['Check environment variables', 'Verify ATTIO_API_KEY is set'];
}
if (error.name === 'ValidationError') {
if (error.field) {
return [`Provide a valid value for ${error.field}`];
}
return [
'Check input parameters',
'Ensure all required fields are provided',
];
}
return ['Review error message for details'];
}