Implementation of MCP for LLM Observability capture to PostHig
Some checks failed
CI - Semantic Release / Semantic Release (push) Failing after 7m48s

This commit is contained in:
2025-07-13 20:42:19 -05:00
commit 05af3880f6
45 changed files with 16894 additions and 0 deletions

58
src/cli/index.ts Normal file
View File

@@ -0,0 +1,58 @@
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');
}

View File

@@ -0,0 +1,60 @@
import { Command } from 'commander';
import { Logger } from '../utils/logger.util.js';
import { handleCliError } from '../utils/error.util.js';
import postHogLlmController 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 };
await postHogLlmController.capture(args);
} catch (error) {
handleCliError(error);
}
});
methodLogger.debug('PostHog LLM CLI commands registered successfully');
}
export default { register };

View File

@@ -0,0 +1,32 @@
import { Logger } from '../utils/logger.util.js';
import { ControllerResponse } from '../types/common.types.js';
import postHogLlmService from '../services/posthog-llm.service.js';
const logger = Logger.forContext('controllers/posthog-llm.controller.ts');
async function capture(args: {
eventName: string;
distinctId: string;
properties: Record<string, unknown>;
}): Promise<ControllerResponse> {
const methodLogger = logger.forMethod('capture');
methodLogger.debug('Capturing PostHog event...');
methodLogger.debug('Arguments:', args);
try {
await postHogLlmService.capture(args);
return {
content: 'Event captured successfully.',
};
} catch (error) {
methodLogger.error('Error capturing event:', error);
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
methodLogger.error('Error capturing event:', errorMessage);
return {
content: `Failed to capture event: ${errorMessage}`,
};
}
}
export default { capture };

68
src/index.ts Normal file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
import { Logger } from './utils/logger.util.js';
import { config } from './utils/config.util.js';
import { runCli } from './cli/index.js';
import { stdioTransport } from './server/stdio.js';
import { streamableHttpTransport } from './server/streamableHttp.js';
const logger = Logger.forContext('index.ts');
/**
* Start the MCP server with the specified transport mode
*/
export async function startServer(): Promise<void> {
const mainLogger = Logger.forContext('index.ts', 'startServer');
// Define available transport modes and their handlers
const transportModes = {
stdio: { handler: stdioTransport, name: 'stdio' },
http: { handler: streamableHttpTransport, name: 'http' },
};
// Get requested transport mode (default to stdio)
const requestedMode = (process.env.TRANSPORT_MODE || 'stdio').toLowerCase();
const transport =
transportModes[requestedMode as keyof typeof transportModes] ||
transportModes.stdio;
// Warn if requested mode is invalid
if (!transportModes[requestedMode as keyof typeof transportModes]) {
mainLogger.warn(
`Unknown TRANSPORT_MODE "${requestedMode}", defaulting to stdio`,
);
}
// Start the selected transport
mainLogger.info(
`Starting server with ${transport.name.toUpperCase()} transport`,
);
transport.handler();
}
/**
* Main entry point
*/
async function main() {
const mainLogger = Logger.forContext('index.ts', 'main');
// Load configuration
config.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();
}
// Run main if executed directly
if (require.main === module) {
main().catch((err) => {
logger.error('Unhandled error in main process', err);
process.exit(1);
});
}

View File

@@ -0,0 +1,74 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Logger } from '../utils/logger.util.js';
import posthogLlmController 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
const result = await posthogLlmController.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 };

90
src/server/mcpServer.ts Normal file
View File

@@ -0,0 +1,90 @@
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 { config } from '../utils/config.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';
export function createServer() {
const serverLogger = Logger.forContext('utils/server.util.ts', 'getServer');
// Load configuration
serverLogger.info('Starting MCP server initialization...');
config.load();
if (config.getBoolean('DEBUG')) {
serverLogger.debug('Debug mode enabled');
}
serverLogger.info(`Initializing LLM Log MCP server v${VERSION}`);
const server = new McpServer({
name: PACKAGE_NAME,
version: VERSION,
});
// Register tools and resources
serverLogger.info('Registering MCP tools and resources...');
posthogLlmTools.registerTools(server);
posthogLlmResources.registerResources(server);
serverLogger.debug('All tools and resources registered');
return server;
}
/**
* Graceful shutdown handler
*/
export function shutdownServer(
transports:
| Record<string, SSEServerTransport | StreamableHTTPServerTransport>
| undefined,
) {
const shutdownLogger = Logger.forContext(
'utils/server.util.ts',
'shutdown',
);
const shutdown = async () => {
try {
shutdownLogger.info('Shutting down server...');
// Close all active transports to properly clean up resources
if (transports) {
for (const sessionId in transports) {
try {
shutdownLogger.debug(
`Closing transport for session ${sessionId}`,
);
const transport = transports[sessionId];
if (
transport &&
'close' in transport &&
typeof transport.close === 'function'
) {
await transport.close();
delete transports[sessionId];
}
} catch (error) {
shutdownLogger.error(
`Error closing transport for session ${sessionId}:`,
error,
);
}
}
}
shutdownLogger.info('Server shutdown complete');
process.exit(0);
} catch (err) {
shutdownLogger.error('Error during shutdown', err);
process.exit(1);
}
};
['SIGINT', 'SIGTERM'].forEach((signal) => {
process.on(signal as NodeJS.Signals, shutdown);
});
}

20
src/server/stdio.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Logger } from '../utils/logger.util';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createServer, shutdownServer } from './mcpServer';
export async function stdioTransport(): Promise<void> {
const stdioLogger = Logger.forContext('server/stdio.ts', 'stdioTransport');
stdioLogger.info('Using STDIO transport');
const transport = new StdioServerTransport();
try {
const server = createServer();
await server.connect(transport);
stdioLogger.info('MCP server started successfully on STDIO transport');
shutdownServer(undefined);
} catch (err) {
stdioLogger.error('Failed to start server on STDIO transport', err);
process.exit(1);
}
}

View File

@@ -0,0 +1,189 @@
import type { Request, Response } from 'express';
import express from 'express';
import cors from 'cors';
import { randomUUID } from 'crypto';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js';
import { Logger } from '../utils/logger.util';
import { VERSION } from '../utils/constants.util.js';
import { createServer, shutdownServer } from './mcpServer.js';
export async function streamableHttpTransport(): Promise<void> {
const streamableHttpLogger = Logger.forContext(
'server/streamableHttp.ts',
'streamableHttpTransport',
);
// HTTP transport with Express
streamableHttpLogger.info('Using Streamable HTTP transport');
const app = express();
app.use(express.json());
app.use(
cors({
origin: '*', // Allow all origins - adjust as needed for production
exposedHeaders: ['Mcp-Session-Id'],
}),
);
// Store transports by session ID
const transports: Record<
string,
StreamableHTTPServerTransport | SSEServerTransport
> = {};
const mcpEndpoint = '/mcp';
streamableHttpLogger.debug(`MCP endpoint: ${mcpEndpoint}`);
// Handle all MCP requests
app.all(mcpEndpoint, async (req: Request, res: Response) => {
streamableHttpLogger.debug(`Received ${req.method} request to /mcp`);
try {
// Check for existing session ID
const sessionId = req.headers['mcp-session-id'] as
| string
| undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
// Check if the transport is of the correct type
const existingTransport = transports[sessionId];
if (
existingTransport instanceof StreamableHTTPServerTransport
) {
// Reuse existing transport
transport = existingTransport;
} else {
// Transport exists but is not a StreamableHTTPServerTransport (could be SSEServerTransport)
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message:
'Bad Request: Session exists but uses a different transport protocol',
},
id: null,
});
return;
}
} else if (
!sessionId &&
req.method === 'POST' &&
isInitializeRequest(req.body)
) {
const eventStore = new InMemoryEventStore();
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore,
onsessioninitialized: (sessionId) => {
// Store the transport by session ID when session is initialized
streamableHttpLogger.info(
`StreamableHTTP session initialized with ID: ${sessionId}`,
);
transports[sessionId] = transport;
},
});
// Connect the transport to the MCP server
const server = createServer();
await server.connect(transport);
} else {
// Invalid request - no session ID or not initialization request
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided',
},
id: null,
});
return;
}
// Handle the request with the transport
streamableHttpLogger.debug(
'MCP request received',
req,
res,
req.body,
);
await transport.handleRequest(req, res, req.body);
} catch (error) {
streamableHttpLogger.error('Error handling MCP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
},
id: null,
});
}
}
});
app.get('/sse', async (_req: Request, res: Response) => {
streamableHttpLogger.debug(
'Received GET request to /sse (deprecated SSE transport)',
);
const transport = new SSEServerTransport('/messages', res);
transports[transport.sessionId] = transport;
res.on('close', () => {
delete transports[transport.sessionId];
});
const server = createServer();
await server.connect(transport);
});
app.post('/messages', async (req: Request, res: Response) => {
const sessionId = req.query.sessionId as string;
let transport: SSEServerTransport;
const existingTransport = transports[sessionId];
if (existingTransport instanceof SSEServerTransport) {
// Reuse existing transport
transport = existingTransport;
} else {
// Transport exists but is not a SSEServerTransport (could be StreamableHTTPServerTransport)
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message:
'Bad Request: Session exists but uses a different transport protocol',
},
id: null,
});
return;
}
if (transport) {
await transport.handlePostMessage(req, res, req.body);
} else {
res.status(400).send('No transport found for sessionId');
}
});
// Health check endpoint
app.get('/', (_req: Request, res: Response) => {
res.send(`LLM Log MCP Server v${VERSION} is running`);
});
// Start HTTP server
const PORT = Number(process.env.PORT ?? 3000);
await new Promise<void>((resolve) => {
app.listen(PORT, () => {
streamableHttpLogger.info(
`HTTP transport listening on http://localhost:${PORT}${mcpEndpoint}`,
);
resolve();
});
});
shutdownServer(transports);
return;
}

