@calltelemetry/cucm-mcp 0.1.2 → 0.1.4

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,10 @@ 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
87
+
88
+ ## Packet Capture Notes
89
+
90
+ - Use the platform/OS admin for SSH (`administrator` user on most lab systems)
91
+ - To request a high packet count without specifying an exact number, pass `maxPackets: true` to `packet_capture_start`
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.4" });
17
18
  const captures = new PacketCaptureManager();
19
+ const captureState = defaultStateStore();
18
20
  const dimeAuthSchema = z
19
21
  .object({
20
22
  username: z.string().optional(),
@@ -104,18 +106,23 @@ server.tool("packet_capture_start", "Start a packet capture on CUCM via SSH (uti
104
106
  auth: sshAuthSchema,
105
107
  iface: z.string().optional().describe("Interface (default eth0)"),
106
108
  fileBase: z.string().optional().describe("Capture base name (no dots). Saved as <fileBase>.cap"),
107
- count: z.number().int().min(1).max(200000).optional().describe("Packet count (file max is typically 100000)"),
109
+ count: z.number().int().min(1).max(1_000_000).optional().describe("Packet count (common max is 1000000)"),
110
+ maxPackets: z
111
+ .boolean()
112
+ .optional()
113
+ .describe("If true and count is omitted, uses a high capture count (1,000,000)"),
108
114
  size: z.string().optional().describe("Packet size (e.g. all)"),
109
115
  hostFilterIp: z.string().optional().describe("Optional filter: host ip <addr>"),
110
116
  portFilter: z.number().int().min(1).max(65535).optional().describe("Optional filter: port <num>"),
111
- }, async ({ host, sshPort, auth, iface, fileBase, count, size, hostFilterIp, portFilter }) => {
117
+ }, async ({ host, sshPort, auth, iface, fileBase, count, maxPackets, size, hostFilterIp, portFilter }) => {
118
+ const resolvedCount = count ?? (maxPackets ? 1_000_000 : undefined);
112
119
  const result = await captures.start({
113
120
  host,
114
121
  sshPort,
115
122
  auth: auth,
116
123
  iface,
117
124
  fileBase,
118
- count,
125
+ count: resolvedCount,
119
126
  size,
120
127
  hostFilterIp,
121
128
  portFilter,
@@ -123,6 +130,35 @@ server.tool("packet_capture_start", "Start a packet capture on CUCM via SSH (uti
123
130
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
124
131
  });
125
132
  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) }] }));
133
+ server.tool("packet_capture_state_list", "List packet captures from the local state file (survives MCP restarts).", {}, async () => {
134
+ const pruned = captureState.pruneExpired(captureState.load());
135
+ captureState.save(pruned);
136
+ const items = Object.values(pruned.captures).sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
137
+ return { content: [{ type: "text", text: JSON.stringify({ path: captureState.path, captures: items }, null, 2) }] };
138
+ });
139
+ server.tool("packet_capture_state_get", "Get a packet capture record from the local state file.", {
140
+ captureId: z.string().min(1),
141
+ }, async ({ captureId }) => {
142
+ const pruned = captureState.pruneExpired(captureState.load());
143
+ const rec = pruned.captures[captureId];
144
+ if (!rec) {
145
+ return {
146
+ content: [
147
+ {
148
+ type: "text",
149
+ text: JSON.stringify({ path: captureState.path, found: false, captureId }, null, 2),
150
+ },
151
+ ],
152
+ };
153
+ }
154
+ return { content: [{ type: "text", text: JSON.stringify({ path: captureState.path, found: true, record: rec }, null, 2) }] };
155
+ });
156
+ server.tool("packet_capture_state_clear", "Delete a capture record from the local state file.", {
157
+ captureId: z.string().min(1),
158
+ }, async ({ captureId }) => {
159
+ captureState.remove(captureId);
160
+ return { content: [{ type: "text", text: JSON.stringify({ removed: true, captureId }, null, 2) }] };
161
+ });
126
162
  server.tool("packet_capture_stop", "Stop a packet capture by captureId (sends Ctrl-C).", {
127
163
  captureId: z.string().min(1),
128
164
  timeoutMs: z.number().int().min(1000).max(10 * 60_000).optional().describe("How long to wait for stop (default ~90s)"),
@@ -180,5 +216,53 @@ server.tool("packet_capture_stop_and_download", "Stop a packet capture and downl
180
216
  ],
181
217
  };
182
218
  });
