@calltelemetry/cucm-mcp 0.1.0 → 0.1.2
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 +3 -3
- package/dist/dime.js +93 -13
- package/dist/index.js +37 -9
- package/dist/packetCapture.js +135 -13
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -49,10 +49,10 @@ Add to `.mcp.json`:
|
|
|
49
49
|
"command": "yarn",
|
|
50
50
|
"args": ["--cwd", "cucm-mcp", "start"],
|
|
51
51
|
"env": {
|
|
52
|
-
"CUCM_DIME_USERNAME": "<
|
|
53
|
-
"CUCM_DIME_PASSWORD": "<
|
|
52
|
+
"CUCM_DIME_USERNAME": "<cucm-username>",
|
|
53
|
+
"CUCM_DIME_PASSWORD": "<cucm-password>",
|
|
54
54
|
"CUCM_SSH_USERNAME": "administrator",
|
|
55
|
-
"CUCM_SSH_PASSWORD": "<
|
|
55
|
+
"CUCM_SSH_PASSWORD": "<ssh-password>",
|
|
56
56
|
"CUCM_MCP_TLS_MODE": "permissive"
|
|
57
57
|
}
|
|
58
58
|
}
|
package/dist/dime.js
CHANGED
|
@@ -3,12 +3,34 @@ import { extractBoundary, parseMultipartRelated } from "./multipart.js";
|
|
|
3
3
|
import { formatCucmDateTime, guessTimezoneString } from "./time.js";
|
|
4
4
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
|
+
// Default to accepting self-signed/invalid certs (common on CUCM lab/dev).
|
|
7
|
+
// Opt back into strict verification with CUCM_MCP_TLS_MODE=strict.
|
|
8
|
+
const tlsMode = (process.env.CUCM_MCP_TLS_MODE || process.env.MCP_TLS_MODE || "").toLowerCase();
|
|
9
|
+
const strictTls = tlsMode === "strict" || tlsMode === "verify";
|
|
10
|
+
if (!strictTls)
|
|
11
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
6
12
|
const parser = new XMLParser({
|
|
7
13
|
ignoreAttributes: false,
|
|
8
14
|
attributeNamePrefix: "@",
|
|
9
15
|
removeNSPrefix: true,
|
|
10
16
|
trimValues: true,
|
|
11
17
|
});
|
|
18
|
+
function isXmlContentType(contentType) {
|
|
19
|
+
const ct = String(contentType || "").toLowerCase();
|
|
20
|
+
if (!ct)
|
|
21
|
+
return false;
|
|
22
|
+
return ct === "text/xml" || ct === "application/xml" || ct === "application/soap+xml" || ct.endsWith("+xml") || ct.includes("/xml");
|
|
23
|
+
}
|
|
24
|
+
function dimeXmlBytes(contentType, bytes) {
|
|
25
|
+
const boundary = extractBoundary(contentType);
|
|
26
|
+
if (!boundary)
|
|
27
|
+
return bytes;
|
|
28
|
+
const parts = parseMultipartRelated(bytes, boundary);
|
|
29
|
+
const xmlParts = parts.filter((p) => isXmlContentType(p.contentType));
|
|
30
|
+
if (xmlParts.length === 0)
|
|
31
|
+
throw new Error("DIME response missing text/xml part");
|
|
32
|
+
return xmlParts[0].body;
|
|
33
|
+
}
|
|
12
34
|
export function normalizeHost(hostOrUrl) {
|
|
13
35
|
const s = String(hostOrUrl || "").trim();
|
|
14
36
|
if (!s)
|
|
@@ -125,12 +147,8 @@ export async function listNodeServiceLogs(hostOrUrl, auth, port) {
|
|
|
125
147
|
const target = resolveTarget(hostOrUrl, port);
|
|
126
148
|
const resolvedAuth = resolveAuth(auth);
|
|
127
149
|
const { contentType, bytes } = await fetchSoap(target, resolvedAuth, "/logcollectionservice2/services/LogCollectionPortTypeService", "listNodeServiceLogs", soapEnvelopeList());
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
const xmlParts = parts.filter((p) => p.contentType.toLowerCase() === "text/xml");
|
|
131
|
-
if (xmlParts.length === 0)
|
|
132
|
-
throw new Error("DIME response missing text/xml part");
|
|
133
|
-
const parsed = parser.parse(xmlParts[0].body.toString("utf8"));
|
|
150
|
+
const xml = dimeXmlBytes(contentType, bytes);
|
|
151
|
+
const parsed = parser.parse(xml.toString("utf8"));
|
|
134
152
|
const env = parsed.Envelope || parsed;
|
|
135
153
|
const body = env.Body || env;
|
|
136
154
|
const resp = body.listNodeServiceLogsResponse;
|
|
@@ -161,12 +179,8 @@ export async function selectLogs(hostOrUrl, criteria, auth, port) {
|
|
|
161
179
|
throw new Error("selectLogs requires at least one of serviceLogs or systemLogs");
|
|
162
180
|
}
|
|
163
181
|
const { contentType, bytes } = await fetchSoap(target, resolvedAuth, "/logcollectionservice2/services/LogCollectionPortTypeService", "selectLogFiles", soapEnvelopeSelect(criteria));
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
const xmlParts = parts.filter((p) => p.contentType.toLowerCase() === "text/xml");
|
|
167
|
-
if (xmlParts.length === 0)
|
|
168
|
-
throw new Error("DIME response missing text/xml part");
|
|
169
|
-
const parsed = parser.parse(xmlParts[0].body.toString("utf8"));
|
|
182
|
+
const xml = dimeXmlBytes(contentType, bytes);
|
|
183
|
+
const parsed = parser.parse(xml.toString("utf8"));
|
|
170
184
|
const env = parsed.Envelope || parsed;
|
|
171
185
|
const body = env.Body || env;
|
|
172
186
|
const resp = body.selectLogFilesResponse;
|
|
@@ -202,14 +216,80 @@ export async function getOneFile(hostOrUrl, filePath, auth, port) {
|
|
|
202
216
|
const resolvedAuth = resolveAuth(auth);
|
|
203
217
|
const { contentType, bytes } = await fetchSoap(target, resolvedAuth, "/logcollectionservice/services/DimeGetFileService", "http://schemas.cisco.com/ast/soap/action/#LogCollectionPort#GetOneFile", soapEnvelopeGetOneFile(filePath));
|
|
204
218
|
const boundary = extractBoundary(contentType);
|
|
219
|
+
if (!boundary) {
|
|
220
|
+
// Some environments respond with a non-multipart body. Prefer treating non-XML bodies as raw file bytes.
|
|
221
|
+
const ct = (contentType || "").toLowerCase();
|
|
222
|
+
if (ct.includes("text/xml") || ct.includes("application/soap+xml")) {
|
|
223
|
+
// Best-effort fault detection
|
|
224
|
+
const asText = bytes.toString("utf8");
|
|
225
|
+
if (/\bFault\b/i.test(asText))
|
|
226
|
+
throw new Error(`CUCM DIME GetOneFile returned SOAP fault: ${asText}`);
|
|
227
|
+
// If it's XML but not a fault, still return raw bytes.
|
|
228
|
+
}
|
|
229
|
+
return { server: target.host, filename: filePath, data: bytes };
|
|
230
|
+
}
|
|
205
231
|
const parts = parseMultipartRelated(bytes, boundary);
|
|
206
232
|
if (parts.length === 0)
|
|
207
233
|
throw new Error("DIME GetOneFile returned no multipart parts");
|
|
208
|
-
const nonXml = parts.find((p) => p.contentType
|
|
234
|
+
const nonXml = parts.find((p) => !isXmlContentType(p.contentType));
|
|
209
235
|
if (!nonXml)
|
|
210
236
|
throw new Error("DIME GetOneFile response missing non-XML file part");
|
|
211
237
|
return { server: target.host, filename: filePath, data: nonXml.body };
|
|
212
238
|
}
|
|
239
|
+
function isDimeMissingFileError(e) {
|
|
240
|
+
const msg = e instanceof Error ? e.message : String(e || "");
|
|
241
|
+
// CUCM DIME commonly responds with HTTP 500 and a SOAP fault like:
|
|
242
|
+
// "FileName ... do not exist"
|
|
243
|
+
return /DIME HTTP 500/i.test(msg) && /do not exist/i.test(msg);
|
|
244
|
+
}
|
|
245
|
+
export async function getOneFileWithRetry(hostOrUrl, filePath, opts) {
|
|
246
|
+
const timeoutMs = Math.max(1000, opts?.timeoutMs ?? 120_000);
|
|
247
|
+
const pollIntervalMs = Math.max(250, opts?.pollIntervalMs ?? 2000);
|
|
248
|
+
const start = Date.now();
|
|
249
|
+
let attempts = 0;
|
|
250
|
+
// Simple fixed-interval poll; CUCM can take time to flush capture files to disk.
|
|
251
|
+
while (true) {
|
|
252
|
+
attempts++;
|
|
253
|
+
try {
|
|
254
|
+
const r = await getOneFile(hostOrUrl, filePath, opts?.auth, opts?.port);
|
|
255
|
+
return { ...r, attempts, waitedMs: Date.now() - start };
|
|
256
|
+
}
|
|
257
|
+
catch (e) {
|
|
258
|
+
const elapsed = Date.now() - start;
|
|
259
|
+
if (!isDimeMissingFileError(e) || elapsed >= timeoutMs)
|
|
260
|
+
throw e;
|
|
261
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
export async function getOneFileAnyWithRetry(hostOrUrl, filePaths, opts) {
|
|
266
|
+
const paths = (filePaths || []).map(String).filter((p) => p.trim() !== "");
|
|
267
|
+
if (paths.length === 0)
|
|
268
|
+
throw new Error("getOneFileAnyWithRetry requires at least one filePath");
|
|
269
|
+
const timeoutMs = Math.max(1000, opts?.timeoutMs ?? 120_000);
|
|
270
|
+
const pollIntervalMs = Math.max(250, opts?.pollIntervalMs ?? 2000);
|
|
271
|
+
const start = Date.now();
|
|
272
|
+
let attempts = 0;
|
|
273
|
+
while (true) {
|
|
274
|
+
attempts++;
|
|
275
|
+
for (const p of paths) {
|
|
276
|
+
try {
|
|
277
|
+
const r = await getOneFile(hostOrUrl, p, opts?.auth, opts?.port);
|
|
278
|
+
return { ...r, attempts, waitedMs: Date.now() - start };
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
if (!isDimeMissingFileError(e))
|
|
282
|
+
throw e;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const elapsed = Date.now() - start;
|
|
286
|
+
if (elapsed >= timeoutMs) {
|
|
287
|
+
// If timeout, throw the same missing-file style error for the first candidate.
|
|
288
|
+
throw new Error(`DIME HTTP 500: FileName ${paths[0]} do not exist (timed out after ${elapsed}ms)`);
|
|
289
|
+
}
|
|
290
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
213
293
|
export function writeDownloadedFile(result, outFile) {
|
|
214
294
|
const baseName = result.filename.split("/").filter(Boolean).pop() || "cucm-file.bin";
|
|
215
295
|
const defaultDir = join("/tmp", "cucm-mcp");
|
package/dist/index.js
CHANGED
|
@@ -2,16 +2,17 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { listNodeServiceLogs, selectLogs, selectLogsMinutes, getOneFile, writeDownloadedFile, } from "./dime.js";
|
|
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
8
|
// Default to accepting self-signed/invalid certs (common on CUCM lab/dev).
|
|
9
9
|
// Opt back into strict verification with CUCM_MCP_TLS_MODE=strict.
|
|
10
10
|
const tlsMode = (process.env.CUCM_MCP_TLS_MODE || process.env.MCP_TLS_MODE || "").toLowerCase();
|
|
11
11
|
const strictTls = tlsMode === "strict" || tlsMode === "verify";
|
|
12
|
-
|
|
12
|
+
// Default: permissive TLS (accept self-signed). This is the common CUCM lab posture.
|
|
13
|
+
// Set CUCM_MCP_TLS_MODE=strict to enforce verification.
|
|
14
|
+
if (!strictTls)
|
|
13
15
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
14
|
-
}
|
|
15
16
|
const server = new McpServer({ name: "cucm", version: "0.1.0" });
|
|
16
17
|
const captures = new PacketCaptureManager();
|
|
17
18
|
const dimeAuthSchema = z
|
|
@@ -124,8 +125,9 @@ server.tool("packet_capture_start", "Start a packet capture on CUCM via SSH (uti
|
|
|
124
125
|
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) }] }));
|
|
125
126
|
server.tool("packet_capture_stop", "Stop a packet capture by captureId (sends Ctrl-C).", {
|
|
126
127
|
captureId: z.string().min(1),
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
timeoutMs: z.number().int().min(1000).max(10 * 60_000).optional().describe("How long to wait for stop (default ~90s)"),
|
|
129
|
+
}, async ({ captureId, timeoutMs }) => {
|
|
130
|
+
const result = await captures.stop(captureId, timeoutMs);
|
|
129
131
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
130
132
|
});
|
|
131
133
|
server.tool("packet_capture_stop_and_download", "Stop a packet capture and download the resulting .cap file via DIME.", {
|
|
@@ -133,9 +135,32 @@ server.tool("packet_capture_stop_and_download", "Stop a packet capture and downl
|
|
|
133
135
|
dimePort: z.number().int().min(1).max(65535).optional(),
|
|
134
136
|
auth: dimeAuthSchema.describe("DIME auth (optional; defaults to CUCM_DIME_USERNAME/CUCM_DIME_PASSWORD)"),
|
|
135
137
|
outFile: z.string().optional().describe("Optional output path for the downloaded .cap file"),
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
138
|
+
stopTimeoutMs: z.number().int().min(1000).max(10 * 60_000).optional().describe("How long to wait for SSH capture stop"),
|
|
139
|
+
downloadTimeoutMs: z
|
|
140
|
+
.number()
|
|
141
|
+
.int()
|
|
142
|
+
.min(1000)
|
|
143
|
+
.max(10 * 60_000)
|
|
144
|
+
.optional()
|
|
145
|
+
.describe("How long to wait for the capture file to appear in DIME"),
|
|
146
|
+
downloadPollIntervalMs: z
|
|
147
|
+
.number()
|
|
148
|
+
.int()
|
|
149
|
+
.min(250)
|
|
150
|
+
.max(30_000)
|
|
151
|
+
.optional()
|
|
152
|
+
.describe("How often to retry DIME GetOneFile when the file isn't there yet"),
|
|
153
|
+
}, async ({ captureId, dimePort, auth, outFile, stopTimeoutMs, downloadTimeoutMs, downloadPollIntervalMs }) => {
|
|
154
|
+
const stopped = await captures.stop(captureId, stopTimeoutMs);
|
|
155
|
+
const candidates = (stopped.remoteFileCandidates || []).length
|
|
156
|
+
? stopped.remoteFileCandidates
|
|
157
|
+
: [stopped.remoteFilePath];
|
|
158
|
+
const dl = await getOneFileAnyWithRetry(stopped.host, candidates, {
|
|
159
|
+
auth: auth,
|
|
160
|
+
port: dimePort,
|
|
161
|
+
timeoutMs: downloadTimeoutMs,
|
|
162
|
+
pollIntervalMs: downloadPollIntervalMs,
|
|
163
|
+
});
|
|
139
164
|
const saved = writeDownloadedFile(dl, outFile);
|
|
140
165
|
return {
|
|
141
166
|
content: [
|
|
@@ -144,9 +169,12 @@ server.tool("packet_capture_stop_and_download", "Stop a packet capture and downl
|
|
|
144
169
|
text: JSON.stringify({
|
|
145
170
|
captureId: stopped.id,
|
|
146
171
|
host: stopped.host,
|
|
147
|
-
remoteFilePath:
|
|
172
|
+
remoteFilePath: dl.filename,
|
|
173
|
+
stopTimedOut: stopped.stopTimedOut || false,
|
|
148
174
|
savedPath: saved.filePath,
|
|
149
175
|
bytes: saved.bytes,
|
|
176
|
+
dimeAttempts: dl.attempts,
|
|
177
|
+
dimeWaitedMs: dl.waitedMs,
|
|
150
178
|
}, null, 2),
|
|
151
179
|
},
|
|
152
180
|
],
|
package/dist/packetCapture.js
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { Client } from "ssh2";
|
|
3
|
+
function extractCapFilePath(text) {
|
|
4
|
+
if (!text)
|
|
5
|
+
return undefined;
|
|
6
|
+
// CUCM typically writes captures to /var/log/active/platform/cli/<name>.cap
|
|
7
|
+
// Sometimes CLI output includes the final on-disk location.
|
|
8
|
+
const re = /\/var\/log\/active\/platform\/cli\/[A-Za-z0-9._-]+\.cap/g;
|
|
9
|
+
const matches = text.match(re);
|
|
10
|
+
if (!matches || matches.length === 0)
|
|
11
|
+
return undefined;
|
|
12
|
+
return matches[matches.length - 1];
|
|
13
|
+
}
|
|
14
|
+
function looksLikeCucmPrompt(text) {
|
|
15
|
+
const t = String(text || "");
|
|
16
|
+
// CUCM CLI commonly ends commands by printing a prompt like:
|
|
17
|
+
// admin:
|
|
18
|
+
// Some environments might show other usernames.
|
|
19
|
+
// We only look at the tail to avoid false positives.
|
|
20
|
+
const tail = t.slice(-80);
|
|
21
|
+
return /(?:^|\n)[A-Za-z0-9_-]+:\s*$/.test(tail);
|
|
22
|
+
}
|
|
3
23
|
export function resolveSshAuth(auth) {
|
|
4
24
|
const username = auth?.username || process.env.CUCM_SSH_USERNAME;
|
|
5
25
|
const password = auth?.password || process.env.CUCM_SSH_PASSWORD;
|
|
@@ -58,6 +78,17 @@ export function remoteCapturePath(fileBase) {
|
|
|
58
78
|
const fb = sanitizeFileBase(fileBase);
|
|
59
79
|
return `/var/log/active/platform/cli/${fb}.cap`;
|
|
60
80
|
}
|
|
81
|
+
export function remoteCaptureCandidates(fileBase, maxParts = 10) {
|
|
82
|
+
const fb = sanitizeFileBase(fileBase);
|
|
83
|
+
const base = `/var/log/active/platform/cli/${fb}.cap`;
|
|
84
|
+
const out = [base];
|
|
85
|
+
// Some CUCM/VOS versions roll packet capture files as .cap01, .cap02, ...
|
|
86
|
+
for (let i = 1; i <= maxParts; i++) {
|
|
87
|
+
const suffix = String(i).padStart(2, "0");
|
|
88
|
+
out.push(`/var/log/active/platform/cli/${fb}.cap${suffix}`);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
61
92
|
export class PacketCaptureManager {
|
|
62
93
|
active = new Map();
|
|
63
94
|
list() {
|
|
@@ -81,6 +112,7 @@ export class PacketCaptureManager {
|
|
|
81
112
|
iface,
|
|
82
113
|
fileBase,
|
|
83
114
|
remoteFilePath: remoteCapturePath(fileBase),
|
|
115
|
+
remoteFileCandidates: remoteCaptureCandidates(fileBase),
|
|
84
116
|
};
|
|
85
117
|
await new Promise((resolve, reject) => {
|
|
86
118
|
client
|
|
@@ -121,33 +153,123 @@ export class PacketCaptureManager {
|
|
|
121
153
|
this.active.set(id, { session, client, channel });
|
|
122
154
|
return session;
|
|
123
155
|
}
|
|
124
|
-
async stop(captureId, timeoutMs =
|
|
156
|
+
async stop(captureId, timeoutMs = 90_000) {
|
|
125
157
|
const a = this.active.get(captureId);
|
|
126
158
|
if (!a)
|
|
127
159
|
throw new Error(`Unknown captureId: ${captureId}`);
|
|
128
160
|
const { channel, client, session } = a;
|
|
129
161
|
const done = new Promise((resolve) => {
|
|
130
|
-
channel
|
|
162
|
+
// CUCM CLI can keep the SSH channel open after the command finishes
|
|
163
|
+
// (it returns to a prompt rather than exiting). Treat prompt as "stopped".
|
|
164
|
+
const onData = () => {
|
|
165
|
+
if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {
|
|
166
|
+
cleanup();
|
|
167
|
+
resolve();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const cleanup = () => {
|
|
171
|
+
channel.off("data", onData);
|
|
172
|
+
channel.stderr.off("data", onData);
|
|
173
|
+
};
|
|
174
|
+
const finish = () => {
|
|
175
|
+
cleanup();
|
|
176
|
+
resolve();
|
|
177
|
+
};
|
|
178
|
+
channel.once("exit", finish);
|
|
179
|
+
channel.once("close", finish);
|
|
180
|
+
channel.once("end", finish);
|
|
181
|
+
channel.on("data", onData);
|
|
182
|
+
channel.stderr.on("data", onData);
|
|
131
183
|
});
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
channel.signal
|
|
136
|
-
|
|
184
|
+
const sendInterrupt = () => {
|
|
185
|
+
// Best-effort interrupt.
|
|
186
|
+
try {
|
|
187
|
+
if (typeof channel.signal === "function")
|
|
188
|
+
channel.signal("INT");
|
|
189
|
+
// Always also write Ctrl-C for PTY sessions.
|
|
137
190
|
channel.write("\x03");
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
try {
|
|
194
|
+
channel.write("\x03");
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// ignore
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
// CUCM can take a while to flush/close captures (especially big buffers).
|
|
202
|
+
// Also, some CLI flows require a second Ctrl-C or a newline to return to prompt.
|
|
203
|
+
const resolvedTimeoutMs = Math.max(5000, timeoutMs || 0);
|
|
204
|
+
const deadline = Date.now() + resolvedTimeoutMs;
|
|
205
|
+
sendInterrupt();
|
|
206
|
+
// Nudge again a couple times if it doesn't exit quickly.
|
|
207
|
+
void (async () => {
|
|
208
|
+
const delays = [750, 1500, 3000];
|
|
209
|
+
for (const d of delays) {
|
|
210
|
+
await new Promise((r) => setTimeout(r, d));
|
|
211
|
+
if (Date.now() >= deadline)
|
|
212
|
+
return;
|
|
213
|
+
// If channel already closed, no-op.
|
|
214
|
+
sendInterrupt();
|
|
215
|
+
try {
|
|
216
|
+
channel.write("\n");
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// ignore
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
})();
|
|
223
|
+
try {
|
|
224
|
+
await Promise.race([
|
|
225
|
+
done,
|
|
226
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout waiting for capture to stop")), resolvedTimeoutMs)),
|
|
227
|
+
]);
|
|
138
228
|
}
|
|
139
|
-
catch {
|
|
229
|
+
catch (e) {
|
|
230
|
+
// Don't hard-fail: if the CLI doesn't emit a prompt/exit, we still want to:
|
|
231
|
+
// - close SSH resources
|
|
232
|
+
// - let the caller try DIME downloads (.cap, .cap01, etc)
|
|
233
|
+
session.stopTimedOut = true;
|
|
140
234
|
try {
|
|
141
|
-
channel.
|
|
235
|
+
channel.close();
|
|
142
236
|
}
|
|
143
237
|
catch {
|
|
144
238
|
// ignore
|
|
145
239
|
}
|
|
146
240
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
241
|
+
session.stoppedAt = new Date().toISOString();
|
|
242
|
+
// If we're back at a CUCM prompt, try to close the session cleanly.
|
|
243
|
+
if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {
|
|
244
|
+
try {
|
|
245
|
+
channel.write("exit\n");
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// ignore
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Ensure the channel is closed so the SSH client can terminate promptly.
|
|
252
|
+
try {
|
|
253
|
+
channel.end();
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// ignore
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
channel.close();
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// ignore
|
|
263
|
+
}
|
|
264
|
+
// Attempt to learn the actual remote file path from CLI output.
|
|
265
|
+
// This helps when CUCM appends suffixes or reports a different on-disk location.
|
|
266
|
+
const inferred = extractCapFilePath(session.lastStdout) || extractCapFilePath(session.lastStderr);
|
|
267
|
+
if (inferred) {
|
|
268
|
+
session.remoteFilePath = inferred;
|
|
269
|
+
if (!session.remoteFileCandidates.includes(inferred)) {
|
|
270
|
+
session.remoteFileCandidates = [inferred, ...session.remoteFileCandidates];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
151
273
|
// stop() implies cleanup
|
|
152
274
|
this.active.delete(captureId);
|
|
153
275
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@calltelemetry/cucm-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "MCP server for CUCM tooling (DIME logs, syslog, packet capture)",
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
"url": "git+https://github.com/calltelemetry/cucm-mcp.git"
|
|
11
11
|
},
|
|
12
12
|
"bin": {
|
|
13
|
-
"cucm-mcp": "
|
|
13
|
+
"cucm-mcp": "dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
14
17
|
},
|
|
15
18
|
"files": [
|
|
16
19
|
"dist/",
|
|
@@ -22,7 +25,7 @@
|
|
|
22
25
|
},
|
|
23
26
|
"scripts": {
|
|
24
27
|
"start": "tsx src/index.ts",
|
|
25
|
-
"build": "tsc",
|
|
28
|
+
"build": "tsc && node scripts/chmod-bin.mjs",
|
|
26
29
|
"typecheck": "tsc --noEmit",
|
|
27
30
|
"test": "yarn build && node --test test/*.test.js",
|
|
28
31
|
"prepack": "yarn test"
|