@owine/unifi-network-mcp 2.15.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -77
  3. package/dist/client.d.ts +26 -1
  4. package/dist/client.js +199 -24
  5. package/dist/config.d.ts +11 -0
  6. package/dist/config.js +102 -3
  7. package/dist/guard.d.ts +46 -0
  8. package/dist/guard.js +180 -0
  9. package/dist/index.js +17 -4
  10. package/dist/instructions.d.ts +9 -0
  11. package/dist/instructions.js +32 -0
  12. package/dist/tls.d.ts +14 -0
  13. package/dist/tls.js +58 -0
  14. package/dist/tools/acl.js +123 -50
  15. package/dist/tools/classic.d.ts +3 -0
  16. package/dist/tools/classic.js +54 -0
  17. package/dist/tools/client-history.js +22 -12
  18. package/dist/tools/clients.js +10 -9
  19. package/dist/tools/devices.js +21 -20
  20. package/dist/tools/dns-policies.js +27 -27
  21. package/dist/tools/exposure.d.ts +3 -0
  22. package/dist/tools/exposure.js +118 -0
  23. package/dist/tools/firewall.d.ts +2 -1
  24. package/dist/tools/firewall.js +200 -75
  25. package/dist/tools/hotspot.js +35 -28
  26. package/dist/tools/index.d.ts +13 -1
  27. package/dist/tools/index.js +67 -4
  28. package/dist/tools/networks.d.ts +2 -1
  29. package/dist/tools/networks.js +44 -31
  30. package/dist/tools/sites.js +2 -2
  31. package/dist/tools/supporting.js +22 -21
  32. package/dist/tools/switching.js +16 -15
  33. package/dist/tools/system.js +1 -1
  34. package/dist/tools/traffic-matching.d.ts +2 -1
  35. package/dist/tools/traffic-matching.js +22 -20
  36. package/dist/tools/wifi.js +76 -30
  37. package/dist/utils/full-put.d.ts +11 -2
  38. package/dist/utils/full-put.js +25 -2
  39. package/dist/utils/output-schemas.d.ts +241 -4
  40. package/dist/utils/output-schemas.js +225 -49
  41. package/dist/utils/params.d.ts +12 -0
  42. package/dist/utils/params.js +21 -0
  43. package/dist/utils/pick.d.ts +6 -0
  44. package/dist/utils/pick.js +11 -0
  45. package/dist/utils/responses.d.ts +2 -0
  46. package/dist/utils/responses.js +28 -2
  47. package/dist/utils/safety.d.ts +35 -1
  48. package/dist/utils/safety.js +53 -3
  49. package/dist/utils/secrets.js +3 -0
  50. package/dist/utils/wan-exposure.d.ts +69 -0
  51. package/dist/utils/wan-exposure.js +245 -0
  52. package/dist/write-log.d.ts +52 -0
  53. package/dist/write-log.js +178 -0
  54. package/dist/write-tools.d.ts +27 -0
  55. package/dist/write-tools.js +50 -0
  56. package/package.json +19 -15
