@calltelemetry/cucm-mcp 0.1.7 → 0.2.3

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/dist/index.js CHANGED
@@ -2,45 +2,73 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
- import { listNodeServiceLogs, selectLogs, selectLogsMinutes, getOneFile, getOneFileAnyWithRetry, writeDownloadedFile, } from "./dime.js";
5
+ import { mkdirSync, writeFileSync } from "fs";
6
+ import { dirname, join } from "path";
7
+ import {
8
+ listNodeServiceLogs,
9
+ selectLogs,
10
+ selectLogsMinutes,
11
+ getOneFile,
12
+ getOneFileAnyWithRetry,
13
+ writeDownloadedFile
14
+ } from "./dime.js";
6
15
  import { guessTimezoneString } from "./time.js";
7
16
  import { PacketCaptureManager } from "./packetCapture.js";
8
17
  import { defaultStateStore } from "./state.js";
9
- // Default to accepting self-signed/invalid certs (common on CUCM lab/dev).
10
- // Opt back into strict verification with CUCM_MCP_TLS_MODE=strict.
18
+ import { applyPhone, updatePhonePacketCapture, axlExecute } from "./axl.js";
19
+ import { pcapCallSummary, pcapSipCalls, pcapScppMessages, pcapRtpStreams, pcapProtocolFilter } from "./pcap-analyze.js";
11
20
  const tlsMode = (process.env.CUCM_MCP_TLS_MODE || process.env.MCP_TLS_MODE || "").toLowerCase();
12
21
  const strictTls = tlsMode === "strict" || tlsMode === "verify";