View File

@@ -0,0 +1,61 @@
import { Logger } from '../utils/logger.util.js';
import { PostHog } from 'posthog-node';
import { config } from '../utils/config.util.js';
// Ensure configuration is loaded before accessing environment variables
config.load();
const logger = Logger.forContext('services/posthog-llm.service.ts');
const posthogApiKey = config.get('POSTHOG_API_KEY');
let posthogClient: PostHog | null = null;
if (posthogApiKey) {
posthogClient = new PostHog(posthogApiKey, {
host: config.get('POSTHOG_HOST') || 'https://app.posthog.com',
});
} else {
logger.warn('POSTHOG_API_KEY is not set. PostHog client not initialized.');
}
async function capture(args: {
eventName: string;
distinctId: string;
properties: Record<string, unknown>;
}): Promise<void> {
if (!posthogClient) {
logger.warn('PostHog client not initialized. Cannot capture event.');
return;
}
const allowedEvents = [
'$ai_generation',
'$ai_trace',
'$ai_span',
'$ai_embedding',
];
if (!allowedEvents.includes(args.eventName)) {
logger.error(
`Invalid event name: ${args.eventName}. Allowed values: ${allowedEvents.join(', ')}`,
);
return;
}
const methodLogger = logger.forMethod('capture');
methodLogger.debug('Capturing event in PostHog...');
posthogClient.capture({
distinctId: args.distinctId,
event: args.eventName,
properties: {
distinct_id: args.distinctId,
...args.properties,
},
});
// Make sure to flush the events
await posthogClient.shutdown();
methodLogger.debug('Event captured and flushed to PostHog.');
}
export default { capture };

View File

@@ -0,0 +1,108 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Logger } from '../utils/logger.util.js';
import { formatErrorForMcpTool } from '../utils/error.util.js';
import posthogLlmController from '../controllers/posthog-llm.controller.js';
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import {
GetToolInputSchema,
GetToolInputSchemaType,
} from './posthog-llm.types.js';
/**
* @function capturePosthogLlmObservability
* @description MCP Tool handler to capture LLM observability events in PostHog.
* It calls the posthogLlmController to track the data and formats the response for the MCP.
* @param {GetToolInputSchemaType} args - Arguments provided to the tool.
* @returns {Promise<CallToolResult>} Formatted response for the MCP.
* @throws {McpError} Formatted error if the controller or service layer encounters an issue.
*/
async function capturePosthogLlmObservability(
args: GetToolInputSchemaType,
): Promise<CallToolResult> {
const methodLogger = Logger.forContext(
'tools/posthog-llm.tool.ts',
'capturePosthogLlmObservability',
);
methodLogger.debug(`Capture LLM Observability in PostHog...`, args);
try {
const trackArgs = GetToolInputSchema.parse(args);
const posthogProperties: Record<string, unknown> = {
$ai_model: trackArgs.model,
$ai_provider: trackArgs.provider,
};
const toPostHogKey: Partial<
Record<keyof GetToolInputSchemaType, string>
> = {
input: '$ai_input',
outputChoices: '$ai_output_choices',
traceId: '$ai_trace_id',
inputTokens: '$ai_input_tokens',
outputTokens: '$ai_output_tokens',
latency: '$ai_latency',
httpStatus: '$ai_http_status',
baseUrl: '$ai_base_url',
};
for (const key of Object.keys(toPostHogKey) as Array<
keyof GetToolInputSchemaType
>) {
if (trackArgs[key] !== undefined) {
const posthogKey = toPostHogKey[key];
if (posthogKey) {
posthogProperties[posthogKey] = trackArgs[key];
}
}
}
// Pass validated args to the controller
const result = await posthogLlmController.capture({
eventName: '$ai_generation',
distinctId: trackArgs.userId,
properties: posthogProperties,
});
methodLogger.error(`Got the response from the controller`, result);
// Format the response for the MCP tool
return {
content: [
{
type: 'text' as const,
text: result.content,
},
],
};
} catch (error) {
methodLogger.error(`Error tracking LLM generation in PostHog`, error);
return formatErrorForMcpTool(error);
}
}
/**
* @function registerTools
* @description Registers the PostHog LLM observability tool ('capture_llm_observability') with the MCP server.
*
* @param {McpServer} server - The MCP server instance.
*/
function registerTools(server: McpServer) {
const methodLogger = Logger.forContext(
'tools/posthog-llm.tool.ts',
'registerTools',
);
methodLogger.debug(`Registering PostHog LLM observability tools...`);
server.tool(
'capture_llm_observability',
`Captures LLM usage in PostHog for observability, including requests, responses, and performance metrics`,
GetToolInputSchema.shape,
capturePosthogLlmObservability,
);
methodLogger.debug(
'Successfully registered capture_llm_observability tool.',
);
}
export default { registerTools };

View File

@@ -0,0 +1,41 @@
import { z } from 'zod';
/**
* Zod schema for the PostHog LLM observability tool arguments.
*/
export const GetToolInputSchema = z.object({
userId: z.string().describe('The distinct ID of the user'),
traceId: z.string().optional().describe('The trace ID to group AI events'),
model: z.string().describe('The model used (e.g., gpt-4, claude-3, etc.)'),
provider: z
.string()
.describe('The LLM provider (e.g., openai, anthropic, etc.)'),
input: z
.any()
.optional()
.describe('The input to the LLM (messages, prompt, etc.)'),
outputChoices: z.any().optional().describe('The output from the LLM'),
inputTokens: z
.number()
.optional()
.describe('The number of tokens in the input'),
outputTokens: z
.number()
.optional()
.describe('The number of tokens in the output'),
latency: z
.number()
.optional()
.describe('The latency of the LLM call in seconds'),
httpStatus: z
.number()
.optional()
.describe('The HTTP status code of the LLM call'),
baseUrl: z.string().optional().describe('The base URL of the LLM API'),
});
/**
* TypeScript type inferred from the GetToolInputSchema Zod schema.
* This represents the optional arguments passed to the tool handler and controller.
*/
export type GetToolInputSchemaType = z.infer<typeof GetToolInputSchema>;

21
src/types/common.types.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Common type definitions shared across controllers.
* These types provide a standard interface for controller interactions.
* Centralized here to ensure consistency across the codebase.
*/
/**
* Common response structure for controller operations.
* All controller methods should return this structure.
*
* All output, including pagination information and any additional metadata,
* is now consolidated into the content field as a single Markdown-formatted string.
*/
export interface ControllerResponse {
/**
* Formatted content to be displayed to the user.
* A comprehensive Markdown-formatted string that includes all necessary information,
* including pagination details and any additional metadata.
*/
content: string;
}

