@calltelemetry/cucm-mcp 0.1.6 → 0.1.8

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
@@ -90,3 +90,58 @@ Live tests are opt-in via env vars; see `test/live.test.js`.
90
90
  - Use the platform/OS admin for SSH (`administrator` user on most lab systems)
91
91
  - To request a high packet count without specifying an exact number, pass `maxPackets: true` to `packet_capture_start`
92
92
  - If traffic is low, a small `count` can still run “forever” waiting for packets; use `packet_capture_stop` to cancel, or set `maxDurationMs` to auto-stop
93
+
94
+ ### Auth Note (DIME vs SSH)
95
+
96
+ CUCM deployments vary:
97
+
98
+ - SSH and DIME may accept different usernames/passwords.
99
+ - Quick check: the right DIME user returns HTTP 200 for the WSDL.
100
+
101
+ ```bash
102
+ curl -k -u "<user>:<pass>" \
103
+ "https://<cucm-host>:8443/logcollectionservice2/services/LogCollectionPortTypeService?wsdl" \
104
+ -o /dev/null -w "%{http_code}\n"
105
+ ```
106
+
107
+ ### Recommended Workflow
108
+
109
+ 1) Start capture (returns quickly; capture continues on CUCM):
110
+
111
+ Tool: `packet_capture_start`
112
+
113
+ Useful options:
114
+
115
+ - `count`: stop after N packets (can wait indefinitely if traffic is low)
116
+ - `maxDurationMs`: stop after a fixed time even if packet count isn’t reached
117
+ - `startTimeoutMs`: fail fast if the CUCM CLI prompt isn’t reachable
118
+ - `maxPackets: true`: sets a high capture count (1,000,000) when `count` is omitted
119
+
120
+ 2) Stop + download the capture:
121
+
122
+ Tool: `packet_capture_stop_and_download`
123
+
124
+ This:
125
+
126
+ - stops the SSH capture (best-effort)
127
+ - retries DIME downloads until the file appears
128
+ - tries rolled filenames (`.cap01`, `.cap02`, ...)
129
+
130
+ ### What to Expect in Output
131
+
132
+ Many MCP clients truncate long JSON. The CUCM MCP tools print a one-line summary first, followed by the full JSON:
133
+
134
+ - `packet_capture_start`: prints `id`, `remoteFilePath`, and a reminder that capture continues on CUCM until stopped
135
+ - `packet_capture_stop_and_download`: prints `savedPath` and `bytes` so you can immediately open the file
136
+
137
+ ### Viewing the Capture (macOS)
138
+
139
+ After download, you’ll get a `savedPath` like `/tmp/foo.cap`.
140
+
141
+ ```bash
142
+ # Reveal in Finder
143
+ open -R "/tmp/foo.cap"
144
+
145
+ # Open in Wireshark
146
+ open -a Wireshark "/tmp/foo.cap"
147
+ ```
package/dist/axl.js ADDED
@@ -0,0 +1,130 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ const parser = new XMLParser({
3
+ ignoreAttributes: false,
4
+ attributeNamePrefix: "@",
5
+ removeNSPrefix: true,
6
+ trimValues: true,
7
+ });
8
+ function basicAuthHeader(username, password) {
9
+ return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
10
+ }
11
+ function escapeXml(s) {
12
+ return s
13
+ .replaceAll("&", "&amp;")
14
+ .replaceAll("<", "&lt;")
15
+ .replaceAll(">", "&gt;")
16
+ .replaceAll('"', "&quot;")
17
+ .replaceAll("'", "&apos;");
18
+ }
19
+ export function normalizeHost(hostOrUrl) {
20
+ const s = String(hostOrUrl || "").trim();
21
+ if (!s)
22
+ throw new Error("host is required");
23
+ if (s.includes("://")) {
24
+ const u = new URL(s);
25
+ return u.hostname;
26
+ }
27
+ return s.replace(/^https?:\/\//, "").replace(/\/+$/, "").split("/")[0];
28
+ }
29
+ export function resolveAxlAuth(auth) {
30
+ const username = auth?.username || process.env.CUCM_AXL_USERNAME || process.env.CUCM_USERNAME || process.env.CUCM_DIME_USERNAME;
31
+ const password = auth?.password || process.env.CUCM_AXL_PASSWORD || process.env.CUCM_PASSWORD || process.env.CUCM_DIME_PASSWORD;
32
+ if (!username || !password) {
33
+ throw new Error("Missing AXL credentials (provide auth or set CUCM_AXL_USERNAME/CUCM_AXL_PASSWORD)");
34
+ }
35
+ return { username, password };
36
+ }
37
+ export function resolveAxlTarget(hostOrUrl, port, version) {
38
+ const host = normalizeHost(hostOrUrl);
39
+ const envPort = process.env.CUCM_AXL_PORT ? Number.parseInt(process.env.CUCM_AXL_PORT, 10) : undefined;
40
+ const resolvedPort = port ?? envPort ?? 8443;
41
+ const resolvedVersion = version || process.env.CUCM_AXL_VERSION || "15.0";
42
+ return { host, port: resolvedPort, version: resolvedVersion };
43
+ }
44
+ function soapEnvelope(axlVersion, innerXml) {
45
+ const ns = `http://www.cisco.com/AXL/API/${escapeXml(axlVersion)}`;
46
+ return (`<?xml version="1.0" encoding="UTF-8"?>` +
47
+ `<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns="${ns}">` +
48
+ `<soapenv:Header/>` +
49
+ `<soapenv:Body>` +
50
+ innerXml +
51
+ `</soapenv:Body>` +
52
+ `</soapenv:Envelope>`);
53
+ }
54
+ async function fetchAxl(target, auth, operation, innerXml, timeoutMs = 30_000) {
55
+ const url = `https://${target.host}:${target.port}/axl/`;
56
+ const xmlBody = soapEnvelope(target.version, innerXml);
57
+ const res = await fetch(url, {
58
+ method: "POST",
59
+ headers: {
60
+ Authorization: basicAuthHeader(auth.username, auth.password),
61
+ "Content-Type": "text/xml; charset=utf-8",
62
+ SOAPAction: `CUCM:DB ver=${target.version} ${operation}`,
63
+ Accept: "*/*",
64
+ },
65
+ body: Buffer.from(xmlBody, "utf8"),
66
+ signal: AbortSignal.timeout(timeoutMs),
67
+ });
68
+ const contentType = res.headers.get("content-type");
69
+ const text = await res.text().catch(() => "");
70
+ if (!res.ok) {
71
+ throw new Error(`CUCM AXL HTTP ${res.status}: ${text || res.statusText}`);
72
+ }
73
+ return { contentType, xml: text };
74
+ }
75
+ function parseSoapReturn(operation, xmlText) {
76
+ const parsed = parser.parse(xmlText);
77
+ const env = parsed.Envelope || parsed;
78
+ const body = env.Body || env;
79
+ const fault = body?.Fault;
80
+ if (fault) {
81
+ const faultString = fault.faultstring || fault.faultcode || JSON.stringify(fault);
82
+ return { fault: String(faultString) };
83
+ }
84
+ const resp = body?.[`${operation}Response`];
85
+ const ret = resp?.return;
86
+ if (ret == null)
87
+ return {};
88
+ if (typeof ret === "string")
89
+ return { returnValue: ret };
90
+ // Some AXL responses wrap the value.
91
+ if (typeof ret === "object" && typeof ret["#text"] === "string")
92
+ return { returnValue: ret["#text"] };
93
+ return { returnValue: String(ret) };
94
+ }
95
+ export async function updatePhonePacketCapture(hostOrUrl, args) {
96
+ const target = resolveAxlTarget(hostOrUrl, args.port, args.version);
97
+ const auth = resolveAxlAuth(args.auth);
98
+ const name = String(args.deviceName || "").trim();
99
+ if (!name)
100
+ throw new Error("deviceName is required");
101
+ const mode = String(args.mode || "").trim();
102
+ if (!mode)
103
+ throw new Error("mode is required");
104
+ const duration = Math.trunc(args.durationSeconds);
105
+ if (!Number.isFinite(duration) || duration <= 0)
106
+ throw new Error("durationSeconds must be > 0");
107
+ const innerXml = `<ns:updatePhone>` +
108
+ `<name>${escapeXml(name)}</name>` +
109
+ `<packetCaptureMode>${escapeXml(mode)}</packetCaptureMode>` +
110
+ `<packetCaptureDuration>${escapeXml(String(duration))}</packetCaptureDuration>` +
111
+ `</ns:updatePhone>`;
112
+ const { xml } = await fetchAxl(target, auth, "updatePhone", innerXml, args.timeoutMs ?? 30_000);
113
+ const parsed = parseSoapReturn("updatePhone", xml);
114
+ if (parsed.fault)
115
+ throw new Error(`AXL updatePhone fault: ${parsed.fault}`);
116
+ return { host: target.host, operation: "updatePhone", returnValue: parsed.returnValue };
117
+ }
118
+ export async function applyPhone(hostOrUrl, args) {
119
+ const target = resolveAxlTarget(hostOrUrl, args.port, args.version);
120
+ const auth = resolveAxlAuth(args.auth);
121
+ const name = String(args.deviceName || "").trim();
122
+ if (!name)
123
+ throw new Error("deviceName is required");
124
+ const innerXml = `<ns:applyPhone><name>${escapeXml(name)}</name></ns:applyPhone>`;
125
+ const { xml } = await fetchAxl(target, auth, "applyPhone", innerXml, args.timeoutMs ?? 30_000);
126
+ const parsed = parseSoapReturn("applyPhone", xml);
127
+ if (parsed.fault)
128
+ throw new Error(`AXL applyPhone fault: ${parsed.fault}`);
129
+ return { host: target.host, operation: "applyPhone", returnValue: parsed.returnValue };
130
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { listNodeServiceLogs, selectLogs, selectLogsMinutes, getOneFile, getOneF
6
6
  import { guessTimezoneString } from "./time.js";
7
7
  import { PacketCaptureManager } from "./packetCapture.js";
8
8
  import { defaultStateStore } from "./state.js";
9
+ import { applyPhone, updatePhonePacketCapture } from "./axl.js";
9
10
  // Default to accepting self-signed/invalid certs (common on CUCM lab/dev).
10
11
  // Opt back into strict verification with CUCM_MCP_TLS_MODE=strict.
11
12
  const tlsMode = (process.env.CUCM_MCP_TLS_MODE || process.env.MCP_TLS_MODE || "").toLowerCase();
@@ -14,7 +15,7 @@ const strictTls = tlsMode === "strict" || tlsMode === "verify";
14
15
  // Set CUCM_MCP_TLS_MODE=strict to enforce verification.
15
16
  if (!strictTls)
16
17
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
17
- const server = new McpServer({ name: "cucm", version: "0.1.6" });
18
+ const server = new McpServer({ name: "cucm", version: "0.1.8" });
18
19
  const captures = new PacketCaptureManager();
19
20
  const captureState = defaultStateStore();
20
21
  const dimeAuthSchema = z
@@ -29,6 +30,12 @@ const sshAuthSchema = z
29
30
  password: z.string().optional(),
30
31
  })
31
32
  .optional();
33
+ const axlAuthSchema = z
34
+ .object({
35
+ username: z.string().optional(),
36
+ password: z.string().optional(),
37
+ })
38
+ .optional();
32
39
  server.tool("guess_timezone_string", "Build a best-effort DIME timezone string for selectLogFiles.", {}, async () => ({
33
40
  content: [{ type: "text", text: JSON.stringify({ timezone: guessTimezoneString(new Date()) }, null, 2) }],
34
41
  }));
@@ -82,6 +89,66 @@ server.tool("select_syslog_minutes", "Convenience wrapper: select system log fil
82
89
  const result = await selectLogsMinutes(host, minutesBack, { systemLogs: [systemLog || "Syslog"], searchStr }, timezone, auth, port);
83
90
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
84
91
  });
92
+ server.tool("phone_packet_capture_enable", "Enable phone packet capture via CUCM AXL (updatePhone packetCaptureMode/Duration + applyPhone).", {
93
+ host: z.string().describe("CUCM host/IP"),
94
+ port: z.number().int().min(1).max(65535).optional().describe("AXL port (default 8443)"),
95
+ axlVersion: z.string().optional().describe("AXL API version (default env CUCM_AXL_VERSION or 15.0)"),
96
+ auth: axlAuthSchema.describe("AXL auth (optional; defaults to CUCM_AXL_USERNAME/CUCM_AXL_PASSWORD)"),
97
+ deviceName: z.string().min(1).describe("Phone device name (e.g. SEP505C885DF37F)"),
98
+ mode: z
99
+ .string()
100
+ .optional()
101
+ .describe('packetCaptureMode value (commonly "Batch Processing Mode")'),
102
+ durationSeconds: z
103
+ .number()
104
+ .int()
105
+ .min(1)
106
+ .max(60 * 60)
107
+ .optional()
108
+ .describe("packetCaptureDuration in seconds (default 60)"),
109
+ apply: z.boolean().optional().describe("Run applyPhone after updatePhone (default true)"),
110
+ timeoutMs: z.number().int().min(1000).max(5 * 60_000).optional().describe("AXL request timeout"),
111
+ }, async ({ host, port, axlVersion, auth, deviceName, mode, durationSeconds, apply, timeoutMs }) => {
112
+ const update = await updatePhonePacketCapture(host, {
113
+ deviceName,
114
+ mode: mode || "Batch Processing Mode",
115
+ durationSeconds: durationSeconds ?? 60,
116
+ auth: auth,
117
+ port,
118
+ version: axlVersion,
119
+ timeoutMs,
120
+ });
121
+ const shouldApply = apply ?? true;
122
+ const applied = shouldApply
123
+ ? await applyPhone(host, {
124
+ deviceName,
125
+ auth: auth,
126
+ port,
127
+ version: axlVersion,
128
+ timeoutMs,
129
+ })
130
+ : undefined;
131
+ return {
132
+ content: [
133
+ {
134
+ type: "text",
135
+ text: JSON.stringify({
136
+ host: update.host,
137
+ deviceName,
138
+ packetCaptureMode: mode || "Batch Processing Mode",
139
+ packetCaptureDuration: durationSeconds ?? 60,
140
+ updatePhoneReturn: update.returnValue,
141
+ applied: shouldApply,
142
+ applyPhoneReturn: applied?.returnValue,
143
+ notes: [
144
+ "Phone may need to reset to pick up config.",
145
+ "Place the call during the duration window; CUCM writes the capture to its TFTP directory (CUCM behavior/version dependent).",
146
+ ],
147
+ }, null, 2),
148
+ },
149
+ ],
150
+ };
151
+ });
85
152
  server.tool("download_file", "Download a single file via DIME GetOneFile and write it to disk.", {
86
153
  host: z.string(),
87
154
  port: z.number().int().min(1).max(65535).optional(),
@@ -143,7 +210,12 @@ server.tool("packet_capture_start", "Start a packet capture on CUCM via SSH (uti
143
210
  maxDurationMs,
144
211
  startTimeoutMs,
145
212
  });
146
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
213
+ const summary = `Started CUCM packet capture (SSH). ` +
214
+ `id=${result.id} host=${result.host} fileBase=${result.fileBase} remoteFilePath=${result.remoteFilePath}. ` +
215
+ `Stops when packet count is reached, when you call packet_capture_stop, or via maxDurationMs.`;
216
+ return {
217
+ content: [{ type: "text", text: `${summary}\n\n${JSON.stringify(result, null, 2)}` }],
218
+ };
147
219
  });