219
+ server.tool("packet_capture_download_from_state", "Download a capture file using the local state record (useful after MCP restart).", {
220
+ captureId: z.string().min(1),
221
+ dimePort: z.number().int().min(1).max(65535).optional(),
222
+ auth: dimeAuthSchema.describe("DIME auth (optional; defaults to CUCM_DIME_USERNAME/CUCM_DIME_PASSWORD)"),
223
+ outFile: z.string().optional().describe("Optional output path for the downloaded .cap file"),
224
+ downloadTimeoutMs: z
225
+ .number()
226
+ .int()
227
+ .min(1000)
228
+ .max(10 * 60_000)
229
+ .optional()
230
+ .describe("How long to wait for the capture file to appear in DIME"),
231
+ downloadPollIntervalMs: z
232
+ .number()
233
+ .int()
234
+ .min(250)
235
+ .max(30_000)
236
+ .optional()
237
+ .describe("How often to retry DIME GetOneFile when the file isn't there yet"),
238
+ }, async ({ captureId, dimePort, auth, outFile, downloadTimeoutMs, downloadPollIntervalMs }) => {
239
+ const pruned = captureState.pruneExpired(captureState.load());
240
+ const rec = pruned.captures[captureId];
241
+ if (!rec)
242
+ throw new Error(`Capture not found in state: ${captureId}`);
243
+ const dl = await getOneFileAnyWithRetry(rec.host, rec.remoteFileCandidates?.length ? rec.remoteFileCandidates : [rec.remoteFilePath], {
244
+ auth: auth,
245
+ port: dimePort,
246
+ timeoutMs: downloadTimeoutMs,
247
+ pollIntervalMs: downloadPollIntervalMs,
248
+ });
249
+ const saved = writeDownloadedFile(dl, outFile);
250
+ return {
251
+ content: [
252
+ {
253
+ type: "text",
254
+ text: JSON.stringify({
255
+ captureId,
256
+ host: rec.host,
257
+ remoteFilePath: dl.filename,
258
+ savedPath: saved.filePath,
259
+ bytes: saved.bytes,
260
+ dimeAttempts: dl.attempts,
261
+ dimeWaitedMs: dl.waitedMs,
262
+ }, null, 2),
263
+ },
264
+ ],
265
+ };
266
+ });
183
267
  const transport = new StdioServerTransport();
184
268
  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;
@@ -89,8 +90,39 @@ export function remoteCaptureCandidates(fileBase, maxParts = 10) {
89
90
  }
90
91
  return out;
91
92
  }
93
+ function waitFor(channel, session, predicate, timeoutMs, timeoutMessage) {
94
+ return new Promise((resolve, reject) => {
95
+ if (predicate())
96
+ return resolve();
97
+ const onData = () => {
98
+ if (predicate())
99
+ cleanup(true);
100
+ };
101
+ let timer;
102
+ const cleanup = (ok) => {
103
+ channel.off("data", onData);
104
+ channel.stderr.off("data", onData);
105
+ if (timer)
106
+ clearTimeout(timer);
107
+ if (ok)
108
+ resolve();
109
+ else {
110
+ const tail = (session.lastStdout || session.lastStderr || "").slice(-400);
111
+ reject(new Error(`${timeoutMessage}. lastOutput=${JSON.stringify(tail)}`));
112
+ }
113
+ };
114
+ channel.on("data", onData);
115
+ channel.stderr.on("data", onData);
116
+ timer = setTimeout(() => cleanup(false), Math.max(1000, timeoutMs));
117
+ timer.unref?.();
118
+ });
119
+ }
92
120
  export class PacketCaptureManager {
93
121
  active = new Map();
122
+ state;
123
+ constructor(opts) {
124
+ this.state = opts?.state || defaultStateStore();
125
+ }
94
126
  list() {
95
127
  return [...this.active.values()].map((a) => a.session);
96
128
  }
@@ -114,6 +146,11 @@ export class PacketCaptureManager {
114
146
  remoteFilePath: remoteCapturePath(fileBase),
115
147
  remoteFileCandidates: remoteCaptureCandidates(fileBase),
116
148
  };
149
+ // Persist early so we can recover/download even if the MCP process restarts.
150
+ this.state.upsert({
151
+ ...session,
152
+ stoppedAt: session.stoppedAt,
153
+ });
117
154
  await new Promise((resolve, reject) => {
118
155
  client
119
156
  .on("ready", () => resolve())
@@ -126,8 +163,10 @@ export class PacketCaptureManager {
126
163
  readyTimeout: 15000,
127
164
  });
128
165
  });
