Skip to content

Conversation

Copy link

Copilot AI commented Jan 25, 2026

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)

@copilot open a new pull request to apply changes based on this feedback


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

elasticdotventures and others added 12 commits December 3, 2025 12:12
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>
Base automatically changed from feature/mcp-semantic-state to master January 25, 2026 01:23
@elasticdotventures elasticdotventures marked this pull request as ready for review January 25, 2026 01:24
Copilot AI review requested due to automatic review settings January 25, 2026 01:24
@elasticdotventures elasticdotventures merged commit 9585f7c into master Jan 25, 2026
1 check failed
@elasticdotventures elasticdotventures deleted the copilot/sub-pr-6 branch January 25, 2026 01:24
Copilot stopped work on behalf of elasticdotventures due to an error January 25, 2026 01:24
Copy link

Copilot AI left a 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_logs for log pattern analysis and pm2_describe_process_safe for 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.

Comment on lines +387 to +397
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***';
Copy link

Copilot AI Jan 25, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +956 to +980
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,
Copy link

Copilot AI Jan 25, 2026

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.

Copilot uses AI. Check for mistakes.
try {
await ensureConnected();
const processes = (await pm2List()).map(formatProcess);
const processes = await Promise.all((await pm2List()).map(proc => enrichProcess(proc)));
Copy link

Copilot AI Jan 25, 2026

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.

Suggested change
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));
}

Copilot uses AI. Check for mistakes.
Comment on lines +499 to +504
}

if (/error|exception|fatal/i.test(line)) {
errorsFound.push({ line });
} else if (/warn(ing)?/i.test(line)) {
warningsFound.push({ line });
Copy link

Copilot AI Jan 25, 2026

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.

Suggested change
}
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 });
}

Copilot uses AI. Check for mistakes.
```

#### Codex (stdio)
[Codex](https://developers.openai.com/codex/mcp) is OpenAI's CLI tool for AI-assisted development with MCP support.
Copy link

Copilot AI Jan 25, 2026

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.

Suggested change
[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.

Copilot uses AI. Check for mistakes.
Comment on lines 134 to 159
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;
}
}
Copy link

Copilot AI Jan 25, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +964 to +970
const { lines: logLines, logPath } = await readRecentLogLines(env, lines);
const logAnalysis = analyzeLogPatterns(logLines);
const semantic = buildSemanticStateFromHeuristics({
env,
monit: description[0].monit,
logAnalysis,
logInfo: { lastModified: null }
Copy link

Copilot AI Jan 25, 2026

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +1006 to +1040
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);
}
})
);
Copy link

Copilot AI Jan 25, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +438 to +597
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;
}
Copy link

Copilot AI Jan 25, 2026

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.

Copilot uses AI. Check for mistakes.
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);
Copy link

Copilot AI Jan 25, 2026

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.

Suggested change
/(?: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);

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants