Skip to content

Commit e5be71b

Browse files
Add semantic state and log analysis tools to MCP server
1 parent 9dea47b commit e5be71b

File tree

1 file changed

+328
-5
lines changed

1 file changed

+328
-5
lines changed

lib/mcp/server.js

Lines changed: 328 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,220 @@ async function tailFile(filePath, lineCount) {
368368
}
369369
}
370370

371+
const SECRET_KEYS = ['KEY', 'TOKEN', 'SECRET', 'PASSWORD', 'PASS', 'CREDENTIAL', 'API'];
372+
373+
function redactValue(value) {
374+
if (typeof value !== 'string') return value;
375+
if (value.length === 0) return value;
376+
377+
const upper = value.toUpperCase();
378+
const looksSecret =
379+
SECRET_KEYS.some(key => upper.includes(key)) ||
380+
/([A-Za-z0-9]{24,}|sk-[A-Za-z0-9]{16,})/.test(value);
381+
382+
if (!looksSecret) return value;
383+
return '***REDACTED***';
384+
}
385+
386+
function filterEnvironment(env = {}, filterList, redactSecrets = true) {
387+
const target = {};
388+
const entries = Object.entries(env);
389+
const shouldFilter = Array.isArray(filterList) && filterList.length > 0;
390+
for (const [key, value] of entries) {
391+
if (shouldFilter && !filterList.includes(key)) continue;
392+
target[key] = redactSecrets ? redactValue(value) : value;
393+
}
394+
return target;
395+
}
396+
397+
function sanitizeDescriptionEntry(entry, options = {}) {
398+
const cloned = JSON.parse(JSON.stringify(entry || {}));
399+
const env = cloned.pm2_env || {};
400+
401+
if (!options.includeEnvironment) {
402+
delete env.env;
403+
delete env.env_pm2;
404+
} else {
405+
env.env = filterEnvironment(env.env, options.environmentFilter, options.redactSecrets);
406+
env.env_pm2 = filterEnvironment(env.env_pm2, options.environmentFilter, options.redactSecrets);
407+
}
408+
409+
cloned.pm2_env = env;
410+
return cloned;
411+
}
412+
413+
const LOG_PATTERNS = [
414+
{ id: 'copying_blob', label: 'copying_blob', regex: /copying blob|pulling fs layer|pulling|download/i, semanticStatus: 'downloading' },
415+
{ id: 'building', label: 'building_assets', regex: /building|webpack|bundling|compile|transpil/i, semanticStatus: 'processing' },
416+
{ id: 'installing', label: 'installing_dependencies', regex: /install(ing)? packages|npm install|yarn install|pnpm install/i, semanticStatus: 'processing' },
417+
{ id: 'server_listening', label: 'server_listening', regex: /listening on|listening at|server (started|listening)|ready on|running at/i, semanticStatus: 'online' },
418+
{ id: 'health_checks', label: 'healthcheck', regex: /healthcheck|health check/i, semanticStatus: 'online' },
419+
{ id: 'retrying', label: 'retrying', regex: /retry|reconnect|backoff/i, semanticStatus: 'degraded' },
420+
{ id: 'error', label: 'error', regex: /error|exception|traceback|fatal/i, semanticStatus: 'degraded' },
421+
{ id: 'warning', label: 'warning', regex: /warn(ing)?/i, semanticStatus: 'online' }
422+
];
423+
424+
function extractProgressFromLine(line) {
425+
const percentMatch = line.match(/(\d{1,3})\s?%/);
426+
if (percentMatch) {
427+
const pct = Number(percentMatch[1]);
428+
if (!Number.isNaN(pct) && pct >= 0 && pct <= 100) {
429+
return { metric: 'percent', current: pct, estimated_total: 100, trend: 'increasing', percent: pct };
430+
}
431+
}
432+
433+
const fractionMatch = line.match(/(\d+)\s*\/\s*(\d+)(?!\d)/);
434+
if (fractionMatch) {
435+
const current = Number(fractionMatch[1]);
436+
const total = Number(fractionMatch[2]);
437+
if (!Number.isNaN(current) && !Number.isNaN(total) && total > 0) {
438+
return {
439+
metric: 'count',
440+
current,
441+
estimated_total: total,
442+
trend: current >= total ? 'stable' : 'increasing',
443+
percent: Math.min(100, Math.round((current / total) * 100))
444+
};
445+
}
446+
}
447+
return null;
448+
}
449+
450+
function analyzeLogPatterns(logLines = []) {
451+
const detectedPatterns = [];
452+
const errorsFound = [];
453+
const warningsFound = [];
454+
const progressIndicators = [];
455+
456+
for (const line of logLines) {
457+
const lower = line.toLowerCase();
458+
459+
const progress = extractProgressFromLine(line);
460+
if (progress) {
461+
progressIndicators.push({
462+
metric: progress.metric === 'percent' ? 'percent_complete' : 'items_processed',
463+
current: progress.current,
464+
estimated_total: progress.estimated_total,
465+
trend: progress.trend,
466+
percent: progress.percent
467+
});
468+
}
469+
470+
for (const pattern of LOG_PATTERNS) {
471+
if (!pattern.regex.test(lower)) continue;
472+
const entry = detectedPatterns.find(p => p.pattern === pattern.label);
473+
if (entry) {
474+
entry.occurrences += 1;
475+
entry.last_seen = new Date().toISOString();
476+
} else {
477+
detectedPatterns.push({
478+
pattern: pattern.label,
479+
occurrences: 1,
480+
last_seen: new Date().toISOString(),
481+
sample: line,
482+
semanticStatus: pattern.semanticStatus
483+
});
484+
}
485+
}
486+
487+
if (/error|exception|fatal/i.test(line)) {
488+
errorsFound.push({ line });
489+
} else if (/warn(ing)?/i.test(line)) {
490+
warningsFound.push({ line });
491+
}
492+
}
493+
494+
const topPattern = detectedPatterns.length > 0 ? detectedPatterns.reduce((a, b) => (a.occurrences >= b.occurrences ? a : b)) : null;
495+
496+
return { detectedPatterns, errorsFound, warningsFound, progressIndicators, topPattern };
497+
}
498+
499+
async function readRecentLogLines(env = {}, lineCount = 200) {
500+
const logPath = env.pm_log_path || env.pm_out_log_path || env.pm_err_log_path;
501+
if (!logPath) return { lines: [], logPath: null, lastModified: null };
502+
try {
503+
const [lines, stats] = await Promise.all([tailFile(logPath, lineCount), fs.promises.stat(logPath)]);
504+
return { lines, logPath, lastModified: stats.mtimeMs };
505+
} catch {
506+
return { lines: [], logPath, lastModified: null };
507+
}
508+
}
509+
510+
function buildSemanticStateFromHeuristics(opts) {
511+
const { env = {}, monit = {}, logAnalysis, logInfo } = opts;
512+
const baseStatus = env.status || 'unknown';
513+
514+
let status = baseStatus === 'online' ? 'online' : baseStatus;
515+
let context;
516+
let inferredFrom = 'status';
517+
let confidence = 0.4;
518+
let progress;
519+
520+
if (logAnalysis?.topPattern) {
521+
status = logAnalysis.topPattern.semanticStatus || status;
522+
context = logAnalysis.topPattern.sample;
523+
inferredFrom = 'log_pattern_match';
524+
confidence = 0.9;
525+
}
526+
527+
if (logAnalysis?.progressIndicators?.length) {
528+
const latest = logAnalysis.progressIndicators[logAnalysis.progressIndicators.length - 1];
529+
progress = latest.percent ?? latest.current;
530+
}
531+
532+
const restartCount = env.restart_time || 0;
533+
if (baseStatus === 'online' && restartCount >= 3 && confidence < 0.85) {
534+
status = 'degraded';
535+
context = `Restarted ${restartCount} times`;
536+
inferredFrom = 'restart_count';
537+
confidence = Math.max(confidence, 0.65);
538+
}
539+
540+
const cpu = typeof monit.cpu === 'number' ? monit.cpu : null;
541+
const now = Date.now();
542+
const logAgeMs = logInfo?.lastModified ? now - logInfo.lastModified : null;
543+
const uptimeMs = env.pm_uptime || null;
544+
545+
if (
546+
baseStatus === 'online' &&
547+
logAgeMs !== null &&
548+
uptimeMs &&
549+
uptimeMs > 2 * 60 * 1000 &&
550+
logAgeMs > 5 * 60 * 1000 &&
551+
cpu !== null &&
552+
cpu < 1
553+
) {
554+
status = 'stuck';
555+
context = `No logs for ${Math.round(logAgeMs / 60000)}m, cpu ${cpu}%`;
556+
inferredFrom = 'log_silence';
557+
confidence = Math.max(confidence, 0.7);
558+
}
559+
560+
if (!context) context = `Status ${status}`;
561+
562+
return {
563+
status,
564+
context,
565+
progress,
566+
confidence: Number(confidence.toFixed(2)),
567+
inferred_from: inferredFrom
568+
};
569+
}
570+
571+
async function buildSemanticState(procLike) {
572+
const env = procLike.pm2_env || {};
573+
const monit = procLike.monit || procLike.pm2_env?.monit || {};
574+
const logInfo = await readRecentLogLines(env, 200);
575+
const logAnalysis = analyzeLogPatterns(logInfo.lines);
576+
return buildSemanticStateFromHeuristics({ env, monit, logAnalysis, logInfo });
577+
}
578+
579+
async function enrichProcess(proc) {
580+
const base = formatProcess(proc);
581+
base.semantic_state = await buildSemanticState(proc);
582+
return base;
583+
}
584+
371585
function registerTools() {
372586
const startSchema = z
373587
.object({
@@ -419,6 +633,19 @@ function registerTools() {
419633
lines: z.number().int().positive().max(500).default(60)
420634
});
421635

636+
const analyzeLogsSchema = z.object({
637+
process: processTargetSchema,
638+
timeframe_minutes: z.number().int().positive().max(1440).default(5),
639+
lines: z.number().int().positive().max(1000).default(400)
640+
});
641+
642+
const describeSafeSchema = z.object({
643+
process: processTargetSchema,
644+
include_environment: z.boolean().default(false),
645+
environment_filter: z.array(z.string()).optional(),
646+
redact_secrets: z.boolean().default(true)
647+
});
648+
422649
server.registerTool(
423650
'pm2_list_processes',
424651
{
@@ -428,7 +655,7 @@ function registerTools() {
428655
wrapTool('pm2_list_processes', async () => {
429656
try {
430657
await ensureConnected();
431-
const processes = (await pm2List()).map(formatProcess);
658+
const processes = await Promise.all((await pm2List()).map(proc => enrichProcess(proc)));
432659
return {
433660
content: textContent(processes),
434661
structuredContent: { processes }
@@ -453,9 +680,12 @@ function registerTools() {
453680
if (!description || description.length === 0) {
454681
throw new Error(`No process found for "${process}"`);
455682
}
683+
const withState = await Promise.all(
684+
description.map(async item => ({ ...item, semantic_state: await buildSemanticState(item) }))
685+
);
456686
return {
457-
content: textContent(description),
458-
structuredContent: { description }
687+
content: textContent(withState),
688+
structuredContent: { description: withState }
459689
};
460690
} catch (err) {
461691
return errorResult(err);
@@ -702,6 +932,99 @@ function registerTools() {
702932
})
703933
);
704934

935+
server.registerTool(
936+
'pm2_analyze_logs',
937+
{
938+
title: 'Analyze PM2 logs',
939+
description: 'Parse recent logs for activity, patterns, and errors.',
940+
inputSchema: analyzeLogsSchema
941+
},
942+
wrapTool('pm2_analyze_logs', async ({ process, timeframe_minutes, lines }) => {
943+
try {
944+
await ensureConnected();
945+
const description = await pm2Describe(process);
946+
if (!description || description.length === 0) {
947+
throw new Error(`No process found for "${process}"`);
948+
}
949+
const env = description[0].pm2_env || {};
950+
const { lines: logLines, logPath } = await readRecentLogLines(env, lines);
951+
const logAnalysis = analyzeLogPatterns(logLines);
952+
const semantic = buildSemanticStateFromHeuristics({
953+
env,
954+
monit: description[0].monit,
955+
logAnalysis,
956+
logInfo: { lastModified: null }
957+
});
958+
959+
let suggested_action = 'none';
960+
if (logAnalysis.errorsFound.length > 0) suggested_action = 'investigate';
961+
else if (logAnalysis.topPattern?.semanticStatus === 'downloading') suggested_action = 'wait_for_completion';
962+
else if (logAnalysis.topPattern?.semanticStatus === 'degraded') suggested_action = 'investigate';
963+
964+
const payload = {
965+
process,
966+
timeframe_minutes,
967+
analysis: {
968+
current_activity: logAnalysis.topPattern?.pattern || semantic.status,
969+
detected_patterns: logAnalysis.detectedPatterns,
970+
errors_found: logAnalysis.errorsFound,
971+
warnings_found: logAnalysis.warningsFound,
972+
progress_indicators: logAnalysis.progressIndicators,
973+
anomalies: [],
974+
suggested_action
975+
},
976+
meta: {
977+
log_path: logPath,
978+
semantic_state: semantic
979+
}
980+
};
981+
982+
return {
983+
content: textContent(payload),
984+
structuredContent: payload
985+
};
986+
} catch (err) {
987+
return errorResult(err);
988+
}
989+
})
990+
);
991+
992+
server.registerTool(
993+
'pm2_describe_process_safe',
994+
{
995+
title: 'Describe a PM2 process (privacy-safe)',
996+
description: 'Describe a process with optional environment filtering and secret redaction.',
997+
inputSchema: describeSafeSchema
998+
},
999+
wrapTool('pm2_describe_process_safe', async ({ process, include_environment, environment_filter, redact_secrets }) => {
1000+
try {
1001+
await ensureConnected();
1002+
const description = await pm2Describe(process);
1003+
if (!description || description.length === 0) {
1004+
throw new Error(`No process found for "${process}"`);
1005+
}
1006+
1007+
const sanitized = await Promise.all(
1008+
description.map(async item => ({
1009+
...sanitizeDescriptionEntry(item, {
1010+
includeEnvironment: include_environment,
1011+
environmentFilter: environment_filter,
1012+
redactSecrets: redact_secrets
1013+
}),
1014+
semantic_state: await buildSemanticState(item)
1015+
}))
1016+
);
1017+
1018+
return {
1019+
content: textContent(sanitized),
1020+
structuredContent: { description: sanitized }
1021+
};
1022+
} catch (err) {
1023+
return errorResult(err);
1024+
}
1025+
})
1026+
);
1027+
7051028
server.registerTool(
7061029
'pm2_kill_daemon',
7071030
{
@@ -735,7 +1058,7 @@ function registerResources() {
7351058
},
7361059
async () => {
7371060
await ensureConnected();
738-
const processes = (await pm2List()).map(formatProcess);
1061+
const processes = await Promise.all((await pm2List()).map(proc => enrichProcess(proc)));
7391062
return {
7401063
contents: [
7411064
{
@@ -794,7 +1117,7 @@ function registerResources() {
7941117
{
7951118
uri: uri.href,
7961119
mimeType: 'application/json',
797-
text: renderJson(description[0])
1120+
text: renderJson({ ...description[0], semantic_state: await buildSemanticState(description[0]) })
7981121
}
7991122
]
8001123
};

0 commit comments

Comments
 (0)