157
src/utils/cli.test.util.ts Normal file
View File

@@ -0,0 +1,157 @@
import { spawn } from 'child_process';
import { join } from 'path';
/**
* Utility for testing CLI commands with real execution
*/
export class CliTestUtil {
/**
* Executes a CLI command and returns the result
*
* @param args - CLI arguments to pass to the command
* @param options - Test options
* @returns Promise with stdout, stderr, and exit code
*/
static async runCommand(
args: string[],
options: {
timeoutMs?: number;
env?: Record<string, string>;
} = {},
): Promise<{
stdout: string;
stderr: string;
exitCode: number;
}> {
// Default timeout of 30 seconds
const timeoutMs = options.timeoutMs || 30000;
// CLI execution path - points to the built CLI script
const cliPath = join(process.cwd(), 'dist', 'index.js');
// Log what command we're about to run
console.log(`Running CLI command: node ${cliPath} ${args.join(' ')}`);
return new Promise((resolve, reject) => {
// Set up timeout handler
const timeoutId = setTimeout(() => {
child.kill();
reject(new Error(`CLI command timed out after ${timeoutMs}ms`));
}, timeoutMs);
// Capture stdout and stderr
let stdout = '';
let stderr = '';
// Spawn the process with given arguments and enhanced environment
const child = spawn('node', [cliPath, ...args], {
env: {
...process.env,
...options.env,
DEBUG: 'true', // Enable debug logging
NODE_ENV: 'test', // Ensure tests are detected
},
});
// Collect stdout data
child.stdout.on('data', (data) => {
const chunk = data.toString();
stdout += chunk;
console.log(`STDOUT chunk: ${chunk.substring(0, 50)}...`);
});
// Collect stderr data
child.stderr.on('data', (data) => {
const chunk = data.toString();
stderr += chunk;
console.log(`STDERR chunk: ${chunk.substring(0, 50)}...`);
});
// Handle process completion
child.on('close', (exitCode) => {
clearTimeout(timeoutId);
console.log(`Command completed with exit code: ${exitCode}`);
console.log(`Total STDOUT length: ${stdout.length} chars`);
// Get the non-debug output for debugging purposes
const nonDebugOutput = stdout
.split('\n')
.filter((line) => !line.match(/^\[\d{2}:\d{2}:\d{2}\]/))
.join('\n');
console.log(
`Non-debug output length: ${nonDebugOutput.length} chars`,
);
console.log(`STDOUT excerpt: ${stdout.substring(0, 100)}...`);
console.log(
`Filtered excerpt: ${nonDebugOutput.substring(0, 100)}...`,
);
resolve({
stdout,
stderr,
exitCode: exitCode ?? 0,
});
});
// Handle process errors
child.on('error', (err) => {
clearTimeout(timeoutId);
console.error(`Command error: ${err.message}`);
reject(err);
});
});
}
/**
* Validates that stdout contains expected strings/patterns
*/
static validateOutputContains(
output: string,
expectedPatterns: (string | RegExp)[],
): void {
// Filter out debug log lines for cleaner validation
const cleanOutput = output
.split('\n')
.filter((line) => !line.match(/^\[\d{2}:\d{2}:\d{2}\]/))
.join('\n');
console.log('==== Cleaned output for validation ====');
console.log(cleanOutput);
console.log('=======================================');
for (const pattern of expectedPatterns) {
if (typeof pattern === 'string') {
expect(cleanOutput).toContain(pattern);
} else {
expect(cleanOutput).toMatch(pattern);
}
}
}
/**
* Validates Markdown output format
*/
static validateMarkdownOutput(output: string): void {
// Filter out debug log lines for cleaner validation
const cleanOutput = output
.split('\n')
.filter((line) => !line.match(/^\[\d{2}:\d{2}:\d{2}\]/))
.join('\n');
// Check for Markdown heading
expect(cleanOutput).toMatch(/^#\s.+/m);
// Check for markdown formatting elements like bold text, lists, etc.
const markdownElements = [
/\*\*.+\*\*/, // Bold text
/-\s.+/, // List items
/\|.+\|.+\|/, // Table rows
/\[.+\]\(.+\)/, // Links
];
expect(
markdownElements.some((pattern) => pattern.test(cleanOutput)),
).toBe(true);
}
}

View File

@@ -0,0 +1,131 @@
import {
ErrorType,
McpError,
createApiError,
createAuthMissingError,
createAuthInvalidError,
createUnexpectedError,
ensureMcpError,
formatErrorForMcpTool,
formatErrorForMcpResource,
} from './error.util.js';
describe('Error Utility', () => {
describe('McpError', () => {
it('should create an error with the correct properties', () => {
const error = new McpError('Test error', ErrorType.API_ERROR, 404);
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(McpError);
expect(error.message).toBe('Test error');
expect(error.type).toBe(ErrorType.API_ERROR);
expect(error.statusCode).toBe(404);
expect(error.name).toBe('McpError');
});
});
describe('Error Factory Functions', () => {
it('should create auth missing error', () => {
const error = createAuthMissingError();
expect(error).toBeInstanceOf(McpError);
expect(error.type).toBe(ErrorType.AUTH_MISSING);
expect(error.message).toBe(
'Authentication credentials are missing',
);
});
it('should create auth invalid error', () => {
const error = createAuthInvalidError('Invalid token');
expect(error).toBeInstanceOf(McpError);
expect(error.type).toBe(ErrorType.AUTH_INVALID);
expect(error.statusCode).toBe(401);
expect(error.message).toBe('Invalid token');
});
it('should create API error', () => {
const originalError = new Error('Original error');
const error = createApiError('API failed', 500, originalError);
expect(error).toBeInstanceOf(McpError);
expect(error.type).toBe(ErrorType.API_ERROR);
expect(error.statusCode).toBe(500);
expect(error.message).toBe('API failed');
expect(error.originalError).toBe(originalError);
});
it('should create unexpected error', () => {
const error = createUnexpectedError();
expect(error).toBeInstanceOf(McpError);
expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR);
expect(error.message).toBe('An unexpected error occurred');
});
});
describe('ensureMcpError', () => {
it('should return the same error if it is already an McpError', () => {
const originalError = createApiError('Original error');
const error = ensureMcpError(originalError);
expect(error).toBe(originalError);
});
it('should wrap a standard Error', () => {
const originalError = new Error('Standard error');
const error = ensureMcpError(originalError);
expect(error).toBeInstanceOf(McpError);
expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR);
expect(error.message).toBe('Standard error');
expect(error.originalError).toBe(originalError);
});
it('should handle non-Error objects', () => {
const error = ensureMcpError('String error');
expect(error).toBeInstanceOf(McpError);
expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR);
expect(error.message).toBe('String error');
});
});
describe('formatErrorForMcpTool', () => {
it('should format an error for MCP tool response', () => {
const error = createApiError('API error');
const response = formatErrorForMcpTool(error);
expect(response).toHaveProperty('content');
expect(response.content).toHaveLength(1);
expect(response.content[0]).toHaveProperty('type', 'text');
expect(response.content[0]).toHaveProperty(
'text',
'Error: API error',
);
});
});
describe('formatErrorForMcpResource', () => {
it('should format an error for MCP resource response', () => {
const error = createApiError('API error');
const response = formatErrorForMcpResource(error, 'test://uri');
expect(response).toHaveProperty('contents');
expect(response.contents).toHaveLength(1);
expect(response.contents[0]).toHaveProperty('uri', 'test://uri');
expect(response.contents[0]).toHaveProperty(
'text',
'Error: API error',
);
expect(response.contents[0]).toHaveProperty(
'mimeType',
'text/plain',
);
expect(response.contents[0]).toHaveProperty(
'description',
'Error: API_ERROR',
);
});
});
});

173
src/utils/config.util.ts Normal file
View File

