@calltelemetry/cucm-mcp 0.1.3 → 0.1.5

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
@@ -84,3 +84,9 @@ Live tests are opt-in via env vars; see `test/live.test.js`.
84
84
  - `packet_capture_stop_and_download` - stop capture + download `.cap` via DIME
85
85
  - `packet_capture_state_list` - list captures from state file
86
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`
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.3" });
17
+ const server = new McpServer({ name: "cucm", version: "0.1.4" });
18
18
  const captures = new PacketCaptureManager();
19
19
  const captureState = defaultStateStore();
20
20
  const dimeAuthSchema = z
@@ -106,21 +106,42 @@ server.tool("packet_capture_start", "Start a packet capture on CUCM via SSH (uti
106
106
  auth: sshAuthSchema,
107
107
  iface: z.string().optional().describe("Interface (default eth0)"),
108
108
  fileBase: z.string().optional().describe("Capture base name (no dots). Saved as <fileBase>.cap"),
109
- count: z.number().int().min(1).max(200000).optional().describe("Packet count (file max is typically 100000)"),
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)"),
110
114
  size: z.string().optional().describe("Packet size (e.g. all)"),
111
115
  hostFilterIp: z.string().optional().describe("Optional filter: host ip <addr>"),
112
116
  portFilter: z.number().int().min(1).max(65535).optional().describe("Optional filter: port <num>"),
113
- }, async ({ host, sshPort, auth, iface, fileBase, count, 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 }) => {
132
+ const resolvedCount = count ?? (maxPackets ? 1_000_000 : undefined);
114
133
  const result = await captures.start({
115
134
  host,
116
135
  sshPort,
117
136
  auth: auth,
118
137
  iface,
119
138
  fileBase,
120
- count,
139
+ count: resolvedCount,
121
140
  size,
122
141
  hostFilterIp,
123
142
  portFilter,
143
+ maxDurationMs,
144
+ startTimeoutMs,
124
145
  });
125
146
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
126
147
  });
@@ -90,6 +90,33 @@ export function remoteCaptureCandidates(fileBase, maxParts = 10) {
90
90
  }
91
91
  return out;
92
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
+ }
93
120
  export class PacketCaptureManager {
94
121
  active = new Map();
95
122
  state;
@@ -136,8 +163,10 @@ export class PacketCaptureManager {
136
163
  readyTimeout: 15000,
137
164
  });
138
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.
139
168
  const channel = await new Promise((resolve, reject) => {
140
- client.exec(cmd, { pty: true }, (err, ch) => {
169
+ client.shell({ term: "vt100", cols: 120, rows: 40 }, (err, ch) => {
141
170
  if (err)
142
171
  return reject(err);
143
172
  resolve(ch);
@@ -165,7 +194,62 @@ export class PacketCaptureManager {
165
194
  // ignore
166
195
  }
167
196
  });
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));
200
+ try {
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");
223
+ channel.write(`${cmd}\n`);
224
+ }
225
+ catch (e) {
226
+ try {
227
+ channel.end();
228
+ channel.close();
229
+ }
230
+ catch {
231
+ // ignore
232
+ }
233
+ try {
234
+ client.end();
235
+ client.destroy();
236
+ }
237
+ catch {
238
+ // ignore
239
+ }
240
+ throw e;
241
+ }
168
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
+ }
169
253
  return session;
170
254
  }
171
255
  async stop(captureId, timeoutMs = 90_000) {
@@ -174,6 +258,9 @@ export class PacketCaptureManager {
174
258
  throw new Error(`Unknown captureId: ${captureId}`);
175
259
  const { channel, client, session } = a;
176
260
  const done = new Promise((resolve) => {
261
+ if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {
262
+ return resolve();
263
+ }
177
264
  // CUCM CLI can keep the SSH channel open after the command finishes
178
265
  // (it returns to a prompt rather than exiting). Treat prompt as "stopped".
179
266
  const onData = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/cucm-mcp",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "MCP server for CUCM tooling (DIME logs, syslog, packet capture)",