@calltelemetry/cucm-mcp 0.1.0 → 0.1.2

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 CHANGED
@@ -49,10 +49,10 @@ Add to `.mcp.json`:
49
49
  "command": "yarn",
50
50
  "args": ["--cwd", "cucm-mcp", "start"],
51
51
  "env": {
52
- "CUCM_DIME_USERNAME": "<user>",
53
- "CUCM_DIME_PASSWORD": "<pass>",
52
+ "CUCM_DIME_USERNAME": "<cucm-username>",
53
+ "CUCM_DIME_PASSWORD": "<cucm-password>",
54
54
  "CUCM_SSH_USERNAME": "administrator",
55
- "CUCM_SSH_PASSWORD": "<pass>",
55
+ "CUCM_SSH_PASSWORD": "<ssh-password>",
56
56
  "CUCM_MCP_TLS_MODE": "permissive"
57
57
  }
58
58
  }
package/dist/dime.js CHANGED
@@ -3,12 +3,34 @@ import { extractBoundary, parseMultipartRelated } from "./multipart.js";
3
3
  import { formatCucmDateTime, guessTimezoneString } from "./time.js";
4
4
  import { mkdirSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
+ // Default to accepting self-signed/invalid certs (common on CUCM lab/dev).
7
+ // Opt back into strict verification with CUCM_MCP_TLS_MODE=strict.
8
+ const tlsMode = (process.env.CUCM_MCP_TLS_MODE || process.env.MCP_TLS_MODE || "").toLowerCase();
9
+ const strictTls = tlsMode === "strict" || tlsMode === "verify";
10
+ if (!strictTls)
11
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
6
12
  const parser = new XMLParser({
7
13
  ignoreAttributes: false,
8
14
  attributeNamePrefix: "@",
9
15
  removeNSPrefix: true,
10
16
  trimValues: true,
11
17
  });
18
+ function isXmlContentType(contentType) {
19
+ const ct = String(contentType || "").toLowerCase();
20
+ if (!ct)
21
+ return false;
22
+ return ct === "text/xml" || ct === "application/xml" || ct === "application/soap+xml" || ct.endsWith("+xml") || ct.includes("/xml");
23
+ }
24
+ function dimeXmlBytes(contentType, bytes) {
25
+ const boundary = extractBoundary(contentType);
26
+ if (!boundary)
27
+ return bytes;
28
+ const parts = parseMultipartRelated(bytes, boundary);
29
+ const xmlParts = parts.filter((p) => isXmlContentType(p.contentType));
30
+ if (xmlParts.length === 0)
31
+ throw new Error("DIME response missing text/xml part");
32
+ return xmlParts[0].body;
33
+ }
12
34
  export function normalizeHost(hostOrUrl) {
13
35
  const s = String(hostOrUrl || "").trim();
14
36
  if (!s)
@@ -125,12 +147,8 @@ export async function listNodeServiceLogs(hostOrUrl, auth, port) {
125
147
  const target = resolveTarget(hostOrUrl, port);
126
148
  const resolvedAuth = resolveAuth(auth);
127
149
  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"));
150
+ const xml = dimeXmlBytes(contentType, bytes);
151
+ const parsed = parser.parse(xml.toString("utf8"));
134
152
  const env = parsed.Envelope || parsed;
135
153
  const body = env.Body || env;
136
154
  const resp = body.listNodeServiceLogsResponse;
@@ -161,12 +179,8 @@ export async function selectLogs(hostOrUrl, criteria, auth, port) {
161
179
  throw new Error("selectLogs requires at least one of serviceLogs or systemLogs");
162
180
  }
163
181
  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"));
182
+ const xml = dimeXmlBytes(contentType, bytes);
183
+ const parsed = parser.parse(xml.toString("utf8"));
170
184
  const env = parsed.Envelope || parsed;
171
185
  const body = env.Body || env;
172
186
  const resp = body.selectLogFilesResponse;
@@ -202,14 +216,80 @@ export async function getOneFile(hostOrUrl, filePath, auth, port) {
202
216
  const resolvedAuth = resolveAuth(auth);
203
217
  const { contentType, bytes } = await fetchSoap(target, resolvedAuth, "/logcollectionservice/services/DimeGetFileService", "http://schemas.cisco.com/ast/soap/action/#LogCollectionPort#GetOneFile", soapEnvelopeGetOneFile(filePath));
204
218
  const boundary = extractBoundary(contentType);
219
+ if (!boundary) {
220
+ // Some environments respond with a non-multipart body. Prefer treating non-XML bodies as raw file bytes.
221
+ const ct = (contentType || "").toLowerCase();
222
+ if (ct.includes("text/xml") || ct.includes("application/soap+xml")) {
223
+ // Best-effort fault detection
224
+ const asText = bytes.toString("utf8");
225
+ if (/\bFault\b/i.test(asText))
226
+ throw new Error(`CUCM DIME GetOneFile returned SOAP fault: ${asText}`);
227
+ // If it's XML but not a fault, still return raw bytes.
228
+ }
229
+ return { server: target.host, filename: filePath, data: bytes };
230
+ }
205
231
  const parts = parseMultipartRelated(bytes, boundary);
206
232
  if (parts.length === 0)
207
233
  throw new Error("DIME GetOneFile returned no multipart parts");
208
- const nonXml = parts.find((p) => p.contentType.toLowerCase() !== "text/xml");
234
+ const nonXml = parts.find((p) => !isXmlContentType(p.contentType));
209
235
  if (!nonXml)
210
236
  throw new Error("DIME GetOneFile response missing non-XML file part");
211
237
  return { server: target.host, filename: filePath, data: nonXml.body };
212
238
  }
239
+ function isDimeMissingFileError(e) {
240
+ const msg = e instanceof Error ? e.message : String(e || "");
241
+ // CUCM DIME commonly responds with HTTP 500 and a SOAP fault like:
242
+ // "FileName ... do not exist"
243
+ return /DIME HTTP 500/i.test(msg) && /do not exist/i.test(msg);
244
+ }
245
+ export async function getOneFileWithRetry(hostOrUrl, filePath, opts) {
246
+ const timeoutMs = Math.max(1000, opts?.timeoutMs ?? 120_000);
247
+ const pollIntervalMs = Math.max(250, opts?.pollIntervalMs ?? 2000);
248
+ const start = Date.now();
249
+ let attempts = 0;
250
+ // Simple fixed-interval poll; CUCM can take time to flush capture files to disk.
251
+ while (true) {
252
+ attempts++;
253
+ try {
254
+ const r = await getOneFile(hostOrUrl, filePath, opts?.auth, opts?.port);
255
+ return { ...r, attempts, waitedMs: Date.now() - start };
256
+ }
257
+ catch (e) {
258
+ const elapsed = Date.now() - start;
259
+ if (!isDimeMissingFileError(e) || elapsed >= timeoutMs)
260
+ throw e;
261
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
262
+ }
263
+ }
264
+ }
265
+ export async function getOneFileAnyWithRetry(hostOrUrl, filePaths, opts) {
266
+ const paths = (filePaths || []).map(String).filter((p) => p.trim() !== "");
267
+ if (paths.length === 0)
268
+ throw new Error("getOneFileAnyWithRetry requires at least one filePath");
269
+ const timeoutMs = Math.max(1000, opts?.timeoutMs ?? 120_000);
270
+ const pollIntervalMs = Math.max(250, opts?.pollIntervalMs ?? 2000);
271
+ const start = Date.now();
272
+ let attempts = 0;
273
+ while (true) {
274
+ attempts++;
275
+ for (const p of paths) {
276
+ try {
277
+ const r = await getOneFile(hostOrUrl, p, opts?.auth, opts?.port);
278
+ return { ...r, attempts, waitedMs: Date.now() - start };
279
+ }
280
+ catch (e) {
281
+ if (!isDimeMissingFileError(e))
282
+ throw e;
283
+ }
284
+ }
285
+ const elapsed = Date.now() - start;
286
+ if (elapsed >= timeoutMs) {
287
+ // If timeout, throw the same missing-file style error for the first candidate.
288
+ throw new Error(`DIME HTTP 500: FileName ${paths[0]} do not exist (timed out after ${elapsed}ms)`);
289
+ }
290
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
291
+ }
292
+ }
213
293
  export function writeDownloadedFile(result, outFile) {
214
294
  const baseName = result.filename.split("/").filter(Boolean).pop() || "cucm-file.bin";
215
295
  const defaultDir = join("/tmp", "cucm-mcp");
package/dist/index.js CHANGED
@@ -2,16 +2,17 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
- import { listNodeServiceLogs, selectLogs, selectLogsMinutes, getOneFile, writeDownloadedFile, } from "./dime.js";
5
+ import { listNodeServiceLogs, selectLogs, selectLogsMinutes, getOneFile, getOneFileAnyWithRetry, writeDownloadedFile, } from "./dime.js";
6
6
  import { guessTimezoneString } from "./time.js";
7
7
  import { PacketCaptureManager } from "./packetCapture.js";
8
8
  // Default to accepting self-signed/invalid certs (common on CUCM lab/dev).
9
9
  // Opt back into strict verification with CUCM_MCP_TLS_MODE=strict.
10
10
  const tlsMode = (process.env.CUCM_MCP_TLS_MODE || process.env.MCP_TLS_MODE || "").toLowerCase();
11
11
  const strictTls = tlsMode === "strict" || tlsMode === "verify";
12
- if (!strictTls && !process.env.NODE_TLS_REJECT_UNAUTHORIZED) {
12
+ // Default: permissive TLS (accept self-signed). This is the common CUCM lab posture.
13
+ // Set CUCM_MCP_TLS_MODE=strict to enforce verification.
14
+ if (!strictTls)
13
15
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
14
- }
15
16
  const server = new McpServer({ name: "cucm", version: "0.1.0" });
16
17
  const captures = new PacketCaptureManager();
17
18
  const dimeAuthSchema = z
@@ -124,8 +125,9 @@ server.tool("packet_capture_start", "Start a packet capture on CUCM via SSH (uti
124
125
  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
126
  server.tool("packet_capture_stop", "Stop a packet capture by captureId (sends Ctrl-C).", {
126
127
  captureId: z.string().min(1),
127
- }, async ({ captureId }) => {
128
- const result = await captures.stop(captureId);
128
+ timeoutMs: z.number().int().min(1000).max(10 * 60_000).optional().describe("How long to wait for stop (default ~90s)"),
129
+ }, async ({ captureId, timeoutMs }) => {
130
+ const result = await captures.stop(captureId, timeoutMs);
129
131
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
130
132
  });
131
133
  server.tool("packet_capture_stop_and_download", "Stop a packet capture and download the resulting .cap file via DIME.", {
@@ -133,9 +135,32 @@ server.tool("packet_capture_stop_and_download", "Stop a packet capture and downl
133
135
  dimePort: z.number().int().min(1).max(65535).optional(),
134
136
  auth: dimeAuthSchema.describe("DIME auth (optional; defaults to CUCM_DIME_USERNAME/CUCM_DIME_PASSWORD)"),
135
137
  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);
138
+ stopTimeoutMs: z.number().int().min(1000).max(10 * 60_000).optional().describe("How long to wait for SSH capture stop"),
139
+ downloadTimeoutMs: z
140
+ .number()
141
+ .int()
142
+ .min(1000)
143
+ .max(10 * 60_000)
144
+ .optional()
145
+ .describe("How long to wait for the capture file to appear in DIME"),
146
+ downloadPollIntervalMs: z
147
+ .number()
148
+ .int()
149
+ .min(250)
150
+ .max(30_000)
151
+ .optional()
152
+ .describe("How often to retry DIME GetOneFile when the file isn't there yet"),
153
+ }, async ({ captureId, dimePort, auth, outFile, stopTimeoutMs, downloadTimeoutMs, downloadPollIntervalMs }) => {
154
+ const stopped = await captures.stop(captureId, stopTimeoutMs);
155
+ const candidates = (stopped.remoteFileCandidates || []).length
156
+ ? stopped.remoteFileCandidates
157
+ : [stopped.remoteFilePath];
158
+ const dl = await getOneFileAnyWithRetry(stopped.host, candidates, {
159
+ auth: auth,
160
+ port: dimePort,
161
+ timeoutMs: downloadTimeoutMs,
162
+ pollIntervalMs: downloadPollIntervalMs,
163
+ });
139
164
  const saved = writeDownloadedFile(dl, outFile);
140
165
  return {
141
166
  content: [
@@ -144,9 +169,12 @@ server.tool("packet_capture_stop_and_download", "Stop a packet capture and downl
144
169
  text: JSON.stringify({
145
170
  captureId: stopped.id,
146
171
  host: stopped.host,
147
- remoteFilePath: stopped.remoteFilePath,
172
+ remoteFilePath: dl.filename,
173
+ stopTimedOut: stopped.stopTimedOut || false,
148
174
  savedPath: saved.filePath,
149
175
  bytes: saved.bytes,
176
+ dimeAttempts: dl.attempts,
177
+ dimeWaitedMs: dl.waitedMs,
150
178
  }, null, 2),
151
179
  },
152
180
  ],
@@ -1,5 +1,25 @@
1
1
  import crypto from "node:crypto";
2
2
  import { Client } from "ssh2";
3
+ function extractCapFilePath(text) {
4
+ if (!text)
5
+ return undefined;
6
+ // CUCM typically writes captures to /var/log/active/platform/cli/<name>.cap
7
+ // Sometimes CLI output includes the final on-disk location.
8
+ const re = /\/var\/log\/active\/platform\/cli\/[A-Za-z0-9._-]+\.cap/g;
9
+ const matches = text.match(re);
10
+ if (!matches || matches.length === 0)
11
+ return undefined;
12
+ return matches[matches.length - 1];
13
+ }
14
+ function looksLikeCucmPrompt(text) {
15
+ const t = String(text || "");
16
+ // CUCM CLI commonly ends commands by printing a prompt like:
17
+ // admin:
18
+ // Some environments might show other usernames.
19
+ // We only look at the tail to avoid false positives.
20
+ const tail = t.slice(-80);
21
+ return /(?:^|\n)[A-Za-z0-9_-]+:\s*$/.test(tail);
22
+ }
3
23
  export function resolveSshAuth(auth) {
4
24
  const username = auth?.username || process.env.CUCM_SSH_USERNAME;
5
25
  const password = auth?.password || process.env.CUCM_SSH_PASSWORD;
@@ -58,6 +78,17 @@ export function remoteCapturePath(fileBase) {
58
78
  const fb = sanitizeFileBase(fileBase);
59
79
  return `/var/log/active/platform/cli/${fb}.cap`;
60
80
  }
81
+ export function remoteCaptureCandidates(fileBase, maxParts = 10) {
82
+ const fb = sanitizeFileBase(fileBase);
83
+ const base = `/var/log/active/platform/cli/${fb}.cap`;
84
+ const out = [base];
85
+ // Some CUCM/VOS versions roll packet capture files as .cap01, .cap02, ...
86
+ for (let i = 1; i <= maxParts; i++) {
87
+ const suffix = String(i).padStart(2, "0");
88
+ out.push(`/var/log/active/platform/cli/${fb}.cap${suffix}`);
89
+ }
90
+ return out;
91
+ }
61
92
  export class PacketCaptureManager {
62
93
  active = new Map();
63
94
  list() {
@@ -81,6 +112,7 @@ export class PacketCaptureManager {
81
112
  iface,
82
113
  fileBase,
83
114
  remoteFilePath: remoteCapturePath(fileBase),
115
+ remoteFileCandidates: remoteCaptureCandidates(fileBase),
84
116
  };
85
117
  await new Promise((resolve, reject) => {
86
118
  client
@@ -121,33 +153,123 @@ export class PacketCaptureManager {
121
153
  this.active.set(id, { session, client, channel });
122
154
  return session;
123
155
  }
124
- async stop(captureId, timeoutMs = 15000) {
156
+ async stop(captureId, timeoutMs = 90_000) {
125
157
  const a = this.active.get(captureId);
126
158
  if (!a)
127
159
  throw new Error(`Unknown captureId: ${captureId}`);
128
160
  const { channel, client, session } = a;
129
161
  const done = new Promise((resolve) => {
130
- channel.once("close", () => resolve());
162
+ // CUCM CLI can keep the SSH channel open after the command finishes
163
+ // (it returns to a prompt rather than exiting). Treat prompt as "stopped".
164
+ const onData = () => {
165
+ if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {
166
+ cleanup();
167
+ resolve();
168
+ }
169
+ };
170
+ const cleanup = () => {
171
+ channel.off("data", onData);
172
+ channel.stderr.off("data", onData);
173
+ };
174
+ const finish = () => {
175
+ cleanup();
176
+ resolve();
177
+ };
178
+ channel.once("exit", finish);
179
+ channel.once("close", finish);
180
+ channel.once("end", finish);
181
+ channel.on("data", onData);
182
+ channel.stderr.on("data", onData);
131
183
  });
132
- // Best-effort interrupt.
133
- try {
134
- if (typeof channel.signal === "function")
135
- channel.signal("INT");
136
- else
184
+ const sendInterrupt = () => {
185
+ // Best-effort interrupt.
186
+ try {
187
+ if (typeof channel.signal === "function")
188
+ channel.signal("INT");
189
+ // Always also write Ctrl-C for PTY sessions.
137
190
  channel.write("\x03");
191
+ }
192
+ catch {
193
+ try {
194
+ channel.write("\x03");
195
+ }
196
+ catch {
197
+ // ignore
198
+ }
199
+ }
200
+ };
201
+ // CUCM can take a while to flush/close captures (especially big buffers).
202
+ // Also, some CLI flows require a second Ctrl-C or a newline to return to prompt.
203
+ const resolvedTimeoutMs = Math.max(5000, timeoutMs || 0);
204
+ const deadline = Date.now() + resolvedTimeoutMs;
205
+ sendInterrupt();
206
+ // Nudge again a couple times if it doesn't exit quickly.
207
+ void (async () => {
208
+ const delays = [750, 1500, 3000];
209
+ for (const d of delays) {
210
+ await new Promise((r) => setTimeout(r, d));
211
+ if (Date.now() >= deadline)
212
+ return;
213
+ // If channel already closed, no-op.
214
+ sendInterrupt();
215
+ try {
216
+ channel.write("\n");
217
+ }
218
+ catch {
219
+ // ignore
220
+ }
221
+ }
222
+ })();
223
+ try {
224
+ await Promise.race([
225
+ done,
226
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout waiting for capture to stop")), resolvedTimeoutMs)),
227
+ ]);
138
228
  }
139
- catch {
229
+ catch (e) {
230
+ // Don't hard-fail: if the CLI doesn't emit a prompt/exit, we still want to:
231
+ // - close SSH resources
232
+ // - let the caller try DIME downloads (.cap, .cap01, etc)
233
+ session.stopTimedOut = true;
140
234
  try {
141
- channel.write("\x03");
235
+ channel.close();
142
236
  }
143
237
  catch {
144
238
  // ignore
145
239
  }
146
240
  }
147
- await Promise.race([
148
- done,
149
- new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout waiting for capture to stop")), timeoutMs)),
150
- ]);
241
+ session.stoppedAt = new Date().toISOString();
242
+ // If we're back at a CUCM prompt, try to close the session cleanly.
243
+ if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {
244
+ try {
245
+ channel.write("exit\n");
246
+ }
247
+ catch {
248
+ // ignore
249
+ }
250
+ }
251
+ // Ensure the channel is closed so the SSH client can terminate promptly.
252
+ try {
253
+ channel.end();
254
+ }
255
+ catch {
256
+ // ignore
257
+ }
258
+ try {
259
+ channel.close();
260
+ }
261
+ catch {
262
+ // ignore
263
+ }
264
+ // Attempt to learn the actual remote file path from CLI output.
265
+ // This helps when CUCM appends suffixes or reports a different on-disk location.
266
+ const inferred = extractCapFilePath(session.lastStdout) || extractCapFilePath(session.lastStderr);
267
+ if (inferred) {
268
+ session.remoteFilePath = inferred;
269
+ if (!session.remoteFileCandidates.includes(inferred)) {
270
+ session.remoteFileCandidates = [inferred, ...session.remoteFileCandidates];
271
+ }
272
+ }
151
273
  // stop() implies cleanup
152
274
  this.active.delete(captureId);
153
275
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/cucm-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "MCP server for CUCM tooling (DIME logs, syslog, packet capture)",
@@ -10,7 +10,10 @@
10
10
  "url": "git+https://github.com/calltelemetry/cucm-mcp.git"
11
11
  },
12
12
  "bin": {
13
- "cucm-mcp": "./dist/index.js"
13
+ "cucm-mcp": "dist/index.js"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
14
17
  },
15
18
  "files": [
16
19
  "dist/",
@@ -22,7 +25,7 @@
22
25
  },
23
26
  "scripts": {
24
27
  "start": "tsx src/index.ts",
25
- "build": "tsc",
28
+ "build": "tsc && node scripts/chmod-bin.mjs",
26
29
  "typecheck": "tsc --noEmit",
27
30
  "test": "yarn build && node --test test/*.test.js",
28
31
  "prepack": "yarn test"