@@ -0,0 +1,173 @@
import fs from 'fs';
import path from 'path';
import { Logger } from './logger.util.js';
import dotenv from 'dotenv';
import os from 'os';
/**
* Configuration loader that handles multiple sources with priority:
* 1. Direct ENV pass (process.env)
* 2. .env file in project root
* 3. Global config file at $HOME/.mcp/configs.json
*/
class ConfigLoader {
private packageName: string;
private configLoaded: boolean = false;
/**
* Create a new ConfigLoader instance
* @param packageName The package name to use for global config lookup
*/
constructor(packageName: string) {
this.packageName = packageName;
}
/**
* Load configuration from all sources with proper priority
*/
load(): void {
const methodLogger = Logger.forContext('utils/config.util.ts', 'load');
if (this.configLoaded) {
methodLogger.debug('Configuration already loaded, skipping');
return;
}
methodLogger.debug('Loading configuration...');
// Priority 3: Load from global config file
this.loadFromGlobalConfig();
// Priority 2: Load from .env file
this.loadFromEnvFile();
// Priority 1: Direct ENV pass is already in process.env
// No need to do anything as it already has highest priority
this.configLoaded = true;
methodLogger.debug('Configuration loaded successfully');
}
/**
* Load configuration from .env file in project root
*/
private loadFromEnvFile(): void {
const methodLogger = Logger.forContext(
'utils/config.util.ts',
'loadFromEnvFile',
);
try {
const result = dotenv.config();
if (result.error) {
methodLogger.debug('No .env file found or error reading it');
return;
}
methodLogger.debug('Loaded configuration from .env file');
} catch (error) {
methodLogger.error('Error loading .env file', error);
}
}
/**
* Load configuration from global config file at $HOME/.mcp/configs.json
*/
private loadFromGlobalConfig(): void {
const methodLogger = Logger.forContext(
'utils/config.util.ts',
'loadFromGlobalConfig',
);
try {
const homedir = os.homedir();
const globalConfigPath = path.join(homedir, '.mcp', 'configs.json');
if (!fs.existsSync(globalConfigPath)) {
methodLogger.debug('Global config file not found');
return;
}
const configContent = fs.readFileSync(globalConfigPath, 'utf8');
const config = JSON.parse(configContent);
// Determine the potential keys for the current package
const shortKey = 'llm-observability-mcp'; // Project-specific short key
const fullPackageName = this.packageName; // e.g., '@sfiorini/llm-observability-mcp'
const unscopedPackageName =
fullPackageName.split('/')[1] || fullPackageName; // e.g., 'llm-observability-mcp'
const potentialKeys = [
shortKey,
fullPackageName,
unscopedPackageName,
];
let foundConfigSection: {
environments?: Record<string, unknown>;
} | null = null;
let usedKey: string | null = null;
for (const key of potentialKeys) {
if (
config[key] &&
typeof config[key] === 'object' &&
config[key].environments
) {
foundConfigSection = config[key];
usedKey = key;
methodLogger.debug(`Found configuration using key: ${key}`);
break; // Stop once found
}
}
if (!foundConfigSection || !foundConfigSection.environments) {
methodLogger.debug(
`No configuration found for ${
this.packageName
} using keys: ${potentialKeys.join(', ')}`,
);
return;
}
const environments = foundConfigSection.environments;
for (const [key, value] of Object.entries(environments)) {
// Only set if not already defined in process.env
if (process.env[key] === undefined) {
process.env[key] = String(value);
}
}
methodLogger.debug(
`Loaded configuration from global config file using key: ${usedKey}`,
);
} catch (error) {
methodLogger.error('Error loading global config file', error);
}
}
/**
* Get a configuration value
* @param key The configuration key
* @param defaultValue The default value if the key is not found
* @returns The configuration value or the default value
*/
get(key: string, defaultValue?: string): string | undefined {
return process.env[key] || defaultValue;
}
/**
* Get a boolean configuration value
* @param key The configuration key
* @param defaultValue The default value if the key is not found
* @returns The boolean configuration value or the default value
*/
getBoolean(key: string, defaultValue: boolean = false): boolean {
const value = this.get(key);
if (value === undefined) {
return defaultValue;
}
return value.toLowerCase() === 'true';
}
}
// Create and export a singleton instance with the package name from package.json
export const config = new ConfigLoader('@sfiorini/llm-observability-mcp');

View File

@@ -0,0 +1,24 @@
/**
* Application constants
*
* This file contains constants used throughout the application.
* Centralizing these values makes them easier to maintain and update.
*/
/**
* Current application version
* This should match the version in package.json
*/
export const VERSION = '0.1.0';
/**
* Package name with scope
* Used for initialization and identification
*/
export const PACKAGE_NAME = '@sfiorini/llm-observability-mcp';
/**
* CLI command name
* Used for binary name and CLI help text
*/
export const CLI_NAME = 'llm-observability-mcp';

View File

@@ -0,0 +1,128 @@
import {
ErrorCode,
detectErrorType,
buildErrorContext,
} from './error-handler.util.js';
import { createApiError, McpError, ErrorType } from './error.util.js';
describe('error-handler.util', () => {
describe('detectErrorType', () => {
it('should detect network errors', () => {
const networkErrors = [
'network error occurred',
'fetch failed with error',
'ECONNREFUSED on 127.0.0.1:8080',
'ENOTFOUND api.example.com',
'Failed to fetch data from server',
'Network request failed',
];
networkErrors.forEach((msg) => {
const { code, statusCode } = detectErrorType(new Error(msg));
expect(code).toBe(ErrorCode.NETWORK_ERROR);
expect(statusCode).toBe(500);
});
});
it('should detect rate limit errors', () => {
const rateLimitErrors = [
'rate limit exceeded',
'too many requests',
new McpError('API error', ErrorType.API_ERROR, 429),
];
rateLimitErrors.forEach((error) => {
const { code, statusCode } = detectErrorType(error);
expect(code).toBe(ErrorCode.RATE_LIMIT_ERROR);
expect(statusCode).toBe(429);
});
});
it('should detect not found errors', () => {
const notFoundErrors = [
'resource not found',
'entity does not exist',
new McpError('Not found', ErrorType.API_ERROR, 404),
];
notFoundErrors.forEach((error) => {
const { code } = detectErrorType(error);
expect(code).toBe(ErrorCode.NOT_FOUND);
});
});
it('should detect access denied errors', () => {
const accessDeniedErrors = [
'access denied',
'permission denied',
'not authorized to access',
'authentication required',
new McpError('Forbidden', ErrorType.API_ERROR, 403),
new McpError('Unauthorized', ErrorType.API_ERROR, 401),
];
accessDeniedErrors.forEach((error) => {
const { code } = detectErrorType(error);
expect(code).toBe(ErrorCode.ACCESS_DENIED);
});
});
it('should default to unexpected error when no patterns match', () => {
const { code, statusCode } = detectErrorType(
new Error('some random error'),
);
expect(code).toBe(ErrorCode.UNEXPECTED_ERROR);
expect(statusCode).toBe(500);
});
});
describe('buildErrorContext', () => {
it('should build a context object with all parameters', () => {
const context = buildErrorContext(
'User',
'create',
'controllers/user.controller.ts@create',
'user123',
{ requestBody: { name: 'Test User' } },
);
expect(context).toEqual({
entityType: 'User',
operation: 'create',
source: 'controllers/user.controller.ts@create',
entityId: 'user123',
additionalInfo: { requestBody: { name: 'Test User' } },
});
});
it('should build a context object with only required parameters', () => {
const context = buildErrorContext(
'User',
'list',
'controllers/user.controller.ts@list',
);
expect(context).toEqual({
entityType: 'User',
operation: 'list',
source: 'controllers/user.controller.ts@list',
});
});
it('should handle object entityId', () => {
const context = buildErrorContext(
'Document',
'get',
'controllers/document.controller.ts@get',
{ project: 'project1', id: 'doc123' },
);
expect(context).toEqual({
entityType: 'Document',
operation: 'get',
source: 'controllers/document.controller.ts@get',
entityId: { project: 'project1', id: 'doc123' },
});
});
});
});

View File

