Refactor config loading (completed)
This commit is contained in:
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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('<eventName>', 'The name of the event to capture.')
|
|
||||||
.argument('<distinctId>', 'The distinct ID of the user.')
|
|
||||||
.option(
|
|
||||||
'-p, --properties <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 };
|
|
||||||
10
src/index.ts
10
src/index.ts
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { Logger } from './utils/logger.util.js';
|
import { Logger } from './utils/logger.util.js';
|
||||||
import configLoader from './config/config-loader.js';
|
import configLoader from './config/config-loader.js';
|
||||||
import { runCli } from './cli/index.js';
|
|
||||||
import { stdioTransport } from './server/stdio.js';
|
import { stdioTransport } from './server/stdio.js';
|
||||||
import { streamableHttpTransport } from './server/streamableHttp.js';
|
import { streamableHttpTransport } from './server/streamableHttp.js';
|
||||||
|
|
||||||
@@ -43,18 +42,9 @@ export async function startServer(): Promise<void> {
|
|||||||
* Main entry point
|
* Main entry point
|
||||||
*/
|
*/
|
||||||
async function main() {
|
async function main() {
|
||||||
const mainLogger = Logger.forContext('index.ts', 'main');
|
|
||||||
|
|
||||||
// Load configuration
|
// Load configuration
|
||||||
configLoader.load();
|
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
|
// Server mode - determine transport and start server
|
||||||
await startServer();
|
await startServer();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown> = {};
|
|
||||||
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 };
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
|
||||||
import { Logger } from '../utils/logger.util';
|
import { Logger } from '../utils/logger.util';
|
||||||
import configLoader from '../config/config-loader';
|
import configLoader from '../config/config-loader';
|
||||||
import { PACKAGE_NAME, VERSION } from '../utils/constants.util';
|
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 posthogLlmTools from '../tools/posthog-llm.tool.js';
|
||||||
import openTelemetryTools from '../tools/opentelemetry-llm.tool.js';
|
import openTelemetryTools from '../tools/opentelemetry-llm.tool.js';
|
||||||
|
|
||||||
@@ -27,12 +25,11 @@ export function createServer() {
|
|||||||
version: VERSION,
|
version: VERSION,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register tools and resources
|
// Register tools
|
||||||
serverLogger.info('Registering MCP tools and resources...');
|
serverLogger.info('Registering MCP tools...');
|
||||||
posthogLlmTools.registerTools(server);
|
posthogLlmTools.registerTools(server);
|
||||||
posthogLlmResources.registerResources(server);
|
|
||||||
openTelemetryTools.registerTools(server);
|
openTelemetryTools.registerTools(server);
|
||||||
serverLogger.debug('All tools and resources registered');
|
serverLogger.debug('All tools registered');
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<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);
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,35 @@
|
|||||||
import {
|
import {
|
||||||
McpError,
|
McpError,
|
||||||
ErrorType,
|
ErrorType,
|
||||||
createApiError,
|
|
||||||
createUnexpectedError,
|
|
||||||
getDeepOriginalError,
|
getDeepOriginalError,
|
||||||
formatErrorForMcpTool,
|
formatErrorForMcpTool,
|
||||||
|
createUnexpectedError,
|
||||||
|
ensureMcpError,
|
||||||
} from './error.util.js';
|
} from './error.util.js';
|
||||||
|
|
||||||
describe('error.util', () => {
|
describe('error.util', () => {
|
||||||
describe('getDeepOriginalError', () => {
|
describe('McpError', () => {
|
||||||
it('should return the deepest original error in a chain', () => {
|
it('should create an error with the correct properties', () => {
|
||||||
// Create a nested chain of errors
|
const error = new McpError('Test error', ErrorType.API_ERROR, 404);
|
||||||
const deepestError = new Error('Root cause');
|
|
||||||
const middleError = createApiError(
|
|
||||||
'Middle error',
|
|
||||||
500,
|
|
||||||
deepestError,
|
|
||||||
);
|
|
||||||
const topError = createUnexpectedError('Top error', middleError);
|
|
||||||
|
|
||||||
// Should extract the deepest error
|
expect(error).toBeInstanceOf(Error);
|
||||||
const result = getDeepOriginalError(topError);
|
expect(error).toBeInstanceOf(McpError);
|
||||||
expect(result).toBe(deepestError);
|
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', () => {
|
it('should handle null/undefined input', () => {
|
||||||
expect(getDeepOriginalError(null)).toBeNull();
|
expect(getDeepOriginalError(null)).toBeNull();
|
||||||
expect(getDeepOriginalError(undefined)).toBeUndefined();
|
expect(getDeepOriginalError(undefined)).toBeUndefined();
|
||||||
@@ -57,30 +63,37 @@ describe('error.util', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatErrorForMcpTool', () => {
|
describe('ensureMcpError', () => {
|
||||||
it('should format McpError with metadata', () => {
|
it('should wrap a standard Error', () => {
|
||||||
const error = createApiError('Test error', 404, {
|
const originalError = new Error('Standard error');
|
||||||
detail: 'Not found',
|
const error = ensureMcpError(originalError);
|
||||||
});
|
|
||||||
const result = formatErrorForMcpTool(error);
|
|
||||||
|
|
||||||
// Check the content
|
expect(error).toBeInstanceOf(McpError);
|
||||||
expect(result.content).toEqual([
|
expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR);
|
||||||
{
|
expect(error.message).toBe('Standard error');
|
||||||
type: 'text',
|
expect(error.originalError).toBe(originalError);
|
||||||
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 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', () => {
|
it('should wrap non-McpError with metadata', () => {
|
||||||
const error = new Error('Regular error');
|
const error = new Error('Regular error');
|
||||||
const result = formatErrorForMcpTool(error);
|
const result = formatErrorForMcpTool(error);
|
||||||
@@ -101,15 +114,5 @@ describe('error.util', () => {
|
|||||||
expect(result.content[0].text).toBe('Error: String error');
|
expect(result.content[0].text).toBe('Error: String error');
|
||||||
expect(result.metadata?.errorType).toBe(ErrorType.UNEXPECTED_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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
* 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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<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 '---';
|
|
||||||
}
|
|
||||||
@@ -194,7 +194,7 @@ let isLoggerInitialized = false;
|
|||||||
* 5. Set DEBUG environment variable to control which modules show debug logs:
|
* 5. Set DEBUG environment variable to control which modules show debug logs:
|
||||||
* - DEBUG=true (enable all debug logs)
|
* - DEBUG=true (enable all debug logs)
|
||||||
* - DEBUG=controllers/*,services/* (enable for specific module groups)
|
* - 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 {
|
class Logger {
|
||||||
private context?: string;
|
private context?: string;
|
||||||
|
|||||||
@@ -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<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