@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 +6 -0
- package/dist/dime.js +12 -2
- package/dist/index.js +25 -4
- package/dist/packetCapture.js +88 -1
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
});
|
package/dist/packetCapture.js
CHANGED
|
@@ -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.
|
|
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 = () => {
|