@@ -0,0 +1,307 @@
import { createApiError } from './error.util.js';
import { Logger } from './logger.util.js';
/**
* Standard error codes for consistent handling
*/
export enum ErrorCode {
NOT_FOUND = 'NOT_FOUND',
INVALID_CURSOR = 'INVALID_CURSOR',
ACCESS_DENIED = 'ACCESS_DENIED',
VALIDATION_ERROR = 'VALIDATION_ERROR',
UNEXPECTED_ERROR = 'UNEXPECTED_ERROR',
NETWORK_ERROR = 'NETWORK_ERROR',
RATE_LIMIT_ERROR = 'RATE_LIMIT_ERROR',
}
/**
* Context information for error handling
*/
export interface ErrorContext {
/**
* Source of the error (e.g., file path and function)
*/
source?: string;
/**
* Type of entity being processed (e.g., 'User')
*/
entityType?: string;
/**
* Identifier of the entity being processed
*/
entityId?: string | Record<string, string>;
/**
* Operation being performed (e.g., 'retrieving', 'searching')
*/
operation?: string;
/**
* Additional information for debugging
*/
additionalInfo?: Record<string, unknown>;
}
/**
* Helper function to create a consistent error context object
* @param entityType Type of entity being processed
* @param operation Operation being performed
* @param source Source of the error (typically file path and function)
* @param entityId Optional identifier of the entity
* @param additionalInfo Optional additional information for debugging
* @returns A formatted ErrorContext object
*/
export function buildErrorContext(
entityType: string,
operation: string,
source: string,
entityId?: string | Record<string, string>,
additionalInfo?: Record<string, unknown>,
): ErrorContext {
return {
entityType,
operation,
source,
...(entityId && { entityId }),
...(additionalInfo && { additionalInfo }),
};
}
/**
* Detect specific error types from raw errors
* @param error The error to analyze
* @param context Context information for better error detection
* @returns Object containing the error code and status code
*/
export function detectErrorType(
error: unknown,
context: ErrorContext = {},
): { code: ErrorCode; statusCode: number } {
const methodLogger = Logger.forContext(
'utils/error-handler.util.ts',
'detectErrorType',
);
methodLogger.debug(`Detecting error type`, { error, context });
const errorMessage = error instanceof Error ? error.message : String(error);
const statusCode =
error instanceof Error && 'statusCode' in error
? (error as { statusCode: number }).statusCode
: undefined;
// Network error detection
if (
errorMessage.includes('network error') ||
errorMessage.includes('fetch failed') ||
errorMessage.includes('ECONNREFUSED') ||
errorMessage.includes('ENOTFOUND') ||
errorMessage.includes('Failed to fetch') ||
errorMessage.includes('Network request failed')
) {
return { code: ErrorCode.NETWORK_ERROR, statusCode: 500 };
}
// Rate limiting detection
if (
errorMessage.includes('rate limit') ||
errorMessage.includes('too many requests') ||
statusCode === 429
) {
return { code: ErrorCode.RATE_LIMIT_ERROR, statusCode: 429 };
}
// Not Found detection
if (
errorMessage.includes('not found') ||
errorMessage.includes('does not exist') ||
statusCode === 404
) {
return { code: ErrorCode.NOT_FOUND, statusCode: 404 };
}
// Access Denied detection
if (
errorMessage.includes('access') ||
errorMessage.includes('permission') ||
errorMessage.includes('authorize') ||
errorMessage.includes('authentication') ||
statusCode === 401 ||
statusCode === 403
) {
return { code: ErrorCode.ACCESS_DENIED, statusCode: statusCode || 403 };
}
// Invalid Cursor detection
if (
(errorMessage.includes('cursor') ||
errorMessage.includes('startAt') ||
errorMessage.includes('page')) &&
(errorMessage.includes('invalid') || errorMessage.includes('not valid'))
) {
return { code: ErrorCode.INVALID_CURSOR, statusCode: 400 };
}
// Validation Error detection
if (
errorMessage.includes('validation') ||
errorMessage.includes('invalid') ||
errorMessage.includes('required') ||
statusCode === 400 ||
statusCode === 422
) {
return {
code: ErrorCode.VALIDATION_ERROR,
statusCode: statusCode || 400,
};
}
// Default to unexpected error
return {
code: ErrorCode.UNEXPECTED_ERROR,
statusCode: statusCode || 500,
};
}
/**
* Create user-friendly error messages based on error type and context
* @param code The error code
* @param context Context information for better error messages
* @param originalMessage The original error message
* @returns User-friendly error message
*/
export function createUserFriendlyErrorMessage(
code: ErrorCode,
context: ErrorContext = {},
originalMessage?: string,
): string {
const methodLogger = Logger.forContext(
'utils/error-handler.util.ts',
'createUserFriendlyErrorMessage',
);
const { entityType, entityId, operation } = context;
// Format entity ID for display
let entityIdStr = '';
if (entityId) {
if (typeof entityId === 'string') {
entityIdStr = entityId;
} else {
// Handle object entityId
entityIdStr = Object.values(entityId).join('/');
}
}
// Determine entity display name
const entity = entityType
? `${entityType}${entityIdStr ? ` ${entityIdStr}` : ''}`
: 'Resource';
let message = '';
switch (code) {
case ErrorCode.NOT_FOUND:
message = `${entity} not found${entityIdStr ? `: ${entityIdStr}` : ''}. Verify the ID is correct and that you have access to this ${entityType?.toLowerCase() || 'resource'}.`;
break;
case ErrorCode.ACCESS_DENIED:
message = `Access denied for ${entity.toLowerCase()}${entityIdStr ? ` ${entityIdStr}` : ''}. Verify your credentials and permissions.`;
break;
case ErrorCode.INVALID_CURSOR:
message = `Invalid pagination cursor. Use the exact cursor string returned from previous results.`;
break;
case ErrorCode.VALIDATION_ERROR:
message =
originalMessage ||
`Invalid data provided for ${operation || 'operation'} ${entity.toLowerCase()}.`;
break;
case ErrorCode.NETWORK_ERROR:
message = `Network error while ${operation || 'connecting to'} the service. Please check your internet connection and try again.`;
break;
case ErrorCode.RATE_LIMIT_ERROR:
message = `Rate limit exceeded. Please wait a moment and try again, or reduce the frequency of requests.`;
break;
default:
message = `An unexpected error occurred while ${operation || 'processing'} ${entity.toLowerCase()}.`;
}
// Include original message details if available and appropriate
if (
originalMessage &&
code !== ErrorCode.NOT_FOUND &&
code !== ErrorCode.ACCESS_DENIED
) {
message += ` Error details: ${originalMessage}`;
}
methodLogger.debug(`Created user-friendly message: ${message}`, {
code,
context,
});
return message;
}
/**
* Handle controller errors consistently
* @param error The error to handle
* @param context Context information for better error messages
* @returns Never returns, always throws an error
*/
export function handleControllerError(
error: unknown,
context: ErrorContext = {},
): never {
const methodLogger = Logger.forContext(
'utils/error-handler.util.ts',
'handleControllerError',
);
// Extract error details
const errorMessage = error instanceof Error ? error.message : String(error);
const statusCode =
error instanceof Error && 'statusCode' in error
? (error as { statusCode: number }).statusCode
: undefined;
// Detect error type using utility
const { code, statusCode: detectedStatus } = detectErrorType(
error,
context,
);
// Combine detected status with explicit status
const finalStatusCode = statusCode || detectedStatus;
// Format entity information for logging
const { entityType, entityId, operation } = context;
const entity = entityType || 'resource';
const entityIdStr = entityId
? typeof entityId === 'string'
? entityId
: JSON.stringify(entityId)
: '';
const actionStr = operation || 'processing';
// Log detailed error information
methodLogger.error(
`Error ${actionStr} ${entity}${
entityIdStr ? `: ${entityIdStr}` : ''
}: ${errorMessage}`,
error,
);
// Create user-friendly error message for the response
const message =
code === ErrorCode.VALIDATION_ERROR
? errorMessage
: createUserFriendlyErrorMessage(code, context, errorMessage);
// Throw an appropriate API error with the user-friendly message
throw createApiError(message, finalStatusCode, error);
}

View File

