@calltelemetry/cucm-mcp 0.1.3 → 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 CHANGED
@@ -84,3 +84,8 @@ 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`
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,18 +106,23 @@ 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
+ }, async ({ host, sshPort, auth, iface, fileBase, count, maxPackets, size, hostFilterIp, portFilter }) => {
118
+ const resolvedCount = count ?? (maxPackets ? 1_000_000 : undefined);
114
119
  const result = await captures.start({
115
120
  host,
116
121
  sshPort,
117
122
  auth: auth,
118
123
  iface,
119
124
  fileBase,
120
- count,
125
+ count: resolvedCount,
121
126
  size,
122
127
  hostFilterIp,
123
128
  portFilter,
@@ -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,6 +194,28 @@ export class PacketCaptureManager {
165
194
  // ignore
166
195
  }
167
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
+ }
168
219
  this.active.set(id, { session, client, channel });
169
220
  return session;
170
221
  }
@@ -174,6 +225,9 @@ export class PacketCaptureManager {
174
225
  throw new Error(`Unknown captureId: ${captureId}`);
175
226
  const { channel, client, session } = a;
176
227
  const done = new Promise((resolve) => {
228
+ if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {
229
+ return resolve();
230
+ }
177
231
  // CUCM CLI can keep the SSH channel open after the command finishes
178
232
  // (it returns to a prompt rather than exiting). Treat prompt as "stopped".
179
233
  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.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "MCP server for CUCM tooling (DIME logs, syslog, packet capture)",