diff --git a/src/cli/index.ts b/src/cli/index.ts deleted file mode 100644 index 334e34b..0000000 --- a/src/cli/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Command } from 'commander'; -import { Logger } from '../utils/logger.util.js'; -import { VERSION, CLI_NAME } from '../utils/constants.util.js'; - -import posthogLlmCli from './posthog-llm.cli.js'; - -/** - * CLI entry point for the LLM Log MCP Server - * - * This file registers all CLI commands and handles command line parsing - */ - -// Package description -const DESCRIPTION = - 'A LLM Log Model Context Protocol (MCP) server implementation using TypeScript'; - -/** - * Run the CLI with the provided arguments - * - * @param args Command line arguments to process - * @returns Promise that resolves when CLI command execution completes - */ -export async function runCli(args: string[]) { - const cliLogger = Logger.forContext('cli/index.ts', 'runCli'); - cliLogger.debug('Initializing CLI with arguments', args); - - const program = new Command(); - - program.name(CLI_NAME).description(DESCRIPTION).version(VERSION); - - // Register CLI commands - cliLogger.debug('Registering CLI commands...'); - posthogLlmCli.register(program); - cliLogger.debug('CLI commands registered successfully'); - - // Handle unknown commands - program.on('command:*', (operands) => { - cliLogger.error(`Unknown command: ${operands[0]}`); - console.log(''); - program.help(); - process.exit(1); - }); - - // Parse arguments; default to help if no command provided - cliLogger.debug('Parsing CLI arguments'); - - // Special handling for top-level commands - if (args.length === 1) { - // Check if it's a known top-level command - const command = program.commands.find((cmd) => cmd.name() === args[0]); - if (command) { - command.outputHelp(); - process.exit(0); - } - } - await program.parseAsync(args.length ? args : ['--help'], { from: 'user' }); - cliLogger.debug('CLI command execution completed'); -} diff --git a/src/cli/posthog-llm.cli.ts b/src/cli/posthog-llm.cli.ts deleted file mode 100644 index 3375b6c..0000000 --- a/src/cli/posthog-llm.cli.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Command } from 'commander'; -import { Logger } from '../utils/logger.util.js'; -import { handleCliError } from '../utils/error.util.js'; -//import { PostHogController } from '../controllers/posthog-llm.controller.js'; - -const logger = Logger.forContext('cli/posthog-llm.cli.ts'); - -/** - * Register PostHog LLM CLI commands - * @param program The Commander program instance - */ -function register(program: Command) { - const methodLogger = logger.forMethod('register'); - methodLogger.debug('Registering PostHog LLM CLI commands...'); - - program - .command('llm-observability-mcp') - .description('Interact with the PostHog LLM API.') - .argument('', 'The name of the event to capture.') - .argument('', 'The distinct ID of the user.') - .option( - '-p, --properties ', - 'JSON string of event properties. Can be a single quoted string or raw key-value pairs.', - ) - .action(async (eventName, distinctId, options) => { - const actionLogger = logger.forMethod('action:capture'); - try { - actionLogger.debug(`CLI posthog-llm capture called`, { - eventName, - distinctId, - options, - }); - - // Handle JSON properties from command object - let properties = {}; - if (options.properties && options.properties.length > 0) { - const propertiesString = options.properties.join(' '); - try { - properties = JSON.parse(propertiesString); - } catch { - // Try to fix common issues with JSON formatting - const fixedJson = propertiesString - .replace(/(\w+):/g, '"$1":') // Add quotes around keys - .replace(/'/g, '"'); // Replace single quotes with double quotes - - properties = JSON.parse(fixedJson); - } - } - - const args = { eventName, distinctId, properties }; - actionLogger.debug(`CLI posthog-llm capture args`, args); - // TODO: Implement the controller capture with correct properties - //await PostHogController.capture(args); - } catch (error) { - handleCliError(error); - } - }); - - methodLogger.debug('PostHog LLM CLI commands registered successfully'); -} - -export default { register }; diff --git a/src/index.ts b/src/index.ts index c51d3de..d480005 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import { Logger } from './utils/logger.util.js'; import configLoader from './config/config-loader.js'; -import { runCli } from './cli/index.js'; import { stdioTransport } from './server/stdio.js'; import { streamableHttpTransport } from './server/streamableHttp.js'; @@ -43,18 +42,9 @@ export async function startServer(): Promise { * Main entry point */ async function main() { - const mainLogger = Logger.forContext('index.ts', 'main'); - // Load configuration configLoader.load(); - // CLI mode - if any arguments are provided - if (process.argv.length > 2) { - mainLogger.info('CLI mode detected'); - await runCli(process.argv.slice(2)); - return; - } - // Server mode - determine transport and start server await startServer(); } diff --git a/src/resources/posthog-llm.resource.ts b/src/resources/posthog-llm.resource.ts deleted file mode 100644 index 7d5c5d7..0000000 --- a/src/resources/posthog-llm.resource.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { Logger } from '../utils/logger.util.js'; -import { PostHogController } from '../controllers/posthog-llm.controller.js'; -import { formatErrorForMcpResource } from '../utils/error.util.js'; - -const logger = Logger.forContext('resources/posthog-llm.resource.ts'); - -/** - * Register PostHog LLM resources with the MCP server - * - * @param server The MCP server instance - */ -function registerResources(server: McpServer) { - const registerLogger = logger.forMethod('registerResources'); - registerLogger.debug('Registering PostHog LLM resources...'); - - // Register the PostHog event capture resource - server.resource( - 'capture_llm_observability', - 'Capture a LLM event in PostHog', - async (uri: URL) => { - const methodLogger = logger.forMethod('posthogEventResource'); - try { - methodLogger.debug('PostHog event resource called', { - uri: uri.toString(), - }); - - // Extract parameters from the URI - // Format: posthog://event_name/distinct_id?properties=JSON - const pathParts = uri.pathname.split('/').filter(Boolean); - const eventName = pathParts[0] || 'event'; - const distinctId = pathParts[1] || 'anonymous'; - - // Parse properties from query parameters if present - let properties: Record = {}; - if (uri.searchParams.has('properties')) { - try { - properties = JSON.parse( - uri.searchParams.get('properties') || '{}', - ); - } catch (e) { - methodLogger.warn('Failed to parse properties JSON', e); - } - } - - // Call the controller to capture the event - // TODO: Implement the controller capture with correct properties - const result = await PostHogController.capture({ - eventName, - distinctId, - properties, - }); - - // Return the content as a text resource - return { - contents: [ - { - uri: uri.toString(), - text: result.content, - mimeType: 'text/plain', - description: `PostHog event: ${eventName}`, - }, - ], - }; - } catch (error) { - methodLogger.error('Resource error', error); - return formatErrorForMcpResource(error, uri.toString()); - } - }, - ); - - registerLogger.debug('PostHog LLM resources registered successfully'); -} - -export default { registerResources }; diff --git a/src/server/mcpServer.ts b/src/server/mcpServer.ts index b871540..7948987 100644 --- a/src/server/mcpServer.ts +++ b/src/server/mcpServer.ts @@ -1,11 +1,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; - import { Logger } from '../utils/logger.util'; import configLoader from '../config/config-loader'; import { PACKAGE_NAME, VERSION } from '../utils/constants.util'; -import posthogLlmResources from '../resources/posthog-llm.resource.js'; import posthogLlmTools from '../tools/posthog-llm.tool.js'; import openTelemetryTools from '../tools/opentelemetry-llm.tool.js'; @@ -27,12 +25,11 @@ export function createServer() { version: VERSION, }); - // Register tools and resources - serverLogger.info('Registering MCP tools and resources...'); + // Register tools + serverLogger.info('Registering MCP tools...'); posthogLlmTools.registerTools(server); - posthogLlmResources.registerResources(server); openTelemetryTools.registerTools(server); - serverLogger.debug('All tools and resources registered'); + serverLogger.debug('All tools registered'); return server; } diff --git a/src/utils/cli.test.util.ts b/src/utils/cli.test.util.ts deleted file mode 100644 index cb566db..0000000 --- a/src/utils/cli.test.util.ts +++ /dev/null @@ -1,157 +0,0 @@ -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; - } = {}, - ): 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); - } -} diff --git a/src/utils/config.util.test.ts b/src/utils/config.util.test.ts deleted file mode 100644 index 8e70482..0000000 --- a/src/utils/config.util.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -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', - ); - }); - }); -}); diff --git a/src/utils/error-handler.util.test.ts b/src/utils/error-handler.util.test.ts deleted file mode 100644 index 8ffd4cc..0000000 --- a/src/utils/error-handler.util.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -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' }, - }); - }); - }); -}); diff --git a/src/utils/error-handler.util.ts b/src/utils/error-handler.util.ts deleted file mode 100644 index 51bed29..0000000 --- a/src/utils/error-handler.util.ts +++ /dev/null @@ -1,307 +0,0 @@ -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; - - /** - * Operation being performed (e.g., 'retrieving', 'searching') - */ - operation?: string; - - /** - * Additional information for debugging - */ - additionalInfo?: Record; -} - -/** - * 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, - additionalInfo?: Record, -): 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); -} diff --git a/src/utils/error.util.test.ts b/src/utils/error.util.test.ts index 86e3635..36b55c3 100644 --- a/src/utils/error.util.test.ts +++ b/src/utils/error.util.test.ts @@ -1,29 +1,35 @@ import { McpError, ErrorType, - createApiError, - createUnexpectedError, getDeepOriginalError, formatErrorForMcpTool, + createUnexpectedError, + ensureMcpError, } 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); + describe('McpError', () => { + it('should create an error with the correct properties', () => { + const error = new McpError('Test error', ErrorType.API_ERROR, 404); - // Should extract the deepest error - const result = getDeepOriginalError(topError); - expect(result).toBe(deepestError); + 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('createUnexpectedError', () => { + 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('getDeepOriginalError', () => { it('should handle null/undefined input', () => { expect(getDeepOriginalError(null)).toBeNull(); expect(getDeepOriginalError(undefined)).toBeUndefined(); @@ -57,30 +63,37 @@ describe('error.util', () => { }); }); - describe('formatErrorForMcpTool', () => { - it('should format McpError with metadata', () => { - const error = createApiError('Test error', 404, { - detail: 'Not found', - }); - const result = formatErrorForMcpTool(error); + describe('ensureMcpError', () => { + it('should wrap a standard Error', () => { + const originalError = new Error('Standard error'); + const error = ensureMcpError(originalError); - // 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', - }); + 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'); + }); + + it('should return the same McpError instance if passed in', () => { + const mcpError = new McpError( + 'Already MCP', + ErrorType.API_ERROR, + 400, + ); + const result = ensureMcpError(mcpError); + expect(result).toBe(mcpError); + }); + }); + + describe('formatErrorForMcpTool', () => { it('should wrap non-McpError with metadata', () => { const error = new Error('Regular error'); const result = formatErrorForMcpTool(error); @@ -101,15 +114,5 @@ describe('error.util', () => { 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); - }); }); }); diff --git a/src/utils/error.util.ts b/src/utils/error.util.ts index 1652106..ecb9f60 100644 --- a/src/utils/error.util.ts +++ b/src/utils/error.util.ts @@ -32,40 +32,6 @@ export class McpError extends Error { } } -/** - * 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 */ @@ -164,77 +130,3 @@ export function formatErrorForMcpTool(error: unknown): { }, }; } - -/** - * 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); -} diff --git a/src/utils/formatter.util.ts b/src/utils/formatter.util.ts deleted file mode 100644 index 1d8147b..0000000 --- a/src/utils/formatter.util.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * 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, - 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 '---'; -} diff --git a/src/utils/logger.util.ts b/src/utils/logger.util.ts index d082755..851aa89 100644 --- a/src/utils/logger.util.ts +++ b/src/utils/logger.util.ts @@ -194,7 +194,7 @@ let isLoggerInitialized = false; * 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) + * - DEBUG=transport,utils/error* (enable specific modules, supports wildcards) */ class Logger { private context?: string; diff --git a/src/utils/transport.util.ts b/src/utils/transport.util.ts deleted file mode 100644 index 049ed59..0000000 --- a/src/utils/transport.util.ts +++ /dev/null @@ -1,143 +0,0 @@ -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; - 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( - url: string, - options: RequestOptions = {}, -): Promise { - 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); - } -}