@@ -0,0 +1,115 @@
import {
McpError,
ErrorType,
createApiError,
createUnexpectedError,
getDeepOriginalError,
formatErrorForMcpTool,
} from './error.util.js';
describe('error.util', () => {
describe('getDeepOriginalError', () => {
it('should return the deepest original error in a chain', () => {
// Create a nested chain of errors
const deepestError = new Error('Root cause');
const middleError = createApiError(
'Middle error',
500,
deepestError,
);
const topError = createUnexpectedError('Top error', middleError);
// Should extract the deepest error
const result = getDeepOriginalError(topError);
expect(result).toBe(deepestError);
});
it('should handle null/undefined input', () => {
expect(getDeepOriginalError(null)).toBeNull();
expect(getDeepOriginalError(undefined)).toBeUndefined();
});
it('should return the input if it has no originalError', () => {
const error = new Error('Simple error');
expect(getDeepOriginalError(error)).toBe(error);
});
it('should handle non-Error objects', () => {
const nonError = { message: 'Not an error' };
expect(getDeepOriginalError(nonError)).toBe(nonError);
});
it('should prevent infinite recursion with circular references', () => {
const error1 = new McpError('Error 1', ErrorType.UNEXPECTED_ERROR);
const error2 = new McpError(
'Error 2',
ErrorType.UNEXPECTED_ERROR,
undefined,
error1,
);
// Create circular reference
error1.originalError = error2;
// Should not cause stack overflow, should return one of the errors
const result = getDeepOriginalError(error1);
expect(result).toBeTruthy();
expect(result instanceof Error).toBe(true);
});
});
describe('formatErrorForMcpTool', () => {
it('should format McpError with metadata', () => {
const error = createApiError('Test error', 404, {
detail: 'Not found',
});
const result = formatErrorForMcpTool(error);
// Check the content
expect(result.content).toEqual([
{
type: 'text',
text: 'Error: Test error',
},
]);
// Check the metadata
expect(result.metadata).toBeDefined();
expect(result.metadata?.errorType).toBe(ErrorType.API_ERROR);
expect(result.metadata?.statusCode).toBe(404);
expect(result.metadata?.errorDetails).toEqual({
detail: 'Not found',
});
});
it('should wrap non-McpError with metadata', () => {
const error = new Error('Regular error');
const result = formatErrorForMcpTool(error);
// Check content
expect(result.content[0].text).toBe('Error: Regular error');
// Check metadata
expect(result.metadata?.errorType).toBe(ErrorType.UNEXPECTED_ERROR);
expect(result.metadata?.errorDetails).toHaveProperty(
'message',
'Regular error',
);
});
it('should extract error message from non-Error objects', () => {
const result = formatErrorForMcpTool('String error');
expect(result.content[0].text).toBe('Error: String error');
expect(result.metadata?.errorType).toBe(ErrorType.UNEXPECTED_ERROR);
});
it('should extract deep original error details', () => {
const deepError = { code: 'DEEP_ERROR', message: 'Deep cause' };
const middleError = createApiError('Middle layer', 500, deepError);
const topError = createUnexpectedError('Top error', middleError);
const result = formatErrorForMcpTool(topError);
expect(result.metadata?.errorDetails).toEqual(deepError);
});
});
});

240
src/utils/error.util.ts Normal file
View File

@@ -0,0 +1,240 @@
import { Logger } from './logger.util.js';
/**
* Error types for classification
*/
export enum ErrorType {
AUTH_MISSING = 'AUTH_MISSING',
AUTH_INVALID = 'AUTH_INVALID',
API_ERROR = 'API_ERROR',
UNEXPECTED_ERROR = 'UNEXPECTED_ERROR',
}
/**
* Custom error class with type classification
*/
export class McpError extends Error {
type: ErrorType;
statusCode?: number;
originalError?: unknown;
constructor(
message: string,
type: ErrorType,
statusCode?: number,
originalError?: unknown,
) {
super(message);
this.name = 'McpError';
this.type = type;
this.statusCode = statusCode;
this.originalError = originalError;
}
}
/**
* Create an authentication missing error
*/
export function createAuthMissingError(
message: string = 'Authentication credentials are missing',
): McpError {
return new McpError(message, ErrorType.AUTH_MISSING);
}
/**
* Create an authentication invalid error
*/
export function createAuthInvalidError(
message: string = 'Authentication credentials are invalid',
): McpError {
return new McpError(message, ErrorType.AUTH_INVALID, 401);
}
/**
* Create an API error
*/
export function createApiError(
message: string,
statusCode?: number,
originalError?: unknown,
): McpError {
return new McpError(
message,
ErrorType.API_ERROR,
statusCode,
originalError,
);
}
/**
* Create an unexpected error
*/
export function createUnexpectedError(
message: string = 'An unexpected error occurred',
originalError?: unknown,
): McpError {
return new McpError(
message,
ErrorType.UNEXPECTED_ERROR,
undefined,
originalError,
);
}
/**
* Ensure an error is an McpError
*/
export function ensureMcpError(error: unknown): McpError {
if (error instanceof McpError) {
return error;
}
if (error instanceof Error) {
return createUnexpectedError(error.message, error);
}
return createUnexpectedError(String(error));
}
/**
* Get the deepest original error from an error chain
* @param error The error to extract the original cause from
* @returns The deepest original error or the error itself
*/
export function getDeepOriginalError(error: unknown): unknown {
if (!error) {
return error;
}
let current = error;
let depth = 0;
const maxDepth = 10; // Prevent infinite recursion
while (
depth < maxDepth &&
current instanceof Error &&
'originalError' in current &&
current.originalError
) {
current = current.originalError;
depth++;
}
return current;
}
/**
* Format error for MCP tool response
*/
export function formatErrorForMcpTool(error: unknown): {
content: Array<{ type: 'text'; text: string }>;
metadata?: {
errorType: ErrorType;
statusCode?: number;
errorDetails?: unknown;
};
} {
const methodLogger = Logger.forContext(
'utils/error.util.ts',
'formatErrorForMcpTool',
);
const mcpError = ensureMcpError(error);
methodLogger.error(`${mcpError.type} error`, mcpError);
// Get the deep original error for additional context
const originalError = getDeepOriginalError(mcpError.originalError);
// Safely extract details from the original error
const errorDetails =
originalError instanceof Error
? { message: originalError.message }
: originalError;
return {
content: [
{
type: 'text' as const,
text: `Error: ${mcpError.message}`,
},
],
metadata: {
errorType: mcpError.type,
statusCode: mcpError.statusCode,
errorDetails,
},
};
}
/**
* Format error for MCP resource response
*/
export function formatErrorForMcpResource(
error: unknown,
uri: string,
): {
contents: Array<{
uri: string;
text: string;
mimeType: string;
description?: string;
}>;
} {
const methodLogger = Logger.forContext(
'utils/error.util.ts',
'formatErrorForMcpResource',
);
const mcpError = ensureMcpError(error);
methodLogger.error(`${mcpError.type} error`, mcpError);
return {
contents: [
{
uri,
text: `Error: ${mcpError.message}`,
mimeType: 'text/plain',
description: `Error: ${mcpError.type}`,
},
],
};
}
/**
* Handle error in CLI context with improved user feedback
*/
export function handleCliError(error: unknown): never {
const methodLogger = Logger.forContext(
'utils/error.util.ts',
'handleCliError',
);
const mcpError = ensureMcpError(error);
methodLogger.error(`${mcpError.type} error`, mcpError);
// Print the error message
console.error(`Error: ${mcpError.message}`);
// Provide helpful context based on error type
if (mcpError.type === ErrorType.AUTH_MISSING) {
console.error(
'\nTip: Make sure to set up your API token in the configuration file or environment variables.',
);
} else if (mcpError.type === ErrorType.AUTH_INVALID) {
console.error(
'\nTip: Check that your API token is correct and has not expired.',
);
} else if (mcpError.type === ErrorType.API_ERROR) {
if (mcpError.statusCode === 429) {
console.error(
'\nTip: You may have exceeded your API rate limits. Try again later or upgrade your API plan.',
);
}
}
// Display DEBUG tip
if (process.env.DEBUG !== 'mcp:*') {
console.error(
'\nFor more detailed error information, run with DEBUG=mcp:* environment variable.',
);
}
process.exit(1);
}

