mcp-jvm-diagnostics 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,19 @@ It analyzes **offline** artifacts (thread dumps, GC logs, heap histograms) rathe
22
22
  - **Supports** G1, ZGC, Parallel, Serial, and Shenandoah GC formats
23
23
  - **No external dependencies** — works on local text input, no API keys needed
24
24
 
25
+ ## Pro Tier
26
+
27
+ **Generate exportable diagnostic reports (HTML + PDF)** with a Pro license key.
28
+
29
+ - Full JVM thread dump analysis report with actionable recommendations
30
+ - PDF export for sharing with your team
31
+ - Priority support
32
+
33
+ <!-- TODO: replace placeholder Stripe Payment Link once STRIPE_SECRET_KEY is configured -->
34
+ **$9.99/month** — [Get Pro License](https://buy.stripe.com/PLACEHOLDER)
35
+
36
+ Pro license key activates the `generate_report` MCP tool in mcp-jvm-diagnostics.
37
+
25
38
  ## Installation
26
39
 
27
40
  ```bash
@@ -63,11 +63,20 @@ export function detectDeadlocks(threads) {
63
63
  for (const t of cycleThreads) {
64
64
  visited.add(t.name);
65
65
  }
66
- const dlThreads = cycleThreads.map(t => ({
67
- name: t.name,
68
- holdsLock: t.holdsLocks[0] || "unknown",
69
- waitingOn: t.waitingOn || "unknown",
70
- }));
66
+ const dlThreads = cycleThreads.map((t, i) => {
67
+ // Use the lock this thread holds that the next thread in the cycle is waiting on.
68
+ // This ensures we report the lock that actually forms the deadlock cycle, not
69
+ // an arbitrary first lock (threads may hold multiple locks).
70
+ const nextThread = cycleThreads[(i + 1) % cycleThreads.length];
71
+ const cycleLock = t.holdsLocks.find(lock => lock === nextThread.waitingOn) ??
72
+ t.holdsLocks[0] ??
73
+ "unknown";
74
+ return {
75
+ name: t.name,
76
+ holdsLock: cycleLock,
77
+ waitingOn: t.waitingOn || "unknown",
78
+ };
79
+ });
71
80
  deadlocks.push({
72
81
  threads: dlThreads,
73
82
  recommendation: generateRecommendation(cycleThreads),
package/build/index.js CHANGED
@@ -10,9 +10,10 @@ import { analyzeGcPressure } from "./analyzers/gc-pressure.js";
10
10
  import { parseHeapHisto } from "./parsers/heap-histo.js";
11
11
  import { compareHeapHistos } from "./analyzers/heap-diff.js";
12
12
  import { parseJfrSummary } from "./parsers/jfr-summary.js";
13
+ import { generateReportFromThreadDump, analyzeThreadDumpMarkdown, } from "./reporting.js";
13
14
  // Handle --help
14
15
  if (process.argv.includes("--help") || process.argv.includes("-h")) {
15
- console.log(`mcp-jvm-diagnostics v0.1.5 — MCP server for JVM diagnostics
16
+ console.log(`mcp-jvm-diagnostics v0.1.6 — MCP server for JVM diagnostics
16
17
 
17
18
  Usage:
18
19
  mcp-jvm-diagnostics [options]
@@ -26,12 +27,13 @@ Tools provided:
26
27
  analyze_heap_histo Parse jmap -histo output, detect memory leak candidates
27
28
  compare_heap_histos Compare two jmap histos to detect memory growth
28
29
  analyze_jfr Parse JFR summary output, detect performance hotspots
29
- diagnose_jvm Unified diagnosis from thread dump + GC log`);
30
+ diagnose_jvm Unified diagnosis from thread dump + GC log
31
+ generate_report Generate an exportable HTML + PDF diagnostic report (Pro)`);
30
32
  process.exit(0);
31
33
  }
32
34
  const server = new McpServer({
33
35
  name: "mcp-jvm-diagnostics",
34
- version: "0.1.4",
36
+ version: "0.1.6",
35
37
  });
36
38
  // --- Tool: analyze_thread_dump ---
37
39
  server.tool("analyze_thread_dump", "Parse a JVM thread dump (jstack output) and analyze thread states, detect deadlocks, identify lock contention hotspots, and find thread starvation patterns. Handles both platform threads and virtual threads (Java 21+).", {
@@ -40,83 +42,57 @@ server.tool("analyze_thread_dump", "Parse a JVM thread dump (jstack output) and
40
42
  .describe("The full thread dump text (from jstack, kill -3, or VisualVM)"),
41
43
  }, async ({ thread_dump }) => {
42
44
  try {
43
- const parsed = parseThreadDump(thread_dump);
44
- const deadlocks = detectDeadlocks(parsed.threads);
45
- const contention = analyzeContention(parsed.threads);
46
- const sections = [];
47
- // Summary
48
- sections.push(`## Thread Dump Analysis`);
49
- sections.push(`\n- **JVM**: ${parsed.jvmInfo || "Unknown"}`);
50
- sections.push(`- **Timestamp**: ${parsed.timestamp || "Unknown"}`);
51
- sections.push(`- **Total threads**: ${parsed.threads.length}`);
52
- // Thread state breakdown
53
- const stateCounts = new Map();
54
- for (const t of parsed.threads) {
55
- stateCounts.set(t.state, (stateCounts.get(t.state) || 0) + 1);
56
- }
57
- // Ensure all canonical Java thread states appear
58
- const canonicalStates = ["RUNNABLE", "WAITING", "TIMED_WAITING", "BLOCKED", "NEW", "TERMINATED"];
59
- for (const s of canonicalStates) {
60
- if (!stateCounts.has(s))
61
- stateCounts.set(s, 0);
62
- }
63
- const total = parsed.threads.length;
64
- const maxCount = Math.max(...stateCounts.values(), 1);
65
- const barMaxWidth = 20;
66
- sections.push(`\n### Thread State Summary`);
67
- sections.push(`| State | Count | % | Histogram |`);
68
- sections.push(`|-------|------:|--:|-----------|`);
69
- // Sort: non-zero descending by count, then zero-count states in canonical order
70
- const sorted = [...stateCounts.entries()].sort((a, b) => {
71
- if (a[1] !== b[1])
72
- return b[1] - a[1];
73
- return canonicalStates.indexOf(a[0]) - canonicalStates.indexOf(b[0]);
45
+ return {
46
+ content: [{ type: "text", text: analyzeThreadDumpMarkdown(thread_dump) }],
47
+ };
48
+ }
49
+ catch (err) {
50
+ return {
51
+ content: [{ type: "text", text: `Error analyzing thread dump: ${err instanceof Error ? err.message : String(err)}` }],
52
+ };
53
+ }
54
+ });
55
+ // --- Tool: generate_report ---
56
+ server.tool("generate_report", "Generate an exportable diagnostic report (HTML + PDF) from JVM diagnostic data. Requires a valid Pro license key.", {
57
+ license_key: z
58
+ .string()
59
+ .describe("A valid MCP JVM Diagnostics Pro license key"),
60
+ thread_dump: z
61
+ .string()
62
+ .describe("The full thread dump text to analyze and export"),
63
+ }, async ({ license_key, thread_dump }) => {
64
+ try {
65
+ const result = await generateReportFromThreadDump({
66
+ licenseKey: license_key,
67
+ threadDump: thread_dump,
74
68
  });
75
- for (const [state, count] of sorted) {
76
- const pct = total > 0 ? ((count / total) * 100).toFixed(1) : "0.0";
77
- const barLen = Math.round((count / maxCount) * barMaxWidth);
78
- const bar = "\u2588".repeat(barLen);
79
- sections.push(`| ${state} | ${count} | ${pct} | \`${bar}\` |`);
80
- }
81
- // Deadlocks
82
- if (deadlocks.length > 0) {
83
- sections.push(`\n### Deadlocks Detected (${deadlocks.length})`);
84
- for (const dl of deadlocks) {
85
- sections.push(`\n**Deadlock cycle** (${dl.threads.length} threads):`);
86
- for (const t of dl.threads) {
87
- sections.push(`- **${t.name}** holds \`${t.holdsLock}\`, waiting for \`${t.waitingOn}\``);
88
- }
89
- sections.push(`\n**Resolution**: ${dl.recommendation}`);
90
- }
91
- }
92
- else {
93
- sections.push(`\n### Deadlocks: None detected`);
94
- }
95
- // Contention
96
- if (contention.hotspots.length > 0) {
97
- sections.push(`\n### Lock Contention Hotspots`);
98
- sections.push(`| Lock | Blocked Threads | Holder |`);
99
- sections.push(`|------|----------------|--------|`);
100
- for (const h of contention.hotspots) {
101
- sections.push(`| \`${h.lock}\` | ${h.blockedCount} | ${h.holderThread} |`);
102
- }
103
- if (contention.recommendations.length > 0) {
104
- sections.push(`\n### Recommendations`);
105
- for (const rec of contention.recommendations) {
106
- sections.push(`- ${rec}`);
107
- }
108
- }
69
+ if (!result.ok) {
70
+ return {
71
+ content: [{ type: "text", text: result.error }],
72
+ };
109
73
  }
110
- // Daemon vs non-daemon
111
- const daemonCount = parsed.threads.filter(t => t.isDaemon).length;
112
- sections.push(`\n### Thread Classification`);
113
- sections.push(`- Daemon threads: ${daemonCount}`);
114
- sections.push(`- Non-daemon threads: ${parsed.threads.length - daemonCount}`);
115
- return { content: [{ type: "text", text: sections.join("\n") }] };
74
+ return {
75
+ content: [
76
+ {
77
+ type: "text",
78
+ text: [
79
+ "Exportable diagnostic report generated successfully.",
80
+ `HTML: ${result.htmlPath}`,
81
+ `PDF: ${result.pdfPath}`,
82
+ `Customer ID: ${result.customerId ?? "Unknown"}`,
83
+ ].join("\n"),
84
+ },
85
+ ],
86
+ };
116
87
  }
117
88
  catch (err) {
118
89
  return {
119
- content: [{ type: "text", text: `Error analyzing thread dump: ${err instanceof Error ? err.message : String(err)}` }],
90
+ content: [
91
+ {
92
+ type: "text",
93
+ text: `Error generating report: ${err instanceof Error ? err.message : String(err)}`,
94
+ },
95
+ ],
120
96
  };
121
97
  }
122
98
  });
@@ -136,6 +112,9 @@ server.tool("analyze_gc_log", "Parse a JVM GC log and analyze garbage collection
136
112
  sections.push(`- **Time span**: ${parsed.timeSpanMs > 0 ? (parsed.timeSpanMs / 1000).toFixed(1) + "s" : "N/A"}`);
137
113
  // Pause time stats
138
114
  if (parsed.events.length > 0) {
115
+ const gcOverheadStr = parsed.hasTimestamps
116
+ ? `${pressure.gcOverheadPct.toFixed(1)}%`
117
+ : "N/A (legacy format has no timestamps)";
139
118
  sections.push(`\n### Pause Time Statistics`);
140
119
  sections.push(`| Metric | Value |`);
141
120
  sections.push(`|--------|-------|`);
@@ -144,7 +123,7 @@ server.tool("analyze_gc_log", "Parse a JVM GC log and analyze garbage collection
144
123
  sections.push(`| Avg pause | ${pressure.avgPauseMs.toFixed(1)} ms |`);
145
124
  sections.push(`| P95 pause | ${pressure.p95PauseMs.toFixed(1)} ms |`);
146
125
  sections.push(`| Total pause time | ${pressure.totalPauseMs.toFixed(0)} ms |`);
147
- sections.push(`| GC overhead | ${pressure.gcOverheadPct.toFixed(1)}% |`);
126
+ sections.push(`| GC overhead | ${gcOverheadStr} |`);
148
127
  }
149
128
  // Heap sizing
150
129
  if (pressure.heapBeforeMb > 0) {
@@ -408,7 +387,10 @@ server.tool("diagnose_jvm", "Unified JVM diagnosis combining thread dump and GC
408
387
  sections.push(`- Algorithm: ${parsed.algorithm}`);
409
388
  sections.push(`- Events: ${parsed.events.length}`);
410
389
  sections.push(`- Max pause: ${pressure.maxPauseMs.toFixed(1)} ms`);
411
- sections.push(`- GC overhead: ${pressure.gcOverheadPct.toFixed(1)}%`);
390
+ const overheadLabel = parsed.hasTimestamps
391
+ ? `${pressure.gcOverheadPct.toFixed(1)}%`
392
+ : "N/A (no timestamps in legacy format)";
393
+ sections.push(`- GC overhead: ${overheadLabel}`);
412
394
  sections.push(`- Issues: ${pressure.issues.length}`);
413
395
  }
414
396
  // Cross-correlation
@@ -126,10 +126,12 @@ export function parseGcLog(text) {
126
126
  }
127
127
  // Calculate time span
128
128
  let timeSpanMs = 0;
129
- if (events.length > 1) {
129
+ // Legacy -verbose:gc events have timestamp=0; unified logging events have real timestamps.
130
+ const hasTimestamps = events.length > 0 && events.some(e => e.timestamp > 0);
131
+ if (hasTimestamps && events.length > 1) {
130
132
  const firstTs = events[0].timestamp;
131
133
  const lastTs = events[events.length - 1].timestamp;
132
134
  timeSpanMs = (lastTs - firstTs) * 1000;
133
135
  }
134
- return { algorithm, events, timeSpanMs };
136
+ return { algorithm, events, timeSpanMs, hasTimestamps };
135
137
  }
@@ -0,0 +1,155 @@
1
+ import { mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { generateDiagnosticPdf, maskLicenseKey, renderDiagnosticReport, validateLicense, } from "@mcp-java-suite/license";
5
+ import { analyzeContention } from "./analyzers/contention.js";
6
+ import { detectDeadlocks } from "./analyzers/deadlock.js";
7
+ import { parseThreadDump } from "./parsers/thread-dump.js";
8
+ const PRODUCT_NAME = "jvm-diagnostics";
9
+ const TOOL_VERSION = "0.1.6";
10
+ export const MISSING_LICENSE_INPUT_ERROR = "Missing required input: license_key";
11
+ export const MISSING_THREAD_DUMP_ERROR = "Missing required input: thread_dump";
12
+ export const MISSING_ENV_LICENSE_ERROR = "MCP_LICENSE_KEY environment variable is not set. generate_report requires a valid Pro license key. Free-tier analysis tools remain available without it.";
13
+ function isGenerateReportFailure(result) {
14
+ return "ok" in result && result.ok === false;
15
+ }
16
+ export function analyzeThreadDumpMarkdown(threadDump) {
17
+ const parsed = parseThreadDump(threadDump);
18
+ const deadlocks = detectDeadlocks(parsed.threads);
19
+ const contention = analyzeContention(parsed.threads);
20
+ const sections = [];
21
+ sections.push("## Thread Dump Analysis");
22
+ sections.push(`\n- **JVM**: ${parsed.jvmInfo || "Unknown"}`);
23
+ sections.push(`- **Timestamp**: ${parsed.timestamp || "Unknown"}`);
24
+ sections.push(`- **Total threads**: ${parsed.threads.length}`);
25
+ const stateCounts = new Map();
26
+ for (const thread of parsed.threads) {
27
+ stateCounts.set(thread.state, (stateCounts.get(thread.state) || 0) + 1);
28
+ }
29
+ const canonicalStates = [
30
+ "RUNNABLE",
31
+ "WAITING",
32
+ "TIMED_WAITING",
33
+ "BLOCKED",
34
+ "NEW",
35
+ "TERMINATED",
36
+ ];
37
+ for (const state of canonicalStates) {
38
+ if (!stateCounts.has(state)) {
39
+ stateCounts.set(state, 0);
40
+ }
41
+ }
42
+ const total = parsed.threads.length;
43
+ const maxCount = Math.max(...stateCounts.values(), 1);
44
+ const barMaxWidth = 20;
45
+ sections.push("\n### Thread State Summary");
46
+ sections.push("| State | Count | % | Histogram |");
47
+ sections.push("|-------|------:|--:|-----------|");
48
+ const sortedStates = [...stateCounts.entries()].sort((a, b) => {
49
+ if (a[1] !== b[1]) {
50
+ return b[1] - a[1];
51
+ }
52
+ return canonicalStates.indexOf(a[0]) - canonicalStates.indexOf(b[0]);
53
+ });
54
+ for (const [state, count] of sortedStates) {
55
+ const percentage = total > 0 ? ((count / total) * 100).toFixed(1) : "0.0";
56
+ const barLength = Math.round((count / maxCount) * barMaxWidth);
57
+ const bar = "\u2588".repeat(barLength);
58
+ sections.push(`| ${state} | ${count} | ${percentage} | \`${bar}\` |`);
59
+ }
60
+ if (deadlocks.length > 0) {
61
+ sections.push(`\n### Deadlocks Detected (${deadlocks.length})`);
62
+ for (const deadlock of deadlocks) {
63
+ sections.push(`\n**Deadlock cycle** (${deadlock.threads.length} threads):`);
64
+ for (const thread of deadlock.threads) {
65
+ sections.push(`- **${thread.name}** holds \`${thread.holdsLock}\`, waiting for \`${thread.waitingOn}\``);
66
+ }
67
+ sections.push(`\n**Resolution**: ${deadlock.recommendation}`);
68
+ }
69
+ }
70
+ else {
71
+ sections.push("\n### Deadlocks: None detected");
72
+ }
73
+ if (contention.hotspots.length > 0) {
74
+ sections.push("\n### Lock Contention Hotspots");
75
+ sections.push("| Lock | Blocked Threads | Holder |");
76
+ sections.push("|------|----------------|--------|");
77
+ for (const hotspot of contention.hotspots) {
78
+ sections.push(`| \`${hotspot.lock}\` | ${hotspot.blockedCount} | ${hotspot.holderThread} |`);
79
+ }
80
+ if (contention.recommendations.length > 0) {
81
+ sections.push("\n### Recommendations");
82
+ for (const recommendation of contention.recommendations) {
83
+ sections.push(`- ${recommendation}`);
84
+ }
85
+ }
86
+ }
87
+ const daemonCount = parsed.threads.filter((thread) => thread.isDaemon).length;
88
+ sections.push("\n### Thread Classification");
89
+ sections.push(`- Daemon threads: ${daemonCount}`);
90
+ sections.push(`- Non-daemon threads: ${parsed.threads.length - daemonCount}`);
91
+ return sections.join("\n");
92
+ }
93
+ function validateConfiguredLicense() {
94
+ const configuredKey = process.env.MCP_LICENSE_KEY;
95
+ if (!configuredKey || configuredKey.trim().length === 0) {
96
+ return { ok: false, error: MISSING_ENV_LICENSE_ERROR };
97
+ }
98
+ return validateProLicense(configuredKey, "Configured MCP_LICENSE_KEY");
99
+ }
100
+ function validateProLicense(key, label) {
101
+ try {
102
+ const license = validateLicense(key, PRODUCT_NAME);
103
+ if (!license.isPro) {
104
+ return {
105
+ ok: false,
106
+ error: `${label} is invalid: ${license.reason}`,
107
+ };
108
+ }
109
+ return license;
110
+ }
111
+ catch (error) {
112
+ return {
113
+ ok: false,
114
+ error: `License validation unavailable: ${error instanceof Error ? error.message : String(error)}`,
115
+ };
116
+ }
117
+ }
118
+ export async function generateReportFromThreadDump(params) {
119
+ if (!params.licenseKey || params.licenseKey.trim().length === 0) {
120
+ return { ok: false, error: MISSING_LICENSE_INPUT_ERROR };
121
+ }
122
+ if (!params.threadDump || params.threadDump.trim().length === 0) {
123
+ return { ok: false, error: MISSING_THREAD_DUMP_ERROR };
124
+ }
125
+ const configuredLicense = validateConfiguredLicense();
126
+ if (isGenerateReportFailure(configuredLicense)) {
127
+ return configuredLicense;
128
+ }
129
+ const providedLicense = validateProLicense(params.licenseKey, "Provided license_key");
130
+ if (isGenerateReportFailure(providedLicense)) {
131
+ return providedLicense;
132
+ }
133
+ const reportMarkdown = analyzeThreadDumpMarkdown(params.threadDump);
134
+ const reportParams = {
135
+ customerName: `Pro Customer #${providedLicense.customerId ?? "Unknown"}`,
136
+ licenseKeyMasked: maskLicenseKey(params.licenseKey),
137
+ reportDate: new Date().toISOString().slice(0, 10),
138
+ toolVersion: TOOL_VERSION,
139
+ reportMarkdown,
140
+ };
141
+ const outputDir = params.outputDir ??
142
+ (await mkdtemp(path.join(tmpdir(), "mcp-jvm-diagnostics-report-")));
143
+ const htmlPath = path.join(outputDir, "diagnostic-report.html");
144
+ const pdfPath = path.join(outputDir, "diagnostic-report.pdf");
145
+ const html = renderDiagnosticReport(reportParams);
146
+ await writeFile(htmlPath, html, "utf8");
147
+ const pdf = await generateDiagnosticPdf(reportParams);
148
+ await writeFile(pdfPath, pdf);
149
+ return {
150
+ ok: true,
151
+ htmlPath,
152
+ pdfPath,
153
+ customerId: providedLicense.customerId,
154
+ };
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-jvm-diagnostics",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "MCP server for JVM diagnostics — analyze thread dumps, detect deadlocks, parse GC logs, and get JVM tuning recommendations",
5
5
  "mcpName": "io.github.dmitriusan/mcp-jvm-diagnostics",
6
6
  "author": "Dmytro Lisnichenko",
@@ -53,6 +53,7 @@
53
53
  "url": "https://github.com/Dmitriusan/mcp-jvm-diagnostics/issues"
54
54
  },
55
55
  "dependencies": {
56
+ "@mcp-java-suite/license": "file:../../mcp-suite-license/mcp-suite-license",
56
57
  "@modelcontextprotocol/sdk": "^1.27.1",
57
58
  "zod": "^3.24.2"
58
59
  },