148
220
  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) }] }));
149
221
  server.tool("packet_capture_state_list", "List packet captures from the local state file (survives MCP restarts).", {}, async () => {
@@ -179,22 +251,47 @@ server.tool("packet_capture_stop", "Stop a packet capture by captureId (sends Ct
179
251
  captureId: z.string().min(1),
180
252
  timeoutMs: z.number().int().min(1000).max(10 * 60_000).optional().describe("How long to wait for stop (default ~90s)"),
181
253
  }, async ({ captureId, timeoutMs }) => {
182
- const result = await captures.stop(captureId, timeoutMs);
183
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
254
+ try {
255
+ const result = await captures.stop(captureId, timeoutMs);
256
+ const summary = `Stopped capture. id=${result.id} stopTimedOut=${Boolean(result.stopTimedOut)} remoteFilePath=${result.remoteFilePath}`;
257
+ return { content: [{ type: "text", text: `${summary}\n\n${JSON.stringify(result, null, 2)}` }] };
258
+ }
259
+ catch (e) {
260
+ const stopError = e instanceof Error ? e.message : String(e || "");
261
+ const pruned = captureState.pruneExpired(captureState.load());
262
+ const rec = pruned.captures[captureId];
263
+ if (!rec)
264
+ throw e;
265
+ const summary = `Failed to stop capture (returning state record). id=${captureId} stopError=${JSON.stringify(stopError)}`;
266
+ return {
267
+ content: [
268
+ {
269
+ type: "text",
270
+ text: `${summary}\n\n${JSON.stringify({ stopError, record: rec }, null, 2)}`,
271
+ },
272
+ ],
273
+ };
274
+ }
184
275
  });
185
276
  server.tool("packet_capture_stop_and_download", "Stop a packet capture and download the resulting .cap file via DIME.", {
186
277
  captureId: z.string().min(1),
187
278
  dimePort: z.number().int().min(1).max(65535).optional(),
188
279
  auth: dimeAuthSchema.describe("DIME auth (optional; defaults to CUCM_DIME_USERNAME/CUCM_DIME_PASSWORD)"),
189
280
  outFile: z.string().optional().describe("Optional output path for the downloaded .cap file"),
190
- stopTimeoutMs: z.number().int().min(1000).max(10 * 60_000).optional().describe("How long to wait for SSH capture stop"),
281
+ stopTimeoutMs: z
282
+ .number()
283
+ .int()
284
+ .min(1000)
285
+ .max(10 * 60_000)
286
+ .optional()
287
+ .describe("How long to wait for SSH capture stop (default 300000)"),
191
288
  downloadTimeoutMs: z
192
289
  .number()
193
290
  .int()
194
291
  .min(1000)
195
292
  .max(10 * 60_000)
196
293
  .optional()
197
- .describe("How long to wait for the capture file to appear in DIME"),
294
+ .describe("How long to wait for the capture file to appear in DIME (default 300000)"),
198
295
  downloadPollIntervalMs: z
199
296
  .number()
200
297
  .int()
@@ -203,31 +300,50 @@ server.tool("packet_capture_stop_and_download", "Stop a packet capture and downl
203
300
  .optional()
204
301
  .describe("How often to retry DIME GetOneFile when the file isn't there yet"),
205
302
  }, async ({ captureId, dimePort, auth, outFile, stopTimeoutMs, downloadTimeoutMs, downloadPollIntervalMs }) => {
206
- const stopped = await captures.stop(captureId, stopTimeoutMs);
207
- const candidates = (stopped.remoteFileCandidates || []).length
208
- ? stopped.remoteFileCandidates
209
- : [stopped.remoteFilePath];
303
+ const stopTimeout = stopTimeoutMs ?? 300_000;
304
+ const dlTimeout = downloadTimeoutMs ?? 300_000;
305
+ const dlPoll = downloadPollIntervalMs ?? 2000;
306
+ let stopped;
307
+ let stopError;
308
+ try {
309
+ stopped = await captures.stop(captureId, stopTimeout);
310
+ }
311
+ catch (e) {
312
+ stopError = e instanceof Error ? e.message : String(e || "");
313
+ // Fall back to state file (useful if stop failed or MCP restarted).
314
+ const pruned = captureState.pruneExpired(captureState.load());
315
+ const rec = pruned.captures[captureId];
316
+ if (!rec)
317
+ throw new Error(`Failed to stop capture and capture not found in state: ${captureId}. stopError=${stopError}`);
318
+ stopped = rec;
319
+ }
320
+ const candidates = (stopped.remoteFileCandidates || []).length ? stopped.remoteFileCandidates : [stopped.remoteFilePath];
210
321
  const dl = await getOneFileAnyWithRetry(stopped.host, candidates, {
211
322
  auth: auth,
212
323
  port: dimePort,
213
- timeoutMs: downloadTimeoutMs,
214
- pollIntervalMs: downloadPollIntervalMs,
324
+ timeoutMs: dlTimeout,
325
+ pollIntervalMs: dlPoll,
215
326
  });
216
327
  const saved = writeDownloadedFile(dl, outFile);
328
+ const summary = `Capture downloaded. ` +
329
+ `id=${captureId} stopTimedOut=${Boolean(stopped.stopTimedOut)} remoteFilePath=${dl.filename} ` +
330
+ `savedPath=${saved.filePath} bytes=${saved.bytes}` +
331
+ (stopError ? ` stopError=${JSON.stringify(stopError)}` : "");
217
332
  return {
218
333
  content: [
219
334
  {
220
335
  type: "text",
221
- text: JSON.stringify({
336
+ text: `${summary}\n\n${JSON.stringify({
222
337
  captureId: stopped.id,
223
338
  host: stopped.host,
224
339
  remoteFilePath: dl.filename,
225
340
  stopTimedOut: stopped.stopTimedOut || false,
341
+ stopError,
226
342
  savedPath: saved.filePath,
227
343
  bytes: saved.bytes,
228
344
  dimeAttempts: dl.attempts,
229
345
  dimeWaitedMs: dl.waitedMs,
230
- }, null, 2),
346
+ }, null, 2)}`,
231
347
  },
232
348
  ],
233
349
  };
@@ -263,11 +379,12 @@ server.tool("packet_capture_download_from_state", "Download a capture file using
263
379
  pollIntervalMs: downloadPollIntervalMs,
264
380
  });
265
381
  const saved = writeDownloadedFile(dl, outFile);
382
+ const summary = `Capture downloaded from state. id=${captureId} remoteFilePath=${dl.filename} savedPath=${saved.filePath} bytes=${saved.bytes}`;
266
383
  return {
267
384
  content: [
268
385
  {
269
386
  type: "text",
270
- text: JSON.stringify({
387
+ text: `${summary}\n\n${JSON.stringify({
271
388
  captureId,
272
389
  host: rec.host,
273
390
  remoteFilePath: dl.filename,
@@ -275,7 +392,7 @@ server.tool("packet_capture_download_from_state", "Download a capture file using
275
392
  bytes: saved.bytes,
276
393
  dimeAttempts: dl.attempts,
277
394
  dimeWaitedMs: dl.waitedMs,
278
- }, null, 2),
395
+ }, null, 2)}`,
279
396
  },
280
397
  ],
281
398
  };
@@ -151,18 +151,33 @@ export class PacketCaptureManager {
151
151
  ...session,
152
152
  stoppedAt: session.stoppedAt,
153
153
  });
154
- await new Promise((resolve, reject) => {
155
- client
156
- .on("ready", () => resolve())
157
- .on("error", (e) => reject(e))
158
- .connect({
159
- host: opts.host,
160
- port: sshPort,
161
- username: auth.username,
162
- password: auth.password,
163
- readyTimeout: 15000,
164
- });
154
+ const startTimeoutMs = Math.max(2000, Math.trunc(opts.startTimeoutMs ?? 30_000));
155
+ let connectTimer;
156
+ const connectTimeout = new Promise((_, reject) => {
157
+ connectTimer = setTimeout(() => reject(new Error(`SSH connect timed out after ${startTimeoutMs}ms`)), startTimeoutMs);
158
+ connectTimer.unref?.();
165
159
  });
160
+ try {
161
+ await Promise.race([
162
+ new Promise((resolve, reject) => {
163
+ client
164
+ .on("ready", () => resolve())
165
+ .on("error", (e) => reject(e))
166
+ .connect({
167
+ host: opts.host,
168
+ port: sshPort,
169
+ username: auth.username,
170
+ password: auth.password,
171
+ readyTimeout: 15000,
172
+ });
173
+ }),
174
+ connectTimeout,
175
+ ]);
176
+ }
177
+ finally {
178
+ if (connectTimer)
179
+ clearTimeout(connectTimer);
180
+ }
166
181
  // CUCM SSH presents an interactive CLI shell. `exec()` is not reliably supported,
167
182
  // so we open a shell and type commands at the prompt.
168
183
  const channel = await new Promise((resolve, reject) => {
@@ -196,7 +211,7 @@ export class PacketCaptureManager {
196
211
  });
197
212
  // Wait for prompt before running capture command.
198
213
  // Some CUCM shells don't print a prompt until you send a newline.
199
- const promptTimeoutMs = Math.max(2000, Math.trunc(opts.startTimeoutMs ?? 30_000));
214
+ const promptTimeoutMs = startTimeoutMs;
200
215
  try {
201
216
  try {
202
217
  channel.write("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/cucm-mcp",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "MCP server for CUCM tooling (DIME logs, syslog, packet capture)",