-
Notifications
You must be signed in to change notification settings - Fork 0
[WIP] Update semantic state and log analysis tools for MCP server #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Add MCP server support to PM2 for process management through MCP-compatible clients.
Features:
- New pm2-mcp binary that exposes PM2 process management via MCP
- 12 MCP tools for process lifecycle, logging, and monitoring:
- pm2_list_processes, pm2_describe_process
- pm2_start_process, pm2_restart_process, pm2_reload_process
- pm2_stop_process, pm2_delete_process
- pm2_flush_logs, pm2_reload_logs, pm2_tail_logs
- pm2_dump, pm2_kill_daemon
- 2 MCP resources for real-time process information:
- pm2://processes (list)
- pm2://process/{id} (detail)
- Automatic sandbox environment detection and adaptation
- Support for stdio and HTTP (Streamable) transports
- Client notifications for sandbox status and recommendations
- Compatible with Claude Code, Codex, and other MCP clients
Implementation:
- New lib/mcp/server.js with full MCP server implementation
- Uses @modelcontextprotocol/sdk for MCP protocol
- Sandbox detection checks home directory writability and environment
- Auto-selects writable PM2_HOME in sandboxed environments
- No-daemon mode by default for MCP client compatibility
- Comprehensive environment variable configuration
Documentation:
- README with MCP server quickstart and setup commands
- Environment variables table (PM2_MCP_*, PM2_HOME, etc.)
- Sandbox detection explanation
- Tool and resource documentation
- Justfile recipes for easy registration with MCP clients
Related:
- Enables pkgx packaging: pkgxdev/pantry#11219
- Development fork: https://github.com/PromptExecution/pm2-mcp
- MCP Specification: https://modelcontextprotocol.io/
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Brian Horakh <35611074+elasticdotventures@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Brian Horakh <35611074+elasticdotventures@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Brian Horakh <35611074+elasticdotventures@users.noreply.github.com>
Signed-off-by: Brian Horakh <35611074+elasticdotventures@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
* Initial plan * Add explanation of Codex CLI tool in README Co-authored-by: elasticdotventures <35611074+elasticdotventures@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: elasticdotventures <35611074+elasticdotventures@users.noreply.github.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This pull request adds semantic state enrichment, log analysis capabilities, and privacy-safe process description to the PM2 MCP server. The changes build upon PR #6 to provide enhanced process monitoring with intelligent log pattern detection and secret redaction features.
Changes:
- Added semantic state inference from log patterns, restart counts, and CPU/log activity metrics
- Introduced two new MCP tools:
pm2_analyze_logsfor log pattern analysis andpm2_describe_process_safefor privacy-aware process descriptions - Enhanced process listings and descriptions with semantic state information
- Updated documentation to clarify Node.js version requirements and add Codex CLI setup instructions
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 13 comments.
| File | Description |
|---|---|
| lib/mcp/server.js | Implements semantic state enrichment, log analysis tools, secret redaction, environment filtering, connection race condition handling, and HTTP timeout configuration |
| README.md | Updates Node.js version documentation and adds Codex CLI integration instructions |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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***'; |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The redactValue function checks if the value string itself contains secret keywords (e.g., "TOKEN", "PASSWORD"), which means any environment variable value that happens to contain these words will be redacted, even if it's not actually a secret. For example, a value like "Use password authentication" would be redacted. Consider checking the key name instead of or in addition to the value to reduce false positives. The function should likely receive both the key and value as parameters.
| 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, |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The timeframe_minutes parameter is accepted and included in the response payload but is not actually used in the log analysis logic. The readRecentLogLines function uses the lines parameter to determine how many log lines to read, but doesn't filter by time. Consider either using this parameter to filter logs by timestamp or remove it from the schema if it's not needed.
| try { | ||
| await ensureConnected(); | ||
| const processes = (await pm2List()).map(formatProcess); | ||
| const processes = await Promise.all((await pm2List()).map(proc => enrichProcess(proc))); |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The enrichProcess function reads log files for each process, which can be slow when listing many processes. In the pm2_list_processes tool and pm2-process-list resource, this happens for all processes in parallel (via Promise.all), which could cause performance issues if there are many processes. Consider making the semantic state enrichment optional or lazy-loaded, especially for the list operation where users may not need detailed state information for every process.
| const processes = await Promise.all((await pm2List()).map(proc => enrichProcess(proc))); | |
| const list = await pm2List(); | |
| const processes = []; | |
| for (const proc of list) { | |
| // Enrich each process sequentially to avoid unbounded parallel log reads | |
| processes.push(await enrichProcess(proc)); | |
| } |
| } | ||
|
|
||
| if (/error|exception|fatal/i.test(line)) { | ||
| errorsFound.push({ line }); | ||
| } else if (/warn(ing)?/i.test(line)) { | ||
| warningsFound.push({ line }); |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the analyzeLogPatterns function, line is converted to lowercase for pattern matching (line 471), but then the original line is tested again with the same regex patterns in lines 501-504 for errors and warnings. The error/warning pattern logic duplicates the patterns already defined in LOG_PATTERNS. Consider removing this duplication and relying solely on the LOG_PATTERNS array for consistency.
| } | |
| if (/error|exception|fatal/i.test(line)) { | |
| errorsFound.push({ line }); | |
| } else if (/warn(ing)?/i.test(line)) { | |
| warningsFound.push({ line }); | |
| if (pattern.semanticStatus === 'error') { | |
| errorsFound.push({ line }); | |
| } else if (pattern.semanticStatus === 'warning') { | |
| warningsFound.push({ line }); | |
| } |
| ``` | ||
|
|
||
| #### Codex (stdio) | ||
| [Codex](https://developers.openai.com/codex/mcp) is OpenAI's CLI tool for AI-assisted development with MCP support. |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The link to Codex documentation (https://developers.openai.com/codex/mcp) may be incorrect or the description may be inaccurate. As of my knowledge cutoff in January 2025, OpenAI's Codex was primarily an API for code generation, not a CLI tool. Please verify that this URL and description are accurate, and update if necessary. If this refers to a different tool or a recent release, consider adding more context or a brief explanation.
| [Codex](https://developers.openai.com/codex/mcp) is OpenAI's CLI tool for AI-assisted development with MCP support. | |
| Codex is a CLI tool for AI-assisted development with MCP support. The following commands assume you have a `codex` MCP client installed and configured on your system. |
| 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; | ||
| } | ||
| } |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If multiple concurrent calls to ensureConnected occur while a connection is in progress, they will all wait for the same connectionPromise, but if the connection fails, only the first caller will see the error. Subsequent callers will return from the function after the promise is set to null in the finally block without attempting to reconnect or receiving the error. This could lead to tools thinking they're connected when they're not. Consider storing and re-throwing the error, or checking isConnected after awaiting the promise.
| const { lines: logLines, logPath } = await readRecentLogLines(env, lines); | ||
| const logAnalysis = analyzeLogPatterns(logLines); | ||
| const semantic = buildSemanticStateFromHeuristics({ | ||
| env, | ||
| monit: description[0].monit, | ||
| logAnalysis, | ||
| logInfo: { lastModified: null } |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the pm2_analyze_logs tool, the logInfo parameter passed to buildSemanticStateFromHeuristics has lastModified set to null (line 970), even though readRecentLogLines returns the actual lastModified value. This means the "stuck" detection logic in buildSemanticStateFromHeuristics (lines 559-572) will never trigger during log analysis. Consider passing the actual logInfo returned from readRecentLogLines instead of creating a new object with lastModified: null.
| const { lines: logLines, logPath } = await readRecentLogLines(env, lines); | |
| const logAnalysis = analyzeLogPatterns(logLines); | |
| const semantic = buildSemanticStateFromHeuristics({ | |
| env, | |
| monit: description[0].monit, | |
| logAnalysis, | |
| logInfo: { lastModified: null } | |
| const { lines: logLines, logPath, logInfo } = await readRecentLogLines(env, lines); | |
| const logAnalysis = analyzeLogPatterns(logLines); | |
| const semantic = buildSemanticStateFromHeuristics({ | |
| env, | |
| monit: description[0].monit, | |
| logAnalysis, | |
| logInfo |
| 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); | ||
| } | ||
| }) | ||
| ); |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new pm2_describe_process_safe tool is not covered by tests in test/mcp/server.mocha.js. The test file has comprehensive coverage for other tools - consider adding test cases for this new tool to maintain consistent test coverage across the codebase.
| 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; | ||
| } |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The semantic state enrichment functionality (buildSemanticState, enrichProcess, analyzeLogPatterns, extractProgressFromLine, etc.) is not covered by tests in test/mcp/server.mocha.js. The test file has comprehensive coverage for other functionality - consider adding test cases for these new functions to verify log pattern detection, progress extraction, and semantic state inference logic.
| 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); |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The regex pattern /[A-Za-z0-9]{40,}/ at the end of the secret detection regex is very broad and will match any alphanumeric string of 40+ characters, which could include non-secret values like UUIDs, hashes, or identifiers. This could lead to excessive false positives. Consider removing this pattern or making it more specific, or only applying it when the value also contains a secret keyword.
| /(?: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); | |
| /(?: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})/.test(value); |
Thanks for the feedback on #6. I've created this new PR, which merges into #6, to address your comment. I will work on the changes and keep this PR's description up to date as I make progress.
Original PR: #6
Triggering review: #6 (comment)
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.