@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 +8 -5
- package/dist/client.d.ts +7 -0
- package/dist/client.js +62 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +6 -0
- package/dist/index.js +15 -0
- package/dist/tools/acl.js +2 -3
- package/dist/tools/devices.js +1 -1
- package/dist/tools/firewall.js +8 -8
- package/dist/utils/list-all.d.ts +4 -0
- package/dist/utils/list-all.js +21 -0
- package/dist/utils/output-schemas.d.ts +1 -0
- package/dist/utils/output-schemas.js +2 -0
- package/dist/utils/responses.d.ts +6 -0
- package/dist/utils/responses.js +11 -4
- package/dist/utils/safety.d.ts +20 -3
- package/dist/utils/safety.js +36 -6
- package/dist/utils/wan-exposure.js +3 -18
- package/package.json +7 -7
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
|
|
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
|
-
| `
|
|
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
|
|
423
|
-
| `Classic controller API returned HTTP
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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)
|
package/dist/tools/devices.js
CHANGED
|
@@ -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"),
|
package/dist/tools/firewall.js
CHANGED
|
@@ -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 (
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
return { letsThrough:
|
|
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";
|
package/dist/utils/responses.js
CHANGED
|
@@ -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
|
-
|
|
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: ${
|
|
40
|
+
content: [{ type: "text", text: `Error: ${describeError(err)}` }],
|
|
34
41
|
isError: true,
|
|
35
42
|
};
|
|
36
43
|
}
|
package/dist/utils/safety.d.ts
CHANGED
|
@@ -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
|
|
43
|
-
*
|
|
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[],
|
|
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 {};
|
package/dist/utils/safety.js
CHANGED
|
@@ -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
|
|
67
|
-
*
|
|
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,
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
52
|
-
"eslint": "10.
|
|
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.
|
|
56
|
-
"vitest": "5.0
|
|
55
|
+
"typescript-eslint": "8.72.2",
|
|
56
|
+
"vitest": "5.1.0"
|
|
57
57
|
}
|
|
58
58
|
}
|