@@ -0,0 +1,178 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WriteLog = void 0;
4
+ exports.noteWriteRequest = noteWriteRequest;
5
+ exports.noteWriteStatus = noteWriteStatus;
6
+ exports.noteRefusal = noteRefusal;
7
+ exports.localTimestamp = localTimestamp;
8
+ exports.openWriteLog = openWriteLog;
9
+ const node_async_hooks_1 = require("node:async_hooks");
10
+ const node_fs_1 = require("node:fs");
11
+ const node_path_1 = require("node:path");
12
+ const responses_js_1 = require("./utils/responses.js");
13
+ const secrets_js_1 = require("./utils/secrets.js");
14
+ const currentCall = new node_async_hooks_1.AsyncLocalStorage();
15
+ const withoutQuery = (path) => path.split("?")[0];
16
+ /** NetworkClient: a write request passed the guard and is about to be sent. */
17
+ function noteWriteRequest(method, path) {
18
+ const call = currentCall.getStore();
19
+ if (call)
20
+ call.request = { method, path: withoutQuery(path) };
21
+ }
22
+ /** NetworkClient: the controller answered the write request. */
23
+ function noteWriteStatus(status) {
24
+ const request = currentCall.getStore()?.request;
25
+ if (request)
26
+ request.status = status;
27
+ }
28
+ /** A check refused the call; the first refusal is the one logged. */
29
+ function noteRefusal(reason, method, path) {
30
+ const call = currentCall.getStore();
31
+ if (call && !call.refusal)
32
+ call.refusal = { reason, method, ...(path === undefined ? {} : { path: withoutQuery(path) }) };
33
+ }
34
+ /**
35
+ * Field names, compared without case, whose value is redacted whatever it
36
+ * is. Names are matched exactly, not by substring, so containers such as
37
+ * presharedKeys keep their network IDs (redactSecrets masks the passphrase
38
+ * in each entry) and fields like keyId stay readable. The list: the names
39
+ * redactSecrets knows from WiFi and RADIUS bodies, their legacy-API x_
40
+ * forms, and the usual names of credentials and key material a firewall
41
+ * policy record (or a mistaken argument) could carry.
42
+ */
43
+ const SECRET_FIELDS = new Set([
44
+ "passphrase", "password", "secret", "sharedSecret", "radiusSecret",
45
+ "x_passphrase", "x_password", "x_secret", "x_shared_secret",
46
+ "apiKey", "api_key", "apiToken", "token", "accessToken", "refreshToken", "authToken",
47
+ "privateKey", "private_key", "presharedKey",
48
+ ].map((name) => name.toLowerCase()));
49
+ function redactArgs(value) {
50
+ if (Array.isArray(value))
51
+ return value.map(redactArgs);
52
+ if (value === null || typeof value !== "object")
53
+ return value;
54
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, SECRET_FIELDS.has(key.toLowerCase()) ? secrets_js_1.REDACTED : redactArgs(entry)]));
55
+ }
56
+ /** "2026-10-18T21:04:05.123+02:00": the local time with its offset. */
57
+ function localTimestamp(date) {
58
+ const pad = (n, width = 2) => String(Math.abs(n)).padStart(width, "0");
59
+ const offset = -date.getTimezoneOffset();
60
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
61
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}` +
62
+ `${offset < 0 ? "-" : "+"}${pad(Math.trunc(offset / 60))}:${pad(offset % 60)}`;
63
+ }
64
+ class WriteLog {
65
+ sink;
66
+ apiKey;
67
+ failed = false;
68
+ /** sink appends one line; it may throw, and a failure is reported once and never breaks the call. */
69
+ constructor(sink, apiKey) {
70
+ this.sink = sink;
71
+ this.apiKey = apiKey;
72
+ }
73
+ /**
74
+ * Open path for appending, creating it 0600 (and its directory 0700) if
75
+ * needed. Throws for a symlink, anything but a regular file, or a file
76
+ * others can write to, which could be truncated or filled with lies.
77
+ *
78
+ * On Windows, Node reports every writable file as mode 0666 and a link
79
+ * count that says nothing about access, and O_NOFOLLOW does not exist, so
80
+ * only the symlink and regular-file checks apply there: the file's ACL is
81
+ * the operator's to set.
82
+ */
83
+ static open(path, apiKey) {
84
+ const windows = process.platform === "win32";
85
+ let existing;
86
+ try {
87
+ existing = (0, node_fs_1.lstatSync)(path);
88
+ }
89
+ catch (err) {
90
+ if (err.code !== "ENOENT")
91
+ throw err;
92
+ }
93
+ if (existing?.isSymbolicLink())
94
+ throw new Error("it is a symlink; point it at the file itself");
95
+ if (existing === undefined)
96
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true, mode: 0o700 });
97
+ // O_NOFOLLOW closes the gap between the check above and the open.
98
+ const noFollow = windows ? 0 : node_fs_1.constants.O_NOFOLLOW;
99
+ const fd = (0, node_fs_1.openSync)(path, node_fs_1.constants.O_WRONLY | node_fs_1.constants.O_APPEND | node_fs_1.constants.O_CREAT | noFollow, 0o600);
100
+ const stats = (0, node_fs_1.fstatSync)(fd);
101
+ const problem = !stats.isFile() ? "it is not a regular file"
102
+ : windows ? undefined
103
+ : stats.nlink > 1 ? "it has other hard links, so lines could land in another file"
104
+ : (stats.mode & 0o022) !== 0 ? `it is group- or world-writable (mode ${(stats.mode & 0o777).toString(8)}); chmod 600 it`
105
+ : undefined;
106
+ if (problem !== undefined) {
107
+ (0, node_fs_1.closeSync)(fd);
108
+ throw new Error(problem);
109
+ }
110
+ return new WriteLog((line) => { (0, node_fs_1.writeSync)(fd, line); }, apiKey);
111
+ }
112
+ /** Run one write tool call and append its line. */
113
+ async record(tool, input, run) {
114
+ const started = new Date();
115
+ const call = {};
116
+ let result;
117
+ try {
118
+ result = await currentCall.run(call, run);
119
+ return result;
120
+ }
121
+ finally {
122
+ this.append(this.entry(tool, input, call, result, started));
123
+ }
124
+ }
125
+ entry(tool, input, call, result, started) {
126
+ const dryRun = input !== null && typeof input === "object" && "dryRun" in input && input.dryRun === true;
127
+ const preview = result?.structuredContent?.dryRun === true ? result.structuredContent.wouldExecute : undefined;
128
+ const base = {
129
+ ts: started.toISOString(),
130
+ tsLocal: localTimestamp(started),
131
+ tool,
132
+ args: redactArgs((0, secrets_js_1.redactSecrets)(input)),
133
+ method: null,
134
+ path: null,
135
+ dryRun,
136
+ };
137
+ const durationMs = Date.now() - started.getTime();
138
+ if (call.refusal) {
139
+ const { reason, method, path } = call.refusal;
140
+ return { ...base, method: method ?? null, path: path ?? null, outcome: "refused", reason, durationMs };
141
+ }
142
+ if (preview) {
143
+ const method = typeof preview.method === "string" ? preview.method : null;
144
+ const path = typeof preview.path === "string" ? withoutQuery(`/integration/v1${preview.path}`) : null;
145
+ return { ...base, method, path, outcome: "dry_run", durationMs };
146
+ }
147
+ const request = call.request;
148
+ const status = request?.status === undefined ? {} : { status: request.status };
149
+ const where = request ? { method: request.method, path: request.path } : {};
150
+ return { ...base, ...where, outcome: result === undefined || result.isError ? "error" : "ok", ...status, durationMs };
151
+ }
152
+ append(entry) {
153
+ try {
154
+ let line = JSON.stringify(entry);
155
+ // Arguments are the model's; if it ever pasted the key into one, keep it out.
156
+ if (this.apiKey.length >= 8)
157
+ line = line.replaceAll(this.apiKey, secrets_js_1.REDACTED);
158
+ this.sink(`${line}\n`);
159
+ }
160
+ catch (err) {
161
+ if (this.failed)
162
+ return;
163
+ this.failed = true;
164
+ console.error(`UNIFI_NETWORK_WRITE_LOG: cannot write the log (${(0, responses_js_1.errorMessage)(err)}); tool calls continue, but are not being logged.`);
165
+ }
166
+ }
167
+ }
168
+ exports.WriteLog = WriteLog;
169
+ /** Open the write log at startup, or exit with a configuration error. */
170
+ function openWriteLog(path, apiKey) {
171
+ try {
172
+ return WriteLog.open(path, apiKey);
173
+ }
174
+ catch (err) {
175
+ console.error(`Configuration error: cannot use UNIFI_NETWORK_WRITE_LOG (${path}): ${(0, responses_js_1.errorMessage)(err)}`);
176
+ return process.exit(1);
177
+ }
178
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Every write tool, grouped by the module in src/tools/ that registers it.
3
+ * UNIFI_NETWORK_WRITE_TOOLS names tools or groups from here, and
4
+ * registerAllTools() refuses to register a write tool that is missing, so the
5
+ * list cannot fall behind the code.
6
+ */
7
+ export declare const WRITE_TOOL_GROUPS: {
8
+ readonly devices: readonly ["unifi_adopt_device", "unifi_remove_device", "unifi_restart_device", "unifi_power_cycle_port"];
9
+ readonly clients: readonly ["unifi_authorize_guest", "unifi_unauthorize_guest"];
10
+ readonly networks: readonly ["unifi_create_network", "unifi_update_network", "unifi_delete_network"];
11
+ readonly wifi: readonly ["unifi_create_wifi", "unifi_update_wifi", "unifi_delete_wifi"];
12
+ readonly hotspot: readonly ["unifi_create_voucher", "unifi_delete_voucher", "unifi_bulk_delete_vouchers"];
13
+ readonly firewall: readonly ["unifi_create_firewall_zone", "unifi_update_firewall_zone", "unifi_delete_firewall_zone", "unifi_create_firewall_policy", "unifi_update_firewall_policy", "unifi_patch_firewall_policy", "unifi_delete_firewall_policy", "unifi_reorder_firewall_policies"];
14
+ readonly acl: readonly ["unifi_create_acl_rule", "unifi_update_acl_rule", "unifi_delete_acl_rule", "unifi_reorder_acl_rules"];
15
+ readonly "dns-policies": readonly ["unifi_create_dns_policy", "unifi_update_dns_policy", "unifi_delete_dns_policy"];
16
+ readonly "traffic-matching": readonly ["unifi_create_traffic_matching_list", "unifi_update_traffic_matching_list", "unifi_delete_traffic_matching_list"];
17
+ };
18
+ export declare const WRITE_TOOLS: readonly string[];
19
+ /**
20
+ * The write tools a UNIFI_NETWORK_WRITE_TOOLS value enables, in catalog
21
+ * order: a comma-separated list of tool and group names, or "" / "none" for
22
+ * none. Names that are neither come back in unknown.
23
+ */
24
+ export declare function parseWriteTools(value: string): {
25
+ tools: string[];
26
+ unknown: string[];
27
+ };
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WRITE_TOOLS = exports.WRITE_TOOL_GROUPS = void 0;
4
+ exports.parseWriteTools = parseWriteTools;
5
+ /**
6
+ * Every write tool, grouped by the module in src/tools/ that registers it.
7
+ * UNIFI_NETWORK_WRITE_TOOLS names tools or groups from here, and
8
+ * registerAllTools() refuses to register a write tool that is missing, so the
9
+ * list cannot fall behind the code.
10
+ */
11
+ exports.WRITE_TOOL_GROUPS = {
12
+ devices: ["unifi_adopt_device", "unifi_remove_device", "unifi_restart_device", "unifi_power_cycle_port"],
13
+ clients: ["unifi_authorize_guest", "unifi_unauthorize_guest"],
14
+ networks: ["unifi_create_network", "unifi_update_network", "unifi_delete_network"],
15
+ wifi: ["unifi_create_wifi", "unifi_update_wifi", "unifi_delete_wifi"],
16
+ hotspot: ["unifi_create_voucher", "unifi_delete_voucher", "unifi_bulk_delete_vouchers"],
17
+ firewall: [
18
+ "unifi_create_firewall_zone", "unifi_update_firewall_zone", "unifi_delete_firewall_zone",
19
+ "unifi_create_firewall_policy", "unifi_update_firewall_policy", "unifi_patch_firewall_policy",
20
+ "unifi_delete_firewall_policy", "unifi_reorder_firewall_policies",
21
+ ],
22
+ acl: ["unifi_create_acl_rule", "unifi_update_acl_rule", "unifi_delete_acl_rule", "unifi_reorder_acl_rules"],
23
+ "dns-policies": ["unifi_create_dns_policy", "unifi_update_dns_policy", "unifi_delete_dns_policy"],
24
+ "traffic-matching": [
25
+ "unifi_create_traffic_matching_list", "unifi_update_traffic_matching_list", "unifi_delete_traffic_matching_list",
26
+ ],
27
+ };
28
+ exports.WRITE_TOOLS = Object.values(exports.WRITE_TOOL_GROUPS).flat();
29
+ const GROUPS = exports.WRITE_TOOL_GROUPS;
30
+ /**
31
+ * The write tools a UNIFI_NETWORK_WRITE_TOOLS value enables, in catalog
32
+ * order: a comma-separated list of tool and group names, or "" / "none" for
33
+ * none. Names that are neither come back in unknown.
34
+ */
35
+ function parseWriteTools(value) {
36
+ const names = value.split(",").map((name) => name.trim()).filter((name) => name !== "");
37
+ if (names.length === 1 && names[0].toLowerCase() === "none")
38
+ return { tools: [], unknown: [] };
39
+ const enabled = new Set();
40
+ const unknown = [];
41
+ for (const name of names) {
42
+ const tools = Object.hasOwn(GROUPS, name) ? GROUPS[name] : exports.WRITE_TOOLS.includes(name) ? [name] : undefined;
43
+ if (tools === undefined)
44
+ unknown.push(name);
45
+ else
46
+ for (const tool of tools)
47
+ enabled.add(tool);
48
+ }
49
+ return { tools: exports.WRITE_TOOLS.filter((tool) => enabled.has(tool)), unknown };
50
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@owine/unifi-network-mcp",
3
- "version": "2.15.0",
4
- "description": "MCP server for the UniFi Network API",
3
+ "version": "3.1.0",
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": {
7
7
  "unifi-network-mcp": "dist/index.js"
@@ -12,17 +12,18 @@
12
12
  "scripts": {
13
13
  "build": "tsc",
14
14
  "start": "node dist/index.js",
15
- "typecheck": "tsc --noEmit",
15
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
16
16
  "test": "vitest run",
17
17
  "test:watch": "vitest",
18
18
  "test:coverage": "vitest run --coverage",
19
19
  "lint": "eslint .",
20
- "lint:fix": "eslint . --fix"
20
+ "lint:fix": "eslint . --fix",
21
+ "test:package": "pnpm run build && sh scripts/smoke-package.sh"
21
22
  },
22
23
  "engines": {
23
- "node": "^22.13.0 || ^24.0.0"
24
+ "node": "^22.19.0 || ^24.0.0 || ^26.0.0"
24
25
  },
25
- "packageManager": "pnpm@12.11.2",
26
+ "packageManager": "pnpm@12.16.1",
26
27
  "repository": {
27
28
  "type": "git",
28
29
  "url": "git+https://github.com/owine/unifi-network-mcp.git"
@@ -32,23 +33,26 @@
32
33
  "network",
33
34
  "mcp",
34
35
  "model-context-protocol",
35
- "ubiquiti"
36
+ "ubiquiti",
37
+ "unifi-network",
38
+ "mcp-server"
36
39
  ],
37
40
  "author": "owine",
38
41
  "license": "MIT",
39
42
  "dependencies": {
40
- "@modelcontextprotocol/sdk": "1.32.1",
41
- "zod": "4.6.5"
43
+ "@modelcontextprotocol/sdk": "1.32.2",
44
+ "undici": "8.12.3",
45
+ "zod": "4.6.7"
42
46
  },
43
47
  "devDependencies": {
44
48
  "@eslint/js": "10.0.1",
45
- "@types/node": "24.19.2",
49
+ "@types/node": "26.6.6",
46
50
  "@typescript/native": "npm:typescript@7.0.2",
47
- "@vitest/coverage-v8": "5.0.3",
48
- "eslint": "10.12.0",
49
- "lefthook": "2.2.1",
51
+ "@vitest/coverage-v8": "5.0.5",
52
+ "eslint": "10.14.0",
53
+ "lefthook": "2.2.2",
50
54
  "typescript": "npm:@typescript/typescript6@6.0.2",
51
- "typescript-eslint": "8.71.1",
52
- "vitest": "5.0.3"
55
+ "typescript-eslint": "8.72.1",
56
+ "vitest": "5.0.5"
53
57
  }
54
58
  }