@calltelemetry/cucm-mcp 0.1.2 → 0.1.3

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
@@ -30,6 +30,14 @@ CUCM lab environments often use self-signed certificates. By default this server
30
30
 
31
31
  - `CUCM_MCP_TLS_MODE=strict` (or `MCP_TLS_MODE=strict`)
32
32
 
33
+ ### Local State (Capture Recovery)
34
+
35
+ This server persists packet capture metadata to a local JSON file so you can recover/download captures after an MCP restart.
36
+
37
+ - `CUCM_MCP_STATE_PATH` (default: `./.cucm-mcp-state.json`)
38
+ - `CUCM_MCP_CAPTURE_RUNNING_TTL_MS` (default: 6 hours)
39
+ - `CUCM_MCP_CAPTURE_STOPPED_TTL_MS` (default: 24 hours)
40
+
33
41
  ## Run
34
42
 
35
43
  ```bash
@@ -74,3 +82,5 @@ Live tests are opt-in via env vars; see `test/live.test.js`.
74
82
  - `select_syslog_minutes` - list recent system log files (defaults to `Syslog`)
75
83
  - `packet_capture_start` / `packet_capture_stop` - control captures via SSH
76
84
  - `packet_capture_stop_and_download` - stop capture + download `.cap` via DIME
85
+ - `packet_capture_state_list` - list captures from state file
86
+ - `packet_capture_download_from_state` - download by captureId after restart
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { z } from "zod";
5
5
  import { listNodeServiceLogs, selectLogs, selectLogsMinutes, getOneFile, getOneFileAnyWithRetry, writeDownloadedFile, } from "./dime.js";
6
6
  import { guessTimezoneString } from "./time.js";
7
7
  import { PacketCaptureManager } from "./packetCapture.js";
8
+ import { defaultStateStore } from "./state.js";
8
9
  // Default to accepting self-signed/invalid certs (common on CUCM lab/dev).
9
10
  // Opt back into strict verification with CUCM_MCP_TLS_MODE=strict.
10
11
  const tlsMode = (process.env.CUCM_MCP_TLS_MODE || process.env.MCP_TLS_MODE || "").toLowerCase();
@@ -13,8 +14,9 @@ const strictTls = tlsMode === "strict" || tlsMode === "verify";
13
14
  // Set CUCM_MCP_TLS_MODE=strict to enforce verification.
14
15
  if (!strictTls)
15
16
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
16
- const server = new McpServer({ name: "cucm", version: "0.1.0" });
17
+ const server = new McpServer({ name: "cucm", version: "0.1.3" });
17
18
  const captures = new PacketCaptureManager();
19
+ const captureState = defaultStateStore();
18
20
  const dimeAuthSchema = z
19
21
  .object({
20
22
  username: z.string().optional(),
@@ -123,6 +125,35 @@ server.tool("packet_capture_start", "Start a packet capture on CUCM via SSH (uti
123
125
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
124
126
  });
125
127
  server.tool("packet_capture_list", "List active packet captures started by this MCP server.", {}, async () => ({ content: [{ type: "text", text: JSON.stringify(captures.list(), null, 2) }] }));
128
+ server.tool("packet_capture_state_list", "List packet captures from the local state file (survives MCP restarts).", {}, async () => {
129
+ const pruned = captureState.pruneExpired(captureState.load());
130
+ captureState.save(pruned);
131
+ const items = Object.values(pruned.captures).sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
132
+ return { content: [{ type: "text", text: JSON.stringify({ path: captureState.path, captures: items }, null, 2) }] };
133
+ });
134
+ server.tool("packet_capture_state_get", "Get a packet capture record from the local state file.", {
135
+ captureId: z.string().min(1),
136
+ }, async ({ captureId }) => {
137
+ const pruned = captureState.pruneExpired(captureState.load());
138
+ const rec = pruned.captures[captureId];
139
+ if (!rec) {
140
+ return {
141
+ content: [
142
+ {
143
+ type: "text",
144
+ text: JSON.stringify({ path: captureState.path, found: false, captureId }, null, 2),
145
+ },
146
+ ],
147
+ };
148
+ }
149
+ return { content: [{ type: "text", text: JSON.stringify({ path: captureState.path, found: true, record: rec }, null, 2) }] };
150
+ });
151
+ server.tool("packet_capture_state_clear", "Delete a capture record from the local state file.", {
152
+ captureId: z.string().min(1),
153
+ }, async ({ captureId }) => {
154
+ captureState.remove(captureId);
155
+ return { content: [{ type: "text", text: JSON.stringify({ removed: true, captureId }, null, 2) }] };
156
+ });
126
157
  server.tool("packet_capture_stop", "Stop a packet capture by captureId (sends Ctrl-C).", {
127
158
  captureId: z.string().min(1),
128
159
  timeoutMs: z.number().int().min(1000).max(10 * 60_000).optional().describe("How long to wait for stop (default ~90s)"),
@@ -180,5 +211,53 @@ server.tool("packet_capture_stop_and_download", "Stop a packet capture and downl
180
211
  ],
181
212
  };
182
213
  });
214
+ server.tool("packet_capture_download_from_state", "Download a capture file using the local state record (useful after MCP restart).", {
215
+ captureId: z.string().min(1),
216
+ dimePort: z.number().int().min(1).max(65535).optional(),
217
+ auth: dimeAuthSchema.describe("DIME auth (optional; defaults to CUCM_DIME_USERNAME/CUCM_DIME_PASSWORD)"),
218
+ outFile: z.string().optional().describe("Optional output path for the downloaded .cap file"),
219
+ downloadTimeoutMs: z
220
+ .number()
221
+ .int()
222
+ .min(1000)
223
+ .max(10 * 60_000)
224
+ .optional()
225
+ .describe("How long to wait for the capture file to appear in DIME"),
226
+ downloadPollIntervalMs: z
227
+ .number()
228
+ .int()
229
+ .min(250)
230
+ .max(30_000)
231
+ .optional()
232
+ .describe("How often to retry DIME GetOneFile when the file isn't there yet"),
233
+ }, async ({ captureId, dimePort, auth, outFile, downloadTimeoutMs, downloadPollIntervalMs }) => {
234
+ const pruned = captureState.pruneExpired(captureState.load());
235
+ const rec = pruned.captures[captureId];
236
+ if (!rec)
237
+ throw new Error(`Capture not found in state: ${captureId}`);
238
+ const dl = await getOneFileAnyWithRetry(rec.host, rec.remoteFileCandidates?.length ? rec.remoteFileCandidates : [rec.remoteFilePath], {
239
+ auth: auth,
240
+ port: dimePort,
241
+ timeoutMs: downloadTimeoutMs,
242
+ pollIntervalMs: downloadPollIntervalMs,
243
+ });
244
+ const saved = writeDownloadedFile(dl, outFile);
245
+ return {
246
+ content: [
247
+ {
248
+ type: "text",
249
+ text: JSON.stringify({
250
+ captureId,
251
+ host: rec.host,
252
+ remoteFilePath: dl.filename,
253
+ savedPath: saved.filePath,
254
+ bytes: saved.bytes,
255
+ dimeAttempts: dl.attempts,
256
+ dimeWaitedMs: dl.waitedMs,
257
+ }, null, 2),
258
+ },
259
+ ],
260
+ };
261
+ });
183
262
  const transport = new StdioServerTransport();
184
263
  await server.connect(transport);
@@ -1,5 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import { Client } from "ssh2";
3
+ import { defaultStateStore } from "./state.js";
3
4
  function extractCapFilePath(text) {
4
5
  if (!text)
5
6
  return undefined;
@@ -91,6 +92,10 @@ export function remoteCaptureCandidates(fileBase, maxParts = 10) {
91
92
  }
92
93
  export class PacketCaptureManager {
93
94
  active = new Map();
95
+ state;
96
+ constructor(opts) {
97
+ this.state = opts?.state || defaultStateStore();
98
+ }
94
99
  list() {
95
100
  return [...this.active.values()].map((a) => a.session);
96
101
  }
@@ -114,6 +119,11 @@ export class PacketCaptureManager {
114
119
  remoteFilePath: remoteCapturePath(fileBase),
115
120
  remoteFileCandidates: remoteCaptureCandidates(fileBase),
116
121
  };
122
+ // Persist early so we can recover/download even if the MCP process restarts.
123
+ this.state.upsert({
124
+ ...session,
125
+ stoppedAt: session.stoppedAt,
126
+ });
117
127
  await new Promise((resolve, reject) => {
118
128
  client
119
129
  .on("ready", () => resolve())
@@ -135,16 +145,21 @@ export class PacketCaptureManager {
135
145
  });
136
146
  channel.on("data", (buf) => {
137
147
  session.lastStdout = buf.toString("utf8").slice(-2000);
148
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
138
149
  });
139
150
  channel.stderr.on("data", (buf) => {
140
151
  session.lastStderr = buf.toString("utf8").slice(-2000);
152
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
141
153
  });
142
154
  channel.on("close", (code) => {
143
155
  session.exitCode = code;
156
+ session.stoppedAt = session.stoppedAt || new Date().toISOString();
144
157
  // If the capture stopped unexpectedly, drop it from the active map.
145
158
  this.active.delete(id);
159
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
146
160
  try {
147
161
  client.end();
162
+ client.destroy();
148
163
  }
149
164
  catch {
150
165
  // ignore
@@ -221,16 +236,27 @@ export class PacketCaptureManager {
221
236
  }
222
237
  })();
223
238
  try {
224
- await Promise.race([
225
- done,
226
- new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout waiting for capture to stop")), resolvedTimeoutMs)),
227
- ]);
239
+ let timer;
240
+ const timeout = new Promise((_, reject) => {
241
+ timer = setTimeout(() => reject(new Error("Timeout waiting for capture to stop")), resolvedTimeoutMs);
242
+ // Don't keep the process alive just because we're waiting.
243
+ timer.unref?.();
244
+ });
245
+ try {
246
+ await Promise.race([done, timeout]);
247
+ }
248
+ finally {
249
+ if (timer)
250
+ clearTimeout(timer);
251
+ }
228
252
  }
229
253
  catch (e) {
230
254
  // Don't hard-fail: if the CLI doesn't emit a prompt/exit, we still want to:
231
255
  // - close SSH resources
232
256
  // - let the caller try DIME downloads (.cap, .cap01, etc)
233
257
  session.stopTimedOut = true;
258
+ session.stoppedAt = session.stoppedAt || new Date().toISOString();
259
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
234
260
  try {
235
261
  channel.close();
236
262
  }
@@ -239,6 +265,7 @@ export class PacketCaptureManager {
239
265
  }
240
266
  }
241
267
  session.stoppedAt = new Date().toISOString();
268
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
242
269
  // If we're back at a CUCM prompt, try to close the session cleanly.
243
270
  if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {
244
271
  try {
@@ -270,10 +297,12 @@ export class PacketCaptureManager {
270
297
  session.remoteFileCandidates = [inferred, ...session.remoteFileCandidates];
271
298
  }
272
299
  }
300
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
273
301
  // stop() implies cleanup
274
302
  this.active.delete(captureId);
275
303
  try {
276
304
  client.end();
305
+ client.destroy();
277
306
  }
278
307
  catch {
279
308
  // ignore
package/dist/state.js ADDED
@@ -0,0 +1,117 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ export function defaultStatePath() {
4
+ // Prefer explicit env var.
5
+ const envPath = process.env.CUCM_MCP_STATE_PATH;
6
+ if (envPath && envPath.trim())
7
+ return envPath.trim();
8
+ // Default: a git-ignored file in the working directory.
9
+ // This matches typical MCP dev setup where command runs with --cwd cucm-mcp.
10
+ return `${process.cwd().replace(/\/+$/, "")}/.cucm-mcp-state.json`;
11
+ }
12
+ export function newEmptyState() {
13
+ return { version: 1, captures: {} };
14
+ }
15
+ export function nowIso() {
16
+ return new Date().toISOString();
17
+ }
18
+ export function clampText(s, max = 2000) {
19
+ if (s == null)
20
+ return undefined;
21
+ const t = String(s);
22
+ if (t.length <= max)
23
+ return t;
24
+ return t.slice(t.length - max);
25
+ }
26
+ export function computeExpiresAt({ startedAt, stoppedAt, runningTtlMs, stoppedTtlMs, }) {
27
+ const base = stoppedAt ? Date.parse(stoppedAt) : Date.parse(startedAt);
28
+ const ttl = stoppedAt ? stoppedTtlMs : runningTtlMs;
29
+ const ms = Number.isFinite(base) ? base + ttl : Date.now() + ttl;
30
+ return new Date(ms).toISOString();
31
+ }
32
+ export function isExpired(rec, atMs = Date.now()) {
33
+ const exp = Date.parse(rec.expiresAt);
34
+ if (!Number.isFinite(exp))
35
+ return false;
36
+ return exp <= atMs;
37
+ }
38
+ export class CaptureStateStore {
39
+ path;
40
+ runningTtlMs;
41
+ stoppedTtlMs;
42
+ constructor(opts) {
43
+ this.path = opts?.path || defaultStatePath();
44
+ this.runningTtlMs = Math.max(60_000, opts?.runningTtlMs ?? 6 * 60 * 60_000);
45
+ this.stoppedTtlMs = Math.max(60_000, opts?.stoppedTtlMs ?? 24 * 60 * 60_000);
46
+ }
47
+ load() {
48
+ try {
49
+ const raw = readFileSync(this.path, "utf8");
50
+ const parsed = JSON.parse(raw);
51
+ if (!parsed || parsed.version !== 1 || typeof parsed.captures !== "object")
52
+ return newEmptyState();
53
+ return parsed;
54
+ }
55
+ catch {
56
+ return newEmptyState();
57
+ }
58
+ }
59
+ save(state) {
60
+ mkdirSync(dirname(this.path), { recursive: true });
61
+ const tmp = `${this.path}.${process.pid}.tmp`;
62
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
63
+ renameSync(tmp, this.path);
64
+ }
65
+ pruneExpired(state) {
66
+ const s = state || this.load();
67
+ const now = Date.now();
68
+ const captures = {};
69
+ for (const [k, v] of Object.entries(s.captures || {})) {
70
+ if (!v || typeof v !== "object")
71
+ continue;
72
+ if (isExpired(v, now))
73
+ continue;
74
+ captures[k] = v;
75
+ }
76
+ return { version: 1, captures };
77
+ }
78
+ upsert(rec) {
79
+ const state = this.pruneExpired(this.load());
80
+ const updatedAt = nowIso();
81
+ const expiresAt = computeExpiresAt({
82
+ startedAt: rec.startedAt,
83
+ stoppedAt: rec.stoppedAt,
84
+ runningTtlMs: this.runningTtlMs,
85
+ stoppedTtlMs: this.stoppedTtlMs,
86
+ });
87
+ state.captures[rec.id] = {
88
+ ...rec,
89
+ lastStdout: clampText(rec.lastStdout),
90
+ lastStderr: clampText(rec.lastStderr),
91
+ updatedAt,
92
+ expiresAt,
93
+ };
94
+ this.save(state);
95
+ }
96
+ remove(id) {
97
+ const state = this.load();
98
+ if (state.captures?.[id]) {
99
+ delete state.captures[id];
100
+ this.save(state);
101
+ }
102
+ }
103
+ }
104
+ export function defaultStateStore() {
105
+ // Allow env tuning.
106
+ const running = process.env.CUCM_MCP_CAPTURE_RUNNING_TTL_MS
107
+ ? Number.parseInt(process.env.CUCM_MCP_CAPTURE_RUNNING_TTL_MS, 10)
108
+ : undefined;
109
+ const stopped = process.env.CUCM_MCP_CAPTURE_STOPPED_TTL_MS
110
+ ? Number.parseInt(process.env.CUCM_MCP_CAPTURE_STOPPED_TTL_MS, 10)
111
+ : undefined;
112
+ return new CaptureStateStore({
113
+ path: defaultStatePath(),
114
+ runningTtlMs: Number.isFinite(running) ? running : undefined,
115
+ stoppedTtlMs: Number.isFinite(stopped) ? stopped : undefined,
116
+ });
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/cucm-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "MCP server for CUCM tooling (DIME logs, syslog, packet capture)",