166
+ // CUCM SSH presents an interactive CLI shell. `exec()` is not reliably supported,
167
+ // so we open a shell and type commands at the prompt.
129
168
  const channel = await new Promise((resolve, reject) => {
130
- client.exec(cmd, { pty: true }, (err, ch) => {
169
+ client.shell({ term: "vt100", cols: 120, rows: 40 }, (err, ch) => {
131
170
  if (err)
132
171
  return reject(err);
133
172
  resolve(ch);
@@ -135,21 +174,48 @@ export class PacketCaptureManager {
135
174
  });
136
175
  channel.on("data", (buf) => {
137
176
  session.lastStdout = buf.toString("utf8").slice(-2000);
177
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
138
178
  });
139
179
  channel.stderr.on("data", (buf) => {
140
180
  session.lastStderr = buf.toString("utf8").slice(-2000);
181
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
141
182
  });
142
183
  channel.on("close", (code) => {
143
184
  session.exitCode = code;
185
+ session.stoppedAt = session.stoppedAt || new Date().toISOString();
144
186
  // If the capture stopped unexpectedly, drop it from the active map.
145
187
  this.active.delete(id);
188
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
146
189
  try {
147
190
  client.end();
191
+ client.destroy();
148
192
  }
149
193
  catch {
150
194
  // ignore
151
195
  }
152
196
  });
197
+ // Wait for prompt before running capture command.
198
+ try {
199
+ await waitFor(channel, session, () => looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr), 30_000, "Timeout waiting for CUCM CLI prompt");
200
+ channel.write(`${cmd}\n`);
201
+ }
202
+ catch (e) {
203
+ try {
204
+ channel.end();
205
+ channel.close();
206
+ }
207
+ catch {
208
+ // ignore
209
+ }
210
+ try {
211
+ client.end();
212
+ client.destroy();
213
+ }
214
+ catch {
215
+ // ignore
216
+ }
217
+ throw e;
218
+ }
153
219
  this.active.set(id, { session, client, channel });
154
220
  return session;
155
221
  }
@@ -159,6 +225,9 @@ export class PacketCaptureManager {
159
225
  throw new Error(`Unknown captureId: ${captureId}`);
160
226
  const { channel, client, session } = a;
161
227
  const done = new Promise((resolve) => {
228
+ if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {
229
+ return resolve();
230
+ }
162
231
  // CUCM CLI can keep the SSH channel open after the command finishes
163
232
  // (it returns to a prompt rather than exiting). Treat prompt as "stopped".
164
233
  const onData = () => {
@@ -221,16 +290,27 @@ export class PacketCaptureManager {
221
290
  }
222
291
  })();
223
292
  try {
224
- await Promise.race([
225
- done,
226
- new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout waiting for capture to stop")), resolvedTimeoutMs)),
227
- ]);
293
+ let timer;
294
+ const timeout = new Promise((_, reject) => {
295
+ timer = setTimeout(() => reject(new Error("Timeout waiting for capture to stop")), resolvedTimeoutMs);
296
+ // Don't keep the process alive just because we're waiting.
297
+ timer.unref?.();
298
+ });
299
+ try {
300
+ await Promise.race([done, timeout]);
301
+ }
302
+ finally {
303
+ if (timer)
304
+ clearTimeout(timer);
305
+ }
228
306
  }
229
307
  catch (e) {
230
308
  // Don't hard-fail: if the CLI doesn't emit a prompt/exit, we still want to:
231
309
  // - close SSH resources
232
310
  // - let the caller try DIME downloads (.cap, .cap01, etc)
233
311
  session.stopTimedOut = true;
312
+ session.stoppedAt = session.stoppedAt || new Date().toISOString();
313
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
234
314
  try {
235
315
  channel.close();
236
316
  }
@@ -239,6 +319,7 @@ export class PacketCaptureManager {
239
319
  }
240
320
  }
241
321
  session.stoppedAt = new Date().toISOString();
322
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
242
323
  // If we're back at a CUCM prompt, try to close the session cleanly.
243
324
  if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {
244
325
  try {
@@ -270,10 +351,12 @@ export class PacketCaptureManager {
270
351
  session.remoteFileCandidates = [inferred, ...session.remoteFileCandidates];
271
352
  }
272
353
  }
354
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
273
355
  // stop() implies cleanup
274
356
  this.active.delete(captureId);
275
357
  try {
276
358
  client.end();
359
+ client.destroy();
277
360
  }
278
361
  catch {
279
362
  // 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.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "MCP server for CUCM tooling (DIME logs, syslog, packet capture)",