@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 +10 -0
- package/dist/index.js +80 -1
- package/dist/packetCapture.js +33 -4
- package/dist/state.js +117 -0
- package/package.json +1 -1
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.
|
|
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);
|
package/dist/packetCapture.js
CHANGED
|
@@ -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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
}
|