@owine/unifi-network-mcp 3.1.0 → 3.2.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 CHANGED
@@ -76,9 +76,10 @@ claude mcp add-json unifi-network '{"command":"node","args":["/path/to/unifi-net
76
76
  | `UNIFI_NETWORK_WRITE_TOOLS` | No | all write tools | Comma-separated write tool names and/or groups (`devices`, `clients`, `networks`, `wifi`, `hotspot`, `firewall`, `acl`, `dns-policies`, `traffic-matching`) to load; `none` or empty loads none. Unknown names stop the server. Ignored, with a warning, while read-only (see [Limiting write mode](#limiting-write-mode)) |
77
77
  | `UNIFI_NETWORK_ALLOW_WAN_EXPOSURE` | No | `false` | Set to `true` to stop refusing writes that could let traffic in from the WAN; they then only need `confirm: true` |
78
78
  | `UNIFI_NETWORK_WAN_ZONE_IDS` | No | — | Comma-separated firewall zone IDs (UUIDs) to treat as the WAN, in addition to the system-defined zone named `External`. Recommended when writes are on |
79
+ | `UNIFI_NETWORK_STARTUP_CHECK` | No | `true` | At startup the server reads the console's Network version and exits if that fails, so the MCP host shows it as failed rather than connected. Set to `false` to start without the check, for example while the console is down |
79
80
  | `UNIFI_NETWORK_WRITE_LOG` | No | — | Absolute path of a JSON Lines file that every write tool call is appended to. Created `0600` (parent directories `0700`); the server will not start if it is a symlink or writable by others. Ignored, with a warning, while read-only |
80
81
 
81
- UniFi consoles ship with a self-signed certificate, so against a local console the first call usually fails with a certificate error. The safe fix is to trust that one certificate: save it and point `UNIFI_NETWORK_CA_CERT` at the file.
82
+ UniFi consoles ship with a self-signed certificate, so against a local console the server usually fails to start with a certificate error. The safe fix is to trust that one certificate: save it and point `UNIFI_NETWORK_CA_CERT` at the file.
82
83
 
83
84
  ```bash
84
85
  openssl s_client -connect 192.168.1.1:443 </dev/null 2>/dev/null | openssl x509 > ~/.config/unifi-console.pem
@@ -396,7 +397,7 @@ Remote Access and console (UniFi OS) SSH are UniFi OS settings outside this API,
396
397
 
397
398
  ## Troubleshooting
398
399
 
399
- Configuration errors go to the MCP host's server log and the server exits. Tool errors reach the agent as results starting with `Error:`.
400
+ Configuration errors, and a console the server cannot use at startup, go to the MCP host's server log and the server exits; the host lists it as failed (in Claude Code, `/mcp`). Tool errors reach the agent as results starting with `Error:`.
400
401
 
401
402
  | Message | Cause and fix |
402
403
  |---|---|
@@ -405,11 +406,13 @@ Configuration errors go to the MCP host's server log and the server exits. Tool
405
406
  | `UNIFI_NETWORK_CA_CERT must name a PEM file containing a certificate` | The file has no `BEGIN CERTIFICATE` block; re-run the `openssl` command above. |
406
407
  | `…cannot be combined with UNIFI_NETWORK_VERIFY_SSL=false` or `TLS verification cannot be disabled for api.ui.com` | Unset `UNIFI_NETWORK_VERIFY_SSL`. |
407
408
  | `Ignoring UNIFI_NETWORK_CONSOLE_ID` (log line) | The console ID only applies with `UNIFI_NETWORK_HOST=api.ui.com`. |
409
+ | `Cannot connect to the UniFi console at …` (log line) | The startup check could not read the console; the rest of the line is one of the errors below, or `the host answered, but not with UniFi Network application info` when something other than the console answered at `UNIFI_NETWORK_HOST`. Fix it and restart the server, or set `UNIFI_NETWORK_STARTUP_CHECK=false` to start anyway. |
408
410
  | `fetch failed (DEPTH_ZERO_SELF_SIGNED_CERT)` or another certificate code | The console's certificate did not verify. Save it to `UNIFI_NETWORK_CA_CERT`; if you already did, the console has a new certificate, so save it again. |
409
411
  | `fetch failed (ECONNREFUSED)`, `(ENOTFOUND)` or a connect timeout | Wrong or unreachable host. `UNIFI_NETWORK_HOST` is a bare IP or hostname, without `https://`. |
410
412
  | `HTTP 401` / `HTTP 403` | The key is wrong or revoked, or its admin lacks the rights (a View Only key cannot write). |
411
413
  | `HTTP 404` | A wrong `siteId` or resource ID, or an endpoint this Network version lacks (check `unifi_get_info`). |
412
- | `UniFi controller did not respond within …` | The console is slow. For a write, check whether it was applied before retrying. |
414
+ | `The console is rate-limiting requests` (after `HTTP 429`, on any route) | The console refused the request as too frequent. Wait as long as the message says (or a little while, when it gives no delay) before retrying. |
415
+ | `the console did not respond within …` (startup) or `UniFi controller did not respond within …` | The console is slow, or something drops traffic to it (at startup, often a wrong IP). For a write, check whether it was applied before retrying. |
413
416
  | `This action requires explicit confirmation: …` | Expected: the change needs `confirm: true`, and the agent should ask you first. |
414
417
  | `Use an ID returned by a list or get tool` | The agent passed a name or malformed ID. |
415
418
  | `Request refused by the request guard: …` | The server would only send a request on its allowlist; this one was not, or belongs to a write tool `UNIFI_NETWORK_WRITE_TOOLS` does not enable. Nothing was sent. If a tool you did not modify hits this, please open an issue. |
@@ -419,8 +422,8 @@ Configuration errors go to the MCP host's server log and the server exits. Tool
419
422
  | A write tool is missing, or `Tool … not found` | `UNIFI_NETWORK_WRITE_TOOLS` does not name it or its group. |
420
423
  | `cannot use UNIFI_NETWORK_WRITE_LOG (…)` | The path is a symlink, not a regular file, hard-linked elsewhere, or writable by others (`chmod 600` it), or its directory cannot be created. |
421
424
  | No write tools listed | Read-only is the default. Set `UNIFI_NETWORK_READ_ONLY=false` (only `false` counts; any other value stays read-only) and restart the host. If it is set, check `UNIFI_NETWORK_WRITE_TOOLS` is not `none` or empty. |
422
- | `Controller history returned HTTP …`, `non-JSON data` or `an empty body` | This console or key cannot use the history routes (see [Retained client connections](#retained-client-connections)). It is not an empty history. |
423
- | `Classic controller API returned HTTP …`, `non-JSON data` or `malformed JSON` | This console or key cannot read the classic controller API (see [Classic controller reads](#classic-controller-reads)). It is not an empty result. |
425
+ | `Controller history returned HTTP …` (other than 429), `non-JSON data` or `an empty body` | This console or key cannot use the history routes (see [Retained client connections](#retained-client-connections)). It is not an empty history. |
426
+ | `Classic controller API returned HTTP …` (other than 429), `non-JSON data` or `malformed JSON` | This console or key cannot read the classic controller API (see [Classic controller reads](#classic-controller-reads)). It is not an empty result. |
424
427
  | `Use the site's internalReference …` | History, classic-API and exposure tools take `internalReference` (often `default`) from `unifi_list_sites`, not the UUID. |
425
428
 
426
429
  ## Development
package/dist/client.d.ts CHANGED
@@ -20,6 +20,13 @@ export declare class NetworkClient {
20
20
  /** Check a request against the guard before anything is sent, and note writes for the write log. */
21
21
  private guard;
22
22
  get(path: string): Promise<unknown>;
23
+ /**
24
+ * Read /info once, so a console that cannot be reached, fails TLS
25
+ * verification or rejects the key stops the server at startup, where the
26
+ * MCP host reports it, instead of the server looking connected and every
27
+ * tool call failing.
28
+ */
29
+ checkConnection(): Promise<void>;
23
30
  post(path: string, body?: unknown): Promise<unknown>;
24
31
  put(path: string, body: unknown): Promise<unknown>;
25
32
  patch(path: string, body: unknown): Promise<unknown>;
package/dist/client.js CHANGED
@@ -14,6 +14,9 @@ const write_log_js_1 = require("./write-log.js");
14
14
  // before the MCP host gives up on it.
15
15
  const REQUEST_TIMEOUT_MS = 30_000;
16
16
  const CONTROLLER_TIMEOUT_MS = 25_000;
17
+ // Short enough that a console that never answers fails startup well inside
18
+ // the time an MCP host waits for a server to come up.
19
+ const STARTUP_TIMEOUT_MS = 10_000;
17
20
  // What undici reports when the console closed the connection under a
18
21
  // request, typically a kept-alive socket it had already timed out.
19
22
  const SOCKET_CLOSED = ["UND_ERR_SOCKET", "ECONNRESET", "EPIPE"];
@@ -83,6 +86,33 @@ function describeErrorBody(text) {
83
86
  }
84
87
  return capped(text);
85
88
  }
89
+ /**
90
+ * What the model should do about a 429, or undefined for any other status.
91
+ * Neither the Integration API docs nor a burst against a UDM (11.1.47) showed
92
+ * a rate limit, so nothing retries here: the model is told how long to wait
93
+ * and decides for itself.
94
+ */
95
+ function rateLimitHint(response) {
96
+ if (response.status !== 429)
97
+ return undefined;
98
+ const wait = retryAfterSeconds(response.headers.get("retry-after"));
99
+ return `The console is rate-limiting requests; ${wait === undefined ? "wait before retrying" : `retry after ${String(wait)}s`}.`;
100
+ }
101
+ // A longer wait than this is no guidance for a model in a conversation.
102
+ const MAX_RETRY_AFTER_SECONDS = 86_400;
103
+ // RFC 9110 IMF-fixdate. Date.parse alone is too lenient: V8 reads "1.5" as a
104
+ // date in 2001, which would come out as "retry after 0s".
105
+ const HTTP_DATE = /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
106
+ /** Retry-After as whole seconds from now (delay-seconds or an HTTP date), or undefined if unusable. */
107
+ function retryAfterSeconds(value) {
108
+ const trimmed = value?.trim() ?? "";
109
+ const seconds = /^\d+$/.test(trimmed)
110
+ ? Number(trimmed)
111
+ : HTTP_DATE.test(trimmed)
112
+ ? Math.max(0, Math.ceil((Date.parse(trimmed) - Date.now()) / 1000))
113
+ : undefined;
114
+ return seconds !== undefined && seconds <= MAX_RETRY_AFTER_SECONDS ? seconds : undefined;
115
+ }
86
116
  function capped(text) {
87
117
  const collapsed = text.replace(/\s+/g, " ").trim();
88
118
  return collapsed.length > ERROR_BODY_LIMIT ? `${collapsed.slice(0, ERROR_BODY_LIMIT)}…` : collapsed;
@@ -157,7 +187,11 @@ class NetworkClient {
157
187
  if (method !== "GET")
158
188
  (0, write_log_js_1.noteWriteStatus)(response.status);
159
189
  if (!response.ok) {
160
- throw new Error(`HTTP ${response.status}: ${describeErrorBody(await response.text())}`);
190
+ const body = describeErrorBody(await response.text());
191
+ const hint = rateLimitHint(response);
192
+ // One period between the body and the hint, whatever the body ends with.
193
+ const detail = hint === undefined ? body : [body.replace(/[.!?\s]+$/, ""), hint].filter((part) => part).join(". ");
194
+ throw new Error(`HTTP ${String(response.status)}: ${detail}`);
161
195
  }
162
196
  const contentType = response.headers.get("content-type") ?? "";
163
197
  if (contentType.includes("application/json")) {
@@ -181,6 +215,30 @@ class NetworkClient {
181
215
  async get(path) {
182
216
  return this.request("GET", path);
183
217
  }
218
+ /**
219
+ * Read /info once, so a console that cannot be reached, fails TLS
220
+ * verification or rejects the key stops the server at startup, where the
221
+ * MCP host reports it, instead of the server looking connected and every
222
+ * tool call failing.
223
+ */
224
+ async checkConnection() {
225
+ const timeout = AbortSignal.timeout(STARTUP_TIMEOUT_MS);
226
+ let info;
227
+ try {
228
+ info = await withCallSignal(timeout, () => this.get("/info"));
229
+ }
230
+ catch (err) {
231
+ if (timeout.aborted) {
232
+ throw new Error(`the console did not respond within ${STARTUP_TIMEOUT_MS / 1000}s`, { cause: err });
233
+ }
234
+ throw err;
235
+ }
236
+ // Any 2xx passes send(), so a proxy or another web server at the host
237
+ // would otherwise pass too, and every tool call would then fail.
238
+ if (!info || typeof info !== "object" || !("applicationVersion" in info) || typeof info.applicationVersion !== "string") {
239
+ throw new Error("the host answered, but not with UniFi Network application info; check UNIFI_NETWORK_HOST names the console");
240
+ }
241
+ }
184
242
  async post(path, body) {
185
243
  return this.request("POST", path, body);
186
244
  }
@@ -261,7 +319,9 @@ class NetworkClient {
261
319
  // Drain before throwing: an unread body pins the socket until GC, so a
262
320
  // run of failures (say, a mis-scoped API key) would leak connections.
263
321
  await response.body?.cancel();
264
- throw new Error(`${kind.name} returned HTTP ${response.status}; verify API-key access and controller support. ${kind.notEmpty}`);
322
+ const hint = rateLimitHint(response);
323
+ const advice = hint === undefined ? "; verify API-key access and controller support." : `. ${hint}`;
324
+ throw new Error(`${kind.name} returned HTTP ${String(response.status)}${advice} ${kind.notEmpty}`);
265
325
  }
266
326
  if (!response.headers.get("content-type")?.includes("application/json")) {
267
327
  await response.body?.cancel();
package/dist/config.d.ts CHANGED
@@ -11,6 +11,7 @@ declare const configSchema: z.ZodObject<{
11
11
  consoleId: z.ZodOptional<z.ZodString>;
12
12
  verifySsl: z.ZodDefault<z.ZodBoolean>;
13
13
  readOnly: z.ZodDefault<z.ZodBoolean>;
14
+ skipStartupCheck: z.ZodOptional<z.ZodLiteral<true>>;
14
15
  writeTools: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string[], string>>>;
15
16
  allowWanExposure: z.ZodOptional<z.ZodLiteral<true>>;
16
17
  wanZoneIds: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodString>, z.ZodTransform<string[], string[]>>>;
package/dist/config.js CHANGED
@@ -46,6 +46,10 @@ const configSchema = zod_1.z.object({
46
46
  .boolean()
47
47
  .default(true)
48
48
  .describe("When true, only read-only tools are registered"),
49
+ skipStartupCheck: zod_1.z
50
+ .literal(true)
51
+ .optional()
52
+ .describe("When true, the server starts without first reading /info to check the console can be used"),
49
53
  writeTools: zod_1.z.string()
50
54
  .transform((value, ctx) => {
51
55
  const { tools, unknown } = (0, write_tools_js_1.parseWriteTools)(value);
@@ -106,6 +110,8 @@ function loadConfig() {
106
110
  ? { consoleId: process.env.UNIFI_NETWORK_CONSOLE_ID } : {}),
107
111
  verifySsl: process.env.UNIFI_NETWORK_VERIFY_SSL?.toLowerCase() !== "false",
108
112
  readOnly: process.env.UNIFI_NETWORK_READ_ONLY?.toLowerCase() !== "false",
113
+ ...(process.env.UNIFI_NETWORK_STARTUP_CHECK?.toLowerCase() === "false"
114
+ ? { skipStartupCheck: true } : {}),
109
115
  ...(caCert !== undefined ? { caCert } : {}),
110
116
  ...(process.env.UNIFI_NETWORK_ALLOW_WAN_EXPOSURE?.toLowerCase() === "true"
111
117
  ? { allowWanExposure: true } : {}),
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ const client_js_1 = require("./client.js");
9
9
  const index_js_1 = require("./tools/index.js");
10
10
  const instructions_js_1 = require("./instructions.js");
11
11
  const write_log_js_1 = require("./write-log.js");
12
+ const responses_js_1 = require("./utils/responses.js");
12
13
  // Read at runtime rather than importing: package.json sits outside rootDir,
13
14
  // so a static import would emit dist/src/ and break the published layout.
14
15
  // It reads 0.0.0-development in the repo; the release workflow stamps the
@@ -18,6 +19,20 @@ async function main() {
18
19
  const config = (0, config_js_1.loadConfig)();
19
20
  const writeLog = config.writeLog === undefined ? undefined : (0, write_log_js_1.openWriteLog)(config.writeLog, config.apiKey);
20
21
  const client = new client_js_1.NetworkClient(config);
22
+ // Before the transport connects: exiting now makes the MCP host report the
23
+ // server as failed, with this message in its log, rather than connected.
24
+ if (config.skipStartupCheck !== true) {
25
+ try {
26
+ await client.checkConnection();
27
+ }
28
+ catch (err) {
29
+ // Exit from the write's callback: stderr to a pipe is asynchronous on
30
+ // macOS, and exiting first could drop the only explanation the host logs.
31
+ process.stderr.write(`Cannot connect to the UniFi console at ${config.host}: ${(0, responses_js_1.describeError)(err)}. ` +
32
+ "Fix the setting the error names and restart, or set UNIFI_NETWORK_STARTUP_CHECK=false to start without this check.\n", () => process.exit(1));
33
+ return;
34
+ }
35
+ }
21
36
  const server = new mcp_js_1.McpServer({ name: "unifi-network", title: "UniFi Network", version }, { instructions: (0, instructions_js_1.serverInstructions)(config.readOnly, { writeTools: config.writeTools, allowWanExposure: config.allowWanExposure === true }) });
22
37
  (0, index_js_1.registerAllTools)(server, client, config.readOnly, {
23
38
  writeTools: config.writeTools,
package/dist/tools/acl.js CHANGED
@@ -299,12 +299,11 @@ function registerAclTools(server, client, readOnly = false) {
299
299
  const body = { orderedAclRuleIds };
300
300
  const current = (0, full_put_js_1.asObject)(await client.get(path), "the current ACL rule ordering");
301
301
  const before = Array.isArray(current.orderedAclRuleIds) ? current.orderedAclRuleIds.filter((id) => typeof id === "string") : [];
302
- const opens = await (0, safety_js_1.reorderOpens)(before, orderedAclRuleIds, async (id) => {
303
- const rule = (0, full_put_js_1.asObject)(await client.get(`/sites/${siteId}/acl-rules/${id}`), "an ACL rule");
302
+ const opens = await (0, safety_js_1.reorderOpens)(before, orderedAclRuleIds, () => (0, safety_js_1.listedEffects)(client, `/sites/${siteId}/acl-rules`, (rule) => {
304
303
  // A disabled rule neither lets anything through nor blocks it.
305
304
  const active = rule.enabled !== false;
306
305
  return { letsThrough: active && rule.action === "ALLOW", blocks: active && rule.action === "BLOCK" };
307
- });
306
+ }));
308
307
  const opening = opens ? "This moves an ALLOW rule ahead of a BLOCK rule it used to follow" : undefined;
309
308
  const guard = (0, safety_js_1.requireConfirmationIf)(opening, confirm, dryRun);
310
309
  if (guard)
@@ -42,7 +42,7 @@ function registerDeviceTools(server, client, readOnly = false) {
42
42
  }
43
43
  });
44
44
  server.registerTool("unifi_get_device", {
45
- description: "Get full configuration for a device. Returns (in addition to list fields): adoptedAt, provisionedAt, configurationId, uplink.deviceId (absent on the ISP modem), features (object keyed by capability: switching {lags[]} / accessPoint {}), interfaces.ports[] for switches, gateways and modems ({idx, state, connector, maxSpeedMbps, speedMbps (may be omitted on DOWN ports), poe:{standard, type, enabled, state} on PoE ports only}), interfaces.radios[] for APs ({wlanStandard, frequencyGHz, channelWidthMHz, channel}). Some devices (e.g. 5G modems) return empty features/interfaces objects. NOTE: in the LIST endpoint, features/interfaces are capability-tag string arrays instead. Use for: switch port layout/PoE state, AP radio config, uplink topology. For live throughput/CPU/memory, use unifi_get_device_statistics.",
45
+ description: "Get full configuration for a device. Returns (in addition to list fields): adoptedAt, provisionedAt, configurationId, uplink.deviceId (absent on the ISP modem), features (object keyed by capability: switching {lags[]} / accessPoint {}), interfaces.ports[] for switches, gateways and modems ({idx, state, connector, maxSpeedMbps, speedMbps (may be omitted on DOWN ports), poe:{standard, type, enabled, state} on PoE ports only}), interfaces.radios[] for APs ({wlanStandard, frequencyGHz, txPowerMode (e.g. MEDIUM; 11.1.47 and later), channelWidthMHz, channel}). Some devices (e.g. 5G modems) return empty features/interfaces objects. NOTE: in the LIST endpoint, features/interfaces are capability-tag string arrays instead. Use for: switch port layout/PoE state, AP radio config, uplink topology. For live throughput/CPU/memory, use unifi_get_device_statistics.",
46
46
  inputSchema: {
47
47
  siteId: (0, params_js_1.resourceId)().describe("Site ID"),
48
48
  deviceId: (0, params_js_1.resourceId)().describe("Device ID"),
@@ -497,14 +497,14 @@ function registerFirewallTools(server, client, readOnly = false, wan = wan_expos
497
497
  const path = `/sites/${siteId}/firewall/policies/ordering${query}`;
498
498
  const body = { orderedFirewallPolicyIds };
499
499
  const current = (0, full_put_js_1.asObject)(await client.get(path), "the current firewall policy ordering");
500
- const opens = await (0, safety_js_1.reorderOpens)(evaluationOrder(current.orderedFirewallPolicyIds), evaluationOrder(orderedFirewallPolicyIds), async (id) => {
501
- // The system-defined policies are not listed, so their actions are
502
- // unknown: moving anything across them counts both ways.
503
- if (id === SYSTEM_DEFINED)
504
- return { letsThrough: true, blocks: true };
505
- const policy = (0, full_put_js_1.asObject)(await client.get(`/sites/${siteId}/firewall/policies/${id}`), "a firewall policy");
506
- const active = policy.enabled !== false;
507
- return { letsThrough: active && letsThrough(policy), blocks: active && !letsThrough(policy) };
500
+ const opens = await (0, safety_js_1.reorderOpens)(evaluationOrder(current.orderedFirewallPolicyIds), evaluationOrder(orderedFirewallPolicyIds), async () => {
501
+ const effects = await (0, safety_js_1.listedEffects)(client, `/sites/${siteId}/firewall/policies`, (policy) => {
502
+ const active = policy.enabled !== false;
503
+ return { letsThrough: active && letsThrough(policy), blocks: active && !letsThrough(policy) };
504
+ });
505
+ // The system-defined policies are not in the ordering by ID, so their
506
+ // actions are unknown: moving anything across them counts both ways.
507
+ return effects.set(SYSTEM_DEFINED, { letsThrough: true, blocks: true });
508
508
  });
509
509
  const opening = opens ? "This moves a firewall policy that lets traffic through ahead of one that blocks it" : undefined;
510
510
  const guard = (0, safety_js_1.requireConfirmationIf)(opening, confirm, dryRun);
@@ -0,0 +1,4 @@
1
+ import { z } from "zod";
2
+ import type { NetworkClient } from "../client.js";
3
+ /** Every item of a paged Integration API list, read page by page. */
4
+ export declare function listAll<T extends z.ZodType>(client: NetworkClient, path: string, item: T): Promise<z.infer<T>[]>;
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listAll = listAll;
4
+ const zod_1 = require("zod");
5
+ const PAGE_LIMIT = 200;
6
+ const MAX_PAGES = 50;
7
+ /** Every item of a paged Integration API list, read page by page. */
8
+ async function listAll(client, path, item) {
9
+ const page = zod_1.z.object({ data: zod_1.z.array(item), totalCount: zod_1.z.number().optional() }).loose();
10
+ const items = [];
11
+ for (let pages = 0; pages < MAX_PAGES; pages++) {
12
+ const { data, totalCount } = page.parse(await client.get(`${path}?offset=${items.length}&limit=${PAGE_LIMIT}`));
13
+ items.push(...data);
14
+ // Without a totalCount, only an empty page shows the list has ended: a
15
+ // controller may cap pages below PAGE_LIMIT, so a short page proves nothing.
16
+ if (data.length === 0 || (totalCount !== undefined && items.length >= totalCount))
17
+ return items;
18
+ }
19
+ // A list that does not end cannot be checked, which must not pass.
20
+ throw new Error(`the list did not end after ${MAX_PAGES} pages`);
21
+ }
@@ -482,6 +482,7 @@ export declare const getDeviceOutputSchema: {
482
482
  radios: z.ZodOptional<z.ZodArray<z.ZodObject<{
483
483
  wlanStandard: z.ZodOptional<z.ZodString>;
484
484
  frequencyGHz: z.ZodOptional<z.ZodNumber>;
485
+ txPowerMode: z.ZodOptional<z.ZodString>;
485
486
  channelWidthMHz: z.ZodOptional<z.ZodNumber>;
486
487
  channel: z.ZodOptional<z.ZodNumber>;
487
488
  }, z.core.$loose>>>;
@@ -75,6 +75,8 @@ const DeviceRadio = zod_1.z
75
75
  .object({
76
76
  wlanStandard: zod_1.z.string().optional(),
77
77
  frequencyGHz: zod_1.z.number().optional(),
78
+ // Undocumented; live-verified on 11.1.47 (e.g. "MEDIUM"), absent on 11.0.83.
79
+ txPowerMode: zod_1.z.string().optional(),
78
80
  channelWidthMHz: zod_1.z.number().optional(),
79
81
  channel: zod_1.z.number().optional(),
80
82
  })
@@ -9,6 +9,12 @@ export declare function formatSuccess(data: unknown, opts?: {
9
9
  };
10
10
  /** The message of a thrown value, which need not be an Error. */
11
11
  export declare function errorMessage(err: unknown): string;
12
+ /**
13
+ * The message of a failed request, with the reason fetch keeps in cause: fetch
14
+ * reports every network failure as "fetch failed" and keeps the reason
15
+ * (ECONNREFUSED, ENOTFOUND, a certificate error) there.
16
+ */
17
+ export declare function describeError(err: unknown): string;
12
18
  export declare function formatError(err: unknown): {
13
19
  content: {
14
20
  type: "text";
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatSuccess = formatSuccess;
4
4
  exports.errorMessage = errorMessage;
5
+ exports.describeError = describeError;
5
6
  exports.formatError = formatError;
6
7
  function formatSuccess(data, opts) {
7
8
  const result = {
@@ -19,18 +20,24 @@ function formatSuccess(data, opts) {
19
20
  function errorMessage(err) {
20
21
  return err instanceof Error ? err.message : String(err);
21
22
  }
22
- function formatError(err) {
23
+ /**
24
+ * The message of a failed request, with the reason fetch keeps in cause: fetch
25
+ * reports every network failure as "fetch failed" and keeps the reason
26
+ * (ECONNREFUSED, ENOTFOUND, a certificate error) there.
27
+ */
28
+ function describeError(err) {
23
29
  let message = errorMessage(err);
24
- // fetch reports every network failure as "fetch failed" and keeps the reason
25
- // (ECONNREFUSED, ENOTFOUND, a certificate error) in cause.
26
30
  const cause = err instanceof Error ? err.cause : undefined;
27
31
  const code = cause instanceof Error && "code" in cause && typeof cause.code === "string" ? cause.code : undefined;
28
32
  if (code) {
29
33
  message += ` (${code})`;
30
34
  message += tlsHint(code);
31
35
  }
36
+ return message;
37
+ }
38
+ function formatError(err) {
32
39
  return {
33
- content: [{ type: "text", text: `Error: ${message}` }],
40
+ content: [{ type: "text", text: `Error: ${describeError(err)}` }],
34
41
  isError: true,
35
42
  };
36
43
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
3
+ import type { NetworkClient } from "../client.js";
3
4
  /** The confirm input of a DESTRUCTIVE tool. */
4
5
  export declare const confirmDestructive: () => z.ZodOptional<z.ZodBoolean>;
5
6
  export declare const READ_ONLY: ToolAnnotations;
@@ -36,13 +37,28 @@ export interface RuleEffect {
36
37
  letsThrough: boolean;
37
38
  blocks: boolean;
38
39
  }
40
+ /**
41
+ * A firewall policy or ACL rule as a list returns it, typed only as far as a
42
+ * reorder check reads it. An entry without an ID cannot be in an ordering, so
43
+ * it is skipped rather than failing the check.
44
+ */
45
+ declare const ListedRule: z.ZodObject<{
46
+ id: z.ZodOptional<z.ZodString>;
47
+ enabled: z.ZodOptional<z.ZodUnknown>;
48
+ action: z.ZodOptional<z.ZodUnknown>;
49
+ }, z.core.$loose>;
50
+ /** Every rule of a list by ID, with its effect, for reorderOpens. */
51
+ export declare function listedEffects(client: NetworkClient, path: string, effectOf: (rule: z.infer<typeof ListedRule>) => RuleEffect): Promise<Map<string, RuleEffect>>;
39
52
  /**
40
53
  * Whether a reorder opens access: whether it moves a rule that lets traffic
41
54
  * through ahead of one that blocks, which it used to follow. Only rules in
42
- * both orders are compared, and effect is asked only about the rules a
43
- * reorder actually moves past one another. IDs are compared without case.
55
+ * both orders are compared. effects reads every rule's effect at once (one
56
+ * list read, not a GET per rule, which a large reshuffle would turn into a
57
+ * burst of hundreds), and is called only when the reorder moves rules past
58
+ * one another. A moved rule it does not return (deleted since the ordering
59
+ * was read) is refused rather than guessed. IDs are compared without case.
44
60
  */
45
- export declare function reorderOpens(before: readonly string[], after: readonly string[], effect: (id: string) => Promise<RuleEffect>): Promise<boolean>;
61
+ export declare function reorderOpens(before: readonly string[], after: readonly string[], effects: () => Promise<ReadonlyMap<string, RuleEffect>>): Promise<boolean>;
46
62
  export declare function requireConfirmation(confirm: boolean | undefined, action: string): {
47
63
  content: {
48
64
  type: "text";
@@ -50,3 +66,4 @@ export declare function requireConfirmation(confirm: boolean | undefined, action
50
66
  }[];
51
67
  isError: true;
52
68
  } | null;
69
+ export {};
@@ -3,10 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DESTRUCTIVE = exports.WRITE_NOT_IDEMPOTENT = exports.WRITE = exports.READ_ONLY = exports.confirmDestructive = void 0;
4
4
  exports.formatDryRun = formatDryRun;
5
5
  exports.requireConfirmationIf = requireConfirmationIf;
6
+ exports.listedEffects = listedEffects;
6
7
  exports.reorderOpens = reorderOpens;
7
8
  exports.requireConfirmation = requireConfirmation;
8
9
  const zod_1 = require("zod");
9
10
  const write_log_js_1 = require("../write-log.js");
11
+ const list_all_js_1 = require("./list-all.js");
12
+ const responses_js_1 = require("./responses.js");
10
13
  /** The confirm input of a DESTRUCTIVE tool. */
11
14
  const confirmDestructive = () => zod_1.z.boolean().optional().describe("Must be true to execute this destructive action; not needed with dryRun: true");
12
15
  exports.confirmDestructive = confirmDestructive;
@@ -60,13 +63,33 @@ function formatDryRun(method, path, body, confirmationRequired) {
60
63
  function requireConfirmationIf(reason, confirm, dryRun) {
61
64
  return reason === undefined || dryRun ? null : requireConfirmation(confirm, reason);
62
65
  }
66
+ /**
67
+ * A firewall policy or ACL rule as a list returns it, typed only as far as a
68
+ * reorder check reads it. An entry without an ID cannot be in an ordering, so
69
+ * it is skipped rather than failing the check.
70
+ */
71
+ const ListedRule = zod_1.z.object({
72
+ id: zod_1.z.string().optional(),
73
+ enabled: zod_1.z.unknown().optional(),
74
+ action: zod_1.z.unknown().optional(),
75
+ }).loose();
76
+ /** Every rule of a list by ID, with its effect, for reorderOpens. */
77
+ async function listedEffects(client, path, effectOf) {
78
+ const rules = await (0, list_all_js_1.listAll)(client, path, ListedRule).catch((err) => {
79
+ throw new Error(`the rules this reorder moves could not be read, so whether it opens access is unknown (${(0, responses_js_1.errorMessage)(err)})`, { cause: err });
80
+ });
81
+ return new Map(rules.flatMap((rule) => rule.id === undefined ? [] : [[rule.id, effectOf(rule)]]));
82
+ }
63
83
  /**
64
84
  * Whether a reorder opens access: whether it moves a rule that lets traffic
65
85
  * through ahead of one that blocks, which it used to follow. Only rules in
66
- * both orders are compared, and effect is asked only about the rules a
67
- * reorder actually moves past one another. IDs are compared without case.
86
+ * both orders are compared. effects reads every rule's effect at once (one
87
+ * list read, not a GET per rule, which a large reshuffle would turn into a
88
+ * burst of hundreds), and is called only when the reorder moves rules past
89
+ * one another. A moved rule it does not return (deleted since the ordering
90
+ * was read) is refused rather than guessed. IDs are compared without case.
68
91
  */
69
- async function reorderOpens(before, after, effect) {
92
+ async function reorderOpens(before, after, effects) {
70
93
  const rank = new Map(before.map((id, index) => [id.toLowerCase(), index]));
71
94
  const passes = [];
72
95
  after.forEach((ahead, i) => {
@@ -76,9 +99,16 @@ async function reorderOpens(before, after, effect) {
76
99
  passes.push([ahead, behind]);
77
100
  }
78
101
  });
79
- const ids = [...new Set(passes.flat())];
80
- const effects = new Map(await Promise.all(ids.map(async (id) => [id, await effect(id)])));
81
- return passes.some(([ahead, behind]) => effects.get(ahead)?.letsThrough === true && effects.get(behind)?.blocks === true);
102
+ if (passes.length === 0)
103
+ return false;
104
+ const known = new Map([...(await effects())].map(([id, effect]) => [id.toLowerCase(), effect]));
105
+ // Every moved rule is checked first, so the answer never rests on a guess.
106
+ for (const id of new Set(passes.flat())) {
107
+ if (!known.has(id.toLowerCase())) {
108
+ throw new Error(`no rule with ID ${JSON.stringify(id)} is in this site's list, though the current ordering names it; read the ordering again`);
109
+ }
110
+ }
111
+ return passes.some(([ahead, behind]) => known.get(ahead.toLowerCase())?.letsThrough === true && known.get(behind.toLowerCase())?.blocks === true);
82
112
  }
83
113
  function requireConfirmation(confirm, action) {
84
114
  if (confirm !== true) {
@@ -5,6 +5,7 @@ exports.wanRefusal = wanRefusal;
5
5
  const zod_1 = require("zod");
6
6
  const write_log_js_1 = require("../write-log.js");
7
7
  const responses_js_1 = require("./responses.js");
8
+ const list_all_js_1 = require("./list-all.js");
8
9
  exports.DEFAULT_WAN_POLICY = { allowExposure: false, zoneIds: [] };
9
10
  const Zone = zod_1.z.object({
10
11
  id: zod_1.z.string(),
@@ -19,22 +20,6 @@ const Policy = zod_1.z.object({
19
20
  source: zod_1.z.unknown().optional(),
20
21
  destination: zod_1.z.unknown().optional(),
21
22
  }).loose();
22
- const PAGE_LIMIT = 200;
23
- const MAX_PAGES = 50;
24
- async function listAll(client, path, item) {
25
- const page = zod_1.z.object({ data: zod_1.z.array(item), totalCount: zod_1.z.number().optional() }).loose();
26
- const items = [];
27
- for (let pages = 0; pages < MAX_PAGES; pages++) {
28
- const { data, totalCount } = page.parse(await client.get(`${path}?offset=${items.length}&limit=${PAGE_LIMIT}`));
29
- items.push(...data);
30
- // Without a totalCount, only an empty page shows the list has ended: a
31
- // controller may cap pages below PAGE_LIMIT, so a short page proves nothing.
32
- if (data.length === 0 || (totalCount !== undefined && items.length >= totalCount))
33
- return items;
34
- }
35
- // A list that does not end cannot be checked, which must not pass.
36
- throw new Error(`the list did not end after ${MAX_PAGES} pages`);
37
- }
38
23
  class Refusal extends Error {
39
24
  }
40
25
  /** The checks for one site; zones and policies are read at most once per call. */
@@ -176,13 +161,13 @@ class WanExposure {
176
161
  return zone;
177
162
  }
178
163
  zoneList() {
179
- this.zones ??= listAll(this.client, `/sites/${this.siteId}/firewall/zones`, Zone).catch((err) => {
164
+ this.zones ??= (0, list_all_js_1.listAll)(this.client, `/sites/${this.siteId}/firewall/zones`, Zone).catch((err) => {
180
165
  throw new Refusal(`the firewall zones could not be read to find the WAN zone (${(0, responses_js_1.errorMessage)(err)})`);
181
166
  });
182
167
  return this.zones;
183
168
  }
184
169
  policyList() {
185
- this.policies ??= listAll(this.client, `/sites/${this.siteId}/firewall/policies`, Policy).catch((err) => {
170
+ this.policies ??= (0, list_all_js_1.listAll)(this.client, `/sites/${this.siteId}/firewall/policies`, Policy).catch((err) => {
186
171
  throw new Refusal(`the firewall policies that the change depends on could not be read (${(0, responses_js_1.errorMessage)(err)})`);
187
172
  });
188
173
  return this.policies;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owine/unifi-network-mcp",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "MCP server exposing the UniFi Network Integration API as tools for Claude and other MCP clients",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  "engines": {
24
24
  "node": "^22.19.0 || ^24.0.0 || ^26.0.0"
25
25
  },
26
- "packageManager": "pnpm@12.16.1",
26
+ "packageManager": "pnpm@12.21.0",
27
27
  "repository": {
28
28
  "type": "git",
29
29
  "url": "git+https://github.com/owine/unifi-network-mcp.git"
@@ -41,18 +41,18 @@
41
41
  "license": "MIT",
42
42
  "dependencies": {
43
43
  "@modelcontextprotocol/sdk": "1.32.2",
44
- "undici": "8.12.3",
44
+ "undici": "8.12.4",
45
45
  "zod": "4.6.7"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@eslint/js": "10.0.1",
49
49
  "@types/node": "26.6.6",
50
50
  "@typescript/native": "npm:typescript@7.0.2",
51
- "@vitest/coverage-v8": "5.0.5",
52
- "eslint": "10.14.0",
51
+ "@vitest/coverage-v8": "5.1.0",
52
+ "eslint": "10.15.0",
53
53
  "lefthook": "2.2.2",
54
54
  "typescript": "npm:@typescript/typescript6@6.0.2",
55
- "typescript-eslint": "8.72.1",
56
- "vitest": "5.0.5"
55
+ "typescript-eslint": "8.72.2",
56
+ "vitest": "5.1.0"
57
57
  }
58
58
  }