Implementation of MCP for LLM Observability capture to PostHig
Some checks failed
CI - Semantic Release / Semantic Release (push) Failing after 7m48s
Some checks failed
CI - Semantic Release / Semantic Release (push) Failing after 7m48s
This commit is contained in:
157
src/utils/cli.test.util.ts
Normal file
157
src/utils/cli.test.util.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Utility for testing CLI commands with real execution
|
||||
*/
|
||||
export class CliTestUtil {
|
||||
/**
|
||||
* Executes a CLI command and returns the result
|
||||
*
|
||||
* @param args - CLI arguments to pass to the command
|
||||
* @param options - Test options
|
||||
* @returns Promise with stdout, stderr, and exit code
|
||||
*/
|
||||
static async runCommand(
|
||||
args: string[],
|
||||
options: {
|
||||
timeoutMs?: number;
|
||||
env?: Record<string, string>;
|
||||
} = {},
|
||||
): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}> {
|
||||
// Default timeout of 30 seconds
|
||||
const timeoutMs = options.timeoutMs || 30000;
|
||||
|
||||
// CLI execution path - points to the built CLI script
|
||||
const cliPath = join(process.cwd(), 'dist', 'index.js');
|
||||
|
||||
// Log what command we're about to run
|
||||
console.log(`Running CLI command: node ${cliPath} ${args.join(' ')}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Set up timeout handler
|
||||
const timeoutId = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error(`CLI command timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
// Capture stdout and stderr
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
// Spawn the process with given arguments and enhanced environment
|
||||
const child = spawn('node', [cliPath, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
...options.env,
|
||||
DEBUG: 'true', // Enable debug logging
|
||||
NODE_ENV: 'test', // Ensure tests are detected
|
||||
},
|
||||
});
|
||||
|
||||
// Collect stdout data
|
||||
child.stdout.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
stdout += chunk;
|
||||
console.log(`STDOUT chunk: ${chunk.substring(0, 50)}...`);
|
||||
});
|
||||
|
||||
// Collect stderr data
|
||||
child.stderr.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
stderr += chunk;
|
||||
console.log(`STDERR chunk: ${chunk.substring(0, 50)}...`);
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
child.on('close', (exitCode) => {
|
||||
clearTimeout(timeoutId);
|
||||
console.log(`Command completed with exit code: ${exitCode}`);
|
||||
console.log(`Total STDOUT length: ${stdout.length} chars`);
|
||||
|
||||
// Get the non-debug output for debugging purposes
|
||||
const nonDebugOutput = stdout
|
||||
.split('\n')
|
||||
.filter((line) => !line.match(/^\[\d{2}:\d{2}:\d{2}\]/))
|
||||
.join('\n');
|
||||
|
||||
console.log(
|
||||
`Non-debug output length: ${nonDebugOutput.length} chars`,
|
||||
);
|
||||
console.log(`STDOUT excerpt: ${stdout.substring(0, 100)}...`);
|
||||
console.log(
|
||||
`Filtered excerpt: ${nonDebugOutput.substring(0, 100)}...`,
|
||||
);
|
||||
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: exitCode ?? 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process errors
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timeoutId);
|
||||
console.error(`Command error: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that stdout contains expected strings/patterns
|
||||
*/
|
||||
static validateOutputContains(
|
||||
output: string,
|
||||
expectedPatterns: (string | RegExp)[],
|
||||
): void {
|
||||
// Filter out debug log lines for cleaner validation
|
||||
const cleanOutput = output
|
||||
.split('\n')
|
||||
.filter((line) => !line.match(/^\[\d{2}:\d{2}:\d{2}\]/))
|
||||
.join('\n');
|
||||
|
||||
console.log('==== Cleaned output for validation ====');
|
||||
console.log(cleanOutput);
|
||||
console.log('=======================================');
|
||||
|
||||
for (const pattern of expectedPatterns) {
|
||||
if (typeof pattern === 'string') {
|
||||
expect(cleanOutput).toContain(pattern);
|
||||
} else {
|
||||
expect(cleanOutput).toMatch(pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates Markdown output format
|
||||
*/
|
||||
static validateMarkdownOutput(output: string): void {
|
||||
// Filter out debug log lines for cleaner validation
|
||||
const cleanOutput = output
|
||||
.split('\n')
|
||||
.filter((line) => !line.match(/^\[\d{2}:\d{2}:\d{2}\]/))
|
||||
.join('\n');
|
||||
|
||||
// Check for Markdown heading
|
||||
expect(cleanOutput).toMatch(/^#\s.+/m);
|
||||
|
||||
// Check for markdown formatting elements like bold text, lists, etc.
|
||||
const markdownElements = [
|
||||
/\*\*.+\*\*/, // Bold text
|
||||
/-\s.+/, // List items
|
||||
/\|.+\|.+\|/, // Table rows
|
||||
/\[.+\]\(.+\)/, // Links
|
||||
];
|
||||
|
||||
expect(
|
||||
markdownElements.some((pattern) => pattern.test(cleanOutput)),
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
131
src/utils/config.util.test.ts
Normal file
131
src/utils/config.util.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
ErrorType,
|
||||
McpError,
|
||||
createApiError,
|
||||
createAuthMissingError,
|
||||
createAuthInvalidError,
|
||||
createUnexpectedError,
|
||||
ensureMcpError,
|
||||
formatErrorForMcpTool,
|
||||
formatErrorForMcpResource,
|
||||
} from './error.util.js';
|
||||
|
||||
describe('Error Utility', () => {
|
||||
describe('McpError', () => {
|
||||
it('should create an error with the correct properties', () => {
|
||||
const error = new McpError('Test error', ErrorType.API_ERROR, 404);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(McpError);
|
||||
expect(error.message).toBe('Test error');
|
||||
expect(error.type).toBe(ErrorType.API_ERROR);
|
||||
expect(error.statusCode).toBe(404);
|
||||
expect(error.name).toBe('McpError');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Factory Functions', () => {
|
||||
it('should create auth missing error', () => {
|
||||
const error = createAuthMissingError();
|
||||
|
||||
expect(error).toBeInstanceOf(McpError);
|
||||
expect(error.type).toBe(ErrorType.AUTH_MISSING);
|
||||
expect(error.message).toBe(
|
||||
'Authentication credentials are missing',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create auth invalid error', () => {
|
||||
const error = createAuthInvalidError('Invalid token');
|
||||
|
||||
expect(error).toBeInstanceOf(McpError);
|
||||
expect(error.type).toBe(ErrorType.AUTH_INVALID);
|
||||
expect(error.statusCode).toBe(401);
|
||||
expect(error.message).toBe('Invalid token');
|
||||
});
|
||||
|
||||
it('should create API error', () => {
|
||||
const originalError = new Error('Original error');
|
||||
const error = createApiError('API failed', 500, originalError);
|
||||
|
||||
expect(error).toBeInstanceOf(McpError);
|
||||
expect(error.type).toBe(ErrorType.API_ERROR);
|
||||
expect(error.statusCode).toBe(500);
|
||||
expect(error.message).toBe('API failed');
|
||||
expect(error.originalError).toBe(originalError);
|
||||
});
|
||||
|
||||
it('should create unexpected error', () => {
|
||||
const error = createUnexpectedError();
|
||||
|
||||
expect(error).toBeInstanceOf(McpError);
|
||||
expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR);
|
||||
expect(error.message).toBe('An unexpected error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureMcpError', () => {
|
||||
it('should return the same error if it is already an McpError', () => {
|
||||
const originalError = createApiError('Original error');
|
||||
const error = ensureMcpError(originalError);
|
||||
|
||||
expect(error).toBe(originalError);
|
||||
});
|
||||
|
||||
it('should wrap a standard Error', () => {
|
||||
const originalError = new Error('Standard error');
|
||||
const error = ensureMcpError(originalError);
|
||||
|
||||
expect(error).toBeInstanceOf(McpError);
|
||||
expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR);
|
||||
expect(error.message).toBe('Standard error');
|
||||
expect(error.originalError).toBe(originalError);
|
||||
});
|
||||
|
||||
it('should handle non-Error objects', () => {
|
||||
const error = ensureMcpError('String error');
|
||||
|
||||
expect(error).toBeInstanceOf(McpError);
|
||||
expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR);
|
||||
expect(error.message).toBe('String error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatErrorForMcpTool', () => {
|
||||
it('should format an error for MCP tool response', () => {
|
||||
const error = createApiError('API error');
|
||||
const response = formatErrorForMcpTool(error);
|
||||
|
||||
expect(response).toHaveProperty('content');
|
||||
expect(response.content).toHaveLength(1);
|
||||
expect(response.content[0]).toHaveProperty('type', 'text');
|
||||
expect(response.content[0]).toHaveProperty(
|
||||
'text',
|
||||
'Error: API error',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatErrorForMcpResource', () => {
|
||||
it('should format an error for MCP resource response', () => {
|
||||
const error = createApiError('API error');
|
||||
const response = formatErrorForMcpResource(error, 'test://uri');
|
||||
|
||||
expect(response).toHaveProperty('contents');
|
||||
expect(response.contents).toHaveLength(1);
|
||||
expect(response.contents[0]).toHaveProperty('uri', 'test://uri');
|
||||
expect(response.contents[0]).toHaveProperty(
|
||||
'text',
|
||||
'Error: API error',
|
||||
);
|
||||
expect(response.contents[0]).toHaveProperty(
|
||||
'mimeType',
|
||||
'text/plain',
|
||||
);
|
||||
expect(response.contents[0]).toHaveProperty(
|
||||
'description',
|
||||
'Error: API_ERROR',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
173
src/utils/config.util.ts
Normal file
173
src/utils/config.util.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Logger } from './logger.util.js';
|
||||
import dotenv from 'dotenv';
|
||||
import os from 'os';
|
||||
|
||||
/**
|
||||
* Configuration loader that handles multiple sources with priority:
|
||||
* 1. Direct ENV pass (process.env)
|
||||
* 2. .env file in project root
|
||||
* 3. Global config file at $HOME/.mcp/configs.json
|
||||
*/
|
||||
class ConfigLoader {
|
||||
private packageName: string;
|
||||
private configLoaded: boolean = false;
|
||||
|
||||
/**
|
||||
* Create a new ConfigLoader instance
|
||||
* @param packageName The package name to use for global config lookup
|
||||
*/
|
||||
constructor(packageName: string) {
|
||||
this.packageName = packageName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from all sources with proper priority
|
||||
*/
|
||||
load(): void {
|
||||
const methodLogger = Logger.forContext('utils/config.util.ts', 'load');
|
||||
|
||||
if (this.configLoaded) {
|
||||
methodLogger.debug('Configuration already loaded, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
methodLogger.debug('Loading configuration...');
|
||||
|
||||
// Priority 3: Load from global config file
|
||||
this.loadFromGlobalConfig();
|
||||
|
||||
// Priority 2: Load from .env file
|
||||
this.loadFromEnvFile();
|
||||
|
||||
// Priority 1: Direct ENV pass is already in process.env
|
||||
// No need to do anything as it already has highest priority
|
||||
|
||||
this.configLoaded = true;
|
||||
methodLogger.debug('Configuration loaded successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from .env file in project root
|
||||
*/
|
||||
private loadFromEnvFile(): void {
|
||||
const methodLogger = Logger.forContext(
|
||||
'utils/config.util.ts',
|
||||
'loadFromEnvFile',
|
||||
);
|
||||
|
||||
try {
|
||||
const result = dotenv.config();
|
||||
if (result.error) {
|
||||
methodLogger.debug('No .env file found or error reading it');
|
||||
return;
|
||||
}
|
||||
methodLogger.debug('Loaded configuration from .env file');
|
||||
} catch (error) {
|
||||
methodLogger.error('Error loading .env file', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from global config file at $HOME/.mcp/configs.json
|
||||
*/
|
||||
private loadFromGlobalConfig(): void {
|
||||
const methodLogger = Logger.forContext(
|
||||
'utils/config.util.ts',
|
||||
'loadFromGlobalConfig',
|
||||
);
|
||||
|
||||
try {
|
||||
const homedir = os.homedir();
|
||||
const globalConfigPath = path.join(homedir, '.mcp', 'configs.json');
|
||||
|
||||
if (!fs.existsSync(globalConfigPath)) {
|
||||
methodLogger.debug('Global config file not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const configContent = fs.readFileSync(globalConfigPath, 'utf8');
|
||||
const config = JSON.parse(configContent);
|
||||
|
||||
// Determine the potential keys for the current package
|
||||
const shortKey = 'llm-observability-mcp'; // Project-specific short key
|
||||
const fullPackageName = this.packageName; // e.g., '@sfiorini/llm-observability-mcp'
|
||||
const unscopedPackageName =
|
||||
fullPackageName.split('/')[1] || fullPackageName; // e.g., 'llm-observability-mcp'
|
||||
|
||||
const potentialKeys = [
|
||||
shortKey,
|
||||
fullPackageName,
|
||||
unscopedPackageName,
|
||||
];
|
||||
let foundConfigSection: {
|
||||
environments?: Record<string, unknown>;
|
||||
} | null = null;
|
||||
let usedKey: string | null = null;
|
||||
|
||||
for (const key of potentialKeys) {
|
||||
if (
|
||||
config[key] &&
|
||||
typeof config[key] === 'object' &&
|
||||
config[key].environments
|
||||
) {
|
||||
foundConfigSection = config[key];
|
||||
usedKey = key;
|
||||
methodLogger.debug(`Found configuration using key: ${key}`);
|
||||
break; // Stop once found
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundConfigSection || !foundConfigSection.environments) {
|
||||
methodLogger.debug(
|
||||
`No configuration found for ${
|
||||
this.packageName
|
||||
} using keys: ${potentialKeys.join(', ')}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const environments = foundConfigSection.environments;
|
||||
for (const [key, value] of Object.entries(environments)) {
|
||||
// Only set if not already defined in process.env
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
methodLogger.debug(
|
||||
`Loaded configuration from global config file using key: ${usedKey}`,
|
||||
);
|
||||
} catch (error) {
|
||||
methodLogger.error('Error loading global config file', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a configuration value
|
||||
* @param key The configuration key
|
||||
* @param defaultValue The default value if the key is not found
|
||||
* @returns The configuration value or the default value
|
||||
*/
|
||||
get(key: string, defaultValue?: string): string | undefined {
|
||||
return process.env[key] || defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a boolean configuration value
|
||||
* @param key The configuration key
|
||||
* @param defaultValue The default value if the key is not found
|
||||
* @returns The boolean configuration value or the default value
|
||||
*/
|
||||
getBoolean(key: string, defaultValue: boolean = false): boolean {
|
||||
const value = this.get(key);
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
return value.toLowerCase() === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance with the package name from package.json
|
||||
export const config = new ConfigLoader('@sfiorini/llm-observability-mcp');
|
||||
24
src/utils/constants.util.ts
Normal file
24
src/utils/constants.util.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Application constants
|
||||
*
|
||||
* This file contains constants used throughout the application.
|
||||
* Centralizing these values makes them easier to maintain and update.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Current application version
|
||||
* This should match the version in package.json
|
||||
*/
|
||||
export const VERSION = '0.1.0';
|
||||
|
||||
/**
|
||||
* Package name with scope
|
||||
* Used for initialization and identification
|
||||
*/
|
||||
export const PACKAGE_NAME = '@sfiorini/llm-observability-mcp';
|
||||
|
||||
/**
|
||||
* CLI command name
|
||||
* Used for binary name and CLI help text
|
||||
*/
|
||||
export const CLI_NAME = 'llm-observability-mcp';
|
||||
128
src/utils/error-handler.util.test.ts
Normal file
128
src/utils/error-handler.util.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
ErrorCode,
|
||||
detectErrorType,
|
||||
buildErrorContext,
|
||||
} from './error-handler.util.js';
|
||||
import { createApiError, McpError, ErrorType } from './error.util.js';
|
||||
|
||||
describe('error-handler.util', () => {
|
||||
describe('detectErrorType', () => {
|
||||
it('should detect network errors', () => {
|
||||
const networkErrors = [
|
||||
'network error occurred',
|
||||
'fetch failed with error',
|
||||
'ECONNREFUSED on 127.0.0.1:8080',
|
||||
'ENOTFOUND api.example.com',
|
||||
'Failed to fetch data from server',
|
||||
'Network request failed',
|
||||
];
|
||||
|
||||
networkErrors.forEach((msg) => {
|
||||
const { code, statusCode } = detectErrorType(new Error(msg));
|
||||
expect(code).toBe(ErrorCode.NETWORK_ERROR);
|
||||
expect(statusCode).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect rate limit errors', () => {
|
||||
const rateLimitErrors = [
|
||||
'rate limit exceeded',
|
||||
'too many requests',
|
||||
new McpError('API error', ErrorType.API_ERROR, 429),
|
||||
];
|
||||
|
||||
rateLimitErrors.forEach((error) => {
|
||||
const { code, statusCode } = detectErrorType(error);
|
||||
expect(code).toBe(ErrorCode.RATE_LIMIT_ERROR);
|
||||
expect(statusCode).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect not found errors', () => {
|
||||
const notFoundErrors = [
|
||||
'resource not found',
|
||||
'entity does not exist',
|
||||
new McpError('Not found', ErrorType.API_ERROR, 404),
|
||||
];
|
||||
|
||||
notFoundErrors.forEach((error) => {
|
||||
const { code } = detectErrorType(error);
|
||||
expect(code).toBe(ErrorCode.NOT_FOUND);
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect access denied errors', () => {
|
||||
const accessDeniedErrors = [
|
||||
'access denied',
|
||||
'permission denied',
|
||||
'not authorized to access',
|
||||
'authentication required',
|
||||
new McpError('Forbidden', ErrorType.API_ERROR, 403),
|
||||
new McpError('Unauthorized', ErrorType.API_ERROR, 401),
|
||||
];
|
||||
|
||||
accessDeniedErrors.forEach((error) => {
|
||||
const { code } = detectErrorType(error);
|
||||
expect(code).toBe(ErrorCode.ACCESS_DENIED);
|
||||
});
|
||||
});
|
||||
|
||||
it('should default to unexpected error when no patterns match', () => {
|
||||
const { code, statusCode } = detectErrorType(
|
||||
new Error('some random error'),
|
||||
);
|
||||
expect(code).toBe(ErrorCode.UNEXPECTED_ERROR);
|
||||
expect(statusCode).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildErrorContext', () => {
|
||||
it('should build a context object with all parameters', () => {
|
||||
const context = buildErrorContext(
|
||||
'User',
|
||||
'create',
|
||||
'controllers/user.controller.ts@create',
|
||||
'user123',
|
||||
{ requestBody: { name: 'Test User' } },
|
||||
);
|
||||
|
||||
expect(context).toEqual({
|
||||
entityType: 'User',
|
||||
operation: 'create',
|
||||
source: 'controllers/user.controller.ts@create',
|
||||
entityId: 'user123',
|
||||
additionalInfo: { requestBody: { name: 'Test User' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('should build a context object with only required parameters', () => {
|
||||
const context = buildErrorContext(
|
||||
'User',
|
||||
'list',
|
||||
'controllers/user.controller.ts@list',
|
||||
);
|
||||
|
||||
expect(context).toEqual({
|
||||
entityType: 'User',
|
||||
operation: 'list',
|
||||
source: 'controllers/user.controller.ts@list',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle object entityId', () => {
|
||||
const context = buildErrorContext(
|
||||
'Document',
|
||||
'get',
|
||||
'controllers/document.controller.ts@get',
|
||||
{ project: 'project1', id: 'doc123' },
|
||||
);
|
||||
|
||||
expect(context).toEqual({
|
||||
entityType: 'Document',
|
||||
operation: 'get',
|
||||
source: 'controllers/document.controller.ts@get',
|
||||
entityId: { project: 'project1', id: 'doc123' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
307
src/utils/error-handler.util.ts
Normal file
307
src/utils/error-handler.util.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { createApiError } from './error.util.js';
|
||||
import { Logger } from './logger.util.js';
|
||||
|
||||
/**
|
||||
* Standard error codes for consistent handling
|
||||
*/
|
||||
export enum ErrorCode {
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
INVALID_CURSOR = 'INVALID_CURSOR',
|
||||
ACCESS_DENIED = 'ACCESS_DENIED',
|
||||
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||
UNEXPECTED_ERROR = 'UNEXPECTED_ERROR',
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
RATE_LIMIT_ERROR = 'RATE_LIMIT_ERROR',
|
||||
}
|
||||
|
||||
/**
|
||||
* Context information for error handling
|
||||
*/
|
||||
export interface ErrorContext {
|
||||
/**
|
||||
* Source of the error (e.g., file path and function)
|
||||
*/
|
||||
source?: string;
|
||||
|
||||
/**
|
||||
* Type of entity being processed (e.g., 'User')
|
||||
*/
|
||||
entityType?: string;
|
||||
|
||||
/**
|
||||
* Identifier of the entity being processed
|
||||
*/
|
||||
entityId?: string | Record<string, string>;
|
||||
|
||||
/**
|
||||
* Operation being performed (e.g., 'retrieving', 'searching')
|
||||
*/
|
||||
operation?: string;
|
||||
|
||||
/**
|
||||
* Additional information for debugging
|
||||
*/
|
||||
additionalInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a consistent error context object
|
||||
* @param entityType Type of entity being processed
|
||||
* @param operation Operation being performed
|
||||
* @param source Source of the error (typically file path and function)
|
||||
* @param entityId Optional identifier of the entity
|
||||
* @param additionalInfo Optional additional information for debugging
|
||||
* @returns A formatted ErrorContext object
|
||||
*/
|
||||
export function buildErrorContext(
|
||||
entityType: string,
|
||||
operation: string,
|
||||
source: string,
|
||||
entityId?: string | Record<string, string>,
|
||||
additionalInfo?: Record<string, unknown>,
|
||||
): ErrorContext {
|
||||
return {
|
||||
entityType,
|
||||
operation,
|
||||
source,
|
||||
...(entityId && { entityId }),
|
||||
...(additionalInfo && { additionalInfo }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect specific error types from raw errors
|
||||
* @param error The error to analyze
|
||||
* @param context Context information for better error detection
|
||||
* @returns Object containing the error code and status code
|
||||
*/
|
||||
export function detectErrorType(
|
||||
error: unknown,
|
||||
context: ErrorContext = {},
|
||||
): { code: ErrorCode; statusCode: number } {
|
||||
const methodLogger = Logger.forContext(
|
||||
'utils/error-handler.util.ts',
|
||||
'detectErrorType',
|
||||
);
|
||||
methodLogger.debug(`Detecting error type`, { error, context });
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const statusCode =
|
||||
error instanceof Error && 'statusCode' in error
|
||||
? (error as { statusCode: number }).statusCode
|
||||
: undefined;
|
||||
|
||||
// Network error detection
|
||||
if (
|
||||
errorMessage.includes('network error') ||
|
||||
errorMessage.includes('fetch failed') ||
|
||||
errorMessage.includes('ECONNREFUSED') ||
|
||||
errorMessage.includes('ENOTFOUND') ||
|
||||
errorMessage.includes('Failed to fetch') ||
|
||||
errorMessage.includes('Network request failed')
|
||||
) {
|
||||
return { code: ErrorCode.NETWORK_ERROR, statusCode: 500 };
|
||||
}
|
||||
|
||||
// Rate limiting detection
|
||||
if (
|
||||
errorMessage.includes('rate limit') ||
|
||||
errorMessage.includes('too many requests') ||
|
||||
statusCode === 429
|
||||
) {
|
||||
return { code: ErrorCode.RATE_LIMIT_ERROR, statusCode: 429 };
|
||||
}
|
||||
|
||||
// Not Found detection
|
||||
if (
|
||||
errorMessage.includes('not found') ||
|
||||
errorMessage.includes('does not exist') ||
|
||||
statusCode === 404
|
||||
) {
|
||||
return { code: ErrorCode.NOT_FOUND, statusCode: 404 };
|
||||
}
|
||||
|
||||
// Access Denied detection
|
||||
if (
|
||||
errorMessage.includes('access') ||
|
||||
errorMessage.includes('permission') ||
|
||||
errorMessage.includes('authorize') ||
|
||||
errorMessage.includes('authentication') ||
|
||||
statusCode === 401 ||
|
||||
statusCode === 403
|
||||
) {
|
||||
return { code: ErrorCode.ACCESS_DENIED, statusCode: statusCode || 403 };
|
||||
}
|
||||
|
||||
// Invalid Cursor detection
|
||||
if (
|
||||
(errorMessage.includes('cursor') ||
|
||||
errorMessage.includes('startAt') ||
|
||||
errorMessage.includes('page')) &&
|
||||
(errorMessage.includes('invalid') || errorMessage.includes('not valid'))
|
||||
) {
|
||||
return { code: ErrorCode.INVALID_CURSOR, statusCode: 400 };
|
||||
}
|
||||
|
||||
// Validation Error detection
|
||||
if (
|
||||
errorMessage.includes('validation') ||
|
||||
errorMessage.includes('invalid') ||
|
||||
errorMessage.includes('required') ||
|
||||
statusCode === 400 ||
|
||||
statusCode === 422
|
||||
) {
|
||||
return {
|
||||
code: ErrorCode.VALIDATION_ERROR,
|
||||
statusCode: statusCode || 400,
|
||||
};
|
||||
}
|
||||
|
||||
// Default to unexpected error
|
||||
return {
|
||||
code: ErrorCode.UNEXPECTED_ERROR,
|
||||
statusCode: statusCode || 500,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user-friendly error messages based on error type and context
|
||||
* @param code The error code
|
||||
* @param context Context information for better error messages
|
||||
* @param originalMessage The original error message
|
||||
* @returns User-friendly error message
|
||||
*/
|
||||
export function createUserFriendlyErrorMessage(
|
||||
code: ErrorCode,
|
||||
context: ErrorContext = {},
|
||||
originalMessage?: string,
|
||||
): string {
|
||||
const methodLogger = Logger.forContext(
|
||||
'utils/error-handler.util.ts',
|
||||
'createUserFriendlyErrorMessage',
|
||||
);
|
||||
const { entityType, entityId, operation } = context;
|
||||
|
||||
// Format entity ID for display
|
||||
let entityIdStr = '';
|
||||
if (entityId) {
|
||||
if (typeof entityId === 'string') {
|
||||
entityIdStr = entityId;
|
||||
} else {
|
||||
// Handle object entityId
|
||||
entityIdStr = Object.values(entityId).join('/');
|
||||
}
|
||||
}
|
||||
|
||||
// Determine entity display name
|
||||
const entity = entityType
|
||||
? `${entityType}${entityIdStr ? ` ${entityIdStr}` : ''}`
|
||||
: 'Resource';
|
||||
|
||||
let message = '';
|
||||
|
||||
switch (code) {
|
||||
case ErrorCode.NOT_FOUND:
|
||||
message = `${entity} not found${entityIdStr ? `: ${entityIdStr}` : ''}. Verify the ID is correct and that you have access to this ${entityType?.toLowerCase() || 'resource'}.`;
|
||||
break;
|
||||
|
||||
case ErrorCode.ACCESS_DENIED:
|
||||
message = `Access denied for ${entity.toLowerCase()}${entityIdStr ? ` ${entityIdStr}` : ''}. Verify your credentials and permissions.`;
|
||||
break;
|
||||
|
||||
case ErrorCode.INVALID_CURSOR:
|
||||
message = `Invalid pagination cursor. Use the exact cursor string returned from previous results.`;
|
||||
break;
|
||||
|
||||
case ErrorCode.VALIDATION_ERROR:
|
||||
message =
|
||||
originalMessage ||
|
||||
`Invalid data provided for ${operation || 'operation'} ${entity.toLowerCase()}.`;
|
||||
break;
|
||||
|
||||
case ErrorCode.NETWORK_ERROR:
|
||||
message = `Network error while ${operation || 'connecting to'} the service. Please check your internet connection and try again.`;
|
||||
break;
|
||||
|
||||
case ErrorCode.RATE_LIMIT_ERROR:
|
||||
message = `Rate limit exceeded. Please wait a moment and try again, or reduce the frequency of requests.`;
|
||||
break;
|
||||
|
||||
default:
|
||||
message = `An unexpected error occurred while ${operation || 'processing'} ${entity.toLowerCase()}.`;
|
||||
}
|
||||
|
||||
// Include original message details if available and appropriate
|
||||
if (
|
||||
originalMessage &&
|
||||
code !== ErrorCode.NOT_FOUND &&
|
||||
code !== ErrorCode.ACCESS_DENIED
|
||||
) {
|
||||
message += ` Error details: ${originalMessage}`;
|
||||
}
|
||||
|
||||
methodLogger.debug(`Created user-friendly message: ${message}`, {
|
||||
code,
|
||||
context,
|
||||
});
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle controller errors consistently
|
||||
* @param error The error to handle
|
||||
* @param context Context information for better error messages
|
||||
* @returns Never returns, always throws an error
|
||||
*/
|
||||
export function handleControllerError(
|
||||
error: unknown,
|
||||
context: ErrorContext = {},
|
||||
): never {
|
||||
const methodLogger = Logger.forContext(
|
||||
'utils/error-handler.util.ts',
|
||||
'handleControllerError',
|
||||
);
|
||||
|
||||
// Extract error details
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const statusCode =
|
||||
error instanceof Error && 'statusCode' in error
|
||||
? (error as { statusCode: number }).statusCode
|
||||
: undefined;
|
||||
|
||||
// Detect error type using utility
|
||||
const { code, statusCode: detectedStatus } = detectErrorType(
|
||||
error,
|
||||
context,
|
||||
);
|
||||
|
||||
// Combine detected status with explicit status
|
||||
const finalStatusCode = statusCode || detectedStatus;
|
||||
|
||||
// Format entity information for logging
|
||||
const { entityType, entityId, operation } = context;
|
||||
const entity = entityType || 'resource';
|
||||
const entityIdStr = entityId
|
||||
? typeof entityId === 'string'
|
||||
? entityId
|
||||
: JSON.stringify(entityId)
|
||||
: '';
|
||||
const actionStr = operation || 'processing';
|
||||
|
||||
// Log detailed error information
|
||||
methodLogger.error(
|
||||
`Error ${actionStr} ${entity}${
|
||||
entityIdStr ? `: ${entityIdStr}` : ''
|
||||
}: ${errorMessage}`,
|
||||
error,
|
||||
);
|
||||
|
||||
// Create user-friendly error message for the response
|
||||
const message =
|
||||
code === ErrorCode.VALIDATION_ERROR
|
||||
? errorMessage
|
||||
: createUserFriendlyErrorMessage(code, context, errorMessage);
|
||||
|
||||
// Throw an appropriate API error with the user-friendly message
|
||||
throw createApiError(message, finalStatusCode, error);
|
||||
}
|
||||
115
src/utils/error.util.test.ts
Normal file
115
src/utils/error.util.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
McpError,
|
||||
ErrorType,
|
||||
createApiError,
|
||||
createUnexpectedError,
|
||||
getDeepOriginalError,
|
||||
formatErrorForMcpTool,
|
||||
} from './error.util.js';
|
||||
|
||||
describe('error.util', () => {
|
||||
describe('getDeepOriginalError', () => {
|
||||
it('should return the deepest original error in a chain', () => {
|
||||
// Create a nested chain of errors
|
||||
const deepestError = new Error('Root cause');
|
||||
const middleError = createApiError(
|
||||
'Middle error',
|
||||
500,
|
||||
deepestError,
|
||||
);
|
||||
const topError = createUnexpectedError('Top error', middleError);
|
||||
|
||||
// Should extract the deepest error
|
||||
const result = getDeepOriginalError(topError);
|
||||
expect(result).toBe(deepestError);
|
||||
});
|
||||
|
||||
it('should handle null/undefined input', () => {
|
||||
expect(getDeepOriginalError(null)).toBeNull();
|
||||
expect(getDeepOriginalError(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the input if it has no originalError', () => {
|
||||
const error = new Error('Simple error');
|
||||
expect(getDeepOriginalError(error)).toBe(error);
|
||||
});
|
||||
|
||||
it('should handle non-Error objects', () => {
|
||||
const nonError = { message: 'Not an error' };
|
||||
expect(getDeepOriginalError(nonError)).toBe(nonError);
|
||||
});
|
||||
|
||||
it('should prevent infinite recursion with circular references', () => {
|
||||
const error1 = new McpError('Error 1', ErrorType.UNEXPECTED_ERROR);
|
||||
const error2 = new McpError(
|
||||
'Error 2',
|
||||
ErrorType.UNEXPECTED_ERROR,
|
||||
undefined,
|
||||
error1,
|
||||
);
|
||||
// Create circular reference
|
||||
error1.originalError = error2;
|
||||
|
||||
// Should not cause stack overflow, should return one of the errors
|
||||
const result = getDeepOriginalError(error1);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result instanceof Error).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatErrorForMcpTool', () => {
|
||||
it('should format McpError with metadata', () => {
|
||||
const error = createApiError('Test error', 404, {
|
||||
detail: 'Not found',
|
||||
});
|
||||
const result = formatErrorForMcpTool(error);
|
||||
|
||||
// Check the content
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: Test error',
|
||||
},
|
||||
]);
|
||||
|
||||
// Check the metadata
|
||||
expect(result.metadata).toBeDefined();
|
||||
expect(result.metadata?.errorType).toBe(ErrorType.API_ERROR);
|
||||
expect(result.metadata?.statusCode).toBe(404);
|
||||
expect(result.metadata?.errorDetails).toEqual({
|
||||
detail: 'Not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap non-McpError with metadata', () => {
|
||||
const error = new Error('Regular error');
|
||||
const result = formatErrorForMcpTool(error);
|
||||
|
||||
// Check content
|
||||
expect(result.content[0].text).toBe('Error: Regular error');
|
||||
|
||||
// Check metadata
|
||||
expect(result.metadata?.errorType).toBe(ErrorType.UNEXPECTED_ERROR);
|
||||
expect(result.metadata?.errorDetails).toHaveProperty(
|
||||
'message',
|
||||
'Regular error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should extract error message from non-Error objects', () => {
|
||||
const result = formatErrorForMcpTool('String error');
|
||||
expect(result.content[0].text).toBe('Error: String error');
|
||||
expect(result.metadata?.errorType).toBe(ErrorType.UNEXPECTED_ERROR);
|
||||
});
|
||||
|
||||
it('should extract deep original error details', () => {
|
||||
const deepError = { code: 'DEEP_ERROR', message: 'Deep cause' };
|
||||
const middleError = createApiError('Middle layer', 500, deepError);
|
||||
const topError = createUnexpectedError('Top error', middleError);
|
||||
|
||||
const result = formatErrorForMcpTool(topError);
|
||||
|
||||
expect(result.metadata?.errorDetails).toEqual(deepError);
|
||||
});
|
||||
});
|
||||
});
|
||||
240
src/utils/error.util.ts
Normal file
240
src/utils/error.util.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Logger } from './logger.util.js';
|
||||
|
||||
/**
|
||||
* Error types for classification
|
||||
*/
|
||||
export enum ErrorType {
|
||||
AUTH_MISSING = 'AUTH_MISSING',
|
||||
AUTH_INVALID = 'AUTH_INVALID',
|
||||
API_ERROR = 'API_ERROR',
|
||||
UNEXPECTED_ERROR = 'UNEXPECTED_ERROR',
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class with type classification
|
||||
*/
|
||||
export class McpError extends Error {
|
||||
type: ErrorType;
|
||||
statusCode?: number;
|
||||
originalError?: unknown;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
type: ErrorType,
|
||||
statusCode?: number,
|
||||
originalError?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'McpError';
|
||||
this.type = type;
|
||||
this.statusCode = statusCode;
|
||||
this.originalError = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an authentication missing error
|
||||
*/
|
||||
export function createAuthMissingError(
|
||||
message: string = 'Authentication credentials are missing',
|
||||
): McpError {
|
||||
return new McpError(message, ErrorType.AUTH_MISSING);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an authentication invalid error
|
||||
*/
|
||||
export function createAuthInvalidError(
|
||||
message: string = 'Authentication credentials are invalid',
|
||||
): McpError {
|
||||
return new McpError(message, ErrorType.AUTH_INVALID, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an API error
|
||||
*/
|
||||
export function createApiError(
|
||||
message: string,
|
||||
statusCode?: number,
|
||||
originalError?: unknown,
|
||||
): McpError {
|
||||
return new McpError(
|
||||
message,
|
||||
ErrorType.API_ERROR,
|
||||
statusCode,
|
||||
originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an unexpected error
|
||||
*/
|
||||
export function createUnexpectedError(
|
||||
message: string = 'An unexpected error occurred',
|
||||
originalError?: unknown,
|
||||
): McpError {
|
||||
return new McpError(
|
||||
message,
|
||||
ErrorType.UNEXPECTED_ERROR,
|
||||
undefined,
|
||||
originalError,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an error is an McpError
|
||||
*/
|
||||
export function ensureMcpError(error: unknown): McpError {
|
||||
if (error instanceof McpError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return createUnexpectedError(error.message, error);
|
||||
}
|
||||
|
||||
return createUnexpectedError(String(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the deepest original error from an error chain
|
||||
* @param error The error to extract the original cause from
|
||||
* @returns The deepest original error or the error itself
|
||||
*/
|
||||
export function getDeepOriginalError(error: unknown): unknown {
|
||||
if (!error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
let current = error;
|
||||
let depth = 0;
|
||||
const maxDepth = 10; // Prevent infinite recursion
|
||||
|
||||
while (
|
||||
depth < maxDepth &&
|
||||
current instanceof Error &&
|
||||
'originalError' in current &&
|
||||
current.originalError
|
||||
) {
|
||||
current = current.originalError;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error for MCP tool response
|
||||
*/
|
||||
export function formatErrorForMcpTool(error: unknown): {
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
metadata?: {
|
||||
errorType: ErrorType;
|
||||
statusCode?: number;
|
||||
errorDetails?: unknown;
|
||||
};
|
||||
} {
|
||||
const methodLogger = Logger.forContext(
|
||||
'utils/error.util.ts',
|
||||
'formatErrorForMcpTool',
|
||||
);
|
||||
const mcpError = ensureMcpError(error);
|
||||
methodLogger.error(`${mcpError.type} error`, mcpError);
|
||||
|
||||
// Get the deep original error for additional context
|
||||
const originalError = getDeepOriginalError(mcpError.originalError);
|
||||
|
||||
// Safely extract details from the original error
|
||||
const errorDetails =
|
||||
originalError instanceof Error
|
||||
? { message: originalError.message }
|
||||
: originalError;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Error: ${mcpError.message}`,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
errorType: mcpError.type,
|
||||
statusCode: mcpError.statusCode,
|
||||
errorDetails,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error for MCP resource response
|
||||
*/
|
||||
export function formatErrorForMcpResource(
|
||||
error: unknown,
|
||||
uri: string,
|
||||
): {
|
||||
contents: Array<{
|
||||
uri: string;
|
||||
text: string;
|
||||
mimeType: string;
|
||||
description?: string;
|
||||
}>;
|
||||
} {
|
||||
const methodLogger = Logger.forContext(
|
||||
'utils/error.util.ts',
|
||||
'formatErrorForMcpResource',
|
||||
);
|
||||
const mcpError = ensureMcpError(error);
|
||||
methodLogger.error(`${mcpError.type} error`, mcpError);
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
text: `Error: ${mcpError.message}`,
|
||||
mimeType: 'text/plain',
|
||||
description: `Error: ${mcpError.type}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle error in CLI context with improved user feedback
|
||||
*/
|
||||
export function handleCliError(error: unknown): never {
|
||||
const methodLogger = Logger.forContext(
|
||||
'utils/error.util.ts',
|
||||
'handleCliError',
|
||||
);
|
||||
const mcpError = ensureMcpError(error);
|
||||
methodLogger.error(`${mcpError.type} error`, mcpError);
|
||||
|
||||
// Print the error message
|
||||
console.error(`Error: ${mcpError.message}`);
|
||||
|
||||
// Provide helpful context based on error type
|
||||
if (mcpError.type === ErrorType.AUTH_MISSING) {
|
||||
console.error(
|
||||
'\nTip: Make sure to set up your API token in the configuration file or environment variables.',
|
||||
);
|
||||
} else if (mcpError.type === ErrorType.AUTH_INVALID) {
|
||||
console.error(
|
||||
'\nTip: Check that your API token is correct and has not expired.',
|
||||
);
|
||||
} else if (mcpError.type === ErrorType.API_ERROR) {
|
||||
if (mcpError.statusCode === 429) {
|
||||
console.error(
|
||||
'\nTip: You may have exceeded your API rate limits. Try again later or upgrade your API plan.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Display DEBUG tip
|
||||
if (process.env.DEBUG !== 'mcp:*') {
|
||||
console.error(
|
||||
'\nFor more detailed error information, run with DEBUG=mcp:* environment variable.',
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
131
src/utils/formatter.util.ts
Normal file
131
src/utils/formatter.util.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Standardized formatting utilities for consistent output across all CLI and Tool interfaces.
|
||||
* These functions should be used by all formatters to ensure consistent formatting.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a date in a standardized way: YYYY-MM-DD HH:MM:SS UTC
|
||||
* @param dateString - ISO date string or Date object
|
||||
* @returns Formatted date string
|
||||
*/
|
||||
export function formatDate(dateString?: string | Date): string {
|
||||
if (!dateString) {
|
||||
return 'Not available';
|
||||
}
|
||||
|
||||
try {
|
||||
const date =
|
||||
typeof dateString === 'string' ? new Date(dateString) : dateString;
|
||||
|
||||
// Format: YYYY-MM-DD HH:MM:SS UTC
|
||||
return date
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace(/\.\d+Z$/, ' UTC');
|
||||
} catch {
|
||||
return 'Invalid date';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a URL as a markdown link
|
||||
* @param url - URL to format
|
||||
* @param title - Link title
|
||||
* @returns Formatted markdown link
|
||||
*/
|
||||
export function formatUrl(url?: string, title?: string): string {
|
||||
if (!url) {
|
||||
return 'Not available';
|
||||
}
|
||||
|
||||
const linkTitle = title || url;
|
||||
return `[${linkTitle}](${url})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a heading with consistent style
|
||||
* @param text - Heading text
|
||||
* @param level - Heading level (1-6)
|
||||
* @returns Formatted heading
|
||||
*/
|
||||
export function formatHeading(text: string, level: number = 1): string {
|
||||
const validLevel = Math.min(Math.max(level, 1), 6);
|
||||
const prefix = '#'.repeat(validLevel);
|
||||
return `${prefix} ${text}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a list of key-value pairs as a bullet list
|
||||
* @param items - Object with key-value pairs
|
||||
* @param keyFormatter - Optional function to format keys
|
||||
* @returns Formatted bullet list
|
||||
*/
|
||||
export function formatBulletList(
|
||||
items: Record<string, unknown>,
|
||||
keyFormatter?: (key: string) => string,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(items)) {
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const formattedKey = keyFormatter ? keyFormatter(key) : key;
|
||||
const formattedValue = formatValue(value);
|
||||
lines.push(`- **${formattedKey}**: ${formattedValue}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value based on its type
|
||||
* @param value - Value to format
|
||||
* @returns Formatted value
|
||||
*/
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return 'Not available';
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return formatDate(value);
|
||||
}
|
||||
|
||||
// Handle URL objects with url and title properties
|
||||
if (typeof value === 'object' && value !== null && 'url' in value) {
|
||||
const urlObj = value as { url: string; title?: string };
|
||||
if (typeof urlObj.url === 'string') {
|
||||
return formatUrl(urlObj.url, urlObj.title);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// Check if it's a URL
|
||||
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||
return formatUrl(value);
|
||||
}
|
||||
|
||||
// Check if it might be a date
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
|
||||
return formatDate(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a separator line
|
||||
* @returns Separator line
|
||||
*/
|
||||
export function formatSeparator(): string {
|
||||
return '---';
|
||||
}
|
||||
369
src/utils/logger.util.ts
Normal file
369
src/utils/logger.util.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Format a timestamp for logging
|
||||
* @returns Formatted timestamp [HH:MM:SS]
|
||||
*/
|
||||
function getTimestamp(): string {
|
||||
const now = new Date();
|
||||
return `[${now.toISOString().split('T')[1].split('.')[0]}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely convert object to string with size limits
|
||||
* @param obj Object to stringify
|
||||
* @param maxLength Maximum length of the resulting string
|
||||
* @returns Safely stringified object
|
||||
*/
|
||||
function safeStringify(obj: unknown, maxLength = 1000): string {
|
||||
try {
|
||||
const str = JSON.stringify(obj);
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
return `${str.substring(0, maxLength)}... (truncated, ${str.length} chars total)`;
|
||||
} catch {
|
||||
return '[Object cannot be stringified]';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract essential values from larger objects for logging
|
||||
* @param obj The object to extract values from
|
||||
* @param keys Keys to extract (if available)
|
||||
* @returns Object containing only the specified keys
|
||||
*/
|
||||
function extractEssentialValues(
|
||||
obj: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
keys.forEach((key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
result[key] = obj[key];
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format source path consistently using the standardized format:
|
||||
* [module/file.ts@function] or [module/file.ts]
|
||||
*
|
||||
* @param filePath File path (with or without src/ prefix)
|
||||
* @param functionName Optional function name
|
||||
* @returns Formatted source path according to standard pattern
|
||||
*/
|
||||
function formatSourcePath(filePath: string, functionName?: string): string {
|
||||
// Always strip 'src/' prefix for consistency
|
||||
const normalizedPath = filePath.replace(/^src\//, '');
|
||||
|
||||
return functionName
|
||||
? `[${normalizedPath}@${functionName}]`
|
||||
: `[${normalizedPath}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if debug logging is enabled for a specific module
|
||||
*
|
||||
* This function parses the DEBUG environment variable to determine if a specific
|
||||
* module should have debug logging enabled. The DEBUG variable can be:
|
||||
* - 'true' or '1': Enable all debug logging
|
||||
* - Comma-separated list of modules: Enable debug only for those modules
|
||||
* - Module patterns with wildcards: e.g., 'controllers/*' enables all controllers
|
||||
*
|
||||
* Examples:
|
||||
* - DEBUG=true
|
||||
* - DEBUG=controllers/*,services/aws.sso.auth.service.ts
|
||||
* - DEBUG=transport,utils/formatter*
|
||||
*
|
||||
* @param modulePath The module path to check against DEBUG patterns
|
||||
* @returns true if debug is enabled for this module, false otherwise
|
||||
*/
|
||||
function isDebugEnabledForModule(modulePath: string): boolean {
|
||||
const debugEnv = process.env.DEBUG;
|
||||
|
||||
if (!debugEnv) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If debug is set to true or 1, enable all debug logging
|
||||
if (debugEnv === 'true' || debugEnv === '1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse comma-separated debug patterns
|
||||
const debugPatterns = debugEnv.split(',').map((p) => p.trim());
|
||||
|
||||
// Check if the module matches any pattern
|
||||
return debugPatterns.some((pattern) => {
|
||||
// Convert glob-like patterns to regex
|
||||
// * matches anything within a path segment
|
||||
// ** matches across path segments
|
||||
const regexPattern = pattern
|
||||
.replace(/\*/g, '.*') // Convert * to regex .*
|
||||
.replace(/\?/g, '.'); // Convert ? to regex .
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
return (
|
||||
regex.test(modulePath) ||
|
||||
// Check for pattern matches without the 'src/' prefix
|
||||
regex.test(modulePath.replace(/^src\//, ''))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a unique session ID for this process
|
||||
const SESSION_ID = crypto.randomUUID();
|
||||
|
||||
// Get the package name from environment variables or default to 'mcp-server'
|
||||
const getPkgName = (): string => {
|
||||
try {
|
||||
// Try to get it from package.json first if available
|
||||
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(packageJsonPath, 'utf8'),
|
||||
);
|
||||
if (packageJson.name) {
|
||||
// Extract the last part of the name if it's scoped
|
||||
const match = packageJson.name.match(/(@[\w-]+\/)?(.+)/);
|
||||
return match ? match[2] : packageJson.name;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail and use default
|
||||
}
|
||||
|
||||
// Fallback to environment variable or default
|
||||
return process.env.PACKAGE_NAME || 'mcp-server';
|
||||
};
|
||||
|
||||
// MCP logs directory setup
|
||||
const HOME_DIR = os.homedir();
|
||||
const MCP_DATA_DIR = path.join(HOME_DIR, '.mcp', 'data');
|
||||
const CLI_NAME = getPkgName();
|
||||
|
||||
// Ensure the MCP data directory exists
|
||||
if (!fs.existsSync(MCP_DATA_DIR)) {
|
||||
fs.mkdirSync(MCP_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Create the log file path with session ID
|
||||
const LOG_FILENAME = `${CLI_NAME}.${SESSION_ID}.log`;
|
||||
const LOG_FILEPATH = path.join(MCP_DATA_DIR, LOG_FILENAME);
|
||||
|
||||
// Write initial log header
|
||||
fs.writeFileSync(
|
||||
LOG_FILEPATH,
|
||||
`# ${CLI_NAME} Log Session\n` +
|
||||
`Session ID: ${SESSION_ID}\n` +
|
||||
`Started: ${new Date().toISOString()}\n` +
|
||||
`Process ID: ${process.pid}\n` +
|
||||
`Working Directory: ${process.cwd()}\n` +
|
||||
`Command: ${process.argv.join(' ')}\n\n` +
|
||||
`## Log Entries\n\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Logger singleton to track initialization
|
||||
let isLoggerInitialized = false;
|
||||
|
||||
/**
|
||||
* Logger class for consistent logging across the application.
|
||||
*
|
||||
* RECOMMENDED USAGE:
|
||||
*
|
||||
* 1. Create a file-level logger using the static forContext method:
|
||||
* ```
|
||||
* const logger = Logger.forContext('controllers/myController.ts');
|
||||
* ```
|
||||
*
|
||||
* 2. For method-specific logging, create a method logger:
|
||||
* ```
|
||||
* const methodLogger = Logger.forContext('controllers/myController.ts', 'myMethod');
|
||||
* ```
|
||||
*
|
||||
* 3. Avoid using raw string prefixes in log messages. Instead, use contextualized loggers.
|
||||
*
|
||||
* 4. For debugging objects, use the debugResponse method to log only essential properties.
|
||||
*
|
||||
* 5. Set DEBUG environment variable to control which modules show debug logs:
|
||||
* - DEBUG=true (enable all debug logs)
|
||||
* - DEBUG=controllers/*,services/* (enable for specific module groups)
|
||||
* - DEBUG=transport,utils/formatter* (enable specific modules, supports wildcards)
|
||||
*/
|
||||
class Logger {
|
||||
private context?: string;
|
||||
private modulePath: string;
|
||||
private static sessionId = SESSION_ID;
|
||||
private static logFilePath = LOG_FILEPATH;
|
||||
|
||||
constructor(context?: string, modulePath: string = '') {
|
||||
this.context = context;
|
||||
this.modulePath = modulePath;
|
||||
|
||||
// Log initialization message only once
|
||||
if (!isLoggerInitialized) {
|
||||
this.info(
|
||||
`Logger initialized with session ID: ${Logger.sessionId}`,
|
||||
);
|
||||
this.info(`Logs will be saved to: ${Logger.logFilePath}`);
|
||||
isLoggerInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a contextualized logger for a specific file or component.
|
||||
* This is the preferred method for creating loggers.
|
||||
*
|
||||
* @param filePath The file path (e.g., 'controllers/aws.sso.auth.controller.ts')
|
||||
* @param functionName Optional function name for more specific context
|
||||
* @returns A new Logger instance with the specified context
|
||||
*
|
||||
* @example
|
||||
* // File-level logger
|
||||
* const logger = Logger.forContext('controllers/myController.ts');
|
||||
*
|
||||
* // Method-level logger
|
||||
* const methodLogger = Logger.forContext('controllers/myController.ts', 'myMethod');
|
||||
*/
|
||||
static forContext(filePath: string, functionName?: string): Logger {
|
||||
return new Logger(formatSourcePath(filePath, functionName), filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a method level logger from a context logger
|
||||
* @param method Method name
|
||||
* @returns A new logger with the method context
|
||||
*/
|
||||
forMethod(method: string): Logger {
|
||||
return Logger.forContext(this.modulePath, method);
|
||||
}
|
||||
|
||||
private _formatMessage(message: string): string {
|
||||
return this.context ? `${this.context} ${message}` : message;
|
||||
}
|
||||
|
||||
private _formatArgs(args: unknown[]): unknown[] {
|
||||
// If the first argument is an object and not an Error, safely stringify it
|
||||
if (
|
||||
args.length > 0 &&
|
||||
typeof args[0] === 'object' &&
|
||||
args[0] !== null &&
|
||||
!(args[0] instanceof Error)
|
||||
) {
|
||||
args[0] = safeStringify(args[0]);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
_log(
|
||||
level: 'info' | 'warn' | 'error' | 'debug',
|
||||
message: string,
|
||||
...args: unknown[]
|
||||
) {
|
||||
// Skip debug messages if not enabled for this module
|
||||
if (level === 'debug' && !isDebugEnabledForModule(this.modulePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = getTimestamp();
|
||||
const prefix = `${timestamp} [${level.toUpperCase()}]`;
|
||||
let logMessage = `${prefix} ${this._formatMessage(message)}`;
|
||||
|
||||
const formattedArgs = this._formatArgs(args);
|
||||
if (formattedArgs.length > 0) {
|
||||
// Handle errors specifically
|
||||
if (formattedArgs[0] instanceof Error) {
|
||||
const error = formattedArgs[0] as Error;
|
||||
logMessage += ` Error: ${error.message}`;
|
||||
if (error.stack) {
|
||||
logMessage += `\n${error.stack}`;
|
||||
}
|
||||
// If there are more args, add them after the error
|
||||
if (formattedArgs.length > 1) {
|
||||
logMessage += ` ${formattedArgs
|
||||
.slice(1)
|
||||
.map((arg) =>
|
||||
typeof arg === 'string' ? arg : safeStringify(arg),
|
||||
)
|
||||
.join(' ')}`;
|
||||
}
|
||||
} else {
|
||||
logMessage += ` ${formattedArgs
|
||||
.map((arg) =>
|
||||
typeof arg === 'string' ? arg : safeStringify(arg),
|
||||
)
|
||||
.join(' ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Write to log file
|
||||
try {
|
||||
fs.appendFileSync(Logger.logFilePath, `${logMessage}\n`, 'utf8');
|
||||
} catch (err) {
|
||||
// If we can't write to the log file, log the error to console
|
||||
console.error(`Failed to write to log file: ${err}`);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
console[level](logMessage);
|
||||
} else {
|
||||
console.error(logMessage);
|
||||
}
|
||||
}
|
||||
|
||||
info(message: string, ...args: unknown[]) {
|
||||
this._log('info', message, ...args);
|
||||
}
|
||||
|
||||
warn(message: string, ...args: unknown[]) {
|
||||
this._log('warn', message, ...args);
|
||||
}
|
||||
|
||||
error(message: string, ...args: unknown[]) {
|
||||
this._log('error', message, ...args);
|
||||
}
|
||||
|
||||
debug(message: string, ...args: unknown[]) {
|
||||
this._log('debug', message, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log essential information about an API response
|
||||
* @param message Log message
|
||||
* @param response API response object
|
||||
* @param essentialKeys Keys to extract from the response
|
||||
*/
|
||||
debugResponse(
|
||||
message: string,
|
||||
response: Record<string, unknown>,
|
||||
essentialKeys: string[],
|
||||
) {
|
||||
const essentialInfo = extractEssentialValues(response, essentialKeys);
|
||||
this.debug(message, essentialInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session ID
|
||||
* @returns The UUID for the current logging session
|
||||
*/
|
||||
static getSessionId(): string {
|
||||
return Logger.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current log file path
|
||||
* @returns The path to the current log file
|
||||
*/
|
||||
static getLogFilePath(): string {
|
||||
return Logger.logFilePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Only export the Logger class to enforce contextual logging via Logger.forContext
|
||||
export { Logger };
|
||||
143
src/utils/transport.util.ts
Normal file
143
src/utils/transport.util.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Logger } from './logger.util.js';
|
||||
import {
|
||||
createApiError,
|
||||
createAuthInvalidError,
|
||||
createUnexpectedError,
|
||||
McpError,
|
||||
} from './error.util.js';
|
||||
|
||||
// Create a contextualized logger for this file
|
||||
const transportLogger = Logger.forContext('utils/transport.util.ts');
|
||||
|
||||
// Log transport utility initialization
|
||||
transportLogger.debug('Transport utility initialized');
|
||||
|
||||
/**
|
||||
* Interface for HTTP request options
|
||||
*/
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic and reusable function to fetch data from any API endpoint.
|
||||
* Handles standard HTTP request setup, response checking, basic error handling, and logging.
|
||||
*
|
||||
* @param url The full URL to fetch data from.
|
||||
* @param options Request options including method, headers, and body.
|
||||
* @returns The response data parsed as type T.
|
||||
* @throws {McpError} If the request fails, including network errors, non-OK HTTP status, or JSON parsing issues.
|
||||
*/
|
||||
export async function fetchApi<T>(
|
||||
url: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const methodLogger = Logger.forContext(
|
||||
'utils/transport.util.ts',
|
||||
'fetchApi',
|
||||
);
|
||||
|
||||
// Prepare standard request options
|
||||
const requestOptions: RequestInit = {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
// Standard headers, allow overrides via options.headers
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
};
|
||||
|
||||
methodLogger.debug(`Executing API call: ${requestOptions.method} ${url}`);
|
||||
const startTime = performance.now(); // Track performance
|
||||
|
||||
try {
|
||||
const response = await fetch(url, requestOptions);
|
||||
const endTime = performance.now();
|
||||
const duration = (endTime - startTime).toFixed(2);
|
||||
|
||||
methodLogger.debug(
|
||||
`API call completed in ${duration}ms with status: ${response.status} ${response.statusText}`,
|
||||
{ url, status: response.status },
|
||||
);
|
||||
|
||||
// Check if the response status is OK (2xx)
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text(); // Get error body for context
|
||||
methodLogger.error(
|
||||
`API error response (${response.status}):`,
|
||||
errorText,
|
||||
);
|
||||
|
||||
// Classify standard HTTP errors
|
||||
if (response.status === 401) {
|
||||
throw createAuthInvalidError(
|
||||
'Authentication failed. Check API token if required.',
|
||||
);
|
||||
} else if (response.status === 403) {
|
||||
throw createAuthInvalidError(
|
||||
'Permission denied for the requested resource.',
|
||||
);
|
||||
} else if (response.status === 404) {
|
||||
throw createApiError(
|
||||
'Resource not found at the specified URL.',
|
||||
response.status,
|
||||
errorText,
|
||||
);
|
||||
} else {
|
||||
// Generic API error for other non-2xx statuses
|
||||
throw createApiError(
|
||||
`API request failed with status ${response.status}: ${response.statusText}`,
|
||||
response.status,
|
||||
errorText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to parse the response body as JSON
|
||||
try {
|
||||
const responseData = await response.json();
|
||||
methodLogger.debug('Response body successfully parsed as JSON.');
|
||||
// methodLogger.debug('Response Data:', responseData); // Uncomment for full response logging
|
||||
return responseData as T;
|
||||
} catch (parseError) {
|
||||
methodLogger.error(
|
||||
'Failed to parse API response JSON:',
|
||||
parseError,
|
||||
);
|
||||
// Throw a specific error for JSON parsing failure
|
||||
throw createApiError(
|
||||
`Failed to parse API response JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
|
||||
response.status, // Include original status for context
|
||||
parseError,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
const duration = (endTime - startTime).toFixed(2);
|
||||
methodLogger.error(
|
||||
`API call failed after ${duration}ms for ${url}:`,
|
||||
error,
|
||||
);
|
||||
|
||||
// Rethrow if it's already an McpError (e.g., from status checks or parsing)
|
||||
if (error instanceof McpError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle potential network errors (TypeError in fetch)
|
||||
if (error instanceof TypeError) {
|
||||
throw createApiError(
|
||||
`Network error during API call: ${error.message}`,
|
||||
undefined, // No specific HTTP status for network errors
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap any other unexpected errors
|
||||
throw createUnexpectedError('Unexpected error during API call', error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user