@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 +15 -0
- package/dist/index.js +88 -4
- package/dist/packetCapture.js +88 -5
- 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,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.
|
|
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(
|
|
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);
|
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;
|
|
@@ -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.
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
}
|