UNPKG

@debugg-ai/debugg-ai-mcp

Version:

Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.

300 lines (299 loc) 13.3 kB
#!/usr/bin/env node /** * DebuggAI MCP Server - Modernized Architecture * * This server provides AI-powered end-to-end testing capabilities through * the Model Context Protocol (MCP). It allows AI assistants to create, * execute, and monitor automated tests for web applications. * * Features: * - Structured logging with Winston * - Input validation with Zod schemas * - Centralized configuration management * - Modular tool architecture * - Proper error handling with MCP error codes * - Progress reporting for long-running operations * * @version 0.1.1 */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { config } from "./config/index.js"; import { initTools, getTools, getTool } from "./tools/index.js"; import { readResource, RESOURCE_COLLECTIONS, RESOURCE_TEMPLATES } from "./handlers/resourcesHandler.js"; import { Logger, validateInput, createErrorResponse, toMCPError, handleConfigurationError, Telemetry, TelemetryEvents, withStructuredContent, } from "./utils/index.js"; import { MCPErrorCode, MCPError, } from "./types/index.js"; // Logger and server are initialized lazily in main() to avoid triggering // config loading at module load time. If config validation fails (bad env vars), // the error is caught by main()'s try-catch instead of crashing before any // error handling is set up. let logger; let server; function createMCPServer() { return new Server({ name: config.server.name, version: config.server.version, description: "AI-powered browser automation and E2E testing platform for web applications.", }, { capabilities: { tools: { listChanged: false, }, resources: { listChanged: false, }, }, }); } /** * Create progress callback for tool execution */ function createProgressCallback(srv, progressToken) { if (!progressToken) return undefined; return async ({ progress, total, message }) => { try { await srv.notification({ method: "notifications/progress", params: { progressToken, progress, total, message, }, }); } catch (error) { logger.warn('Failed to send progress notification', { progressToken, progress, total, message, error: error instanceof Error ? error.message : String(error) }); } }; } /** * Build a fully-configured MCP Server (capabilities + all request handlers). * The HTTP transport calls this once per request for stateless isolation; main() * uses it for the long-lived stdio server. */ export function buildConfiguredServer() { const srv = createMCPServer(); registerHandlers(srv); return srv; } /** * Register MCP request handlers on a server instance. Called for the stdio * singleton in main(), and once per request by the HTTP transport (stateless). */ function registerHandlers(srv) { srv.setRequestHandler(CallToolRequestSchema, async (req) => { const typedReq = req; const requestId = `req_${Date.now()}`; const requestLogger = logger.child({ requestId }); requestLogger.info("Received tool call request", { toolName: typedReq.params.name, hasProgressToken: !!typedReq.params._meta?.progressToken, progressToken: typedReq.params._meta?.progressToken, progressTokenType: typeof typedReq.params._meta?.progressToken }); const { name, arguments: args } = typedReq.params; const progressToken = typedReq.params._meta?.progressToken; const tool = getTool(name); if (!tool) { requestLogger.warn(`Tool not found: ${name}`); throw new Error(`Unknown tool: ${name}`); } // Deferred config validation (bead cma): if DEBUGGAI_API_KEY is missing, // return a structured error MCP clients can surface in their UI — instead // of letting the backend return a cryptic 401. if (!config.api.key) { const mcpError = new MCPError(MCPErrorCode.CONFIGURATION_ERROR, 'DEBUGGAI_API_KEY is not set. ' + 'Configure it in your MCP server registration (e.g. `claude mcp add debugg-ai -s user -e DEBUGGAI_API_KEY=<your-key> -- npx -y @debugg-ai/debugg-ai-mcp`). ' + 'Get a key at https://debugg.ai.', { missingEnvVars: ['DEBUGGAI_API_KEY'] }); requestLogger.warn('Tool call blocked — DEBUGGAI_API_KEY missing', { tool: name }); return createErrorResponse(mcpError, name); } try { const validatedInput = validateInput(tool.inputSchema, args, name); const context = { progressToken: typeof progressToken === 'string' ? progressToken : undefined, requestId, timestamp: new Date(), }; const progressCallback = createProgressCallback(srv, typeof progressToken === 'string' || typeof progressToken === 'number' ? String(progressToken) : undefined); requestLogger.info(`Executing tool: ${name}`); const toolStart = Date.now(); const result = await tool.handler(validatedInput, context, progressCallback); const toolDuration = Date.now() - toolStart; requestLogger.info(`Tool execution completed: ${name}`); Telemetry.capture(TelemetryEvents.TOOL_EXECUTED, { toolName: name, durationMs: toolDuration, success: true }); // Promote the JSON text payload to structuredContent (back-compat: text stays). return withStructuredContent(result); } catch (error) { const mcpError = toMCPError(error, 'tool execution'); requestLogger.error('Tool execution failed', { errorCode: mcpError.code, message: mcpError.message, data: mcpError.data }); Telemetry.capture(TelemetryEvents.TOOL_FAILED, { toolName: name, errorCode: mcpError.code }); return createErrorResponse(mcpError, typedReq.params.name); } }); srv.setRequestHandler(ListToolsRequestSchema, async () => { const tools = getTools(); logger.info('Tools list requested', { toolCount: tools.length }); return { tools }; }); // Resources (epic pglam): browse projects/environments/executions as // addressable read URIs. Reads dispatch to the same entity handlers as the // tools, so data + auth stay consistent. srv.setRequestHandler(ListResourcesRequestSchema, async () => { logger.info('Resources list requested', { resourceCount: RESOURCE_COLLECTIONS.length }); return { resources: RESOURCE_COLLECTIONS }; }); srv.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { return { resourceTemplates: RESOURCE_TEMPLATES }; }); srv.setRequestHandler(ReadResourceRequestSchema, async (req) => { const uri = req.params?.uri; logger.info('Resource read requested', { uri }); try { return await readResource(uri); } catch (error) { const mcpError = toMCPError(error, 'resource read'); logger.error('Resource read failed', { uri, errorCode: mcpError.code, message: mcpError.message }); throw mcpError; } }); } /** * Main server initialization and startup */ async function main() { try { // Initialize logger and server here (not at module load time) so config // validation errors are caught by this try-catch instead of crashing. logger = new Logger({ module: 'main' }); const transportMode = (process.env.DEBUGGAI_MCP_TRANSPORT || 'stdio').toLowerCase(); logger.info('Starting DebuggAI MCP Server', { nodeVersion: process.version, platform: process.platform, architecture: process.arch, pid: process.pid, transport: transportMode, }); // stdio is single-user: the API key comes from the environment and is // validated at first tool call (bead cma). HTTP is multi-user: each request // carries its own bearer token, so a missing env key at boot is expected. if (transportMode !== 'http' && !config.api.key) { logger.warn('DEBUGGAI_API_KEY is not set. Server will boot but every tool call will return a ConfigurationError until the env var is configured.'); } // Initialize telemetry: PostHog by default (public project key embedded // in config so the team can observe cache hit rates, poll cadence, etc. // across all installs). Falls back to Noop when DEBUGGAI_TELEMETRY_DISABLED // is set. Distinct ID is SHA-256(apiKey) — never the raw key. Telemetry.setDistinctId(config.api.key); if (config.telemetry.posthogApiKey) { const { PostHogProvider } = await import('./services/posthogProvider.js'); Telemetry.configure(new PostHogProvider(config.telemetry.posthogApiKey, { host: config.telemetry.posthogHost, })); const usingDefault = !process.env.POSTHOG_API_KEY; logger.info(usingDefault ? 'Telemetry enabled (PostHog, DebuggAI default project). Set DEBUGGAI_TELEMETRY_DISABLED=1 to opt out.' : 'Telemetry enabled (PostHog, custom POSTHOG_API_KEY)'); } else { logger.info('Telemetry disabled (DEBUGGAI_TELEMETRY_DISABLED is set)'); } // No API calls at boot. Project context is resolved lazily on first tool // invocation (list_environments / list_credentials / check_app_in_browser). initTools(null); if (transportMode === 'http') { // Remote/hosted transport (epic lybfq): stateless Streamable HTTP + OAuth // Resource Server. stdio stays the default and is unaffected. const { startHttpServer } = await import('./httpServer.js'); const port = Number(process.env.PORT) || 3000; await startHttpServer({ port, buildServer: buildConfiguredServer, logger }); logger.info('DebuggAI MCP Server is running and ready to accept requests', { transport: 'http', port, toolsAvailable: getTools().map(t => t.name), }); } else { server = createMCPServer(); registerHandlers(server); const transport = new StdioServerTransport(); await server.connect(transport); logger.info('DebuggAI MCP Server is running and ready to accept requests', { transport: 'stdio', toolsAvailable: getTools().map(t => t.name), }); } } catch (error) { logger.error('Failed to start DebuggAI MCP Server', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }); throw handleConfigurationError(error); } } /** * Safe log helper — falls back to stderr if logger isn't initialized yet * (e.g. config validation failed before logger was created). */ function safeLog(level, message, meta) { try { if (logger) { logger[level](message, meta); return; } } catch { // Logger init failed (config validation error) — fall through to stderr } process.stderr.write(`[${level.toUpperCase()}] ${message} ${meta ? JSON.stringify(meta) : ''}\n`); } /** * Handle graceful shutdown */ async function gracefulShutdown(signal) { safeLog('info', `Received ${signal}, shutting down gracefully`); const { tunnelManager } = await import('./services/ngrok/tunnelManager.js'); await tunnelManager.stopAllTunnels().catch((err) => safeLog('warn', 'stopAllTunnels failed during shutdown', { error: String(err) })); await Telemetry.shutdown(); process.exit(0); } process.on('SIGINT', () => { gracefulShutdown('SIGINT'); }); process.on('SIGTERM', () => { gracefulShutdown('SIGTERM'); }); process.on('unhandledRejection', (reason) => { safeLog('error', 'Unhandled promise rejection', { error: reason instanceof Error ? reason.message : String(reason), stack: reason instanceof Error ? reason.stack : undefined, }); }); process.on('uncaughtException', (error) => { safeLog('error', 'Uncaught exception', { error: error.message, stack: error.stack, }); }); /** * Start the server */ main().catch((error) => { safeLog('error', 'Fatal error during startup', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); process.exit(1); });