@calltelemetry/cucm-mcp 0.1.0

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 ADDED
@@ -0,0 +1,76 @@
1
+ # CUCM MCP
2
+
3
+ MCP (Model Context Protocol) server for CUCM tooling.
4
+
5
+ Included capabilities:
6
+
7
+ - Query + download trace/log files via CUCM DIME Log Collection SOAP services
8
+ - Query/download Syslog via DIME SystemLogs selections
9
+ - Start/stop packet captures via CUCM CLI over SSH, then download the resulting `.cap` via DIME
10
+
11
+ ## Configuration
12
+
13
+ Credentials are read from tool args or environment variables.
14
+
15
+ ### DIME (HTTPS)
16
+
17
+ - `CUCM_DIME_USERNAME`
18
+ - `CUCM_DIME_PASSWORD`
19
+ - `CUCM_DIME_PORT` (default `8443`)
20
+
21
+ ### SSH (CLI)
22
+
23
+ - `CUCM_SSH_USERNAME` (often `administrator`)
24
+ - `CUCM_SSH_PASSWORD`
25
+ - `CUCM_SSH_PORT` (default `22`)
26
+
27
+ ### TLS
28
+
29
+ CUCM lab environments often use self-signed certificates. By default this server sets `NODE_TLS_REJECT_UNAUTHORIZED=0` unless you opt into strict verification:
30
+
31
+ - `CUCM_MCP_TLS_MODE=strict` (or `MCP_TLS_MODE=strict`)
32
+
33
+ ## Run
34
+
35
+ ```bash
36
+ yarn install
37
+ yarn start
38
+ ```
39
+
40
+ ## MCP Config
41
+
42
+ Add to `.mcp.json`:
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "cucm": {
48
+ "type": "stdio",
49
+ "command": "yarn",
50
+ "args": ["--cwd", "cucm-mcp", "start"],
51
+ "env": {
52
+ "CUCM_DIME_USERNAME": "<user>",
53
+ "CUCM_DIME_PASSWORD": "<pass>",
54
+ "CUCM_SSH_USERNAME": "administrator",
55
+ "CUCM_SSH_PASSWORD": "<pass>",
56
+ "CUCM_MCP_TLS_MODE": "permissive"
57
+ }
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ ## Testing
64
+
65
+ ```bash
66
+ yarn test
67
+ ```
68
+
69
+ Live tests are opt-in via env vars; see `test/live.test.js`.
70
+
71
+ ## Useful Tools
72
+
73
+ - `select_logs_minutes` - list recent ServiceLogs/SystemLogs files
74
+ - `select_syslog_minutes` - list recent system log files (defaults to `Syslog`)
75
+ - `packet_capture_start` / `packet_capture_stop` - control captures via SSH
76
+ - `packet_capture_stop_and_download` - stop capture + download `.cap` via DIME
package/dist/dime.js ADDED
@@ -0,0 +1,220 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import { extractBoundary, parseMultipartRelated } from "./multipart.js";
3
+ import { formatCucmDateTime, guessTimezoneString } from "./time.js";
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ const parser = new XMLParser({
7
+ ignoreAttributes: false,
8
+ attributeNamePrefix: "@",
9
+ removeNSPrefix: true,
10
+ trimValues: true,
11
+ });
12
+ export function normalizeHost(hostOrUrl) {
13
+ const s = String(hostOrUrl || "").trim();
14
+ if (!s)
15
+ throw new Error("host is required");
16
+ if (s.includes("://")) {
17
+ const u = new URL(s);
18
+ return u.hostname;
19
+ }
20
+ return s.replace(/^https?:\/\//, "").replace(/\/+$/, "").split("/")[0];
21
+ }
22
+ export function resolveAuth(auth) {
23
+ const username = auth?.username || process.env.CUCM_DIME_USERNAME || process.env.CUCM_USERNAME;
24
+ const password = auth?.password || process.env.CUCM_DIME_PASSWORD || process.env.CUCM_PASSWORD;
25
+ if (!username || !password) {
26
+ throw new Error("Missing DIME credentials (provide auth or set CUCM_DIME_USERNAME/CUCM_DIME_PASSWORD)");
27
+ }
28
+ return { username, password };
29
+ }
30
+ export function resolveTarget(hostOrUrl, port) {
31
+ const host = normalizeHost(hostOrUrl);
32
+ const envPort = process.env.CUCM_DIME_PORT ? Number.parseInt(process.env.CUCM_DIME_PORT, 10) : undefined;
33
+ const resolvedPort = port ?? envPort ?? 8443;
34
+ return { host, port: resolvedPort };
35
+ }
36
+ function basicAuthHeader(username, password) {
37
+ return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
38
+ }
39
+ function escapeXml(s) {
40
+ return s
41
+ .replaceAll("&", "&amp;")
42
+ .replaceAll("<", "&lt;")
43
+ .replaceAll(">", "&gt;")
44
+ .replaceAll('"', "&quot;")
45
+ .replaceAll("'", "&apos;");
46
+ }
47
+ function soapEnvelopeList() {
48
+ return ('<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:soap="http://schemas.cisco.com/ast/soap">' +
49
+ "<soapenv:Header/>" +
50
+ "<soapenv:Body>" +
51
+ "<soap:listNodeServiceLogs>" +
52
+ "<soap:ListRequest></soap:ListRequest>" +
53
+ "</soap:listNodeServiceLogs>" +
54
+ "</soapenv:Body>" +
55
+ "</soapenv:Envelope>");
56
+ }
57
+ function soapItems(tag, values) {
58
+ const vals = (values || []).filter((v) => String(v || "").trim() !== "");
59
+ if (vals.length === 0)
60
+ return `<soap:${tag}><soap:item></soap:item></soap:${tag}>`;
61
+ return `<soap:${tag}>${vals.map((v) => `<soap:item>${escapeXml(v)}</soap:item>`).join("")}</soap:${tag}>`;
62
+ }
63
+ function soapEnvelopeSelect(criteria) {
64
+ const serviceLogs = soapItems("ServiceLogs", criteria.serviceLogs);
65
+ const systemLogs = soapItems("SystemLogs", criteria.systemLogs);
66
+ const searchStr = escapeXml(criteria.searchStr || "");
67
+ return ('<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:soap="http://schemas.cisco.com/ast/soap">' +
68
+ "<soapenv:Header/>" +
69
+ "<soapenv:Body>" +
70
+ "<soap:selectLogFiles>" +
71
+ "<soap:FileSelectionCriteria>" +
72
+ serviceLogs +
73
+ systemLogs +
74
+ `<soap:SearchStr>${searchStr}</soap:SearchStr>` +
75
+ "<soap:Frequency>OnDemand</soap:Frequency>" +
76
+ "<soap:JobType>DownloadtoClient</soap:JobType>" +
77
+ `<soap:ToDate>${escapeXml(criteria.toDate)}</soap:ToDate>` +
78
+ `<soap:FromDate>${escapeXml(criteria.fromDate)}</soap:FromDate>` +
79
+ `<soap:TimeZone>${escapeXml(criteria.timezone)}</soap:TimeZone>` +
80
+ "<soap:RelText>None</soap:RelText>" +
81
+ "<soap:RelTime>0</soap:RelTime>" +
82
+ "<soap:Port></soap:Port>" +
83
+ "<soap:IPAddress></soap:IPAddress>" +
84
+ "<soap:UserName></soap:UserName>" +
85
+ "<soap:Password></soap:Password>" +
86
+ "<soap:ZipInfo></soap:ZipInfo>" +
87
+ "<soap:RemoteFolder></soap:RemoteFolder>" +
88
+ "</soap:FileSelectionCriteria>" +
89
+ "</soap:selectLogFiles>" +
90
+ "</soapenv:Body>" +
91
+ "</soapenv:Envelope>");
92
+ }
93
+ function soapEnvelopeGetOneFile(fileName) {
94
+ return ('<soapenv:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:soap="http://schemas.cisco.com/ast/soap/">' +
95
+ "<soapenv:Header/>" +
96
+ "<soapenv:Body>" +
97
+ '<soap:GetOneFile soapenv:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' +
98
+ `<FileName xsi:type="get:FileName" xmlns:get="http://cisco.com/ccm/serviceability/soap/LogCollection/GetFile/">${escapeXml(fileName)}</FileName>` +
99
+ "</soap:GetOneFile>" +
100
+ "</soapenv:Body>" +
101
+ "</soapenv:Envelope>");
102
+ }
103
+ async function fetchSoap(target, auth, path, soapAction, xmlBody, timeoutMs = 30000) {
104
+ const url = `https://${target.host}:${target.port}${path}`;
105
+ const res = await fetch(url, {
106
+ method: "POST",
107
+ headers: {
108
+ Authorization: basicAuthHeader(auth.username, auth.password),
109
+ SOAPAction: soapAction,
110
+ "Content-Type": "text/xml;charset=UTF-8",
111
+ Accept: "*/*",
112
+ },
113
+ body: Buffer.from(xmlBody, "utf8"),
114
+ signal: AbortSignal.timeout(timeoutMs),
115
+ });
116
+ if (!res.ok) {
117
+ const text = await res.text().catch(() => "");
118
+ throw new Error(`CUCM DIME HTTP ${res.status}: ${text || res.statusText}`);
119
+ }
120
+ const contentType = res.headers.get("content-type");
121
+ const ab = await res.arrayBuffer();
122
+ return { contentType, bytes: Buffer.from(ab) };
123
+ }
124
+ export async function listNodeServiceLogs(hostOrUrl, auth, port) {
125
+ const target = resolveTarget(hostOrUrl, port);
126
+ const resolvedAuth = resolveAuth(auth);
127
+ const { contentType, bytes } = await fetchSoap(target, resolvedAuth, "/logcollectionservice2/services/LogCollectionPortTypeService", "listNodeServiceLogs", soapEnvelopeList());
128
+ const boundary = extractBoundary(contentType);
129
+ const parts = parseMultipartRelated(bytes, boundary);
130
+ const xmlParts = parts.filter((p) => p.contentType.toLowerCase() === "text/xml");
131
+ if (xmlParts.length === 0)
132
+ throw new Error("DIME response missing text/xml part");
133
+ const parsed = parser.parse(xmlParts[0].body.toString("utf8"));
134
+ const env = parsed.Envelope || parsed;
135
+ const body = env.Body || env;
136
+ const resp = body.listNodeServiceLogsResponse;
137
+ const ret = resp?.listNodeServiceLogsReturn;
138
+ if (!ret)
139
+ throw new Error("Unexpected listNodeServiceLogs response shape");
140
+ const items = Array.isArray(ret) ? ret : [ret];
141
+ return items.map((it) => {
142
+ const serviceLogsRaw = it?.ServiceLog?.item;
143
+ const serviceLogs = Array.isArray(serviceLogsRaw)
144
+ ? serviceLogsRaw
145
+ : typeof serviceLogsRaw === "string"
146
+ ? [serviceLogsRaw]
147
+ : [];
148
+ return {
149
+ server: String(it?.name || ""),
150
+ serviceLogs,
151
+ count: serviceLogs.length,
152
+ };
153
+ });
154
+ }
155
+ export async function selectLogs(hostOrUrl, criteria, auth, port) {
156
+ const target = resolveTarget(hostOrUrl, port);
157
+ const resolvedAuth = resolveAuth(auth);
158
+ const hasService = (criteria.serviceLogs || []).some((x) => String(x || "").trim() !== "");
159
+ const hasSystem = (criteria.systemLogs || []).some((x) => String(x || "").trim() !== "");
160
+ if (!hasService && !hasSystem) {
161
+ throw new Error("selectLogs requires at least one of serviceLogs or systemLogs");
162
+ }
163
+ const { contentType, bytes } = await fetchSoap(target, resolvedAuth, "/logcollectionservice2/services/LogCollectionPortTypeService", "selectLogFiles", soapEnvelopeSelect(criteria));
164
+ const boundary = extractBoundary(contentType);
165
+ const parts = parseMultipartRelated(bytes, boundary);
166
+ const xmlParts = parts.filter((p) => p.contentType.toLowerCase() === "text/xml");
167
+ if (xmlParts.length === 0)
168
+ throw new Error("DIME response missing text/xml part");
169
+ const parsed = parser.parse(xmlParts[0].body.toString("utf8"));
170
+ const env = parsed.Envelope || parsed;
171
+ const body = env.Body || env;
172
+ const resp = body.selectLogFilesResponse;
173
+ const resultSet = resp?.ResultSet;
174
+ const serviceFileList = resultSet?.SchemaFileSelectionResult?.Node?.ServiceList?.ServiceLogs?.SetOfFiles?.File;
175
+ const systemFileList = resultSet?.SchemaFileSelectionResult?.Node?.ServiceList?.SystemLogs?.SetOfFiles?.File;
176
+ const toArray = (x) => (x == null ? [] : Array.isArray(x) ? x : [x]);
177
+ const combined = [...toArray(serviceFileList), ...toArray(systemFileList)];
178
+ if (combined.length === 0)
179
+ throw new Error("No files found (missing ServiceLogs/SystemLogs SetOfFiles/File)");
180
+ return combined.map((f) => {
181
+ const absolutePath = f?.absolutepath || f?.AbsolutePath || f?.Absolutepath;
182
+ const fileName = f?.filename || f?.FileName || f?.Filename;
183
+ return {
184
+ server: target.host,
185
+ absolutePath: absolutePath ? String(absolutePath) : undefined,
186
+ fileName: fileName ? String(fileName) : undefined,
187
+ ...f,
188
+ };
189
+ });
190
+ }
191
+ export async function selectLogsMinutes(hostOrUrl, minutesBack, select, timezone, auth, port) {
192
+ const now = new Date();
193
+ const past = new Date(now.getTime() - minutesBack * 60_000);
194
+ const fromDate = formatCucmDateTime(past);
195
+ const toDate = formatCucmDateTime(now);
196
+ const tz = timezone || guessTimezoneString(now);
197
+ const files = await selectLogs(hostOrUrl, { ...select, fromDate, toDate, timezone: tz }, auth, port);
198
+ return { fromDate, toDate, timezone: tz, files };
199
+ }
200
+ export async function getOneFile(hostOrUrl, filePath, auth, port) {
201
+ const target = resolveTarget(hostOrUrl, port);
202
+ const resolvedAuth = resolveAuth(auth);
203
+ const { contentType, bytes } = await fetchSoap(target, resolvedAuth, "/logcollectionservice/services/DimeGetFileService", "http://schemas.cisco.com/ast/soap/action/#LogCollectionPort#GetOneFile", soapEnvelopeGetOneFile(filePath));
204
+ const boundary = extractBoundary(contentType);
205
+ const parts = parseMultipartRelated(bytes, boundary);
206
+ if (parts.length === 0)
207
+ throw new Error("DIME GetOneFile returned no multipart parts");
208
+ const nonXml = parts.find((p) => p.contentType.toLowerCase() !== "text/xml");
209
+ if (!nonXml)
210
+ throw new Error("DIME GetOneFile response missing non-XML file part");
211
+ return { server: target.host, filename: filePath, data: nonXml.body };
212
+ }
213
+ export function writeDownloadedFile(result, outFile) {
214
+ const baseName = result.filename.split("/").filter(Boolean).pop() || "cucm-file.bin";
215
+ const defaultDir = join("/tmp", "cucm-mcp");
216
+ const filePath = outFile || join(defaultDir, baseName);
217
+ mkdirSync(dirname(filePath), { recursive: true });
218
+ writeFileSync(filePath, result.data);
219
+ return { filePath, bytes: result.data.length, baseName };
220
+ }
package/dist/index.js ADDED
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { listNodeServiceLogs, selectLogs, selectLogsMinutes, getOneFile, writeDownloadedFile, } from "./dime.js";
6
+ import { guessTimezoneString } from "./time.js";
7
+ import { PacketCaptureManager } from "./packetCapture.js";
8
+ // Default to accepting self-signed/invalid certs (common on CUCM lab/dev).
9
+ // Opt back into strict verification with CUCM_MCP_TLS_MODE=strict.
10
+ const tlsMode = (process.env.CUCM_MCP_TLS_MODE || process.env.MCP_TLS_MODE || "").toLowerCase();
11
+ const strictTls = tlsMode === "strict" || tlsMode === "verify";
12
+ if (!strictTls && !process.env.NODE_TLS_REJECT_UNAUTHORIZED) {
13
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
14
+ }
15
+ const server = new McpServer({ name: "cucm", version: "0.1.0" });
16
+ const captures = new PacketCaptureManager();
17
+ const dimeAuthSchema = z
18
+ .object({
19
+ username: z.string().optional(),
20
+ password: z.string().optional(),
21
+ })
22
+ .optional();
23
+ const sshAuthSchema = z
24
+ .object({
25
+ username: z.string().optional(),
26
+ password: z.string().optional(),
27
+ })
28
+ .optional();
29
+ server.tool("guess_timezone_string", "Build a best-effort DIME timezone string for selectLogFiles.", {}, async () => ({
30
+ content: [{ type: "text", text: JSON.stringify({ timezone: guessTimezoneString(new Date()) }, null, 2) }],
31
+ }));
32
+ server.tool("list_node_service_logs", "List CUCM cluster nodes and their available service logs (DIME listNodeServiceLogs).", {
33
+ host: z.string(),
34
+ port: z.number().int().min(1).max(65535).optional(),
35
+ auth: dimeAuthSchema,
36
+ }, async ({ host, port, auth }) => {
37
+ const result = await listNodeServiceLogs(host, auth, port);
38
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
39
+ });
40
+ server.tool("select_logs", "List log/trace files using DIME selectLogFiles. Supports ServiceLogs and SystemLogs.", {
41
+ host: z.string(),
42
+ port: z.number().int().min(1).max(65535).optional(),
43
+ auth: dimeAuthSchema,
44
+ serviceLogs: z.array(z.string()).optional().describe("ServiceLogs selections"),
45
+ systemLogs: z.array(z.string()).optional().describe("SystemLogs selections"),
46
+ searchStr: z.string().optional().describe("Optional filename substring filter"),
47
+ fromDate: z.string(),
48
+ toDate: z.string(),
49
+ timezone: z.string(),
50
+ }, async ({ host, port, auth, serviceLogs, systemLogs, searchStr, fromDate, toDate, timezone }) => {
51
+ const result = await selectLogs(host, { serviceLogs, systemLogs, searchStr, fromDate, toDate, timezone }, auth, port);
52
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
53
+ });
54
+ server.tool("select_logs_minutes", "Convenience wrapper: select logs using a minutes-back window.", {
55
+ host: z.string(),
56
+ port: z.number().int().min(1).max(65535).optional(),
57
+ auth: dimeAuthSchema,
58
+ minutesBack: z.number().int().min(1).max(60 * 24 * 30),
59
+ serviceLogs: z.array(z.string()).optional(),
60
+ systemLogs: z.array(z.string()).optional(),
61
+ searchStr: z.string().optional(),
62
+ timezone: z.string().optional(),
63
+ }, async ({ host, port, auth, minutesBack, serviceLogs, systemLogs, searchStr, timezone }) => {
64
+ const result = await selectLogsMinutes(host, minutesBack, { serviceLogs, systemLogs, searchStr }, timezone, auth, port);
65
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
66
+ });
67
+ server.tool("select_syslog_minutes", "Convenience wrapper: select system log files (e.g. Syslog) using a minutes-back window.", {
68
+ host: z.string(),
69
+ port: z.number().int().min(1).max(65535).optional(),
70
+ auth: dimeAuthSchema,
71
+ minutesBack: z.number().int().min(1).max(60 * 24 * 30),
72
+ systemLog: z
73
+ .string()
74
+ .optional()
75
+ .describe("System log selection name. Default is 'Syslog' (may vary by CUCM version)."),
76
+ searchStr: z.string().optional(),
77
+ timezone: z.string().optional(),
78
+ }, async ({ host, port, auth, minutesBack, systemLog, searchStr, timezone }) => {
79
+ const result = await selectLogsMinutes(host, minutesBack, { systemLogs: [systemLog || "Syslog"], searchStr }, timezone, auth, port);
80
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
81
+ });
82
+ server.tool("download_file", "Download a single file via DIME GetOneFile and write it to disk.", {
83
+ host: z.string(),
84
+ port: z.number().int().min(1).max(65535).optional(),
85
+ auth: dimeAuthSchema,
86
+ filePath: z.string().min(1).describe("Absolute path on CUCM"),
87
+ outFile: z.string().optional().describe("Optional output path. Default: /tmp/cucm-mcp/<basename>"),
88
+ }, async ({ host, port, auth, filePath, outFile }) => {
89
+ const dl = await getOneFile(host, filePath, auth, port);
90
+ const saved = writeDownloadedFile(dl, outFile);
91
+ return {
92
+ content: [
93
+ {
94
+ type: "text",
95
+ text: JSON.stringify({ server: dl.server, sourcePath: dl.filename, savedPath: saved.filePath, bytes: saved.bytes }, null, 2),
96
+ },
97
+ ],
98
+ };
99
+ });
100
+ server.tool("packet_capture_start", "Start a packet capture on CUCM via SSH (utils network capture). Returns a captureId.", {
101
+ host: z.string().describe("CUCM host/IP"),
102
+ sshPort: z.number().int().min(1).max(65535).optional(),
103
+ auth: sshAuthSchema,
104
+ iface: z.string().optional().describe("Interface (default eth0)"),
105
+ fileBase: z.string().optional().describe("Capture base name (no dots). Saved as <fileBase>.cap"),
106
+ count: z.number().int().min(1).max(200000).optional().describe("Packet count (file max is typically 100000)"),
107
+ size: z.string().optional().describe("Packet size (e.g. all)"),
108
+ hostFilterIp: z.string().optional().describe("Optional filter: host ip <addr>"),
109
+ portFilter: z.number().int().min(1).max(65535).optional().describe("Optional filter: port <num>"),
110
+ }, async ({ host, sshPort, auth, iface, fileBase, count, size, hostFilterIp, portFilter }) => {
111
+ const result = await captures.start({
112
+ host,
113
+ sshPort,
114
+ auth: auth,
115
+ iface,
116
+ fileBase,
117
+ count,
118
+ size,
119
+ hostFilterIp,
120
+ portFilter,
121
+ });
122
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
123
+ });
124
+ 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) }] }));
125
+ server.tool("packet_capture_stop", "Stop a packet capture by captureId (sends Ctrl-C).", {
126
+ captureId: z.string().min(1),
127
+ }, async ({ captureId }) => {
128
+ const result = await captures.stop(captureId);
129
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
130
+ });
131
+ server.tool("packet_capture_stop_and_download", "Stop a packet capture and download the resulting .cap file via DIME.", {
132
+ captureId: z.string().min(1),
133
+ dimePort: z.number().int().min(1).max(65535).optional(),
134
+ auth: dimeAuthSchema.describe("DIME auth (optional; defaults to CUCM_DIME_USERNAME/CUCM_DIME_PASSWORD)"),
135
+ outFile: z.string().optional().describe("Optional output path for the downloaded .cap file"),
136
+ }, async ({ captureId, dimePort, auth, outFile }) => {
137
+ const stopped = await captures.stop(captureId);
138
+ const dl = await getOneFile(stopped.host, stopped.remoteFilePath, auth, dimePort);
139
+ const saved = writeDownloadedFile(dl, outFile);
140
+ return {
141
+ content: [
142
+ {
143
+ type: "text",
144
+ text: JSON.stringify({
145
+ captureId: stopped.id,
146
+ host: stopped.host,
147
+ remoteFilePath: stopped.remoteFilePath,
148
+ savedPath: saved.filePath,
149
+ bytes: saved.bytes,
150
+ }, null, 2),
151
+ },
152
+ ],
153
+ };
154
+ });
155
+ const transport = new StdioServerTransport();
156
+ await server.connect(transport);
@@ -0,0 +1,73 @@
1
+ export function extractBoundary(contentTypeHeader) {
2
+ if (!contentTypeHeader)
3
+ return "";
4
+ const m = contentTypeHeader.match(/boundary\s*=\s*"?([^";]+)"?/i);
5
+ return (m?.[1] || "").trim();
6
+ }
7
+ function parseHeaderLines(lines) {
8
+ const out = {};
9
+ for (const line of lines) {
10
+ const idx = line.indexOf(":");
11
+ if (idx <= 0)
12
+ continue;
13
+ const k = line.slice(0, idx).trim().toLowerCase();
14
+ const v = line.slice(idx + 1).trim();
15
+ out[k] = v;
16
+ }
17
+ return out;
18
+ }
19
+ export function parseMultipartRelated(body, boundary) {
20
+ if (!boundary)
21
+ return [];
22
+ const boundaryLine = `--${boundary}`;
23
+ const boundaryBuf = Buffer.from(boundaryLine, "utf8");
24
+ const parts = [];
25
+ const findNextBoundary = (start) => {
26
+ for (let p = start; p <= body.length - boundaryBuf.length; p++) {
27
+ if (body[p] !== boundaryBuf[0])
28
+ continue;
29
+ if (body.subarray(p, p + boundaryBuf.length).equals(boundaryBuf))
30
+ return p;
31
+ }
32
+ return -1;
33
+ };
34
+ let i = findNextBoundary(0);
35
+ if (i === -1)
36
+ return [];
37
+ while (i !== -1) {
38
+ const lineEnd = body.indexOf("\n", i);
39
+ if (lineEnd === -1)
40
+ break;
41
+ const boundaryText = body.subarray(i, lineEnd).toString("utf8").trim();
42
+ if (boundaryText.endsWith("--"))
43
+ break;
44
+ let cursor = lineEnd + 1;
45
+ const headerLines = [];
46
+ while (cursor < body.length) {
47
+ const nextNl = body.indexOf("\n", cursor);
48
+ if (nextNl === -1)
49
+ break;
50
+ const rawLine = body.subarray(cursor, nextNl).toString("utf8");
51
+ const line = rawLine.replace(/\r$/, "");
52
+ cursor = nextNl + 1;
53
+ if (line.trim() === "")
54
+ break;
55
+ headerLines.push(line);
56
+ }
57
+ const headers = parseHeaderLines(headerLines);
58
+ const contentType = (headers["content-type"] || "application/octet-stream")
59
+ .split(";")[0]
60
+ .trim();
61
+ const nextBoundary = findNextBoundary(cursor);
62
+ if (nextBoundary === -1)
63
+ break;
64
+ let end = nextBoundary;
65
+ if (end >= 2 && body[end - 2] === 0x0d && body[end - 1] === 0x0a)
66
+ end -= 2;
67
+ else if (end >= 1 && body[end - 1] === 0x0a)
68
+ end -= 1;
69
+ parts.push({ contentType, headers, body: Buffer.from(body.subarray(cursor, end)) });
70
+ i = nextBoundary;
71
+ }
72
+ return parts;
73
+ }
@@ -0,0 +1,161 @@
1
+ import crypto from "node:crypto";
2
+ import { Client } from "ssh2";
3
+ export function resolveSshAuth(auth) {
4
+ const username = auth?.username || process.env.CUCM_SSH_USERNAME;
5
+ const password = auth?.password || process.env.CUCM_SSH_PASSWORD;
6
+ if (!username || !password) {
7
+ throw new Error("Missing SSH credentials (provide auth or set CUCM_SSH_USERNAME/CUCM_SSH_PASSWORD)");
8
+ }
9
+ return { username, password };
10
+ }
11
+ export function sanitizeFileBase(s) {
12
+ // CUCM note: fname should not contain '.'
13
+ // Keep it conservative.
14
+ const cleaned = String(s || "")
15
+ .trim()
16
+ .replaceAll(".", "_")
17
+ .replace(/[^A-Za-z0-9_-]+/g, "_")
18
+ .replace(/^_+/, "")
19
+ .replace(/_+$/, "");
20
+ if (!cleaned)
21
+ throw new Error("fileBase is required (after sanitization)");
22
+ return cleaned;
23
+ }
24
+ export function buildCaptureCommand(opts) {
25
+ const iface = String(opts.iface || "eth0").trim() || "eth0";
26
+ const fileBase = sanitizeFileBase(opts.fileBase);
27
+ const count = Number.isFinite(opts.count) ? Math.trunc(opts.count) : 100000;
28
+ if (count <= 0)
29
+ throw new Error("count must be > 0");
30
+ const size = String(opts.size || "all").trim() || "all";
31
+ const args = [
32
+ "utils",
33
+ "network",
34
+ "capture",
35
+ iface,
36
+ "file",
37
+ fileBase,
38
+ "count",
39
+ String(count),
40
+ "size",
41
+ size,
42
+ ];
43
+ if (opts.portFilter != null) {
44
+ const p = Math.trunc(opts.portFilter);
45
+ if (p < 1 || p > 65535)
46
+ throw new Error("portFilter must be 1..65535");
47
+ args.push("port", String(p));
48
+ }
49
+ if (opts.hostFilterIp) {
50
+ const ip = String(opts.hostFilterIp).trim();
51
+ if (!ip)
52
+ throw new Error("hostFilterIp must be non-empty");
53
+ args.push("host", "ip", ip);
54
+ }
55
+ return args.join(" ");
56
+ }
57
+ export function remoteCapturePath(fileBase) {
58
+ const fb = sanitizeFileBase(fileBase);
59
+ return `/var/log/active/platform/cli/${fb}.cap`;
60
+ }
61
+ export class PacketCaptureManager {
62
+ active = new Map();
63
+ list() {
64
+ return [...this.active.values()].map((a) => a.session);
65
+ }
66
+ async start(opts) {
67
+ const iface = String(opts.iface || "eth0").trim() || "eth0";
68
+ const fileBase = sanitizeFileBase(opts.fileBase || `cap_${Date.now()}`);
69
+ const count = opts.count ?? 100000;
70
+ const size = opts.size ?? "all";
71
+ const cmd = buildCaptureCommand({ iface, fileBase, count, size, hostFilterIp: opts.hostFilterIp, portFilter: opts.portFilter });
72
+ const id = crypto.randomUUID();
73
+ const sshPort = opts.sshPort ?? (process.env.CUCM_SSH_PORT ? Number.parseInt(process.env.CUCM_SSH_PORT, 10) : 22);
74
+ const auth = resolveSshAuth(opts.auth);
75
+ const client = new Client();
76
+ const startedAt = new Date().toISOString();
77
+ const session = {
78
+ id,
79
+ host: opts.host,
80
+ startedAt,
81
+ iface,
82
+ fileBase,
83
+ remoteFilePath: remoteCapturePath(fileBase),
84
+ };
85
+ await new Promise((resolve, reject) => {
86
+ client
87
+ .on("ready", () => resolve())
88
+ .on("error", (e) => reject(e))
89
+ .connect({
90
+ host: opts.host,
91
+ port: sshPort,
92
+ username: auth.username,
93
+ password: auth.password,
94
+ readyTimeout: 15000,
95
+ });
96
+ });
97
+ const channel = await new Promise((resolve, reject) => {
98
+ client.exec(cmd, { pty: true }, (err, ch) => {
99
+ if (err)
100
+ return reject(err);
101
+ resolve(ch);
102
+ });
103
+ });
104
+ channel.on("data", (buf) => {
105
+ session.lastStdout = buf.toString("utf8").slice(-2000);
106
+ });
107
+ channel.stderr.on("data", (buf) => {
108
+ session.lastStderr = buf.toString("utf8").slice(-2000);
109
+ });
110
+ channel.on("close", (code) => {
111
+ session.exitCode = code;
112
+ // If the capture stopped unexpectedly, drop it from the active map.
113
+ this.active.delete(id);
114
+ try {
115
+ client.end();
116
+ }
117
+ catch {
118
+ // ignore
119
+ }
120
+ });
121
+ this.active.set(id, { session, client, channel });
122
+ return session;
123
+ }
124
+ async stop(captureId, timeoutMs = 15000) {
125
+ const a = this.active.get(captureId);
126
+ if (!a)
127
+ throw new Error(`Unknown captureId: ${captureId}`);
128
+ const { channel, client, session } = a;
129
+ const done = new Promise((resolve) => {
130
+ channel.once("close", () => resolve());
131
+ });
132
+ // Best-effort interrupt.
133
+ try {
134
+ if (typeof channel.signal === "function")
135
+ channel.signal("INT");
136
+ else
137
+ channel.write("\x03");
138
+ }
139
+ catch {
140
+ try {
141
+ channel.write("\x03");
142
+ }
143
+ catch {
144
+ // ignore
145
+ }
146
+ }
147
+ await Promise.race([
148
+ done,
149
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout waiting for capture to stop")), timeoutMs)),
150
+ ]);
151
+ // stop() implies cleanup
152
+ this.active.delete(captureId);
153
+ try {
154
+ client.end();
155
+ }
156
+ catch {
157
+ // ignore
158
+ }
159
+ return session;
160
+ }
161
+ }
package/dist/time.js ADDED
@@ -0,0 +1,25 @@
1
+ export function formatCucmDateTime(d) {
2
+ // DIME examples use: "MM/DD/YY HH:MM AM" (US locale)
3
+ const date = new Intl.DateTimeFormat("en-US", {
4
+ month: "2-digit",
5
+ day: "2-digit",
6
+ year: "2-digit",
7
+ }).format(d);
8
+ const time = new Intl.DateTimeFormat("en-US", {
9
+ hour: "numeric",
10
+ minute: "2-digit",
11
+ hour12: true,
12
+ }).format(d);
13
+ return `${date} ${time}`;
14
+ }
15
+ export function guessTimezoneString(now = new Date()) {
16
+ // Best-effort DIME timezone string:
17
+ // "Client: (GMT-8:0)America/Los_Angeles"
18
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
19
+ const offsetMin = -now.getTimezoneOffset();
20
+ const sign = offsetMin >= 0 ? "+" : "-";
21
+ const abs = Math.abs(offsetMin);
22
+ const h = Math.floor(abs / 60);
23
+ const m = abs % 60;
24
+ return `Client: (GMT${sign}${h}:${m})${tz}`;
25
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@calltelemetry/cucm-mcp",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "MCP server for CUCM tooling (DIME logs, syslog, packet capture)",
7
+ "license": "UNLICENSED",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/calltelemetry/cucm-mcp.git"
11
+ },
12
+ "bin": {
13
+ "cucm-mcp": "./dist/index.js"
14
+ },
15
+ "files": [
16
+ "dist/",
17
+ "README.md",
18
+ "package.json"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "scripts": {
24
+ "start": "tsx src/index.ts",
25
+ "build": "tsc",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "yarn build && node --test test/*.test.js",
28
+ "prepack": "yarn test"
29
+ },
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.12.1",
32
+ "fast-xml-parser": "^4.5.3",
33
+ "ssh2": "^1.16.0",
34
+ "zod": "^3.24.2"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^25.1.0",
38
+ "@types/ssh2": "^1.15.4",
39
+ "tsx": "^4.19.4",
40
+ "typescript": "^5.7.3"
41
+ }
42
+ }