131
src/utils/formatter.util.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* Standardized formatting utilities for consistent output across all CLI and Tool interfaces.
* These functions should be used by all formatters to ensure consistent formatting.
*/
/**
* Format a date in a standardized way: YYYY-MM-DD HH:MM:SS UTC
* @param dateString - ISO date string or Date object
* @returns Formatted date string
*/
export function formatDate(dateString?: string | Date): string {
if (!dateString) {
return 'Not available';
}
try {
const date =
typeof dateString === 'string' ? new Date(dateString) : dateString;
// Format: YYYY-MM-DD HH:MM:SS UTC
return date
.toISOString()
.replace('T', ' ')
.replace(/\.\d+Z$/, ' UTC');
} catch {
return 'Invalid date';
}
}
/**
* Format a URL as a markdown link
* @param url - URL to format
* @param title - Link title
* @returns Formatted markdown link
*/
export function formatUrl(url?: string, title?: string): string {
if (!url) {
return 'Not available';
}
const linkTitle = title || url;
return `[${linkTitle}](${url})`;
}
/**
* Format a heading with consistent style
* @param text - Heading text
* @param level - Heading level (1-6)
* @returns Formatted heading
*/
export function formatHeading(text: string, level: number = 1): string {
const validLevel = Math.min(Math.max(level, 1), 6);
const prefix = '#'.repeat(validLevel);
return `${prefix} ${text}`;
}
/**
* Format a list of key-value pairs as a bullet list
* @param items - Object with key-value pairs
* @param keyFormatter - Optional function to format keys
* @returns Formatted bullet list
*/
export function formatBulletList(
items: Record<string, unknown>,
keyFormatter?: (key: string) => string,
): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(items)) {
if (value === undefined || value === null) {
continue;
}
const formattedKey = keyFormatter ? keyFormatter(key) : key;
const formattedValue = formatValue(value);
lines.push(`- **${formattedKey}**: ${formattedValue}`);
}
return lines.join('\n');
}
/**
* Format a value based on its type
* @param value - Value to format
* @returns Formatted value
*/
function formatValue(value: unknown): string {
if (value === undefined || value === null) {
return 'Not available';
}
if (value instanceof Date) {
return formatDate(value);
}
// Handle URL objects with url and title properties
if (typeof value === 'object' && value !== null && 'url' in value) {
const urlObj = value as { url: string; title?: string };
if (typeof urlObj.url === 'string') {
return formatUrl(urlObj.url, urlObj.title);
}
}
if (typeof value === 'string') {
// Check if it's a URL
if (value.startsWith('http://') || value.startsWith('https://')) {
return formatUrl(value);
}
// Check if it might be a date
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
return formatDate(value);
}
return value;
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
return String(value);
}
/**
* Format a separator line
* @returns Separator line
*/
export function formatSeparator(): string {
return '---';
}

369
src/utils/logger.util.ts Normal file
View File

