@calltelemetry/cucm-mcp 0.1.8 → 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 +54 -7
- package/dist/axl.js +219 -114
- package/dist/axl.js.map +7 -0
- package/dist/dime.js +231 -269
- package/dist/dime.js.map +7 -0
- package/dist/index.js +661 -284
- 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 -388
- 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,52 +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
|
-
import { applyPhone, updatePhonePacketCapture } from "./axl.js";
|
|
10
|
-
|
|
11
|
-
// 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";
|
|
12
20
|
const tlsMode = (process.env.CUCM_MCP_TLS_MODE || process.env.MCP_TLS_MODE || "").toLowerCase();
|
|
13
21
|
const strictTls = tlsMode === "strict" || tlsMode === "verify";
|
|
14
|
-
|
|
15
|
-
// Set CUCM_MCP_TLS_MODE=strict to enforce verification.
|
|
16
|
-
if (!strictTls)
|
|
17
|
-
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
22
|
+
if (!strictTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
18
23
|
const server = new McpServer({ name: "cucm", version: "0.1.8" });
|
|
19
24
|
const captures = new PacketCaptureManager();
|
|
20
25
|
const captureState = defaultStateStore();
|
|
21
|
-
const dimeAuthSchema = z
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
server.tool(
|
|
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
|
+
{
|
|
43
59
|
host: z.string(),
|
|
44
60
|
port: z.number().int().min(1).max(65535).optional(),
|
|
45
|
-
auth: dimeAuthSchema
|
|
46
|
-
},
|
|
61
|
+
auth: dimeAuthSchema
|
|
62
|
+
},
|
|
63
|
+
async ({ host, port, auth }) => {
|
|
47
64
|
const result = await listNodeServiceLogs(host, auth, port);
|
|
48
65
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
49
|
-
}
|
|
50
|
-
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
server.tool(
|
|
69
|
+
"select_logs",
|
|
70
|
+
"List log/trace files using DIME selectLogFiles. Supports ServiceLogs and SystemLogs.",
|
|
71
|
+
{
|
|
51
72
|
host: z.string(),
|
|
52
73
|
port: z.number().int().min(1).max(65535).optional(),
|
|
53
74
|
auth: dimeAuthSchema,
|
|
@@ -56,12 +77,22 @@ server.tool("select_logs", "List log/trace files using DIME selectLogFiles. Supp
|
|
|
56
77
|
searchStr: z.string().optional().describe("Optional filename substring filter"),
|
|
57
78
|
fromDate: z.string(),
|
|
58
79
|
toDate: z.string(),
|
|
59
|
-
timezone: z.string()
|
|
60
|
-
},
|
|
61
|
-
|
|
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
|
+
);
|
|
62
89
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
63
|
-
}
|
|
64
|
-
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
server.tool(
|
|
93
|
+
"select_logs_minutes",
|
|
94
|
+
"Convenience wrapper: select logs using a minutes-back window.",
|
|
95
|
+
{
|
|
65
96
|
host: z.string(),
|
|
66
97
|
port: z.number().int().min(1).max(65535).optional(),
|
|
67
98
|
auth: dimeAuthSchema,
|
|
@@ -69,333 +100,679 @@ server.tool("select_logs_minutes", "Convenience wrapper: select logs using a min
|
|
|
69
100
|
serviceLogs: z.array(z.string()).optional(),
|
|
70
101
|
systemLogs: z.array(z.string()).optional(),
|
|
71
102
|
searchStr: z.string().optional(),
|
|
72
|
-
timezone: z.string().optional()
|
|
73
|
-
},
|
|
74
|
-
|
|
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
|
+
);
|
|
75
114
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
76
|
-
}
|
|
77
|
-
|
|
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
|
+
{
|
|
78
121
|
host: z.string(),
|
|
79
122
|
port: z.number().int().min(1).max(65535).optional(),
|
|
80
123
|
auth: dimeAuthSchema,
|
|
81
124
|
minutesBack: z.number().int().min(1).max(60 * 24 * 30),
|
|
82
|
-
systemLog: z
|
|
83
|
-
.string()
|
|
84
|
-
.optional()
|
|
85
|
-
.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)."),
|
|
86
126
|
searchStr: z.string().optional(),
|
|
87
|
-
timezone: z.string().optional()
|
|
88
|
-
},
|
|
89
|
-
|
|
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
|
+
);
|
|
90
138
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
91
|
-
}
|
|
92
|
-
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
server.tool(
|
|
142
|
+
"phone_packet_capture_enable",
|
|
143
|
+
"Enable phone packet capture via CUCM AXL (updatePhone packetCaptureMode/Duration + applyPhone).",
|
|
144
|
+
{
|
|
93
145
|
host: z.string().describe("CUCM host/IP"),
|
|
94
146
|
port: z.number().int().min(1).max(65535).optional().describe("AXL port (default 8443)"),
|
|
95
147
|
axlVersion: z.string().optional().describe("AXL API version (default env CUCM_AXL_VERSION or 15.0)"),
|
|
96
148
|
auth: axlAuthSchema.describe("AXL auth (optional; defaults to CUCM_AXL_USERNAME/CUCM_AXL_PASSWORD)"),
|
|
97
149
|
deviceName: z.string().min(1).describe("Phone device name (e.g. SEP505C885DF37F)"),
|
|
98
|
-
mode: z
|
|
99
|
-
|
|
100
|
-
.optional()
|
|
101
|
-
.describe('packetCaptureMode value (commonly "Batch Processing Mode")'),
|
|
102
|
-
durationSeconds: z
|
|
103
|
-
.number()
|
|
104
|
-
.int()
|
|
105
|
-
.min(1)
|
|
106
|
-
.max(60 * 60)
|
|
107
|
-
.optional()
|
|
108
|
-
.describe("packetCaptureDuration in seconds (default 60)"),
|
|
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)"),
|
|
109
152
|
apply: z.boolean().optional().describe("Run applyPhone after updatePhone (default true)"),
|
|
110
|
-
timeoutMs: z.number().int().min(
|
|
111
|
-
},
|
|
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 }) => {
|
|
112
156
|
const update = await updatePhonePacketCapture(host, {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
157
|
+
deviceName,
|
|
158
|
+
mode: mode || "Batch Processing Mode",
|
|
159
|
+
durationSeconds: durationSeconds ?? 60,
|
|
160
|
+
auth,
|
|
161
|
+
port,
|
|
162
|
+
version: axlVersion,
|
|
163
|
+
timeoutMs
|
|
120
164
|
});
|
|
121
165
|
const shouldApply = apply ?? true;
|
|
122
|
-
const applied = shouldApply
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
})
|
|
130
|
-
: undefined;
|
|
166
|
+
const applied = shouldApply ? await applyPhone(host, {
|
|
167
|
+
deviceName,
|
|
168
|
+
auth,
|
|
169
|
+
port,
|
|
170
|
+
version: axlVersion,
|
|
171
|
+
timeoutMs
|
|
172
|
+
}) : void 0;
|
|
131
173
|
return {
|
|
132
|
-
|
|
174
|
+
content: [
|
|
175
|
+
{
|
|
176
|
+
type: "text",
|
|
177
|
+
text: JSON.stringify(
|
|
133
178
|
{
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
"Place the call during the duration window; CUCM writes the capture to its TFTP directory (CUCM behavior/version dependent).",
|
|
146
|
-
],
|
|
147
|
-
}, null, 2),
|
|
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
|
+
]
|
|
148
190
|
},
|
|
149
|
-
|
|
191
|
+
null,
|
|
192
|
+
2
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
]
|
|
150
196
|
};
|
|
151
|
-
}
|
|
152
|
-
|
|
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
|
+
{
|
|
153
417
|
host: z.string(),
|
|
154
418
|
port: z.number().int().min(1).max(65535).optional(),
|
|
155
419
|
auth: dimeAuthSchema,
|
|
156
420
|
filePath: z.string().min(1).describe("Absolute path on CUCM"),
|
|
157
|
-
outFile: z.string().optional().describe("Optional output path. Default: /tmp/cucm-mcp/<basename>")
|
|
158
|
-
},
|
|
421
|
+
outFile: z.string().optional().describe("Optional output path. Default: /tmp/cucm-mcp/<basename>")
|
|
422
|
+
},
|
|
423
|
+
async ({ host, port, auth, filePath, outFile }) => {
|
|
159
424
|
const dl = await getOneFile(host, filePath, auth, port);
|
|
160
425
|
const saved = writeDownloadedFile(dl, outFile);
|
|
161
426
|
return {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
]
|
|
168
433
|
};
|
|
169
|
-
}
|
|
170
|
-
|
|
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
|
+
{
|
|
171
440
|
host: z.string().describe("CUCM host/IP"),
|
|
172
441
|
sshPort: z.number().int().min(1).max(65535).optional(),
|
|
173
442
|
auth: sshAuthSchema,
|
|
174
443
|
iface: z.string().optional().describe("Interface (default eth0)"),
|
|
175
444
|
fileBase: z.string().optional().describe("Capture base name (no dots). Saved as <fileBase>.cap"),
|
|
176
|
-
count: z.number().int().min(1).max(
|
|
177
|
-
maxPackets: z
|
|
178
|
-
.boolean()
|
|
179
|
-
.optional()
|
|
180
|
-
.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)"),
|
|
181
447
|
size: z.string().optional().describe("Packet size (e.g. all)"),
|
|
182
448
|
hostFilterIp: z.string().optional().describe("Optional filter: host ip <addr>"),
|
|
183
449
|
portFilter: z.number().int().min(1).max(65535).optional().describe("Optional filter: port <num>"),
|
|
184
|
-
maxDurationMs: z
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
.optional()
|
|
190
|
-
.describe("Stop after this duration even if packet count isn't reached"),
|
|
191
|
-
startTimeoutMs: z
|
|
192
|
-
.number()
|
|
193
|
-
.int()
|
|
194
|
-
.min(2000)
|
|
195
|
-
.max(120_000)
|
|
196
|
-
.optional()
|
|
197
|
-
.describe("Timeout for starting capture (SSH connect + command start)"),
|
|
198
|
-
}, async ({ host, sshPort, auth, iface, fileBase, count, maxPackets, size, hostFilterIp, portFilter, maxDurationMs, startTimeoutMs }) => {
|
|
199
|
-
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);
|
|
200
455
|
const result = await captures.start({
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
456
|
+
host,
|
|
457
|
+
sshPort,
|
|
458
|
+
auth,
|
|
459
|
+
iface,
|
|
460
|
+
fileBase,
|
|
461
|
+
count: resolvedCount,
|
|
462
|
+
size,
|
|
463
|
+
hostFilterIp,
|
|
464
|
+
portFilter,
|
|
465
|
+
maxDurationMs,
|
|
466
|
+
startTimeoutMs
|
|
212
467
|
});
|
|
213
|
-
const summary = `Started CUCM packet capture (SSH).
|
|
214
|
-
`id=${result.id} host=${result.host} fileBase=${result.fileBase} remoteFilePath=${result.remoteFilePath}. ` +
|
|
215
|
-
`Stops when packet count is reached, when you call packet_capture_stop, or via maxDurationMs.`;
|
|
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.`;
|
|
216
469
|
return {
|
|
217
|
-
|
|
470
|
+
content: [{ type: "text", text: `${summary}
|
|
471
|
+
|
|
472
|
+
${JSON.stringify(result, null, 2)}` }]
|
|
218
473
|
};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
server.tool(
|
|
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 () => {
|
|
222
487
|
const pruned = captureState.pruneExpired(captureState.load());
|
|
223
488
|
captureState.save(pruned);
|
|
224
|
-
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);
|
|
225
490
|
return { content: [{ type: "text", text: JSON.stringify({ path: captureState.path, captures: items }, null, 2) }] };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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 }) => {
|
|
230
500
|
const pruned = captureState.pruneExpired(captureState.load());
|
|
231
501
|
const rec = pruned.captures[captureId];
|
|
232
502
|
if (!rec) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
503
|
+
return {
|
|
504
|
+
content: [
|
|
505
|
+
{
|
|
506
|
+
type: "text",
|
|
507
|
+
text: JSON.stringify({ path: captureState.path, found: false, captureId }, null, 2)
|
|
508
|
+
}
|
|
509
|
+
]
|
|
510
|
+
};
|
|
241
511
|
}
|
|
242
512
|
return { content: [{ type: "text", text: JSON.stringify({ path: captureState.path, found: true, record: rec }, null, 2) }] };
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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 }) => {
|
|
247
522
|
captureState.remove(captureId);
|
|
248
523
|
return { content: [{ type: "text", text: JSON.stringify({ removed: true, captureId }, null, 2) }] };
|
|
249
|
-
}
|
|
250
|
-
|
|
524
|
+
}
|
|
525
|
+
);
|
|
526
|
+
server.tool(
|
|
527
|
+
"packet_capture_stop",
|
|
528
|
+
"Stop a packet capture by captureId (sends Ctrl-C).",
|
|
529
|
+
{
|
|
251
530
|
captureId: z.string().min(1),
|
|
252
|
-
timeoutMs: z.number().int().min(
|
|
253
|
-
},
|
|
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 }) => {
|
|
254
534
|
try {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
+
};
|
|
274
556
|
}
|
|
275
|
-
}
|
|
276
|
-
|
|
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
|
+
{
|
|
277
563
|
captureId: z.string().min(1),
|
|
278
564
|
dimePort: z.number().int().min(1).max(65535).optional(),
|
|
279
565
|
auth: dimeAuthSchema.describe("DIME auth (optional; defaults to CUCM_DIME_USERNAME/CUCM_DIME_PASSWORD)"),
|
|
280
566
|
outFile: z.string().optional().describe("Optional output path for the downloaded .cap file"),
|
|
281
|
-
stopTimeoutMs: z
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
.number()
|
|
290
|
-
.int()
|
|
291
|
-
.min(1000)
|
|
292
|
-
.max(10 * 60_000)
|
|
293
|
-
.optional()
|
|
294
|
-
.describe("How long to wait for the capture file to appear in DIME (default 300000)"),
|
|
295
|
-
downloadPollIntervalMs: z
|
|
296
|
-
.number()
|
|
297
|
-
.int()
|
|
298
|
-
.min(250)
|
|
299
|
-
.max(30_000)
|
|
300
|
-
.optional()
|
|
301
|
-
.describe("How often to retry DIME GetOneFile when the file isn't there yet"),
|
|
302
|
-
}, async ({ captureId, dimePort, auth, outFile, stopTimeoutMs, downloadTimeoutMs, downloadPollIntervalMs }) => {
|
|
303
|
-
const stopTimeout = stopTimeoutMs ?? 300_000;
|
|
304
|
-
const dlTimeout = downloadTimeoutMs ?? 300_000;
|
|
305
|
-
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;
|
|
306
575
|
let stopped;
|
|
307
576
|
let stopError;
|
|
308
577
|
try {
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if (!rec)
|
|
317
|
-
throw new Error(`Failed to stop capture and capture not found in state: ${captureId}. stopError=${stopError}`);
|
|
318
|
-
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;
|
|
319
585
|
}
|
|
320
586
|
const candidates = (stopped.remoteFileCandidates || []).length ? stopped.remoteFileCandidates : [stopped.remoteFilePath];
|
|
321
587
|
const dl = await getOneFileAnyWithRetry(stopped.host, candidates, {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
588
|
+
auth,
|
|
589
|
+
port: dimePort,
|
|
590
|
+
timeoutMs: dlTimeout,
|
|
591
|
+
pollIntervalMs: dlPoll
|
|
326
592
|
});
|
|
327
593
|
const saved = writeDownloadedFile(dl, outFile);
|
|
328
|
-
const summary = `Capture downloaded. ` +
|
|
329
|
-
`id=${captureId} stopTimedOut=${Boolean(stopped.stopTimedOut)} remoteFilePath=${dl.filename} ` +
|
|
330
|
-
`savedPath=${saved.filePath} bytes=${saved.bytes}` +
|
|
331
|
-
(stopError ? ` stopError=${JSON.stringify(stopError)}` : "");
|
|
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)}` : "");
|
|
332
595
|
return {
|
|
333
|
-
|
|
596
|
+
content: [
|
|
597
|
+
{
|
|
598
|
+
type: "text",
|
|
599
|
+
text: `${summary}
|
|
600
|
+
|
|
601
|
+
${JSON.stringify(
|
|
334
602
|
{
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
dimeAttempts: dl.attempts,
|
|
345
|
-
dimeWaitedMs: dl.waitedMs,
|
|
346
|
-
}, 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
|
|
347
612
|
},
|
|
348
|
-
|
|
613
|
+
null,
|
|
614
|
+
2
|
|
615
|
+
)}`
|
|
616
|
+
}
|
|
617
|
+
]
|
|
349
618
|
};
|
|
350
|
-
}
|
|
351
|
-
|
|
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
|
+
{
|
|
352
625
|
captureId: z.string().min(1),
|
|
353
626
|
dimePort: z.number().int().min(1).max(65535).optional(),
|
|
354
627
|
auth: dimeAuthSchema.describe("DIME auth (optional; defaults to CUCM_DIME_USERNAME/CUCM_DIME_PASSWORD)"),
|
|
355
628
|
outFile: z.string().optional().describe("Optional output path for the downloaded .cap file"),
|
|
356
|
-
downloadTimeoutMs: z
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
.max(10 * 60_000)
|
|
361
|
-
.optional()
|
|
362
|
-
.describe("How long to wait for the capture file to appear in DIME"),
|
|
363
|
-
downloadPollIntervalMs: z
|
|
364
|
-
.number()
|
|
365
|
-
.int()
|
|
366
|
-
.min(250)
|
|
367
|
-
.max(30_000)
|
|
368
|
-
.optional()
|
|
369
|
-
.describe("How often to retry DIME GetOneFile when the file isn't there yet"),
|
|
370
|
-
}, 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 }) => {
|
|
371
633
|
const pruned = captureState.pruneExpired(captureState.load());
|
|
372
634
|
const rec = pruned.captures[captureId];
|
|
373
|
-
if (!rec)
|
|
374
|
-
throw new Error(`Capture not found in state: ${captureId}`);
|
|
635
|
+
if (!rec) throw new Error(`Capture not found in state: ${captureId}`);
|
|
375
636
|
const dl = await getOneFileAnyWithRetry(rec.host, rec.remoteFileCandidates?.length ? rec.remoteFileCandidates : [rec.remoteFilePath], {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
637
|
+
auth,
|
|
638
|
+
port: dimePort,
|
|
639
|
+
timeoutMs: downloadTimeoutMs,
|
|
640
|
+
pollIntervalMs: downloadPollIntervalMs
|
|
380
641
|
});
|
|
381
642
|
const saved = writeDownloadedFile(dl, outFile);
|
|
382
643
|
const summary = `Capture downloaded from state. id=${captureId} remoteFilePath=${dl.filename} savedPath=${saved.filePath} bytes=${saved.bytes}`;
|
|
383
644
|
return {
|
|
384
|
-
|
|
645
|
+
content: [
|
|
646
|
+
{
|
|
647
|
+
type: "text",
|
|
648
|
+
text: `${summary}
|
|
649
|
+
|
|
650
|
+
${JSON.stringify(
|
|
385
651
|
{
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
dimeAttempts: dl.attempts,
|
|
394
|
-
dimeWaitedMs: dl.waitedMs,
|
|
395
|
-
}, 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
|
|
396
659
|
},
|
|
397
|
-
|
|
660
|
+
null,
|
|
661
|
+
2
|
|
662
|
+
)}`
|
|
663
|
+
}
|
|
664
|
+
]
|
|
398
665
|
};
|
|
399
|
-
}
|
|
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
|
+
);
|
|
400
776
|
const transport = new StdioServerTransport();
|
|
401
777
|
await server.connect(transport);
|
|
778
|
+
//# sourceMappingURL=index.js.map
|