13
- // Default: permissive TLS (accept self-signed). This is the common CUCM lab posture.
14
- // Set CUCM_MCP_TLS_MODE=strict to enforce verification.
15
- if (!strictTls)
16
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
17
- const server = new McpServer({ name: "cucm", version: "0.1.7" });
22
+ if (!strictTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
23
+ const server = new McpServer({ name: "cucm", version: "0.1.8" });
18
24
  const captures = new PacketCaptureManager();
19
25
  const captureState = defaultStateStore();
20
- const dimeAuthSchema = z
21
- .object({
22
- username: z.string().optional(),
23
- password: z.string().optional(),
24
- })
25
- .optional();
26
- const sshAuthSchema = z
27
- .object({
28
- username: z.string().optional(),
29
- password: z.string().optional(),
30
- })
31
- .optional();
32
- server.tool("guess_timezone_string", "Build a best-effort DIME timezone string for selectLogFiles.", {}, async () => ({
33
- content: [{ type: "text", text: JSON.stringify({ timezone: guessTimezoneString(new Date()) }, null, 2) }],
34
- }));
35
- server.tool("list_node_service_logs", "List CUCM cluster nodes and their available service logs (DIME listNodeServiceLogs).", {
26
+ const dimeAuthSchema = z.object({
27
+ username: z.string().optional(),
28
+ password: z.string().optional()
29
+ }).optional();
30
+ const sshAuthSchema = z.object({
31
+ username: z.string().optional(),
32
+ password: z.string().optional()
33
+ }).optional();
34
+ const axlAuthSchema = z.object({
35
+ username: z.string().optional(),
36
+ password: z.string().optional()
37
+ }).optional();
38
+ function formatUnknownError(e) {
39
+ if (e instanceof Error) return e.message;
40
+ if (typeof e === "string") return e;
41
+ try {
42
+ return JSON.stringify(e);
43
+ } catch {
44
+ return String(e);
45
+ }
46
+ }
47
+ server.tool(
48
+ "guess_timezone_string",
49
+ "Build a best-effort DIME timezone string for selectLogFiles.",
50
+ {},
51
+ async () => ({
52
+ content: [{ type: "text", text: JSON.stringify({ timezone: guessTimezoneString(/* @__PURE__ */ new Date()) }, null, 2) }]
53
+ })
54
+ );
55
+ server.tool(
56
+ "list_node_service_logs",
57
+ "List CUCM cluster nodes and their available service logs (DIME listNodeServiceLogs).",
58
+ {
36
59
  host: z.string(),
37
60
  port: z.number().int().min(1).max(65535).optional(),
38
- auth: dimeAuthSchema,
39
- }, async ({ host, port, auth }) => {
61
+ auth: dimeAuthSchema
62
+ },
63
+ async ({ host, port, auth }) => {
40
64
  const result = await listNodeServiceLogs(host, auth, port);
41
65
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
42
- });
43
- server.tool("select_logs", "List log/trace files using DIME selectLogFiles. Supports ServiceLogs and SystemLogs.", {
66
+ }
67
+ );
68
+ server.tool(
69
+ "select_logs",
70
+ "List log/trace files using DIME selectLogFiles. Supports ServiceLogs and SystemLogs.",
71
+ {
44
72
  host: z.string(),
45
73
  port: z.number().int().min(1).max(65535).optional(),
46
74
  auth: dimeAuthSchema,
@@ -49,12 +77,22 @@ server.tool("select_logs", "List log/trace files using DIME selectLogFiles. Supp
49
77
  searchStr: z.string().optional().describe("Optional filename substring filter"),
50
78
  fromDate: z.string(),
51
79
  toDate: z.string(),
52
- timezone: z.string(),
53
- }, async ({ host, port, auth, serviceLogs, systemLogs, searchStr, fromDate, toDate, timezone }) => {
54
- const result = await selectLogs(host, { serviceLogs, systemLogs, searchStr, fromDate, toDate, timezone }, auth, port);
80
+ timezone: z.string()
81
+ },
82
+ async ({ host, port, auth, serviceLogs, systemLogs, searchStr, fromDate, toDate, timezone }) => {
83
+ const result = await selectLogs(
84
+ host,
85
+ { serviceLogs, systemLogs, searchStr, fromDate, toDate, timezone },
86
+ auth,
87
+ port
88
+ );
55
89
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
56
- });
57
- server.tool("select_logs_minutes", "Convenience wrapper: select logs using a minutes-back window.", {
90
+ }
91
+ );
92
+ server.tool(
93
+ "select_logs_minutes",
94
+ "Convenience wrapper: select logs using a minutes-back window.",
95
+ {
58
96
  host: z.string(),
59
97
  port: z.number().int().min(1).max(65535).optional(),
60
98
  auth: dimeAuthSchema,
@@ -62,244 +100,679 @@ server.tool("select_logs_minutes", "Convenience wrapper: select logs using a min
62
100
  serviceLogs: z.array(z.string()).optional(),
63
101
  systemLogs: z.array(z.string()).optional(),
64
102
  searchStr: z.string().optional(),
65
- timezone: z.string().optional(),
66
- }, async ({ host, port, auth, minutesBack, serviceLogs, systemLogs, searchStr, timezone }) => {
67
- const result = await selectLogsMinutes(host, minutesBack, { serviceLogs, systemLogs, searchStr }, timezone, auth, port);
103
+ timezone: z.string().optional()
104
+ },
105
+ async ({ host, port, auth, minutesBack, serviceLogs, systemLogs, searchStr, timezone }) => {
106
+ const result = await selectLogsMinutes(
107
+ host,
108
+ minutesBack,
109
+ { serviceLogs, systemLogs, searchStr },
110
+ timezone,
111
+ auth,
112
+ port
113
+ );
68
114
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
69
- });
70
- server.tool("select_syslog_minutes", "Convenience wrapper: select system log files (e.g. Syslog) using a minutes-back window.", {
115
+ }
116
+ );
117
+ server.tool(
118
+ "select_syslog_minutes",
119
+ "Convenience wrapper: select system log files (e.g. Syslog) using a minutes-back window.",
120
+ {
71
121
  host: z.string(),
72
122
  port: z.number().int().min(1).max(65535).optional(),
73
123
  auth: dimeAuthSchema,
74
124
  minutesBack: z.number().int().min(1).max(60 * 24 * 30),
75
- systemLog: z
76
- .string()
77
- .optional()
78
- .describe("System log selection name. Default is 'Syslog' (may vary by CUCM version)."),
125
+ systemLog: z.string().optional().describe("System log selection name. Default is 'Syslog' (may vary by CUCM version)."),
79
126
  searchStr: z.string().optional(),
80
- timezone: z.string().optional(),
81
- }, async ({ host, port, auth, minutesBack, systemLog, searchStr, timezone }) => {
82
- const result = await selectLogsMinutes(host, minutesBack, { systemLogs: [systemLog || "Syslog"], searchStr }, timezone, auth, port);
127
+ timezone: z.string().optional()
128
+ },
129
+ async ({ host, port, auth, minutesBack, systemLog, searchStr, timezone }) => {
130
+ const result = await selectLogsMinutes(
131
+ host,
132
+ minutesBack,
133
+ { systemLogs: [systemLog || "Syslog"], searchStr },
134
+ timezone,
135
+ auth,
136
+ port
137
+ );
83
138
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
84
- });
85
- server.tool("download_file", "Download a single file via DIME GetOneFile and write it to disk.", {
139
+ }
140
+ );
141
+ server.tool(
142
+ "phone_packet_capture_enable",
143
+ "Enable phone packet capture via CUCM AXL (updatePhone packetCaptureMode/Duration + applyPhone).",
144
+ {
145
+ host: z.string().describe("CUCM host/IP"),
146
+ port: z.number().int().min(1).max(65535).optional().describe("AXL port (default 8443)"),
147
+ axlVersion: z.string().optional().describe("AXL API version (default env CUCM_AXL_VERSION or 15.0)"),
148
+ auth: axlAuthSchema.describe("AXL auth (optional; defaults to CUCM_AXL_USERNAME/CUCM_AXL_PASSWORD)"),
149
+ deviceName: z.string().min(1).describe("Phone device name (e.g. SEP505C885DF37F)"),
150
+ mode: z.string().optional().describe('packetCaptureMode value (commonly "Batch Processing Mode")'),
151
+ durationSeconds: z.number().int().min(1).max(60 * 60).optional().describe("packetCaptureDuration in seconds (default 60)"),
152
+ apply: z.boolean().optional().describe("Run applyPhone after updatePhone (default true)"),
153
+ timeoutMs: z.number().int().min(1e3).max(5 * 6e4).optional().describe("AXL request timeout")
154
+ },
155
+ async ({ host, port, axlVersion, auth, deviceName, mode, durationSeconds, apply, timeoutMs }) => {
156
+ const update = await updatePhonePacketCapture(host, {
157
+ deviceName,
158
+ mode: mode || "Batch Processing Mode",
159
+ durationSeconds: durationSeconds ?? 60,
160
+ auth,
161
+ port,
162
+ version: axlVersion,
163
+ timeoutMs
164
+ });
165
+ const shouldApply = apply ?? true;
166
+ const applied = shouldApply ? await applyPhone(host, {
167
+ deviceName,
168
+ auth,
169
+ port,
170
+ version: axlVersion,
171
+ timeoutMs
172
+ }) : void 0;
173
+ return {
174
+ content: [
175
+ {
176
+ type: "text",
177
+ text: JSON.stringify(
178
+ {
179
+ host: update.host,
180
+ deviceName,
181
+ packetCaptureMode: mode || "Batch Processing Mode",
182
+ packetCaptureDuration: durationSeconds ?? 60,
183
+ updatePhoneReturn: update.returnValue,
184
+ applied: shouldApply,
185
+ applyPhoneReturn: applied?.returnValue,
186
+ notes: [
187
+ "Phone may need to reset to pick up config.",
188
+ "Place the call during the duration window; CUCM writes the capture to its TFTP directory (CUCM behavior/version dependent)."
189
+ ]
190
+ },
191
+ null,
192
+ 2
193
+ )
194
+ }
195
+ ]
196
+ };
197
+ }
198
+ );
199
+ server.tool(
200
+ "axl_execute",
201
+ "Execute an arbitrary CUCM AXL SOAP operation. Accepts a JSON-ish data payload and converts it to XML. Tip: for get* operations, returnedTags can be an array of strings (supports dotted paths like 'lines.line.dirn.pattern').",
202
+ {
203
+ operation: z.string().min(1).describe("AXL operation name, e.g. getPhone, updateLine, updateCtiRoutePoint"),
204
+ data: z.any().optional().describe("Operation payload as an object. Keys become XML tags."),
205
+ cucm_host: z.string().describe("CUCM host/IP"),
206
+ cucm_port: z.number().int().min(1).max(65535).optional().describe("AXL port (default 8443)"),
207
+ cucm_version: z.string().optional().describe("AXL API version (e.g. 15.0)"),
208
+ cucm_username: z.string().optional().describe("AXL username (optional if env CUCM_AXL_USERNAME is set)"),
209
+ cucm_password: z.string().optional().describe("AXL password (optional if env CUCM_AXL_PASSWORD is set)"),
210
+ timeoutMs: z.number().int().min(1e3).max(5 * 6e4).optional().describe("AXL request timeout"),
211
+ includeRequestXml: z.boolean().optional().describe("Include SOAP request XML in response (debug)"),
212
+ includeResponseXml: z.boolean().optional().describe("Include SOAP response XML in response (debug)")
213
+ },
214
+ async ({
215
+ operation,
216
+ data,
217
+ cucm_host,
218
+ cucm_port,
219
+ cucm_version,
220
+ cucm_username,
221
+ cucm_password,
222
+ timeoutMs,
223
+ includeRequestXml,
224
+ includeResponseXml
225
+ }) => {
226
+ const auth = cucm_username && cucm_password ? { username: cucm_username, password: cucm_password } : void 0;
227
+ try {
228
+ const result = await axlExecute(cucm_host, {
229
+ operation,
230
+ data,
231
+ auth,
232
+ port: cucm_port,
233
+ version: cucm_version,
234
+ timeoutMs,
235
+ includeRequestXml,
236
+ includeResponseXml
237
+ });
238
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, ...result }, null, 2) }] };
239
+ } catch (e) {
240
+ const msg = formatUnknownError(e);
241
+ const guessGetOperation = (op) => {
242
+ const s = String(op || "").trim();
243
+ if (!s) return null;
244
+ const prefixes = ["update", "add", "remove", "set"];
245
+ for (const p of prefixes) {
246
+ if (s.startsWith(p) && s.length > p.length) {
247
+ return `get${s.slice(p.length)}`;
248
+ }
249
+ }
250
+ return null;
251
+ };
252
+ const getOp = guessGetOperation(operation);
253
+ const hints = [
254
+ "If the error is opaque, retry with includeRequestXml=true and includeResponseXml=true to see the SOAP fault.",
255
+ "For get* operations, try returnedTags as an array of strings (supports dotted paths).",
256
+ "For update* operations, first run the corresponding get* to copy the exact nested shape and/or uuids.",
257
+ "If you see schema/version errors, make sure cucm_version matches your CUCM AXL version (Help > About in CUCM)."
258
+ ];
259
+ if (/self-signed certificate|unable to verify|CERT/i.test(msg)) {
260
+ hints.unshift(
261
+ "TLS: this MCP defaults to permissive TLS, but your runtime may still be enforcing verification. Set CUCM_MCP_TLS_MODE=permissive (or MCP_TLS_MODE=permissive)."
262
+ );
263
+ }
264
+ if (/401|403|auth/i.test(msg)) {
265
+ hints.unshift(
266
+ "Auth: verify AXL credentials and that the CUCM user has AXL permissions for this operation (AXL role/group)."
267
+ );
268
+ }
269
+ if (/not found|does not exist|unknown/i.test(msg)) {
270
+ hints.unshift(
271
+ "Existence: confirm the target object exists by calling a get* operation first (e.g. getCtiRoutePoint/getLine)."
272
+ );
273
+ }
274
+ const nextToolCalls = [
275
+ ...getOp ? [
276
+ {
277
+ tool: "axl_execute",
278
+ args: {
279
+ operation: getOp,
280
+ cucm_host,
281
+ cucm_port,
282
+ cucm_version,
283
+ cucm_username,
284
+ cucm_password,
285
+ includeResponseXml: true
286
+ },
287
+ note: 'Fetch the current object first (add an appropriate data payload, e.g. {name: "..."} or {uuid: "..."}) to learn nesting/uuid requirements.'
288
+ }
289
+ ] : [],
290
+ {
291
+ tool: "axl_execute",
292
+ args: {
293
+ operation,
294
+ cucm_host,
295
+ cucm_port,
296
+ cucm_version,
297
+ cucm_username,
298
+ cucm_password,
299
+ includeRequestXml: true,
300
+ includeResponseXml: true
301
+ },
302
+ note: "Re-run with debug XML included to capture the SOAP Fault details."
303
+ }
304
+ ];
305
+ return {
306
+ content: [
307
+ {
308
+ type: "text",
309
+ text: JSON.stringify(
310
+ {
311
+ ok: false,
312
+ error: true,
313
+ message: msg,
314
+ operation,
315
+ cucm_host,
316
+ hints,
317
+ nextToolCalls
318
+ },
319
+ null,
320
+ 2
321
+ )
322
+ }
323
+ ]
324
+ };
325
+ }
326
+ }
327
+ );
328
+ server.tool(
329
+ "axl_download_wsdl",
330
+ "Download the CUCM AXL WSDL to a local file. Useful when you need schema hints for an operation.",
331
+ {
332
+ cucm_host: z.string().describe("CUCM host/IP"),
333
+ cucm_port: z.number().int().min(1).max(65535).optional().describe("AXL port (default 8443)"),
334
+ cucm_username: z.string().optional().describe("AXL username (optional if env CUCM_AXL_USERNAME is set)"),
335
+ cucm_password: z.string().optional().describe("AXL password (optional if env CUCM_AXL_PASSWORD is set)"),
336
+ outFile: z.string().optional().describe("Optional output file path (default /tmp/cucm-mcp/axl.wsdl)")
337
+ },
338
+ async ({ cucm_host, cucm_port, cucm_username, cucm_password, outFile }) => {
339
+ const port = cucm_port ?? 8443;
340
+ const user = cucm_username || process.env.CUCM_AXL_USERNAME || process.env.CUCM_USERNAME;
341
+ const pass = cucm_password || process.env.CUCM_AXL_PASSWORD || process.env.CUCM_PASSWORD;
342
+ if (!user || !pass) {
343
+ return {
344
+ content: [
345
+ {
346
+ type: "text",
347
+ text: JSON.stringify(
348
+ {
349
+ ok: false,
350
+ error: true,
351
+ message: "Missing AXL credentials (provide cucm_username/cucm_password or set CUCM_AXL_USERNAME/CUCM_AXL_PASSWORD)"
352
+ },
353
+ null,
354
+ 2
355
+ )
356
+ }
357
+ ]
358
+ };
359
+ }
360
+ const url = `https://${cucm_host}:${port}/axl/?wsdl`;
361
+ try {
362
+ const res = await fetch(url, {
363
+ headers: {
364
+ Authorization: `Basic ${Buffer.from(`${user}:${pass}`, "utf8").toString("base64")}`,
365
+ Accept: "application/xml, text/xml, */*"
366
+ },
367
+ signal: AbortSignal.timeout(3e4)
368
+ });
369
+ const text = await res.text().catch(() => "");
370
+ const file = outFile || join("/tmp", "cucm-mcp", "axl.wsdl");
371
+ mkdirSync(dirname(file), { recursive: true });
372
+ writeFileSync(file, text, "utf8");
373
+ return {
374
+ content: [
375
+ {
376
+ type: "text",
377
+ text: JSON.stringify(
378
+ {
379
+ ok: res.ok,
380
+ url,
381
+ status: res.status,
382
+ savedPath: file,
383
+ bytes: Buffer.byteLength(text, "utf8"),
384
+ note: "WSDL often imports XSDs; you may need to fetch those referenced URLs too."
385
+ },
386
+ null,
387
+ 2
388
+ )
389
+ }
390
+ ]
391
+ };
392
+ } catch (e) {
393
+ return {
394
+ content: [
395
+ {
396
+ type: "text",
397
+ text: JSON.stringify(
398
+ {
399
+ ok: false,
400
+ error: true,
401
+ url,
402
+ message: formatUnknownError(e)
403
+ },
404
+ null,
405
+ 2
406
+ )
407
+ }
408
+ ]
409
+ };
410
+ }
411
+ }
412
+ );
413
+ server.tool(
414
+ "download_file",
415
+ "Download a single file via DIME GetOneFile and write it to disk.",
416
+ {
86
417
  host: z.string(),
87
418
  port: z.number().int().min(1).max(65535).optional(),
88
419
  auth: dimeAuthSchema,
89
420
  filePath: z.string().min(1).describe("Absolute path on CUCM"),
90
- outFile: z.string().optional().describe("Optional output path. Default: /tmp/cucm-mcp/<basename>"),
91
- }, async ({ host, port, auth, filePath, outFile }) => {
421
+ outFile: z.string().optional().describe("Optional output path. Default: /tmp/cucm-mcp/<basename>")
422
+ },
423
+ async ({ host, port, auth, filePath, outFile }) => {
92
424
  const dl = await getOneFile(host, filePath, auth, port);
93
425
  const saved = writeDownloadedFile(dl, outFile);
94
426
  return {
95
- content: [
96
- {
97
- type: "text",
98
- text: JSON.stringify({ server: dl.server, sourcePath: dl.filename, savedPath: saved.filePath, bytes: saved.bytes }, null, 2),
99
- },
100
- ],
427
+ content: [
428
+ {
429
+ type: "text",
430
+ text: JSON.stringify({ server: dl.server, sourcePath: dl.filename, savedPath: saved.filePath, bytes: saved.bytes }, null, 2)
431
+ }
432
+ ]
101
433
  };
102
- });
103
- server.tool("packet_capture_start", "Start a packet capture on CUCM via SSH (utils network capture). Returns a captureId.", {
434
+ }
435
+ );
436
+ server.tool(
437
+ "packet_capture_start",
438
+ "Start a packet capture on CUCM via SSH (utils network capture). Returns a captureId.",
439
+ {
104
440
  host: z.string().describe("CUCM host/IP"),
105
441
  sshPort: z.number().int().min(1).max(65535).optional(),
106
442
  auth: sshAuthSchema,
107
443
  iface: z.string().optional().describe("Interface (default eth0)"),
108
444
  fileBase: z.string().optional().describe("Capture base name (no dots). Saved as <fileBase>.cap"),
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)"),
445
+ count: z.number().int().min(1).max(1e6).optional().describe("Packet count (common max is 1000000)"),
446
+ maxPackets: z.boolean().optional().describe("If true and count is omitted, uses a high capture count (1,000,000)"),
114
447
  size: z.string().optional().describe("Packet size (e.g. all)"),
115
448
  hostFilterIp: z.string().optional().describe("Optional filter: host ip <addr>"),
116
449
  portFilter: z.number().int().min(1).max(65535).optional().describe("Optional filter: port <num>"),
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);
450
+ maxDurationMs: z.number().int().min(250).max(24 * 60 * 6e4).optional().describe("Stop after this duration even if packet count isn't reached"),
451
+ startTimeoutMs: z.number().int().min(2e3).max(12e4).optional().describe("Timeout for starting capture (SSH connect + command start)")
452
+ },
453
+ async ({ host, sshPort, auth, iface, fileBase, count, maxPackets, size, hostFilterIp, portFilter, maxDurationMs, startTimeoutMs }) => {
454
+ const resolvedCount = count ?? (maxPackets ? 1e6 : void 0);
133
455
  const result = await captures.start({
134
- host,
135
- sshPort,
136
- auth: auth,
137
- iface,
138
- fileBase,
139
- count: resolvedCount,
140
- size,
141
- hostFilterIp,
142
- portFilter,
143
- maxDurationMs,
144
- startTimeoutMs,
456
+ host,
457
+ sshPort,
458
+ auth,
459
+ iface,
460
+ fileBase,
461
+ count: resolvedCount,
462
+ size,
463
+ hostFilterIp,
464
+ portFilter,
465
+ maxDurationMs,
466
+ startTimeoutMs
145
467
  });
146
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
147
- });
148
- 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
- server.tool("packet_capture_state_list", "List packet captures from the local state file (survives MCP restarts).", {}, async () => {
468
+ const summary = `Started CUCM packet capture (SSH). id=${result.id} host=${result.host} fileBase=${result.fileBase} remoteFilePath=${result.remoteFilePath}. Stops when packet count is reached, when you call packet_capture_stop, or via maxDurationMs.`;
469
+ return {
470
+ content: [{ type: "text", text: `${summary}
471
+
472
+ ${JSON.stringify(result, null, 2)}` }]
473
+ };
474
+ }
475
+ );
476
+ server.tool(
477
+ "packet_capture_list",
478
+ "List active packet captures started by this MCP server.",
479
+ {},
480
+ async () => ({ content: [{ type: "text", text: JSON.stringify(captures.list(), null, 2) }] })
481
+ );
482
+ server.tool(
483
+ "packet_capture_state_list",
484
+ "List packet captures from the local state file (survives MCP restarts).",
485
+ {},
486
+ async () => {
150
487
  const pruned = captureState.pruneExpired(captureState.load());
151
488
  captureState.save(pruned);
152
- const items = Object.values(pruned.captures).sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
489
+ const items = Object.values(pruned.captures).sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
153
490
  return { content: [{ type: "text", text: JSON.stringify({ path: captureState.path, captures: items }, null, 2) }] };
154
- });
155
- server.tool("packet_capture_state_get", "Get a packet capture record from the local state file.", {
156
- captureId: z.string().min(1),
157
- }, async ({ captureId }) => {
491
+ }
492
+ );
493
+ server.tool(
494
+ "packet_capture_state_get",
495
+ "Get a packet capture record from the local state file.",
496
+ {
497
+ captureId: z.string().min(1)
498
+ },
499
+ async ({ captureId }) => {
158
500
  const pruned = captureState.pruneExpired(captureState.load());
159
501
  const rec = pruned.captures[captureId];
160
502
  if (!rec) {
161
- return {
162
- content: [
163
- {
164
- type: "text",
165
- text: JSON.stringify({ path: captureState.path, found: false, captureId }, null, 2),
166
- },
167
- ],
168
- };
503
+ return {
504
+ content: [
505
+ {
506
+ type: "text",
507
+ text: JSON.stringify({ path: captureState.path, found: false, captureId }, null, 2)
508
+ }
509
+ ]
510
+ };
169
511
  }
170
512
  return { content: [{ type: "text", text: JSON.stringify({ path: captureState.path, found: true, record: rec }, null, 2) }] };
171
- });
172
- server.tool("packet_capture_state_clear", "Delete a capture record from the local state file.", {
173
- captureId: z.string().min(1),
174
- }, async ({ captureId }) => {
513
+ }
514
+ );
515
+ server.tool(
516
+ "packet_capture_state_clear",
517
+ "Delete a capture record from the local state file.",
518
+ {
519
+ captureId: z.string().min(1)
520
+ },
521
+ async ({ captureId }) => {
175
522
  captureState.remove(captureId);
176
523
  return { content: [{ type: "text", text: JSON.stringify({ removed: true, captureId }, null, 2) }] };
177
- });
178
- server.tool("packet_capture_stop", "Stop a packet capture by captureId (sends Ctrl-C).", {
524
+ }
525
+ );
526
+ server.tool(
527
+ "packet_capture_stop",
528
+ "Stop a packet capture by captureId (sends Ctrl-C).",
529
+ {
179
530
  captureId: z.string().min(1),
180
- timeoutMs: z.number().int().min(1000).max(10 * 60_000).optional().describe("How long to wait for stop (default ~90s)"),
181
- }, async ({ captureId, timeoutMs }) => {
182
- const result = await captures.stop(captureId, timeoutMs);
183
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
184
- });
185
- server.tool("packet_capture_stop_and_download", "Stop a packet capture and download the resulting .cap file via DIME.", {
531
+ timeoutMs: z.number().int().min(1e3).max(10 * 6e4).optional().describe("How long to wait for stop (default ~90s)")
532
+ },
533
+ async ({ captureId, timeoutMs }) => {
534
+ try {
535
+ const result = await captures.stop(captureId, timeoutMs);
536
+ const summary = `Stopped capture. id=${result.id} stopTimedOut=${Boolean(result.stopTimedOut)} remoteFilePath=${result.remoteFilePath}`;
537
+ return { content: [{ type: "text", text: `${summary}
538
+
539
+ ${JSON.stringify(result, null, 2)}` }] };
540
+ } catch (e) {
541
+ const stopError = e instanceof Error ? e.message : String(e || "");
542
+ const pruned = captureState.pruneExpired(captureState.load());
543
+ const rec = pruned.captures[captureId];
544
+ if (!rec) throw e;
545
+ const summary = `Failed to stop capture (returning state record). id=${captureId} stopError=${JSON.stringify(stopError)}`;
546
+ return {
547
+ content: [
548
+ {
549
+ type: "text",
550
+ text: `${summary}
551
+
552
+ ${JSON.stringify({ stopError, record: rec }, null, 2)}`
553
+ }
554
+ ]
555
+ };
556
+ }
557
+ }
558
+ );
559
+ server.tool(
560
+ "packet_capture_stop_and_download",
561
+ "Stop a packet capture and download the resulting .cap file via DIME.",
562
+ {
186
563
  captureId: z.string().min(1),
187
564
  dimePort: z.number().int().min(1).max(65535).optional(),
188
565
  auth: dimeAuthSchema.describe("DIME auth (optional; defaults to CUCM_DIME_USERNAME/CUCM_DIME_PASSWORD)"),
189
566
  outFile: z.string().optional().describe("Optional output path for the downloaded .cap file"),
190
- stopTimeoutMs: z
191
- .number()
192
- .int()
193
- .min(1000)
194
- .max(10 * 60_000)
195
- .optional()
196
- .describe("How long to wait for SSH capture stop (default 300000)"),
197
- downloadTimeoutMs: z
198
- .number()
199
- .int()
200
- .min(1000)
201
- .max(10 * 60_000)
202
- .optional()
203
- .describe("How long to wait for the capture file to appear in DIME (default 300000)"),
204
- downloadPollIntervalMs: z
205
- .number()
206
- .int()
207
- .min(250)
208
- .max(30_000)
209
- .optional()
210
- .describe("How often to retry DIME GetOneFile when the file isn't there yet"),
211
- }, async ({ captureId, dimePort, auth, outFile, stopTimeoutMs, downloadTimeoutMs, downloadPollIntervalMs }) => {
212
- const stopTimeout = stopTimeoutMs ?? 300_000;
213
- const dlTimeout = downloadTimeoutMs ?? 300_000;
214
- const dlPoll = downloadPollIntervalMs ?? 2000;
567
+ stopTimeoutMs: z.number().int().min(1e3).max(10 * 6e4).optional().describe("How long to wait for SSH capture stop (default 300000)"),
568
+ downloadTimeoutMs: z.number().int().min(1e3).max(10 * 6e4).optional().describe("How long to wait for the capture file to appear in DIME (default 300000)"),
569
+ downloadPollIntervalMs: z.number().int().min(250).max(3e4).optional().describe("How often to retry DIME GetOneFile when the file isn't there yet")
570
+ },
571
+ async ({ captureId, dimePort, auth, outFile, stopTimeoutMs, downloadTimeoutMs, downloadPollIntervalMs }) => {
572
+ const stopTimeout = stopTimeoutMs ?? 3e5;
573
+ const dlTimeout = downloadTimeoutMs ?? 3e5;
574
+ const dlPoll = downloadPollIntervalMs ?? 2e3;
215
575
  let stopped;
216
576
  let stopError;
217
577
  try {
218
- stopped = await captures.stop(captureId, stopTimeout);
219
- }
220
- catch (e) {
221
- stopError = e instanceof Error ? e.message : String(e || "");
222
- // Fall back to state file (useful if stop failed or MCP restarted).
223
- const pruned = captureState.pruneExpired(captureState.load());
224
- const rec = pruned.captures[captureId];
225
- if (!rec)
226
- throw new Error(`Failed to stop capture and capture not found in state: ${captureId}. stopError=${stopError}`);
227
- stopped = rec;
578
+ stopped = await captures.stop(captureId, stopTimeout);
579
+ } catch (e) {
580
+ stopError = e instanceof Error ? e.message : String(e || "");
581
+ const pruned = captureState.pruneExpired(captureState.load());
582
+ const rec = pruned.captures[captureId];
583
+ if (!rec) throw new Error(`Failed to stop capture and capture not found in state: ${captureId}. stopError=${stopError}`);
584
+ stopped = rec;
228
585
  }
229
586
  const candidates = (stopped.remoteFileCandidates || []).length ? stopped.remoteFileCandidates : [stopped.remoteFilePath];
230
587
  const dl = await getOneFileAnyWithRetry(stopped.host, candidates, {
231
- auth: auth,
232
- port: dimePort,
233
- timeoutMs: dlTimeout,
234
- pollIntervalMs: dlPoll,
588
+ auth,
589
+ port: dimePort,
590
+ timeoutMs: dlTimeout,
591
+ pollIntervalMs: dlPoll
235
592
  });
236
593
  const saved = writeDownloadedFile(dl, outFile);
594
+ const summary = `Capture downloaded. id=${captureId} stopTimedOut=${Boolean(stopped.stopTimedOut)} remoteFilePath=${dl.filename} savedPath=${saved.filePath} bytes=${saved.bytes}` + (stopError ? ` stopError=${JSON.stringify(stopError)}` : "");
237
595
  return {
238
- content: [
596
+ content: [
597
+ {
598
+ type: "text",
599
+ text: `${summary}
600
+
601
+ ${JSON.stringify(
239
602
  {
240
- type: "text",
241
- text: JSON.stringify({
242
- captureId: stopped.id,
243
- host: stopped.host,
244
- remoteFilePath: dl.filename,
245
- stopTimedOut: stopped.stopTimedOut || false,
246
- stopError,
247
- savedPath: saved.filePath,
248
- bytes: saved.bytes,
249
- dimeAttempts: dl.attempts,
250
- dimeWaitedMs: dl.waitedMs,
251
- }, null, 2),
603
+ captureId: stopped.id,
604
+ host: stopped.host,
605
+ remoteFilePath: dl.filename,
606
+ stopTimedOut: stopped.stopTimedOut || false,
607
+ stopError,
608
+ savedPath: saved.filePath,
609
+ bytes: saved.bytes,
610
+ dimeAttempts: dl.attempts,
611
+ dimeWaitedMs: dl.waitedMs
252
612
  },
253
- ],
613
+ null,
614
+ 2
615
+ )}`
616
+ }
617
+ ]
254
618
  };
255
- });
256
- server.tool("packet_capture_download_from_state", "Download a capture file using the local state record (useful after MCP restart).", {
619
+ }
620
+ );
621
+ server.tool(
622
+ "packet_capture_download_from_state",
623
+ "Download a capture file using the local state record (useful after MCP restart).",
624
+ {
257
625
  captureId: z.string().min(1),
258
626
  dimePort: z.number().int().min(1).max(65535).optional(),
259
627
  auth: dimeAuthSchema.describe("DIME auth (optional; defaults to CUCM_DIME_USERNAME/CUCM_DIME_PASSWORD)"),
260
628
  outFile: z.string().optional().describe("Optional output path for the downloaded .cap file"),
261
- downloadTimeoutMs: z
262
- .number()
263
- .int()
264
- .min(1000)
265
- .max(10 * 60_000)
266
- .optional()
267
- .describe("How long to wait for the capture file to appear in DIME"),
268
- downloadPollIntervalMs: z
269
- .number()
270
- .int()
271
- .min(250)
272
- .max(30_000)
273
- .optional()
274
- .describe("How often to retry DIME GetOneFile when the file isn't there yet"),
275
- }, async ({ captureId, dimePort, auth, outFile, downloadTimeoutMs, downloadPollIntervalMs }) => {
629
+ downloadTimeoutMs: z.number().int().min(1e3).max(10 * 6e4).optional().describe("How long to wait for the capture file to appear in DIME"),
630
+ downloadPollIntervalMs: z.number().int().min(250).max(3e4).optional().describe("How often to retry DIME GetOneFile when the file isn't there yet")
631
+ },
632
+ async ({ captureId, dimePort, auth, outFile, downloadTimeoutMs, downloadPollIntervalMs }) => {
276
633
  const pruned = captureState.pruneExpired(captureState.load());
277
634
  const rec = pruned.captures[captureId];
278
- if (!rec)
279
- throw new Error(`Capture not found in state: ${captureId}`);
635
+ if (!rec) throw new Error(`Capture not found in state: ${captureId}`);
280
636
  const dl = await getOneFileAnyWithRetry(rec.host, rec.remoteFileCandidates?.length ? rec.remoteFileCandidates : [rec.remoteFilePath], {
281
- auth: auth,
282
- port: dimePort,
283
- timeoutMs: downloadTimeoutMs,
284
- pollIntervalMs: downloadPollIntervalMs,
637
+ auth,
638
+ port: dimePort,
639
+ timeoutMs: downloadTimeoutMs,
640
+ pollIntervalMs: downloadPollIntervalMs
285
641
  });
286
642
  const saved = writeDownloadedFile(dl, outFile);
643
+ const summary = `Capture downloaded from state. id=${captureId} remoteFilePath=${dl.filename} savedPath=${saved.filePath} bytes=${saved.bytes}`;
287
644
  return {
288
- content: [
645
+ content: [
646
+ {
647
+ type: "text",
648
+ text: `${summary}
649
+
650
+ ${JSON.stringify(
289
651
  {
290
- type: "text",
291
- text: JSON.stringify({
292
- captureId,
293
- host: rec.host,
294
- remoteFilePath: dl.filename,
295
- savedPath: saved.filePath,
296
- bytes: saved.bytes,
297
- dimeAttempts: dl.attempts,
298
- dimeWaitedMs: dl.waitedMs,
299
- }, null, 2),
652
+ captureId,
653
+ host: rec.host,
654
+ remoteFilePath: dl.filename,
655
+ savedPath: saved.filePath,
656
+ bytes: saved.bytes,
657
+ dimeAttempts: dl.attempts,
658
+ dimeWaitedMs: dl.waitedMs
300
659
  },
301
- ],
660
+ null,
661
+ 2
662
+ )}`
663
+ }
664
+ ]
302
665
  };
303
- });
666
+ }
667
+ );
668
+ function resolveCapturePath(input) {
669
+ if (input.includes("/") || input.includes("\\") || input.endsWith(".cap") || input.endsWith(".pcap")) {
670
+ return input;
671
+ }
672
+ const pruned = captureState.pruneExpired(captureState.load());
673
+ const rec = pruned.captures[input];
674
+ if (!rec) throw new Error(`No capture file path and no state record for: ${input}`);
675
+ const basename = rec.remoteFilePath.split("/").pop() || `${rec.fileBase}.cap`;
676
+ const localPath = `/tmp/cucm-mcp/${basename}`;
677
+ return localPath;
678
+ }
679
+ server.tool(
680
+ "pcap_call_summary",
681
+ "High-level overview of a packet capture: protocols present, SIP call count, RTP streams, endpoints. Use this first to understand what's in a capture before drilling into specific calls.",
682
+ {
683
+ filePath: z.string().min(1).describe("Path to .cap/.pcap file, or a captureId from packet_capture_start")
684
+ },
685
+ async ({ filePath }) => {
686
+ try {
687
+ const resolved = resolveCapturePath(filePath);
688
+ const result = await pcapCallSummary(resolved);
689
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
690
+ } catch (e) {
691
+ return { content: [{ type: "text", text: JSON.stringify({ error: true, message: formatUnknownError(e) }, null, 2) }] };
692
+ }
693
+ }
694
+ );
695
+ server.tool(
696
+ "pcap_sip_calls",
697
+ "Extract SIP call flows from a capture, grouped by Call-ID. Shows INVITE/100/180/200/BYE sequences, From/To, SDP media info, and call setup timing.",
698
+ {
699
+ filePath: z.string().min(1).describe("Path to .cap/.pcap file, or a captureId"),
700
+ callId: z.string().optional().describe("Filter to a specific SIP Call-ID")
701
+ },
702
+ async ({ filePath, callId }) => {
703
+ try {
704
+ const resolved = resolveCapturePath(filePath);
705
+ const result = await pcapSipCalls(resolved, callId);
706
+ const summary = `Found ${result.length} SIP call(s)` + (result.length > 0 ? `: ${result.map((c) => `${c.callId} (${c.metrics.messageCount} msgs, ${c.metrics.answered ? "answered" : "unanswered"})`).join(", ")}` : "");
707
+ return { content: [{ type: "text", text: `${summary}
708
+
709
+ ${JSON.stringify(result, null, 2)}` }] };
710
+ } catch (e) {
711
+ return { content: [{ type: "text", text: JSON.stringify({ error: true, message: formatUnknownError(e) }, null, 2) }] };
712
+ }
713
+ }
714
+ );
715
+ server.tool(
716
+ "pcap_sccp_messages",
717
+ "Extract Skinny/SCCP messages from a capture. Shows phone registration, call state changes, media channel setup (OpenReceiveChannel, StartMediaTransmission), and key presses.",
718
+ {
719
+ filePath: z.string().min(1).describe("Path to .cap/.pcap file, or a captureId"),
720
+ deviceFilter: z.string().optional().describe("Filter to a specific device IP address")
721
+ },
722
+ async ({ filePath, deviceFilter }) => {
723
+ try {
724
+ const resolved = resolveCapturePath(filePath);
725
+ const result = await pcapScppMessages(resolved, deviceFilter);
726
+ const summary = `Found ${result.totalMessages} SCCP message(s) across ${result.devices.length} device(s). Top message types: ${Object.entries(result.messageTypes).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k, v]) => `${k}(${v})`).join(", ")}`;
727
+ return { content: [{ type: "text", text: `${summary}
728
+
729
+ ${JSON.stringify(result, null, 2)}` }] };
730
+ } catch (e) {
731
+ return { content: [{ type: "text", text: JSON.stringify({ error: true, message: formatUnknownError(e) }, null, 2) }] };
732
+ }
733
+ }
734
+ );
735
+ server.tool(
736
+ "pcap_rtp_streams",
737
+ "Analyze RTP media streams in a capture. Shows per-stream jitter, packet loss, codec, duration. Use to assess call quality.",
738
+ {
739
+ filePath: z.string().min(1).describe("Path to .cap/.pcap file, or a captureId"),
740
+ ssrcFilter: z.string().optional().describe("Filter to a specific RTP SSRC (hex, e.g. 0xABCD1234)")
741
+ },
742
+ async ({ filePath, ssrcFilter }) => {
743
+ try {
744
+ const resolved = resolveCapturePath(filePath);
745
+ const result = await pcapRtpStreams(resolved, ssrcFilter);
746
+ const summary = `Found ${result.summary.totalStreams} RTP stream(s). Worst loss: ${result.summary.worstLoss}, worst jitter: ${result.summary.worstJitter}`;
747
+ return { content: [{ type: "text", text: `${summary}
748
+
749
+ ${JSON.stringify(result, null, 2)}` }] };
750
+ } catch (e) {
751
+ return { content: [{ type: "text", text: JSON.stringify({ error: true, message: formatUnknownError(e) }, null, 2) }] };
752
+ }
753
+ }
754
+ );
755
+ server.tool(
756
+ "pcap_protocol_filter",
757
+ "Run an arbitrary tshark display filter on a capture and extract specific fields. Escape hatch for deep protocol investigation. Examples: 'sip.Method == INVITE', 'skinny.callState == 12', 'rtp.ssrc == 0xABCD'.",
758
+ {
759
+ filePath: z.string().min(1).describe("Path to .cap/.pcap file, or a captureId"),
760
+ displayFilter: z.string().min(1).describe("tshark display filter expression"),
761
+ fields: z.array(z.string()).optional().describe("Specific tshark fields to extract (e.g. ['sip.Call-ID', 'sip.from.addr']). If omitted, returns frame basics."),
762
+ maxPackets: z.number().int().min(1).max(1e3).optional().describe("Max packets to return (default 100, max 1000)")
763
+ },
764
+ async ({ filePath, displayFilter, fields, maxPackets }) => {
765
+ try {
766
+ const resolved = resolveCapturePath(filePath);
767
+ const result = await pcapProtocolFilter(resolved, displayFilter, fields, maxPackets);
768
+ return { content: [{ type: "text", text: `${result.length} packet(s) matched filter "${displayFilter}"
769
+
770
+ ${JSON.stringify(result, null, 2)}` }] };
771
+ } catch (e) {
772
+ return { content: [{ type: "text", text: JSON.stringify({ error: true, message: formatUnknownError(e) }, null, 2) }] };
773
+ }
774
+ }
775
+ );
304
776
  const transport = new StdioServerTransport();
305
777
  await server.connect(transport);
778
+ //# sourceMappingURL=index.js.map