@@ -0,0 +1,369 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as crypto from 'crypto';
/**
* Format a timestamp for logging
* @returns Formatted timestamp [HH:MM:SS]
*/
function getTimestamp(): string {
const now = new Date();
return `[${now.toISOString().split('T')[1].split('.')[0]}]`;
}
/**
* Safely convert object to string with size limits
* @param obj Object to stringify
* @param maxLength Maximum length of the resulting string
* @returns Safely stringified object
*/
function safeStringify(obj: unknown, maxLength = 1000): string {
try {
const str = JSON.stringify(obj);
if (str.length <= maxLength) {
return str;
}
return `${str.substring(0, maxLength)}... (truncated, ${str.length} chars total)`;
} catch {
return '[Object cannot be stringified]';
}
}
/**
* Extract essential values from larger objects for logging
* @param obj The object to extract values from
* @param keys Keys to extract (if available)
* @returns Object containing only the specified keys
*/
function extractEssentialValues(
obj: Record<string, unknown>,
keys: string[],
): Record<string, unknown> {
const result: Record<string, unknown> = {};
keys.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
result[key] = obj[key];
}
});
return result;
}
/**
* Format source path consistently using the standardized format:
* [module/file.ts@function] or [module/file.ts]
*
* @param filePath File path (with or without src/ prefix)
* @param functionName Optional function name
* @returns Formatted source path according to standard pattern
*/
function formatSourcePath(filePath: string, functionName?: string): string {
// Always strip 'src/' prefix for consistency
const normalizedPath = filePath.replace(/^src\//, '');
return functionName
? `[${normalizedPath}@${functionName}]`
: `[${normalizedPath}]`;
}
/**
* Check if debug logging is enabled for a specific module
*
* This function parses the DEBUG environment variable to determine if a specific
* module should have debug logging enabled. The DEBUG variable can be:
* - 'true' or '1': Enable all debug logging
* - Comma-separated list of modules: Enable debug only for those modules
* - Module patterns with wildcards: e.g., 'controllers/*' enables all controllers
*
* Examples:
* - DEBUG=true
* - DEBUG=controllers/*,services/aws.sso.auth.service.ts
* - DEBUG=transport,utils/formatter*
*
* @param modulePath The module path to check against DEBUG patterns
* @returns true if debug is enabled for this module, false otherwise
*/
function isDebugEnabledForModule(modulePath: string): boolean {
const debugEnv = process.env.DEBUG;
if (!debugEnv) {
return false;
}
// If debug is set to true or 1, enable all debug logging
if (debugEnv === 'true' || debugEnv === '1') {
return true;
}
// Parse comma-separated debug patterns
const debugPatterns = debugEnv.split(',').map((p) => p.trim());
// Check if the module matches any pattern
return debugPatterns.some((pattern) => {
// Convert glob-like patterns to regex
// * matches anything within a path segment
// ** matches across path segments
const regexPattern = pattern
.replace(/\*/g, '.*') // Convert * to regex .*
.replace(/\?/g, '.'); // Convert ? to regex .
const regex = new RegExp(`^${regexPattern}$`);
return (
regex.test(modulePath) ||
// Check for pattern matches without the 'src/' prefix
regex.test(modulePath.replace(/^src\//, ''))
);
});
}
// Generate a unique session ID for this process
const SESSION_ID = crypto.randomUUID();
// Get the package name from environment variables or default to 'mcp-server'
const getPkgName = (): string => {
try {
// Try to get it from package.json first if available
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(
fs.readFileSync(packageJsonPath, 'utf8'),
);
if (packageJson.name) {
// Extract the last part of the name if it's scoped
const match = packageJson.name.match(/(@[\w-]+\/)?(.+)/);
return match ? match[2] : packageJson.name;
}
}
} catch {
// Silently fail and use default
}
// Fallback to environment variable or default
return process.env.PACKAGE_NAME || 'mcp-server';
};
// MCP logs directory setup
const HOME_DIR = os.homedir();
const MCP_DATA_DIR = path.join(HOME_DIR, '.mcp', 'data');
const CLI_NAME = getPkgName();
// Ensure the MCP data directory exists
if (!fs.existsSync(MCP_DATA_DIR)) {
fs.mkdirSync(MCP_DATA_DIR, { recursive: true });
}
// Create the log file path with session ID
const LOG_FILENAME = `${CLI_NAME}.${SESSION_ID}.log`;
const LOG_FILEPATH = path.join(MCP_DATA_DIR, LOG_FILENAME);
// Write initial log header
fs.writeFileSync(
LOG_FILEPATH,
`# ${CLI_NAME} Log Session\n` +
`Session ID: ${SESSION_ID}\n` +
`Started: ${new Date().toISOString()}\n` +
`Process ID: ${process.pid}\n` +
`Working Directory: ${process.cwd()}\n` +
`Command: ${process.argv.join(' ')}\n\n` +
`## Log Entries\n\n`,
'utf8',
);
// Logger singleton to track initialization
let isLoggerInitialized = false;
/**
* Logger class for consistent logging across the application.
*
* RECOMMENDED USAGE:
*
* 1. Create a file-level logger using the static forContext method:
* ```
* const logger = Logger.forContext('controllers/myController.ts');
* ```
*
* 2. For method-specific logging, create a method logger:
* ```
* const methodLogger = Logger.forContext('controllers/myController.ts', 'myMethod');
* ```
*
* 3. Avoid using raw string prefixes in log messages. Instead, use contextualized loggers.
*
* 4. For debugging objects, use the debugResponse method to log only essential properties.
*
* 5. Set DEBUG environment variable to control which modules show debug logs:
* - DEBUG=true (enable all debug logs)
* - DEBUG=controllers/*,services/* (enable for specific module groups)
* - DEBUG=transport,utils/formatter* (enable specific modules, supports wildcards)
*/
class Logger {
private context?: string;
private modulePath: string;
private static sessionId = SESSION_ID;
private static logFilePath = LOG_FILEPATH;
constructor(context?: string, modulePath: string = '') {
this.context = context;
this.modulePath = modulePath;
// Log initialization message only once
if (!isLoggerInitialized) {
this.info(
`Logger initialized with session ID: ${Logger.sessionId}`,
);
this.info(`Logs will be saved to: ${Logger.logFilePath}`);
isLoggerInitialized = true;
}
}
/**
* Create a contextualized logger for a specific file or component.
* This is the preferred method for creating loggers.
*
* @param filePath The file path (e.g., 'controllers/aws.sso.auth.controller.ts')
* @param functionName Optional function name for more specific context
* @returns A new Logger instance with the specified context
*
* @example
* // File-level logger
* const logger = Logger.forContext('controllers/myController.ts');
*
* // Method-level logger
* const methodLogger = Logger.forContext('controllers/myController.ts', 'myMethod');
*/
static forContext(filePath: string, functionName?: string): Logger {
return new Logger(formatSourcePath(filePath, functionName), filePath);
}
/**
* Create a method level logger from a context logger
* @param method Method name
* @returns A new logger with the method context
*/
forMethod(method: string): Logger {
return Logger.forContext(this.modulePath, method);
}
private _formatMessage(message: string): string {
return this.context ? `${this.context} ${message}` : message;
}
private _formatArgs(args: unknown[]): unknown[] {
// If the first argument is an object and not an Error, safely stringify it
if (
args.length > 0 &&
typeof args[0] === 'object' &&
args[0] !== null &&
!(args[0] instanceof Error)
) {
args[0] = safeStringify(args[0]);
}
return args;
}
_log(
level: 'info' | 'warn' | 'error' | 'debug',
message: string,
...args: unknown[]
) {
// Skip debug messages if not enabled for this module
if (level === 'debug' && !isDebugEnabledForModule(this.modulePath)) {
return;
}
const timestamp = getTimestamp();
const prefix = `${timestamp} [${level.toUpperCase()}]`;
let logMessage = `${prefix} ${this._formatMessage(message)}`;
const formattedArgs = this._formatArgs(args);
if (formattedArgs.length > 0) {
// Handle errors specifically
if (formattedArgs[0] instanceof Error) {
const error = formattedArgs[0] as Error;
logMessage += ` Error: ${error.message}`;
if (error.stack) {
logMessage += `\n${error.stack}`;
}
// If there are more args, add them after the error
if (formattedArgs.length > 1) {
logMessage += ` ${formattedArgs
.slice(1)
.map((arg) =>
typeof arg === 'string' ? arg : safeStringify(arg),
)
.join(' ')}`;
}
} else {
logMessage += ` ${formattedArgs
.map((arg) =>
typeof arg === 'string' ? arg : safeStringify(arg),
)
.join(' ')}`;
}
}
// Write to log file
try {
fs.appendFileSync(Logger.logFilePath, `${logMessage}\n`, 'utf8');
} catch (err) {
// If we can't write to the log file, log the error to console
console.error(`Failed to write to log file: ${err}`);
}
if (process.env.NODE_ENV === 'test') {
console[level](logMessage);
} else {
console.error(logMessage);
}
}
info(message: string, ...args: unknown[]) {
this._log('info', message, ...args);
}
warn(message: string, ...args: unknown[]) {
this._log('warn', message, ...args);
}
error(message: string, ...args: unknown[]) {
this._log('error', message, ...args);
}
debug(message: string, ...args: unknown[]) {
this._log('debug', message, ...args);
}
/**
* Log essential information about an API response
* @param message Log message
* @param response API response object
* @param essentialKeys Keys to extract from the response
*/
debugResponse(
message: string,
response: Record<string, unknown>,
essentialKeys: string[],
) {
const essentialInfo = extractEssentialValues(response, essentialKeys);
this.debug(message, essentialInfo);
}
/**
* Get the current session ID
* @returns The UUID for the current logging session
*/
static getSessionId(): string {
return Logger.sessionId;
}
/**
* Get the current log file path
* @returns The path to the current log file
*/
static getLogFilePath(): string {
return Logger.logFilePath;
}
}
// Only export the Logger class to enforce contextual logging via Logger.forContext
export { Logger };

143
src/utils/transport.util.ts Normal file
View File

@@ -0,0 +1,143 @@
import { Logger } from './logger.util.js';
import {
createApiError,
createAuthInvalidError,
createUnexpectedError,
McpError,
} from './error.util.js';
// Create a contextualized logger for this file
const transportLogger = Logger.forContext('utils/transport.util.ts');
// Log transport utility initialization
transportLogger.debug('Transport utility initialized');
/**
* Interface for HTTP request options
*/
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
headers?: Record<string, string>;
body?: unknown;
}
/**
* Generic and reusable function to fetch data from any API endpoint.
* Handles standard HTTP request setup, response checking, basic error handling, and logging.
*
* @param url The full URL to fetch data from.
* @param options Request options including method, headers, and body.
* @returns The response data parsed as type T.
* @throws {McpError} If the request fails, including network errors, non-OK HTTP status, or JSON parsing issues.
*/
export async function fetchApi<T>(
url: string,
options: RequestOptions = {},
): Promise<T> {
const methodLogger = Logger.forContext(
'utils/transport.util.ts',
'fetchApi',
);
// Prepare standard request options
const requestOptions: RequestInit = {
method: options.method || 'GET',
headers: {
// Standard headers, allow overrides via options.headers
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
};
methodLogger.debug(`Executing API call: ${requestOptions.method} ${url}`);
const startTime = performance.now(); // Track performance
try {
const response = await fetch(url, requestOptions);
const endTime = performance.now();
const duration = (endTime - startTime).toFixed(2);
methodLogger.debug(
`API call completed in ${duration}ms with status: ${response.status} ${response.statusText}`,
{ url, status: response.status },
);
// Check if the response status is OK (2xx)
if (!response.ok) {
const errorText = await response.text(); // Get error body for context
methodLogger.error(
`API error response (${response.status}):`,
errorText,
);
// Classify standard HTTP errors
if (response.status === 401) {
throw createAuthInvalidError(
'Authentication failed. Check API token if required.',
);
} else if (response.status === 403) {
throw createAuthInvalidError(
'Permission denied for the requested resource.',
);
} else if (response.status === 404) {
throw createApiError(
'Resource not found at the specified URL.',
response.status,
errorText,
);
} else {
// Generic API error for other non-2xx statuses
throw createApiError(
`API request failed with status ${response.status}: ${response.statusText}`,
response.status,
errorText,
);
}
}
// Attempt to parse the response body as JSON
try {
const responseData = await response.json();
methodLogger.debug('Response body successfully parsed as JSON.');
// methodLogger.debug('Response Data:', responseData); // Uncomment for full response logging
return responseData as T;
} catch (parseError) {
methodLogger.error(
'Failed to parse API response JSON:',
parseError,
);
// Throw a specific error for JSON parsing failure
throw createApiError(
`Failed to parse API response JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
response.status, // Include original status for context
parseError,
);
}
} catch (error) {
const endTime = performance.now();
const duration = (endTime - startTime).toFixed(2);
methodLogger.error(
`API call failed after ${duration}ms for ${url}:`,
error,
);
// Rethrow if it's already an McpError (e.g., from status checks or parsing)
if (error instanceof McpError) {
throw error;
}
// Handle potential network errors (TypeError in fetch)
if (error instanceof TypeError) {
throw createApiError(
`Network error during API call: ${error.message}`,
undefined, // No specific HTTP status for network errors
error,
);
}
// Wrap any other unexpected errors
throw createUnexpectedError('Unexpected error during API call', error);
}
}