@calltelemetry/cucm-mcp 0.1.6 → 0.1.8
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 +55 -0
- package/dist/axl.js +130 -0
- package/dist/index.js +133 -16
- package/dist/packetCapture.js +27 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -90,3 +90,58 @@ Live tests are opt-in via env vars; see `test/live.test.js`.
|
|
|
90
90
|
- Use the platform/OS admin for SSH (`administrator` user on most lab systems)
|
|
91
91
|
- To request a high packet count without specifying an exact number, pass `maxPackets: true` to `packet_capture_start`
|
|
92
92
|
- If traffic is low, a small `count` can still run “forever” waiting for packets; use `packet_capture_stop` to cancel, or set `maxDurationMs` to auto-stop
|
|
93
|
+
|
|
94
|
+
### Auth Note (DIME vs SSH)
|
|
95
|
+
|
|
96
|
+
CUCM deployments vary:
|
|
97
|
+
|
|
98
|
+
- SSH and DIME may accept different usernames/passwords.
|
|
99
|
+
- Quick check: the right DIME user returns HTTP 200 for the WSDL.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
curl -k -u "<user>:<pass>" \
|
|
103
|
+
"https://<cucm-host>:8443/logcollectionservice2/services/LogCollectionPortTypeService?wsdl" \
|
|
104
|
+
-o /dev/null -w "%{http_code}\n"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Recommended Workflow
|
|
108
|
+
|
|
109
|
+
1) Start capture (returns quickly; capture continues on CUCM):
|
|
110
|
+
|
|
111
|
+
Tool: `packet_capture_start`
|
|
112
|
+
|
|
113
|
+
Useful options:
|
|
114
|
+
|
|
115
|
+
- `count`: stop after N packets (can wait indefinitely if traffic is low)
|
|
116
|
+
- `maxDurationMs`: stop after a fixed time even if packet count isn’t reached
|
|
117
|
+
- `startTimeoutMs`: fail fast if the CUCM CLI prompt isn’t reachable
|
|
118
|
+
- `maxPackets: true`: sets a high capture count (1,000,000) when `count` is omitted
|
|
119
|
+
|
|
120
|
+
2) Stop + download the capture:
|
|
121
|
+
|
|
122
|
+
Tool: `packet_capture_stop_and_download`
|
|
123
|
+
|
|
124
|
+
This:
|
|
125
|
+
|
|
126
|
+
- stops the SSH capture (best-effort)
|
|
127
|
+
- retries DIME downloads until the file appears
|
|
128
|
+
- tries rolled filenames (`.cap01`, `.cap02`, ...)
|
|
129
|
+
|
|
130
|
+
### What to Expect in Output
|
|
131
|
+
|
|
132
|
+
Many MCP clients truncate long JSON. The CUCM MCP tools print a one-line summary first, followed by the full JSON:
|
|
133
|
+
|
|
134
|
+
- `packet_capture_start`: prints `id`, `remoteFilePath`, and a reminder that capture continues on CUCM until stopped
|
|
135
|
+
- `packet_capture_stop_and_download`: prints `savedPath` and `bytes` so you can immediately open the file
|
|
136
|
+
|
|
137
|
+
### Viewing the Capture (macOS)
|
|
138
|
+
|
|
139
|
+
After download, you’ll get a `savedPath` like `/tmp/foo.cap`.
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
# Reveal in Finder
|
|
143
|
+
open -R "/tmp/foo.cap"
|
|
144
|
+
|
|
145
|
+
# Open in Wireshark
|
|
146
|
+
open -a Wireshark "/tmp/foo.cap"
|
|
147
|
+
```
|
package/dist/axl.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { XMLParser } from "fast-xml-parser";
|
|
2
|
+
const parser = new XMLParser({
|
|
3
|
+
ignoreAttributes: false,
|
|
4
|
+
attributeNamePrefix: "@",
|
|
5
|
+
removeNSPrefix: true,
|
|
6
|
+
trimValues: true,
|
|
7
|
+
});
|
|
8
|
+
function basicAuthHeader(username, password) {
|
|
9
|
+
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
|
|
10
|
+
}
|
|
11
|
+
function escapeXml(s) {
|
|
12
|
+
return s
|
|
13
|
+
.replaceAll("&", "&")
|
|
14
|
+
.replaceAll("<", "<")
|
|
15
|
+
.replaceAll(">", ">")
|
|
16
|
+
.replaceAll('"', """)
|
|
17
|
+
.replaceAll("'", "'");
|
|
18
|
+
}
|
|
19
|
+
export function normalizeHost(hostOrUrl) {
|
|
20
|
+
const s = String(hostOrUrl || "").trim();
|
|
21
|
+
if (!s)
|
|
22
|
+
throw new Error("host is required");
|
|
23
|
+
if (s.includes("://")) {
|
|
24
|
+
const u = new URL(s);
|
|
25
|
+
return u.hostname;
|
|
26
|
+
}
|
|
27
|
+
return s.replace(/^https?:\/\//, "").replace(/\/+$/, "").split("/")[0];
|
|
28
|
+
}
|
|
29
|
+
export function resolveAxlAuth(auth) {
|
|
30
|
+
const username = auth?.username || process.env.CUCM_AXL_USERNAME || process.env.CUCM_USERNAME || process.env.CUCM_DIME_USERNAME;
|
|
31
|
+
const password = auth?.password || process.env.CUCM_AXL_PASSWORD || process.env.CUCM_PASSWORD || process.env.CUCM_DIME_PASSWORD;
|
|
32
|
+
if (!username || !password) {
|
|
33
|
+
throw new Error("Missing AXL credentials (provide auth or set CUCM_AXL_USERNAME/CUCM_AXL_PASSWORD)");
|
|
34
|
+
}
|
|
35
|
+
return { username, password };
|
|
36
|
+
}
|
|
37
|
+
export function resolveAxlTarget(hostOrUrl, port, version) {
|
|
38
|
+
const host = normalizeHost(hostOrUrl);
|
|
39
|
+
const envPort = process.env.CUCM_AXL_PORT ? Number.parseInt(process.env.CUCM_AXL_PORT, 10) : undefined;
|
|
40
|
+
const resolvedPort = port ?? envPort ?? 8443;
|
|
41
|
+
const resolvedVersion = version || process.env.CUCM_AXL_VERSION || "15.0";
|
|
42
|
+
return { host, port: resolvedPort, version: resolvedVersion };
|
|
43
|
+
}
|
|
44
|
+
function soapEnvelope(axlVersion, innerXml) {
|
|
45
|
+
const ns = `http://www.cisco.com/AXL/API/${escapeXml(axlVersion)}`;
|
|
46
|
+
return (`<?xml version="1.0" encoding="UTF-8"?>` +
|
|
47
|
+
`<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns="${ns}">` +
|
|
48
|
+
`<soapenv:Header/>` +
|
|
49
|
+
`<soapenv:Body>` +
|
|
50
|
+
innerXml +
|
|
51
|
+
`</soapenv:Body>` +
|
|
52
|
+
`</soapenv:Envelope>`);
|
|
53
|
+
}
|
|
54
|
+
async function fetchAxl(target, auth, operation, innerXml, timeoutMs = 30_000) {
|
|
55
|
+
const url = `https://${target.host}:${target.port}/axl/`;
|
|
56
|
+
const xmlBody = soapEnvelope(target.version, innerXml);
|
|
57
|
+
const res = await fetch(url, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: {
|
|
60
|
+
Authorization: basicAuthHeader(auth.username, auth.password),
|
|
61
|
+
"Content-Type": "text/xml; charset=utf-8",
|
|
62
|
+
SOAPAction: `CUCM:DB ver=${target.version} ${operation}`,
|
|
63
|
+
Accept: "*/*",
|
|
64
|
+
},
|
|
65
|
+
body: Buffer.from(xmlBody, "utf8"),
|
|
66
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
67
|
+
});
|
|
68
|
+
const contentType = res.headers.get("content-type");
|
|
69
|
+
const text = await res.text().catch(() => "");
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
throw new Error(`CUCM AXL HTTP ${res.status}: ${text || res.statusText}`);
|
|
72
|
+
}
|
|
73
|
+
return { contentType, xml: text };
|
|
74
|
+
}
|
|
75
|
+
function parseSoapReturn(operation, xmlText) {
|
|
76
|
+
const parsed = parser.parse(xmlText);
|
|
77
|
+
const env = parsed.Envelope || parsed;
|
|
78
|
+
const body = env.Body || env;
|
|
79
|
+
const fault = body?.Fault;
|
|
80
|
+
if (fault) {
|
|
81
|
+
const faultString = fault.faultstring || fault.faultcode || JSON.stringify(fault);
|
|
82
|
+
return { fault: String(faultString) };
|
|
83
|
+
}
|
|
84
|
+
const resp = body?.[`${operation}Response`];
|
|
85
|
+
const ret = resp?.return;
|
|
86
|
+
if (ret == null)
|
|
87
|
+
return {};
|
|
88
|
+
if (typeof ret === "string")
|
|
89
|
+
return { returnValue: ret };
|
|
90
|
+
// Some AXL responses wrap the value.
|
|
91
|
+
if (typeof ret === "object" && typeof ret["#text"] === "string")
|
|
92
|
+
return { returnValue: ret["#text"] };
|
|
93
|
+
return { returnValue: String(ret) };
|
|
94
|
+
}
|
|
95
|
+
export async function updatePhonePacketCapture(hostOrUrl, args) {
|
|
96
|
+
const target = resolveAxlTarget(hostOrUrl, args.port, args.version);
|
|
97
|
+
const auth = resolveAxlAuth(args.auth);
|
|
98
|
+
const name = String(args.deviceName || "").trim();
|
|
99
|
+
if (!name)
|
|
100
|
+
throw new Error("deviceName is required");
|
|
101
|
+
const mode = String(args.mode || "").trim();
|
|
102
|
+
if (!mode)
|
|
103
|
+
throw new Error("mode is required");
|
|
104
|
+
const duration = Math.trunc(args.durationSeconds);
|
|
105
|
+
if (!Number.isFinite(duration) || duration <= 0)
|
|
106
|
+
throw new Error("durationSeconds must be > 0");
|
|
107
|
+
const innerXml = `<ns:updatePhone>` +
|
|
108
|
+
`<name>${escapeXml(name)}</name>` +
|
|
109
|
+
`<packetCaptureMode>${escapeXml(mode)}</packetCaptureMode>` +
|
|
110
|
+
`<packetCaptureDuration>${escapeXml(String(duration))}</packetCaptureDuration>` +
|
|
111
|
+
`</ns:updatePhone>`;
|
|
112
|
+
const { xml } = await fetchAxl(target, auth, "updatePhone", innerXml, args.timeoutMs ?? 30_000);
|
|
113
|
+
const parsed = parseSoapReturn("updatePhone", xml);
|
|
114
|
+
if (parsed.fault)
|
|
115
|
+
throw new Error(`AXL updatePhone fault: ${parsed.fault}`);
|
|
116
|
+
return { host: target.host, operation: "updatePhone", returnValue: parsed.returnValue };
|
|
117
|
+
}
|
|
118
|
+
export async function applyPhone(hostOrUrl, args) {
|
|
119
|
+
const target = resolveAxlTarget(hostOrUrl, args.port, args.version);
|
|
120
|
+
const auth = resolveAxlAuth(args.auth);
|
|
121
|
+
const name = String(args.deviceName || "").trim();
|
|
122
|
+
if (!name)
|
|
123
|
+
throw new Error("deviceName is required");
|
|
124
|
+
const innerXml = `<ns:applyPhone><name>${escapeXml(name)}</name></ns:applyPhone>`;
|
|
125
|
+
const { xml } = await fetchAxl(target, auth, "applyPhone", innerXml, args.timeoutMs ?? 30_000);
|
|
126
|
+
const parsed = parseSoapReturn("applyPhone", xml);
|
|
127
|
+
if (parsed.fault)
|
|
128
|
+
throw new Error(`AXL applyPhone fault: ${parsed.fault}`);
|
|
129
|
+
return { host: target.host, operation: "applyPhone", returnValue: parsed.returnValue };
|
|
130
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { listNodeServiceLogs, selectLogs, selectLogsMinutes, getOneFile, getOneF
|
|
|
6
6
|
import { guessTimezoneString } from "./time.js";
|
|
7
7
|
import { PacketCaptureManager } from "./packetCapture.js";
|
|
8
8
|
import { defaultStateStore } from "./state.js";
|
|
9
|
+
import { applyPhone, updatePhonePacketCapture } from "./axl.js";
|
|
9
10
|
// Default to accepting self-signed/invalid certs (common on CUCM lab/dev).
|
|
10
11
|
// Opt back into strict verification with CUCM_MCP_TLS_MODE=strict.
|
|
11
12
|
const tlsMode = (process.env.CUCM_MCP_TLS_MODE || process.env.MCP_TLS_MODE || "").toLowerCase();
|
|
@@ -14,7 +15,7 @@ const strictTls = tlsMode === "strict" || tlsMode === "verify";
|
|
|
14
15
|
// Set CUCM_MCP_TLS_MODE=strict to enforce verification.
|
|
15
16
|
if (!strictTls)
|
|
16
17
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
17
|
-
const server = new McpServer({ name: "cucm", version: "0.1.
|
|
18
|
+
const server = new McpServer({ name: "cucm", version: "0.1.8" });
|
|
18
19
|
const captures = new PacketCaptureManager();
|
|
19
20
|
const captureState = defaultStateStore();
|
|
20
21
|
const dimeAuthSchema = z
|
|
@@ -29,6 +30,12 @@ const sshAuthSchema = z
|
|
|
29
30
|
password: z.string().optional(),
|
|
30
31
|
})
|
|
31
32
|
.optional();
|
|
33
|
+
const axlAuthSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
username: z.string().optional(),
|
|
36
|
+
password: z.string().optional(),
|
|
37
|
+
})
|
|
38
|
+
.optional();
|
|
32
39
|
server.tool("guess_timezone_string", "Build a best-effort DIME timezone string for selectLogFiles.", {}, async () => ({
|
|
33
40
|
content: [{ type: "text", text: JSON.stringify({ timezone: guessTimezoneString(new Date()) }, null, 2) }],
|
|
34
41
|
}));
|
|
@@ -82,6 +89,66 @@ server.tool("select_syslog_minutes", "Convenience wrapper: select system log fil
|
|
|
82
89
|
const result = await selectLogsMinutes(host, minutesBack, { systemLogs: [systemLog || "Syslog"], searchStr }, timezone, auth, port);
|
|
83
90
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
84
91
|
});
|
|
92
|
+
server.tool("phone_packet_capture_enable", "Enable phone packet capture via CUCM AXL (updatePhone packetCaptureMode/Duration + applyPhone).", {
|
|
93
|
+
host: z.string().describe("CUCM host/IP"),
|
|
94
|
+
port: z.number().int().min(1).max(65535).optional().describe("AXL port (default 8443)"),
|
|
95
|
+
axlVersion: z.string().optional().describe("AXL API version (default env CUCM_AXL_VERSION or 15.0)"),
|
|
96
|
+
auth: axlAuthSchema.describe("AXL auth (optional; defaults to CUCM_AXL_USERNAME/CUCM_AXL_PASSWORD)"),
|
|
97
|
+
deviceName: z.string().min(1).describe("Phone device name (e.g. SEP505C885DF37F)"),
|
|
98
|
+
mode: z
|
|
99
|
+
.string()
|
|
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)"),
|
|
109
|
+
apply: z.boolean().optional().describe("Run applyPhone after updatePhone (default true)"),
|
|
110
|
+
timeoutMs: z.number().int().min(1000).max(5 * 60_000).optional().describe("AXL request timeout"),
|
|
111
|
+
}, async ({ host, port, axlVersion, auth, deviceName, mode, durationSeconds, apply, timeoutMs }) => {
|
|
112
|
+
const update = await updatePhonePacketCapture(host, {
|
|
113
|
+
deviceName,
|
|
114
|
+
mode: mode || "Batch Processing Mode",
|
|
115
|
+
durationSeconds: durationSeconds ?? 60,
|
|
116
|
+
auth: auth,
|
|
117
|
+
port,
|
|
118
|
+
version: axlVersion,
|
|
119
|
+
timeoutMs,
|
|
120
|
+
});
|
|
121
|
+
const shouldApply = apply ?? true;
|
|
122
|
+
const applied = shouldApply
|
|
123
|
+
? await applyPhone(host, {
|
|
124
|
+
deviceName,
|
|
125
|
+
auth: auth,
|
|
126
|
+
port,
|
|
127
|
+
version: axlVersion,
|
|
128
|
+
timeoutMs,
|
|
129
|
+
})
|
|
130
|
+
: undefined;
|
|
131
|
+
return {
|
|
132
|
+
content: [
|
|
133
|
+
{
|
|
134
|
+
type: "text",
|
|
135
|
+
text: JSON.stringify({
|
|
136
|
+
host: update.host,
|
|
137
|
+
deviceName,
|
|
138
|
+
packetCaptureMode: mode || "Batch Processing Mode",
|
|
139
|
+
packetCaptureDuration: durationSeconds ?? 60,
|
|
140
|
+
updatePhoneReturn: update.returnValue,
|
|
141
|
+
applied: shouldApply,
|
|
142
|
+
applyPhoneReturn: applied?.returnValue,
|
|
143
|
+
notes: [
|
|
144
|
+
"Phone may need to reset to pick up config.",
|
|
145
|
+
"Place the call during the duration window; CUCM writes the capture to its TFTP directory (CUCM behavior/version dependent).",
|
|
146
|
+
],
|
|
147
|
+
}, null, 2),
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
});
|
|
85
152
|
server.tool("download_file", "Download a single file via DIME GetOneFile and write it to disk.", {
|
|
86
153
|
host: z.string(),
|
|
87
154
|
port: z.number().int().min(1).max(65535).optional(),
|
|
@@ -143,7 +210,12 @@ server.tool("packet_capture_start", "Start a packet capture on CUCM via SSH (uti
|
|
|
143
210
|
maxDurationMs,
|
|
144
211
|
startTimeoutMs,
|
|
145
212
|
});
|
|
146
|
-
|
|
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.`;
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: "text", text: `${summary}\n\n${JSON.stringify(result, null, 2)}` }],
|
|
218
|
+
};
|
|
147
219
|
});
|
|
148
220
|
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
221
|
server.tool("packet_capture_state_list", "List packet captures from the local state file (survives MCP restarts).", {}, async () => {
|
|
@@ -179,22 +251,47 @@ server.tool("packet_capture_stop", "Stop a packet capture by captureId (sends Ct
|
|
|
179
251
|
captureId: z.string().min(1),
|
|
180
252
|
timeoutMs: z.number().int().min(1000).max(10 * 60_000).optional().describe("How long to wait for stop (default ~90s)"),
|
|
181
253
|
}, async ({ captureId, timeoutMs }) => {
|
|
182
|
-
|
|
183
|
-
|
|
254
|
+
try {
|
|
255
|
+
const result = await captures.stop(captureId, timeoutMs);
|
|
256
|
+
const summary = `Stopped capture. id=${result.id} stopTimedOut=${Boolean(result.stopTimedOut)} remoteFilePath=${result.remoteFilePath}`;
|
|
257
|
+
return { content: [{ type: "text", text: `${summary}\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
const stopError = e instanceof Error ? e.message : String(e || "");
|
|
261
|
+
const pruned = captureState.pruneExpired(captureState.load());
|
|
262
|
+
const rec = pruned.captures[captureId];
|
|
263
|
+
if (!rec)
|
|
264
|
+
throw e;
|
|
265
|
+
const summary = `Failed to stop capture (returning state record). id=${captureId} stopError=${JSON.stringify(stopError)}`;
|
|
266
|
+
return {
|
|
267
|
+
content: [
|
|
268
|
+
{
|
|
269
|
+
type: "text",
|
|
270
|
+
text: `${summary}\n\n${JSON.stringify({ stopError, record: rec }, null, 2)}`,
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
184
275
|
});
|
|
185
276
|
server.tool("packet_capture_stop_and_download", "Stop a packet capture and download the resulting .cap file via DIME.", {
|
|
186
277
|
captureId: z.string().min(1),
|
|
187
278
|
dimePort: z.number().int().min(1).max(65535).optional(),
|
|
188
279
|
auth: dimeAuthSchema.describe("DIME auth (optional; defaults to CUCM_DIME_USERNAME/CUCM_DIME_PASSWORD)"),
|
|
189
280
|
outFile: z.string().optional().describe("Optional output path for the downloaded .cap file"),
|
|
190
|
-
stopTimeoutMs: z
|
|
281
|
+
stopTimeoutMs: z
|
|
282
|
+
.number()
|
|
283
|
+
.int()
|
|
284
|
+
.min(1000)
|
|
285
|
+
.max(10 * 60_000)
|
|
286
|
+
.optional()
|
|
287
|
+
.describe("How long to wait for SSH capture stop (default 300000)"),
|
|
191
288
|
downloadTimeoutMs: z
|
|
192
289
|
.number()
|
|
193
290
|
.int()
|
|
194
291
|
.min(1000)
|
|
195
292
|
.max(10 * 60_000)
|
|
196
293
|
.optional()
|
|
197
|
-
.describe("How long to wait for the capture file to appear in DIME"),
|
|
294
|
+
.describe("How long to wait for the capture file to appear in DIME (default 300000)"),
|
|
198
295
|
downloadPollIntervalMs: z
|
|
199
296
|
.number()
|
|
200
297
|
.int()
|
|
@@ -203,31 +300,50 @@ server.tool("packet_capture_stop_and_download", "Stop a packet capture and downl
|
|
|
203
300
|
.optional()
|
|
204
301
|
.describe("How often to retry DIME GetOneFile when the file isn't there yet"),
|
|
205
302
|
}, async ({ captureId, dimePort, auth, outFile, stopTimeoutMs, downloadTimeoutMs, downloadPollIntervalMs }) => {
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
303
|
+
const stopTimeout = stopTimeoutMs ?? 300_000;
|
|
304
|
+
const dlTimeout = downloadTimeoutMs ?? 300_000;
|
|
305
|
+
const dlPoll = downloadPollIntervalMs ?? 2000;
|
|
306
|
+
let stopped;
|
|
307
|
+
let stopError;
|
|
308
|
+
try {
|
|
309
|
+
stopped = await captures.stop(captureId, stopTimeout);
|
|
310
|
+
}
|
|
311
|
+
catch (e) {
|
|
312
|
+
stopError = e instanceof Error ? e.message : String(e || "");
|
|
313
|
+
// Fall back to state file (useful if stop failed or MCP restarted).
|
|
314
|
+
const pruned = captureState.pruneExpired(captureState.load());
|
|
315
|
+
const rec = pruned.captures[captureId];
|
|
316
|
+
if (!rec)
|
|
317
|
+
throw new Error(`Failed to stop capture and capture not found in state: ${captureId}. stopError=${stopError}`);
|
|
318
|
+
stopped = rec;
|
|
319
|
+
}
|
|
320
|
+
const candidates = (stopped.remoteFileCandidates || []).length ? stopped.remoteFileCandidates : [stopped.remoteFilePath];
|
|
210
321
|
const dl = await getOneFileAnyWithRetry(stopped.host, candidates, {
|
|
211
322
|
auth: auth,
|
|
212
323
|
port: dimePort,
|
|
213
|
-
timeoutMs:
|
|
214
|
-
pollIntervalMs:
|
|
324
|
+
timeoutMs: dlTimeout,
|
|
325
|
+
pollIntervalMs: dlPoll,
|
|
215
326
|
});
|
|
216
327
|
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)}` : "");
|
|
217
332
|
return {
|
|
218
333
|
content: [
|
|
219
334
|
{
|
|
220
335
|
type: "text",
|
|
221
|
-
text: JSON.stringify({
|
|
336
|
+
text: `${summary}\n\n${JSON.stringify({
|
|
222
337
|
captureId: stopped.id,
|
|
223
338
|
host: stopped.host,
|
|
224
339
|
remoteFilePath: dl.filename,
|
|
225
340
|
stopTimedOut: stopped.stopTimedOut || false,
|
|
341
|
+
stopError,
|
|
226
342
|
savedPath: saved.filePath,
|
|
227
343
|
bytes: saved.bytes,
|
|
228
344
|
dimeAttempts: dl.attempts,
|
|
229
345
|
dimeWaitedMs: dl.waitedMs,
|
|
230
|
-
}, null, 2)
|
|
346
|
+
}, null, 2)}`,
|
|
231
347
|
},
|
|
232
348
|
],
|
|
233
349
|
};
|
|
@@ -263,11 +379,12 @@ server.tool("packet_capture_download_from_state", "Download a capture file using
|
|
|
263
379
|
pollIntervalMs: downloadPollIntervalMs,
|
|
264
380
|
});
|
|
265
381
|
const saved = writeDownloadedFile(dl, outFile);
|
|
382
|
+
const summary = `Capture downloaded from state. id=${captureId} remoteFilePath=${dl.filename} savedPath=${saved.filePath} bytes=${saved.bytes}`;
|
|
266
383
|
return {
|
|
267
384
|
content: [
|
|
268
385
|
{
|
|
269
386
|
type: "text",
|
|
270
|
-
text: JSON.stringify({
|
|
387
|
+
text: `${summary}\n\n${JSON.stringify({
|
|
271
388
|
captureId,
|
|
272
389
|
host: rec.host,
|
|
273
390
|
remoteFilePath: dl.filename,
|
|
@@ -275,7 +392,7 @@ server.tool("packet_capture_download_from_state", "Download a capture file using
|
|
|
275
392
|
bytes: saved.bytes,
|
|
276
393
|
dimeAttempts: dl.attempts,
|
|
277
394
|
dimeWaitedMs: dl.waitedMs,
|
|
278
|
-
}, null, 2)
|
|
395
|
+
}, null, 2)}`,
|
|
279
396
|
},
|
|
280
397
|
],
|
|
281
398
|
};
|
package/dist/packetCapture.js
CHANGED
|
@@ -151,18 +151,33 @@ export class PacketCaptureManager {
|
|
|
151
151
|
...session,
|
|
152
152
|
stoppedAt: session.stoppedAt,
|
|
153
153
|
});
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
host: opts.host,
|
|
160
|
-
port: sshPort,
|
|
161
|
-
username: auth.username,
|
|
162
|
-
password: auth.password,
|
|
163
|
-
readyTimeout: 15000,
|
|
164
|
-
});
|
|
154
|
+
const startTimeoutMs = Math.max(2000, Math.trunc(opts.startTimeoutMs ?? 30_000));
|
|
155
|
+
let connectTimer;
|
|
156
|
+
const connectTimeout = new Promise((_, reject) => {
|
|
157
|
+
connectTimer = setTimeout(() => reject(new Error(`SSH connect timed out after ${startTimeoutMs}ms`)), startTimeoutMs);
|
|
158
|
+
connectTimer.unref?.();
|
|
165
159
|
});
|
|
160
|
+
try {
|
|
161
|
+
await Promise.race([
|
|
162
|
+
new Promise((resolve, reject) => {
|
|
163
|
+
client
|
|
164
|
+
.on("ready", () => resolve())
|
|
165
|
+
.on("error", (e) => reject(e))
|
|
166
|
+
.connect({
|
|
167
|
+
host: opts.host,
|
|
168
|
+
port: sshPort,
|
|
169
|
+
username: auth.username,
|
|
170
|
+
password: auth.password,
|
|
171
|
+
readyTimeout: 15000,
|
|
172
|
+
});
|
|
173
|
+
}),
|
|
174
|
+
connectTimeout,
|
|
175
|
+
]);
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
if (connectTimer)
|
|
179
|
+
clearTimeout(connectTimer);
|
|
180
|
+
}
|
|
166
181
|
// CUCM SSH presents an interactive CLI shell. `exec()` is not reliably supported,
|
|
167
182
|
// so we open a shell and type commands at the prompt.
|
|
168
183
|
const channel = await new Promise((resolve, reject) => {
|
|
@@ -196,7 +211,7 @@ export class PacketCaptureManager {
|
|
|
196
211
|
});
|
|
197
212
|
// Wait for prompt before running capture command.
|
|
198
213
|
// Some CUCM shells don't print a prompt until you send a newline.
|
|
199
|
-
const promptTimeoutMs =
|
|
214
|
+
const promptTimeoutMs = startTimeoutMs;
|
|
200
215
|
try {
|
|
201
216
|
try {
|
|
202
217
|
channel.write("\n");
|