diff --git a/README.md b/README.md index a081098f4..b11fdeb74 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ PM2 is constantly assailed by [more than 1800 tests](https://github.com/Unitech/ Official website: [https://pm2.keymetrics.io/](https://pm2.keymetrics.io/) -Works on Linux (stable) & macOS (stable) & Windows (stable). All Node.js versions are supported starting Node.js 22.0.0 and Bun since v1 +Works on Linux (stable) & macOS (stable) & Windows (stable). Node.js 22.0.0 and later are supported, and Bun since v1 ## Installing PM2 @@ -241,6 +241,8 @@ claude mcp get pm2-mcp ``` #### Codex (stdio) +[Codex](https://developers.openai.com/codex/mcp) is OpenAI's CLI tool for AI-assisted development with MCP support. + ```bash # Add pm2-mcp to Codex codex mcp add pm2-mcp -- pm2-mcp diff --git a/lib/mcp/server.js b/lib/mcp/server.js index d8d7bd271..d19c67f49 100644 --- a/lib/mcp/server.js +++ b/lib/mcp/server.js @@ -129,23 +129,33 @@ function errorResult(err) { }; } +let connectionPromise = null; + async function ensureConnected() { if (isConnected) return; - log('connecting to PM2 (noDaemon default true, override with PM2_MCP_NO_DAEMON)'); - await new Promise((resolve, reject) => { - // Default to no-daemon mode so the MCP server can start without needing an existing PM2 daemon. - const noDaemon = - process.env.PM2_MCP_NO_DAEMON === undefined - ? true - : process.env.PM2_MCP_NO_DAEMON === 'true'; - log('pm2.connect noDaemon=%s', noDaemon); - pm2.connect(noDaemon, err => { - if (err) return reject(err); - isConnected = true; - log('connected to PM2'); - return resolve(); + if (connectionPromise) return connectionPromise; + connectionPromise = (async () => { + log('connecting to PM2 (noDaemon default true, override with PM2_MCP_NO_DAEMON)'); + await new Promise((resolve, reject) => { + // Default to no-daemon mode so the MCP server can start without needing an existing PM2 daemon. + const noDaemon = + process.env.PM2_MCP_NO_DAEMON === undefined + ? true + : process.env.PM2_MCP_NO_DAEMON === 'true'; + log('pm2.connect noDaemon=%s', noDaemon); + pm2.connect(noDaemon, err => { + if (err) return reject(err); + isConnected = true; + log('connected to PM2'); + return resolve(); + }); }); - }); + })(); + try { + await connectionPromise; + } finally { + connectionPromise = null; + } } async function disconnectPm2() { @@ -334,6 +344,10 @@ function createTransport(options = {}) { } }); + // Set timeouts to protect against slow HTTP attacks + httpServer.timeout = HTTP_SERVER_TIMEOUT_MS; + httpServer.headersTimeout = HTTP_HEADERS_TIMEOUT_MS; + httpServer.requestTimeout = HTTP_REQUEST_TIMEOUT_MS; httpServer.listen(port, host, () => { if (process.env.PM2_MCP_DEBUG === 'true') { console.error('[pm2-mcp][debug] HTTP transport listening', `${host}:${port}${pathPart}`); @@ -368,6 +382,220 @@ async function tailFile(filePath, lineCount) { } } +const SECRET_KEYS = ['KEY', 'TOKEN', 'SECRET', 'PASSWORD', 'PASS', 'CREDENTIAL', 'API']; + +function redactValue(value) { + if (typeof value !== 'string') return value; + if (value.length === 0) return value; + + const upper = value.toUpperCase(); + const looksSecret = + SECRET_KEYS.some(key => upper.includes(key)) || + /(?:sk-[A-Za-z0-9]{32,}|gh[pousr]_[A-Za-z0-9]{36,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,48}|[A-Za-z0-9]{40,})/.test(value); + + if (!looksSecret) return value; + return '***REDACTED***'; +} + +function filterEnvironment(env = {}, filterList, redactSecrets = true) { + const target = {}; + const entries = Object.entries(env); + const shouldFilter = Array.isArray(filterList) && filterList.length > 0; + for (const [key, value] of entries) { + if (shouldFilter && !filterList.includes(key)) continue; + target[key] = redactSecrets ? redactValue(value) : value; + } + return target; +} + +function sanitizeDescriptionEntry(entry, options = {}) { + const cloned = JSON.parse(JSON.stringify(entry || {})); + const env = cloned.pm2_env || {}; + + if (!options.includeEnvironment) { + delete env.env; + delete env.env_pm2; + } else { + env.env = filterEnvironment(env.env, options.environmentFilter, options.redactSecrets); + env.env_pm2 = filterEnvironment(env.env_pm2, options.environmentFilter, options.redactSecrets); + } + + cloned.pm2_env = env; + return cloned; +} + +const LOG_PATTERNS = [ + { id: 'copying_blob', label: 'copying_blob', regex: /copying blob|pulling fs layer|pulling|download/i, semanticStatus: 'downloading' }, + { id: 'building', label: 'building_assets', regex: /building|webpack|bundling|compile|transpil/i, semanticStatus: 'processing' }, + { id: 'installing', label: 'installing_dependencies', regex: /install(ing)? packages|npm install|yarn install|pnpm install/i, semanticStatus: 'processing' }, + { id: 'server_listening', label: 'server_listening', regex: /listening on|listening at|server (started|listening)|ready on|running at/i, semanticStatus: 'online' }, + { id: 'health_checks', label: 'healthcheck', regex: /healthcheck|health check/i, semanticStatus: 'online' }, + { id: 'retrying', label: 'retrying', regex: /retry|reconnect|backoff/i, semanticStatus: 'degraded' }, + { id: 'error', label: 'error', regex: /error|exception|traceback|fatal/i, semanticStatus: 'degraded' }, + { id: 'warning', label: 'warning', regex: /warn(ing)?/i, semanticStatus: 'online' } +]; + +function extractProgressFromLine(line) { + const percentMatch = line.match(/(\d{1,3})\s?%/); + if (percentMatch) { + const pct = Number(percentMatch[1]); + if (!Number.isNaN(pct) && pct >= 0 && pct <= 100) { + return { metric: 'percent', current: pct, estimated_total: 100, trend: 'increasing', percent: pct }; + } + } + + const fractionMatch = line.match(/(\d+)\s*\/\s*(\d+(?:\.\d+)?)(\b|[^\d]|$)/); + if (fractionMatch) { + const current = Number(fractionMatch[1]); + const total = Number(fractionMatch[2]); + if (!Number.isNaN(current) && !Number.isNaN(total) && total > 0) { + return { + metric: 'count', + current, + estimated_total: total, + trend: current >= total ? 'stable' : 'increasing', + percent: Math.min(100, Math.round((current / total) * 100)) + }; + } + } + return null; +} + +function analyzeLogPatterns(logLines = []) { + const detectedPatterns = []; + const errorsFound = []; + const warningsFound = []; + const progressIndicators = []; + + for (const line of logLines) { + const lower = line.toLowerCase(); + + const progress = extractProgressFromLine(line); + if (progress) { + progressIndicators.push({ + metric: progress.metric === 'percent' ? 'percent_complete' : 'items_processed', + current: progress.current, + estimated_total: progress.estimated_total, + trend: progress.trend, + percent: progress.percent + }); + } + + for (const pattern of LOG_PATTERNS) { + if (!pattern.regex.test(lower)) continue; + const entry = detectedPatterns.find(p => p.pattern === pattern.label); + if (entry) { + entry.occurrences += 1; + entry.last_seen = new Date().toISOString(); + } else { + detectedPatterns.push({ + pattern: pattern.label, + occurrences: 1, + last_seen: new Date().toISOString(), + sample: line, + semanticStatus: pattern.semanticStatus + }); + } + } + + if (/error|exception|fatal/i.test(line)) { + errorsFound.push({ line }); + } else if (/warn(ing)?/i.test(line)) { + warningsFound.push({ line }); + } + } + + const topPattern = detectedPatterns.length > 0 ? detectedPatterns.reduce((a, b) => (a.occurrences >= b.occurrences ? a : b)) : null; + + return { detectedPatterns, errorsFound, warningsFound, progressIndicators, topPattern }; +} + +async function readRecentLogLines(env = {}, lineCount = 200) { + const logPath = env.pm_log_path || env.pm_out_log_path || env.pm_err_log_path; + if (!logPath) return { lines: [], logPath: null, lastModified: null }; + try { + const [lines, stats] = await Promise.all([tailFile(logPath, lineCount), fs.promises.stat(logPath)]); + return { lines, logPath, lastModified: stats.mtimeMs }; + } catch { + return { lines: [], logPath, lastModified: null }; + } +} + +function buildSemanticStateFromHeuristics(opts) { + const { env = {}, monit = {}, logAnalysis, logInfo } = opts; + const baseStatus = env.status || 'unknown'; + + let status = baseStatus === 'online' ? 'online' : baseStatus; + let context; + let inferredFrom = 'status'; + let confidence = 0.4; + let progress; + + if (logAnalysis?.topPattern) { + status = logAnalysis.topPattern.semanticStatus || status; + context = logAnalysis.topPattern.sample; + inferredFrom = 'log_pattern_match'; + confidence = 0.9; + } + + if (logAnalysis?.progressIndicators?.length) { + const latest = logAnalysis.progressIndicators[logAnalysis.progressIndicators.length - 1]; + progress = latest.percent ?? latest.current; + } + + const restartCount = env.restart_time || 0; + if (baseStatus === 'online' && restartCount >= 3 && confidence < 0.85) { + status = 'degraded'; + context = `Restarted ${restartCount} times`; + inferredFrom = 'restart_count'; + confidence = Math.max(confidence, 0.65); + } + + const cpu = typeof monit.cpu === 'number' ? monit.cpu : null; + const now = Date.now(); + const logAgeMs = logInfo?.lastModified ? now - logInfo.lastModified : null; + const uptimeMs = env.pm_uptime || null; + + if ( + baseStatus === 'online' && + logAgeMs !== null && + uptimeMs && + uptimeMs > 2 * 60 * 1000 && + logAgeMs > 5 * 60 * 1000 && + cpu !== null && + cpu < 1 + ) { + status = 'stuck'; + context = `No logs for ${Math.round(logAgeMs / 60000)}m, cpu ${cpu}%`; + inferredFrom = 'log_silence'; + confidence = Math.max(confidence, 0.7); + } + + if (!context) context = `Status ${status}`; + + return { + status, + context, + progress, + confidence: Number(confidence.toFixed(2)), + inferred_from: inferredFrom + }; +} + +async function buildSemanticState(procLike) { + const env = procLike.pm2_env || {}; + const monit = procLike.monit || procLike.pm2_env?.monit || {}; + const logInfo = await readRecentLogLines(env, 200); + const logAnalysis = analyzeLogPatterns(logInfo.lines); + return buildSemanticStateFromHeuristics({ env, monit, logAnalysis, logInfo }); +} + +async function enrichProcess(proc) { + const base = formatProcess(proc); + base.semantic_state = await buildSemanticState(proc); + return base; +} + function registerTools() { const startSchema = z .object({ @@ -419,6 +647,19 @@ function registerTools() { lines: z.number().int().positive().max(500).default(60) }); + const analyzeLogsSchema = z.object({ + process: processTargetSchema, + timeframe_minutes: z.number().int().positive().max(1440).default(5), + lines: z.number().int().positive().max(1000).default(400) + }); + + const describeSafeSchema = z.object({ + process: processTargetSchema, + include_environment: z.boolean().default(false), + environment_filter: z.array(z.string()).optional(), + redact_secrets: z.boolean().default(true) + }); + server.registerTool( 'pm2_list_processes', { @@ -428,7 +669,7 @@ function registerTools() { wrapTool('pm2_list_processes', async () => { try { await ensureConnected(); - const processes = (await pm2List()).map(formatProcess); + const processes = await Promise.all((await pm2List()).map(proc => enrichProcess(proc))); return { content: textContent(processes), structuredContent: { processes } @@ -453,9 +694,12 @@ function registerTools() { if (!description || description.length === 0) { throw new Error(`No process found for "${process}"`); } + const withState = await Promise.all( + description.map(async item => ({ ...item, semantic_state: await buildSemanticState(item) })) + ); return { - content: textContent(description), - structuredContent: { description } + content: textContent(withState), + structuredContent: { description: withState } }; } catch (err) { return errorResult(err); @@ -702,6 +946,99 @@ function registerTools() { }) ); + server.registerTool( + 'pm2_analyze_logs', + { + title: 'Analyze PM2 logs', + description: 'Parse recent logs for activity, patterns, and errors.', + inputSchema: analyzeLogsSchema + }, + wrapTool('pm2_analyze_logs', async ({ process, timeframe_minutes, lines }) => { + try { + await ensureConnected(); + const description = await pm2Describe(process); + if (!description || description.length === 0) { + throw new Error(`No process found for "${process}"`); + } + const env = description[0].pm2_env || {}; + const { lines: logLines, logPath } = await readRecentLogLines(env, lines); + const logAnalysis = analyzeLogPatterns(logLines); + const semantic = buildSemanticStateFromHeuristics({ + env, + monit: description[0].monit, + logAnalysis, + logInfo: { lastModified: null } + }); + + let suggested_action = 'none'; + if (logAnalysis.errorsFound.length > 0) suggested_action = 'investigate'; + else if (logAnalysis.topPattern?.semanticStatus === 'downloading') suggested_action = 'wait_for_completion'; + else if (logAnalysis.topPattern?.semanticStatus === 'degraded') suggested_action = 'investigate'; + + const payload = { + process, + timeframe_minutes, + analysis: { + current_activity: logAnalysis.topPattern?.pattern || semantic.status, + detected_patterns: logAnalysis.detectedPatterns, + errors_found: logAnalysis.errorsFound, + warnings_found: logAnalysis.warningsFound, + progress_indicators: logAnalysis.progressIndicators, + anomalies: [], + suggested_action + }, + meta: { + log_path: logPath, + semantic_state: semantic + } + }; + + return { + content: textContent(payload), + structuredContent: payload + }; + } catch (err) { + return errorResult(err); + } + }) + ); + + server.registerTool( + 'pm2_describe_process_safe', + { + title: 'Describe a PM2 process (privacy-safe)', + description: 'Describe a process with optional environment filtering and secret redaction.', + inputSchema: describeSafeSchema + }, + wrapTool('pm2_describe_process_safe', async ({ process, include_environment, environment_filter, redact_secrets }) => { + try { + await ensureConnected(); + const description = await pm2Describe(process); + if (!description || description.length === 0) { + throw new Error(`No process found for "${process}"`); + } + + const sanitized = await Promise.all( + description.map(async item => ({ + ...sanitizeDescriptionEntry(item, { + includeEnvironment: include_environment, + environmentFilter: environment_filter, + redactSecrets: redact_secrets + }), + semantic_state: await buildSemanticState(item) + })) + ); + + return { + content: textContent(sanitized), + structuredContent: { description: sanitized } + }; + } catch (err) { + return errorResult(err); + } + }) + ); + server.registerTool( 'pm2_kill_daemon', { @@ -735,7 +1072,7 @@ function registerResources() { }, async () => { await ensureConnected(); - const processes = (await pm2List()).map(formatProcess); + const processes = await Promise.all((await pm2List()).map(proc => enrichProcess(proc))); return { contents: [ { @@ -794,7 +1131,7 @@ function registerResources() { { uri: uri.href, mimeType: 'application/json', - text: renderJson(description[0]) + text: renderJson({ ...description[0], semantic_state: await buildSemanticState(description[0]) }) } ] };