@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/README.md +61 -7
- package/dist/axl.js +235 -0
- package/dist/axl.js.map +7 -0
- package/dist/dime.js +231 -269
- package/dist/dime.js.map +7 -0
- package/dist/index.js +680 -207
- package/dist/index.js.map +7 -0
- package/dist/multipart.js +58 -68
- package/dist/multipart.js.map +7 -0
- package/dist/packetCapture.js +315 -373
- package/dist/packetCapture.js.map +7 -0
- package/dist/pcap-analyze.js +601 -0
- package/dist/pcap-analyze.js.map +7 -0
- package/dist/state.js +105 -104
- package/dist/state.js.map +7 -0
- package/dist/time.js +25 -23
- package/dist/time.js.map +7 -0
- package/package.json +4 -6
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 {
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
},
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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) =>
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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(
|
|
181
|
-
},
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
596
|
+
content: [
|
|
597
|
+
{
|
|
598
|
+
type: "text",
|
|
599
|
+
text: `${summary}
|
|
600
|
+
|
|
601
|
+
${JSON.stringify(
|
|
239
602
|
{
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
645
|
+
content: [
|
|
646
|
+
{
|
|
647
|
+
type: "text",
|
|
648
|
+
text: `${summary}
|
|
649
|
+
|
|
650
|
+
${JSON.stringify(
|
|
289
651
|
{
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|