@calltelemetry/cucm-mcp 0.1.4 → 0.1.6

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
@@ -89,3 +89,4 @@ Live tests are opt-in via env vars; see `test/live.test.js`.
89
89
 
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
+ - 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
package/dist/dime.js CHANGED
@@ -27,8 +27,18 @@ function dimeXmlBytes(contentType, bytes) {
27
27
  return bytes;
28
28
  const parts = parseMultipartRelated(bytes, boundary);
29
29
  const xmlParts = parts.filter((p) => isXmlContentType(p.contentType));
30
- if (xmlParts.length === 0)
31
- throw new Error("DIME response missing text/xml part");
30
+ if (xmlParts.length === 0) {
31
+ // Some CUCM versions have been observed returning multipart bodies where the XML part
32
+ // does not advertise a classic XML content-type. Fall back to sniffing the payload.
33
+ const sniffed = parts.find((p) => {
34
+ const head = p.body.subarray(0, 64).toString("utf8");
35
+ return head.includes("<?xml") || head.trimStart().startsWith("<");
36
+ });
37
+ if (sniffed)
38
+ return sniffed.body;
39
+ const partTypes = parts.map((p) => p.contentType).join(", ");
40
+ throw new Error(`DIME response missing text/xml part (boundary=${boundary}; parts=[${partTypes || "none"}])`);
41
+ }
32
42
  return xmlParts[0].body;
33
43
  }
34
44
  export function normalizeHost(hostOrUrl) {
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ const strictTls = tlsMode === "strict" || tlsMode === "verify";
14
14
  // Set CUCM_MCP_TLS_MODE=strict to enforce verification.
15
15
  if (!strictTls)
16
16
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
17
- const server = new McpServer({ name: "cucm", version: "0.1.4" });
17
+ const server = new McpServer({ name: "cucm", version: "0.1.6" });
18
18
  const captures = new PacketCaptureManager();
19
19
  const captureState = defaultStateStore();
20
20
  const dimeAuthSchema = z
@@ -114,7 +114,21 @@ server.tool("packet_capture_start", "Start a packet capture on CUCM via SSH (uti
114
114
  size: z.string().optional().describe("Packet size (e.g. all)"),
115
115
  hostFilterIp: z.string().optional().describe("Optional filter: host ip <addr>"),
116
116
  portFilter: z.number().int().min(1).max(65535).optional().describe("Optional filter: port <num>"),
117
- }, async ({ host, sshPort, auth, iface, fileBase, count, maxPackets, size, hostFilterIp, portFilter }) => {
117
+ maxDurationMs: z
118
+ .number()
119
+ .int()
120
+ .min(250)
121
+ .max(24 * 60 * 60_000)
122
+ .optional()
123
+ .describe("Stop after this duration even if packet count isn't reached"),
124
+ startTimeoutMs: z
125
+ .number()
126
+ .int()
127
+ .min(2000)
128
+ .max(120_000)
129
+ .optional()
130
+ .describe("Timeout for starting capture (SSH connect + command start)"),
131
+ }, async ({ host, sshPort, auth, iface, fileBase, count, maxPackets, size, hostFilterIp, portFilter, maxDurationMs, startTimeoutMs }) => {
118
132
  const resolvedCount = count ?? (maxPackets ? 1_000_000 : undefined);
119
133
  const result = await captures.start({
120
134
  host,
@@ -126,6 +140,8 @@ server.tool("packet_capture_start", "Start a packet capture on CUCM via SSH (uti
126
140
  size,
127
141
  hostFilterIp,
128
142
  portFilter,
143
+ maxDurationMs,
144
+ startTimeoutMs,
129
145
  });
130
146
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
131
147
  });
@@ -195,8 +195,31 @@ export class PacketCaptureManager {
195
195
  }
196
196
  });
197
197
  // Wait for prompt before running capture command.
198
+ // 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));
198
200
  try {
199
- await waitFor(channel, session, () => looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr), 30_000, "Timeout waiting for CUCM CLI prompt");
201
+ try {
202
+ channel.write("\n");
203
+ }
204
+ catch {
205
+ // ignore
206
+ }
207
+ // Nudge a couple times if no prompt yet.
208
+ void (async () => {
209
+ const delays = [400, 1200, 2500];
210
+ for (const d of delays) {
211
+ await new Promise((r) => setTimeout(r, d));
212
+ if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr))
213
+ return;
214
+ try {
215
+ channel.write("\n");
216
+ }
217
+ catch {
218
+ // ignore
219
+ }
220
+ }
221
+ })();
222
+ await waitFor(channel, session, () => looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr), promptTimeoutMs, "Timeout waiting for CUCM CLI prompt");
200
223
  channel.write(`${cmd}\n`);
201
224
  }
202
225
  catch (e) {
@@ -217,6 +240,16 @@ export class PacketCaptureManager {
217
240
  throw e;
218
241
  }
219
242
  this.active.set(id, { session, client, channel });
243
+ // Optional guard: stop after a duration even if packet count isn't reached.
244
+ if (opts.maxDurationMs != null) {
245
+ const dur = Math.max(250, Math.trunc(opts.maxDurationMs));
246
+ const t = setTimeout(() => {
247
+ void this.stop(id).catch(() => {
248
+ // ignore
249
+ });
250
+ }, dur);
251
+ t.unref?.();
252
+ }
220
253
  return session;
221
254
  }
222
255
  async stop(captureId, timeoutMs = 90_000) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/cucm-mcp",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "MCP server for CUCM tooling (DIME logs, syslog, packet capture)",