@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
package/dist/tools/acl.js CHANGED
@@ -1,29 +1,72 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerAclTools = registerAclTools;
4
+ const node_util_1 = require("node:util");
4
5
  const zod_1 = require("zod");
5
6
  const responses_js_1 = require("../utils/responses.js");
6
7
  const query_js_1 = require("../utils/query.js");
7
8
  const safety_js_1 = require("../utils/safety.js");
8
9
  const output_schemas_js_1 = require("../utils/output-schemas.js");
10
+ const params_js_1 = require("../utils/params.js");
11
+ const full_put_js_1 = require("../utils/full-put.js");
12
+ // PUT body fields per the 11.0.83 docs. index is deprecated (and read-only in
13
+ // practice), so it stays out of the body even though GET returns it.
14
+ const ACL_RULE_FIELDS = [
15
+ "type", "enabled", "name", "description", "action",
16
+ "enforcingDeviceFilter", "sourceFilter", "destinationFilter", "protocolFilter",
17
+ ];
18
+ const ACL_RULE_REQUIRED = ["type", "enabled", "name", "action"];
19
+ // The fields that decide which traffic a rule applies to.
20
+ const ACL_MATCH_FIELDS = [
21
+ "type", "enforcingDeviceFilter", "sourceFilter", "destinationFilter", "protocolFilter",
22
+ ];
23
+ // Shaped per rule type (IPV4 or MAC), so a type change does not keep them.
24
+ const ACL_TYPE_FIELDS = ["sourceFilter", "destinationFilter", "protocolFilter"];
25
+ /**
26
+ * What an ACL rule write would open, if anything. before is undefined on
27
+ * create. Changing what an enabled rule matches counts whatever its action:
28
+ * widening an ALLOW and narrowing a BLOCK both let more through, and which
29
+ * of the two a filter change does cannot be told from here.
30
+ */
31
+ function aclOpening(before, after) {
32
+ if (before === undefined) {
33
+ return after.action === "ALLOW" ? "This creates an ACL rule that ALLOWs traffic" : undefined;
34
+ }
35
+ if (after.action === "ALLOW" && before.action !== "ALLOW")
36
+ return "This changes the ACL rule's action to ALLOW";
37
+ if (after.enabled === false) {
38
+ return before.enabled !== false && before.action === "BLOCK" ? "This disables an ACL rule that blocks traffic" : undefined;
39
+ }
40
+ if (before.enabled === false) {
41
+ return after.action === "ALLOW" ? "This enables an ACL rule that ALLOWs traffic" : undefined;
42
+ }
43
+ const changed = ACL_MATCH_FIELDS.filter((field) => !(0, node_util_1.isDeepStrictEqual)(before[field], after[field]));
44
+ return changed.length > 0
45
+ ? `This changes what an enabled ACL rule matches (${changed.join(", ")}), which can let more traffic through`
46
+ : undefined;
47
+ }
48
+ const confirmOpening = zod_1.z
49
+ .boolean()
50
+ .optional()
51
+ .describe("Set true to confirm a change that can let traffic through: an ALLOW action, enabling an ALLOW rule, disabling a BLOCK rule, or changing what an enabled rule matches; not needed with dryRun: true");
9
52
  function registerAclTools(server, client, readOnly = false) {
10
53
  server.registerTool("unifi_list_acl_rules", {
11
54
  description: "List ACL rules (switch/AP-level access control lists, distinct from zone-based firewall policies) at a site. Returns: id, type (IPV4/MAC), name, enabled, action (ALLOW/BLOCK), description, protocolFilter, sourceFilter/destinationFilter matchers, enforcingDeviceFilter (which devices apply the rule), index (evaluation order — read-only; change it with unifi_reorder_acl_rules), metadata.origin. ACLs apply earlier in the path than firewall policies.",
12
55
  inputSchema: {
13
- siteId: zod_1.z.string().describe("Site ID"),
56
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
14
57
  offset: zod_1.z
15
58
  .number()
16
59
  .int()
17
60
  .nonnegative()
18
61
  .optional()
19
- .describe("Number of records to skip (default: 0)"),
62
+ .describe("Number of records to skip"),
20
63
  limit: zod_1.z
21
64
  .number()
22
65
  .int()
23
66
  .min(0)
24
67
  .max(200)
25
68
  .optional()
26
- .describe("Number of records to return (default: 25, max: 200)"),
69
+ .describe("Number of records to return (API default: 25)"),
27
70
  filter: zod_1.z
28
71
  .string()
29
72
  .optional()
@@ -44,8 +87,8 @@ function registerAclTools(server, client, readOnly = false) {
44
87
  server.registerTool("unifi_get_acl_rule", {
45
88
  description: "Get a specific ACL rule by ID (full match criteria and action).",
46
89
  inputSchema: {
47
- siteId: zod_1.z.string().describe("Site ID"),
48
- aclRuleId: zod_1.z.string().describe("ACL rule ID"),
90
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
91
+ aclRuleId: (0, params_js_1.resourceId)().describe("ACL rule ID"),
49
92
  },
50
93
  outputSchema: output_schemas_js_1.aclRuleOutputSchema,
51
94
  annotations: safety_js_1.READ_ONLY,
@@ -61,7 +104,7 @@ function registerAclTools(server, client, readOnly = false) {
61
104
  server.registerTool("unifi_get_acl_rule_ordering", {
62
105
  description: "Get the evaluation order of user-defined ACL rules. Returns: orderedAclRuleIds[] — rules are evaluated in list order.",
63
106
  inputSchema: {
64
- siteId: zod_1.z.string().describe("Site ID"),
107
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
65
108
  },
66
109
  outputSchema: output_schemas_js_1.aclRuleOrderingOutputSchema,
67
110
  annotations: safety_js_1.READ_ONLY,
@@ -79,7 +122,7 @@ function registerAclTools(server, client, readOnly = false) {
79
122
  server.registerTool("unifi_create_acl_rule", {
80
123
  description: "Create a new ACL rule",
81
124
  inputSchema: {
82
- siteId: zod_1.z.string().describe("Site ID"),
125
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
83
126
  type: zod_1.z.enum(["IPV4", "MAC"]).describe("Rule type"),
84
127
  name: zod_1.z.string().describe("Rule name"),
85
128
  enabled: zod_1.z.boolean().describe("Enable the rule"),
@@ -91,7 +134,7 @@ function registerAclTools(server, client, readOnly = false) {
91
134
  protocolFilter: zod_1.z
92
135
  .array(zod_1.z.enum(["TCP", "UDP"]))
93
136
  .optional()
94
- .describe("Protocols this ACL rule will be applied to (TCP, UDP). When null, applies to all protocols"),
137
+ .describe("Protocols this ACL rule will be applied to (TCP, UDP). Omit to apply to all protocols"),
95
138
  sourceFilter: zod_1.z
96
139
  .unknown()
97
140
  .optional()
@@ -104,14 +147,19 @@ function registerAclTools(server, client, readOnly = false) {
104
147
  .unknown()
105
148
  .optional()
106
149
  .describe("Device filter object (with a type discriminator) selecting the switch-capable devices that enforce the rule. When null/omitted, the rule is provisioned to all switches on the site"),
150
+ confirm: confirmOpening,
107
151
  dryRun: zod_1.z
108
152
  .boolean()
109
153
  .optional()
110
154
  .describe("Preview this action without executing it"),
111
155
  },
112
- outputSchema: output_schemas_js_1.aclRuleOutputSchema,
156
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.aclRuleOutputSchema),
113
157
  annotations: safety_js_1.WRITE_NOT_IDEMPOTENT,
114
- }, async ({ siteId, type, name, enabled, action, description, protocolFilter, sourceFilter, destinationFilter, enforcingDeviceFilter, dryRun }) => {
158
+ }, async ({ siteId, type, name, enabled, action, description, protocolFilter, sourceFilter, destinationFilter, enforcingDeviceFilter, confirm, dryRun }) => {
159
+ const opening = aclOpening(undefined, { action });
160
+ const guard = (0, safety_js_1.requireConfirmationIf)(opening, confirm, dryRun);
161
+ if (guard)
162
+ return guard;
115
163
  try {
116
164
  const body = { type, name, enabled, action };
117
165
  if (description !== undefined)
@@ -125,7 +173,7 @@ function registerAclTools(server, client, readOnly = false) {
125
173
  if (enforcingDeviceFilter !== undefined)
126
174
  body.enforcingDeviceFilter = enforcingDeviceFilter;
127
175
  if (dryRun)
128
- return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/acl-rules`, body);
176
+ return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/acl-rules`, body, opening);
129
177
  const data = await client.post(`/sites/${siteId}/acl-rules`, body);
130
178
  return (0, responses_js_1.formatSuccess)(data, { structured: true });
131
179
  }
@@ -134,20 +182,22 @@ function registerAclTools(server, client, readOnly = false) {
134
182
  }
135
183
  });
136
184
  server.registerTool("unifi_update_acl_rule", {
137
- description: "Update an ACL rule",
185
+ description: "Update an ACL rule. Pass only the fields to change. The API's PUT replaces the whole rule, and an omitted filter means \"all\" (all protocols, sources, destinations or switches), so this tool reads the current rule, overlays your fields and sends the full body. Filters are replaced whole; null clears one. Changing type drops the current source, destination and protocol filters, which are shaped per type, so pass the new type's filters with it. dryRun shows the merged body.",
138
186
  inputSchema: {
139
- siteId: zod_1.z.string().describe("Site ID"),
140
- aclRuleId: zod_1.z.string().describe("ACL rule ID"),
141
- type: zod_1.z.enum(["IPV4", "MAC"]).describe("ACL rule type"),
142
- name: zod_1.z.string().describe("ACL rule name"),
143
- enabled: zod_1.z.boolean().describe("Whether the rule is enabled"),
144
- action: zod_1.z.enum(["ALLOW", "BLOCK"]).describe("Rule action"),
187
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
188
+ aclRuleId: (0, params_js_1.resourceId)().describe("ACL rule ID"),
189
+ type: zod_1.z.enum(["IPV4", "MAC"]).optional().describe("ACL rule type"),
190
+ name: zod_1.z.string().min(1).optional().describe("ACL rule name"),
191
+ enabled: zod_1.z.boolean().optional().describe("Whether the rule is enabled"),
192
+ action: zod_1.z.enum(["ALLOW", "BLOCK"]).optional().describe("Rule action"),
145
193
  description: zod_1.z
146
194
  .string()
195
+ .nullable()
147
196
  .optional()
148
- .describe("Rule description"),
197
+ .describe("Rule description; null clears it"),
149
198
  protocolFilter: zod_1.z
150
199
  .array(zod_1.z.enum(["TCP", "UDP"]))
200
+ .nullable()
151
201
  .optional()
152
202
  .describe("Protocols this ACL rule will be applied to (TCP, UDP). When null, applies to all protocols"),
153
203
  sourceFilter: zod_1.z
@@ -162,30 +212,39 @@ function registerAclTools(server, client, readOnly = false) {
162
212
  .unknown()
163
213
  .optional()
164
214
  .describe("Device filter object (with a type discriminator) selecting the switch-capable devices that enforce the rule. When null/omitted, the rule is provisioned to all switches on the site"),
215
+ confirm: confirmOpening,
165
216
  dryRun: zod_1.z
166
217
  .boolean()
167
218
  .optional()
168
- .describe("Preview this action without executing it"),
219
+ .describe("Preview the merged PUT body without executing it (still reads the current rule)"),
169
220
  },
170
- outputSchema: output_schemas_js_1.aclRuleOutputSchema,
221
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.aclRuleOutputSchema),
171
222
  annotations: safety_js_1.WRITE,
172
- }, async ({ siteId, aclRuleId, type, name, enabled, action, description, protocolFilter, sourceFilter, destinationFilter, enforcingDeviceFilter, dryRun }) => {
223
+ }, async ({ siteId, aclRuleId, confirm, dryRun, ...fields }) => {
173
224
  try {
174
- const body = { type, name, enabled, action };
175
- if (description !== undefined)
176
- body.description = description;
177
- if (protocolFilter !== undefined)
178
- body.protocolFilter = protocolFilter;
179
- if (sourceFilter !== undefined)
180
- body.sourceFilter = sourceFilter;
181
- if (destinationFilter !== undefined)
182
- body.destinationFilter = destinationFilter;
183
- if (enforcingDeviceFilter !== undefined)
184
- body.enforcingDeviceFilter = enforcingDeviceFilter;
185
- if (dryRun)
186
- return (0, safety_js_1.formatDryRun)("PUT", `/sites/${siteId}/acl-rules/${aclRuleId}`, body);
187
- const data = await client.put(`/sites/${siteId}/acl-rules/${aclRuleId}`, body);
188
- return (0, responses_js_1.formatSuccess)(data, { structured: true });
225
+ const path = `/sites/${siteId}/acl-rules/${aclRuleId}`;
226
+ return await (0, full_put_js_1.withResourceLock)(path, async () => {
227
+ const current = (0, full_put_js_1.asObject)(await client.get(path), "the current ACL rule");
228
+ const updates = (0, full_put_js_1.definedEntries)(fields);
229
+ // A new type starts its filters afresh: the old type's would send
230
+ // a rule that mixes both shapes. Filters not given mean "all".
231
+ const base = updates.type !== undefined && updates.type !== current.type
232
+ ? Object.fromEntries(Object.entries(current).filter(([field]) => !ACL_TYPE_FIELDS.includes(field)))
233
+ : current;
234
+ const body = (0, full_put_js_1.mergeForPut)(base, updates, ACL_RULE_FIELDS);
235
+ const missing = (0, full_put_js_1.missingFields)(body, ACL_RULE_REQUIRED);
236
+ if (missing.length > 0) {
237
+ throw new Error(`Missing required fields: ${missing.join(", ")}`);
238
+ }
239
+ const opening = aclOpening(current, body);
240
+ const guard = (0, safety_js_1.requireConfirmationIf)(opening, confirm, dryRun);
241
+ if (guard)
242
+ return guard;
243
+ if (dryRun)
244
+ return (0, safety_js_1.formatDryRun)("PUT", path, body, opening);
245
+ const data = await client.put(path, body);
246
+ return (0, responses_js_1.formatSuccess)(data, { structured: true });
247
+ });
189
248
  }
190
249
  catch (err) {
191
250
  return (0, responses_js_1.formatError)(err);
@@ -194,12 +253,9 @@ function registerAclTools(server, client, readOnly = false) {
194
253
  server.registerTool("unifi_delete_acl_rule", {
195
254
  description: "DESTRUCTIVE: Delete an ACL rule",
196
255
  inputSchema: {
197
- siteId: zod_1.z.string().describe("Site ID"),
198
- aclRuleId: zod_1.z.string().describe("ACL rule ID"),
199
- confirm: zod_1.z
200
- .boolean()
201
- .optional()
202
- .describe("Must be true to execute this destructive action"),
256
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
257
+ aclRuleId: (0, params_js_1.resourceId)().describe("ACL rule ID"),
258
+ confirm: (0, safety_js_1.confirmDestructive)(),
203
259
  dryRun: zod_1.z
204
260
  .boolean()
205
261
  .optional()
@@ -207,12 +263,12 @@ function registerAclTools(server, client, readOnly = false) {
207
263
  },
208
264
  annotations: safety_js_1.DESTRUCTIVE,
209
265
  }, async ({ siteId, aclRuleId, confirm, dryRun }) => {
210
- const guard = (0, safety_js_1.requireConfirmation)(confirm, "This will delete the ACL rule");
266
+ const guard = dryRun ? null : (0, safety_js_1.requireConfirmation)(confirm, "This will delete the ACL rule");
211
267
  if (guard)
212
268
  return guard;
213
269
  try {
214
270
  if (dryRun)
215
- return (0, safety_js_1.formatDryRun)("DELETE", `/sites/${siteId}/acl-rules/${aclRuleId}`, {});
271
+ return (0, safety_js_1.formatDryRun)("DELETE", `/sites/${siteId}/acl-rules/${aclRuleId}`);
216
272
  const data = await client.delete(`/sites/${siteId}/acl-rules/${aclRuleId}`);
217
273
  return (0, responses_js_1.formatSuccess)(data);
218
274
  }
@@ -221,24 +277,41 @@ function registerAclTools(server, client, readOnly = false) {
221
277
  }
222
278
  });
223
279
  server.registerTool("unifi_reorder_acl_rules", {
224
- description: "Reorder user-defined ACL rules",
280
+ description: "Reorder user-defined ACL rules. Rules are evaluated in list order, so moving an ALLOW ahead of a BLOCK it used to follow lets that traffic through; such a reorder needs confirm. Reads the current order and the rules it moves past one another, on a dry run too.",
225
281
  inputSchema: {
226
- siteId: zod_1.z.string().describe("Site ID"),
282
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
227
283
  orderedAclRuleIds: zod_1.z
228
284
  .array(zod_1.z.string())
229
285
  .describe("Ordered ACL rule IDs"),
286
+ confirm: zod_1.z
287
+ .boolean()
288
+ .optional()
289
+ .describe("Set true to confirm a reorder that moves an ALLOW rule ahead of a BLOCK rule it used to follow; not needed with dryRun: true"),
230
290
  dryRun: zod_1.z
231
291
  .boolean()
232
292
  .optional()
233
293
  .describe("Preview this action without executing it"),
234
294
  },
235
295
  annotations: safety_js_1.WRITE,
236
- }, async ({ siteId, orderedAclRuleIds, dryRun }) => {
296
+ }, async ({ siteId, orderedAclRuleIds, confirm, dryRun }) => {
237
297
  try {
298
+ const path = `/sites/${siteId}/acl-rules/ordering`;
238
299
  const body = { orderedAclRuleIds };
300
+ const current = (0, full_put_js_1.asObject)(await client.get(path), "the current ACL rule ordering");
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");
304
+ // A disabled rule neither lets anything through nor blocks it.
305
+ const active = rule.enabled !== false;
306
+ return { letsThrough: active && rule.action === "ALLOW", blocks: active && rule.action === "BLOCK" };
307
+ });
308
+ const opening = opens ? "This moves an ALLOW rule ahead of a BLOCK rule it used to follow" : undefined;
309
+ const guard = (0, safety_js_1.requireConfirmationIf)(opening, confirm, dryRun);
310
+ if (guard)
311
+ return guard;
239
312
  if (dryRun)
240
- return (0, safety_js_1.formatDryRun)("PUT", `/sites/${siteId}/acl-rules/ordering`, body);
241
- const data = await client.put(`/sites/${siteId}/acl-rules/ordering`, body);
313
+ return (0, safety_js_1.formatDryRun)("PUT", path, body, opening);
314
+ const data = await client.put(path, body);
242
315
  return (0, responses_js_1.formatSuccess)(data);
243
316
  }
244
317
  catch (err) {
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { NetworkClient } from "../client.js";
3
+ export declare function registerClassicTools(server: McpServer, client: NetworkClient): void;
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerClassicTools = registerClassicTools;
4
+ const responses_js_1 = require("../utils/responses.js");
5
+ const safety_js_1 = require("../utils/safety.js");
6
+ const params_js_1 = require("../utils/params.js");
7
+ const pick_js_1 = require("../utils/pick.js");
8
+ const output_schemas_js_1 = require("../utils/output-schemas.js");
9
+ const CLASSIC_IDS = "IDs are the classic controller's _id values, which differ from Integration API IDs: do not pass one to an Integration API tool, or the reverse.";
10
+ const ruleset = (rule) => (typeof rule.ruleset === "string" ? rule.ruleset : "");
11
+ // A number or a numeric string, depending on the controller version.
12
+ function ruleIndex(rule) {
13
+ const index = Number.parseFloat(String(rule.rule_index));
14
+ return Number.isNaN(index) ? Number.MAX_SAFE_INTEGER : index;
15
+ }
16
+ /** The order legacy rules are evaluated in; a rule with no usable index goes last in its ruleset. */
17
+ function byEvaluationOrder(a, b) {
18
+ return ruleset(a).localeCompare(ruleset(b)) || ruleIndex(a) - ruleIndex(b);
19
+ }
20
+ function registerClassicTools(server, client) {
21
+ const list = (collection, row, order) => {
22
+ const fields = Object.keys(row.shape);
23
+ return async ({ siteReference }) => {
24
+ try {
25
+ const { data: rows } = output_schemas_js_1.ClassicEnvelope.parse(await client.getClassic(siteReference, collection));
26
+ const data = rows.map((record) => row.strip().parse((0, pick_js_1.pick)(record, fields)));
27
+ if (order)
28
+ data.sort(order);
29
+ return (0, responses_js_1.formatSuccess)({ data, count: data.length, siteReference }, { structured: true });
30
+ }
31
+ catch (err) {
32
+ return (0, responses_js_1.formatError)(err);
33
+ }
34
+ };
35
+ };
36
+ server.registerTool("unifi_list_classic_networks", {
37
+ description: `List the site's networks from the classic controller API (rest/networkconf), whole and unpaged. Use it to resolve the classic network IDs legacy firewall rules reference; for everything else prefer unifi_list_networks. ${CLASSIC_IDS} Rows carry only the fields in the output schema; credentials are never returned.`,
38
+ inputSchema: { siteReference: (0, params_js_1.siteReference)() },
39
+ outputSchema: output_schemas_js_1.listClassicNetworksOutputSchema,
40
+ annotations: safety_js_1.READ_ONLY,
41
+ }, list("networkconf", output_schemas_js_1.ClassicNetwork));
42
+ server.registerTool("unifi_list_firewall_groups", {
43
+ description: `List the site's firewall groups (address, IPv6 address and port groups) from the classic controller API (rest/firewallgroup), whole and unpaged. Legacy firewall rules reference these by ID. ${CLASSIC_IDS}`,
44
+ inputSchema: { siteReference: (0, params_js_1.siteReference)() },
45
+ outputSchema: output_schemas_js_1.listFirewallGroupsOutputSchema,
46
+ annotations: safety_js_1.READ_ONLY,
47
+ }, list("firewallgroup", output_schemas_js_1.ClassicFirewallGroup));
48
+ server.registerTool("unifi_list_legacy_firewall_rules", {
49
+ description: `List the site's legacy firewall rules (WAN_IN, LAN_IN, GUEST_LOCAL and the other rulesets) from the classic controller API (rest/firewallrule), whole and unpaged. On a site that uses the zone-based firewall these rules may not be enforced; its rules are the policies unifi_list_firewall_policies returns. ${CLASSIC_IDS} Rules reference networks by unifi_list_classic_networks IDs and groups by unifi_list_firewall_groups IDs. Sorted by ruleset, then rule_index, the order they are evaluated in.`,
50
+ inputSchema: { siteReference: (0, params_js_1.siteReference)() },
51
+ outputSchema: output_schemas_js_1.listLegacyFirewallRulesOutputSchema,
52
+ annotations: safety_js_1.READ_ONLY,
53
+ }, list("firewallrule", output_schemas_js_1.LegacyFirewallRule, byEvaluationOrder));
54
+ }
@@ -4,32 +4,40 @@ exports.registerClientHistoryTools = registerClientHistoryTools;
4
4
  const zod_1 = require("zod");
5
5
  const responses_js_1 = require("../utils/responses.js");
6
6
  const safety_js_1 = require("../utils/safety.js");
7
+ const params_js_1 = require("../utils/params.js");
7
8
  const output_schemas_js_1 = require("../utils/output-schemas.js");
8
- const siteReference = zod_1.z.string().regex(/^[A-Za-z0-9_-]{1,64}$/)
9
- .describe("Site internalReference from unifi_list_sites (often default), not the Integration API UUID");
9
+ const siteReference = (0, params_js_1.siteReference)();
10
10
  function registerClientHistoryTools(server, client) {
11
11
  server.registerTool("unifi_list_client_history", {
12
- description: "List historical/offline client inventory using the controller v2 API. Returns OFFLINE clients only — currently connected clients are excluded, so merge with unifi_list_clients for a full inventory. Includes names, MACs, first_seen and last_seen epoch seconds when available. This is inventory, not session history; use unifi_list_client_sessions for past connections. Requires controller history API-key support; errors never mean zero activity. Retention depends on the controller.",
13
- inputSchema: { siteReference, withinHours: zod_1.z.number().int().min(1).max(8760).default(168).describe("Lookback in hours, up to one year; does not extend controller retention") },
12
+ description: `List historical/offline client inventory using the controller v2 API. Returns OFFLINE clients only — currently connected clients are excluded, so merge with unifi_list_clients for a full inventory. Includes names, MACs, first_seen and last_seen epoch seconds when available. This is inventory, not session history; use unifi_list_client_sessions for past connections. Requires controller history API-key support; errors never mean zero activity. Retention depends on the controller. Returns the most recently seen clients first, up to limit, with totalCount; truncated: true means less recently seen clients were left out, so raise limit (narrowing withinHours drops those same clients). Rows carry only ${Object.keys(output_schemas_js_1.HistoryClient.shape).join(", ")}.`,
13
+ inputSchema: {
14
+ siteReference,
15
+ withinHours: zod_1.z.number().int().min(1).max(8760).default(168).describe("Lookback in hours, up to one year; does not extend controller retention"),
16
+ limit: zod_1.z.number().int().min(1).max(500).default(100).describe("Maximum clients, most recently seen first"),
17
+ },
14
18
  outputSchema: output_schemas_js_1.listClientHistoryOutputSchema,
15
19
  annotations: safety_js_1.READ_ONLY,
16
- }, async ({ siteReference, withinHours }) => {
20
+ }, async ({ siteReference, withinHours, limit }) => {
17
21
  try {
18
- const data = zod_1.z.array(output_schemas_js_1.HistoryClient).parse(await client.getClientHistory(siteReference, withinHours));
19
- return (0, responses_js_1.formatSuccess)({ data, count: data.length, withinHours, coverage: "Historical client inventory returned by the controller; not a complete session log." }, { structured: true });
22
+ // The controller returns every client in the window with no paging, so
23
+ // bound the result here: an unbounded inventory can run to megabytes.
24
+ const all = zod_1.z.array(output_schemas_js_1.HistoryClient).parse(await client.getClientHistory(siteReference, withinHours));
25
+ all.sort((a, b) => (b.last_seen ?? 0) - (a.last_seen ?? 0));
26
+ const data = all.slice(0, limit).map((row) => output_schemas_js_1.HistoryClient.strip().parse(row));
27
+ return (0, responses_js_1.formatSuccess)({ data, count: data.length, totalCount: all.length, limit, truncated: all.length > limit, withinHours, coverage: "Historical client inventory returned by the controller; not a complete session log." }, { structured: true });
20
28
  }
21
29
  catch (err) {
22
30
  return (0, responses_js_1.formatError)(err);
23
31
  }
24
32
  });
25
33
  server.registerTool("unifi_list_client_sessions", {
26
- description: "Read retained past client connections, including disconnected clients, from the controller stat/session query endpoint. POST here only reads statistics. Returns controller session IDs, MACs, assoc_time (epoch seconds), duration (seconds), rx_bytes/tx_bytes (controller perspective), and roaming_sessions when available. Use an explicit UTC epoch-second window (max 31 days). If mayBeTruncated is true, split the time window (overlap boundaries and deduplicate IDs) or filter by MAC. The controller ignores offset pagination. Records reflect controller retention and may omit ongoing sessions. Wi-Fi association/bytes do not establish end-to-end Internet success. Do not interpret controller is_guest as proof of a person's identity or ownership.",
34
+ description: "Read retained past client connections, including disconnected clients, from the controller stat/session query endpoint. POST here only reads statistics. Rows carry only _id, mac, hostname, is_wired, assoc_time (epoch seconds), duration (seconds), rx_bytes/tx_bytes (controller perspective) and roaming_sessions when available. Use an explicit UTC epoch-second window (max 31 days). If mayBeTruncated is true, split the time window (overlap boundaries and deduplicate IDs) or filter by MAC. The controller ignores offset pagination. Records reflect controller retention and may omit ongoing sessions. Wi-Fi association/bytes do not establish end-to-end Internet success.",
27
35
  inputSchema: {
28
36
  siteReference,
29
37
  start: zod_1.z.number().int().min(0).max(4102444800).describe("Start Unix epoch seconds, not milliseconds"),
30
38
  end: zod_1.z.number().int().min(1).max(4102444800).describe("End Unix epoch seconds; after start and at most 31 days later"),
31
39
  mac: zod_1.z.string().regex(/^([\da-f]{2}:){5}[\da-f]{2}$/i).optional().describe("Optional client MAC address"),
32
- limit: zod_1.z.number().int().min(1).max(1000).default(1000).describe("Maximum records; a full result requires a narrower window or MAC filter"),
40
+ limit: zod_1.z.number().int().min(1).max(1000).default(100).describe("Maximum records; a full result requires a narrower window or MAC filter"),
33
41
  },
34
42
  outputSchema: output_schemas_js_1.listClientSessionsOutputSchema,
35
43
  annotations: safety_js_1.READ_ONLY,
@@ -49,10 +57,12 @@ function registerClientHistoryTools(server, client) {
49
57
  seen.add(row._id);
50
58
  deduped.push(row);
51
59
  }
52
- const data = deduped.slice(0, limit);
53
- const mayBeTruncated = deduped.length >= limit;
60
+ const data = deduped.slice(0, limit).map((row) => output_schemas_js_1.ClientSession.strip().parse(row));
61
+ // Judge truncation by the page the controller sent: duplicates removed
62
+ // above say nothing about whether more sessions exist past it.
63
+ const mayBeTruncated = rows.length >= limit;
54
64
  return (0, responses_js_1.formatSuccess)({ data, count: data.length, limit, mayBeTruncated, start, end,
55
- coverage: "Retained controller sessions; A non-truncated response does not prove retention coverage or include every ongoing connection." }, { structured: true });
65
+ coverage: "Retained controller sessions; a non-truncated response does not prove retention coverage or include every ongoing connection." }, { structured: true });
56
66
  }
57
67
  catch (err) {
58
68
  return (0, responses_js_1.formatError)(err);
@@ -6,24 +6,25 @@ const responses_js_1 = require("../utils/responses.js");
6
6
  const query_js_1 = require("../utils/query.js");
7
7
  const safety_js_1 = require("../utils/safety.js");
8
8
  const output_schemas_js_1 = require("../utils/output-schemas.js");
9
+ const params_js_1 = require("../utils/params.js");
9
10
  function registerClientTools(server, client, readOnly = false) {
10
11
  server.registerTool("unifi_list_clients", {
11
12
  description: "List currently connected clients at a site. Returns per client: id, name, type (WIRED/WIRELESS/VPN/TELEPORT), macAddress, ipAddress (may be absent), connectedAt, uplinkDeviceId (the switch/AP they're attached to), access.type. NOTE: verified against 11.0.83 (WIRED/WIRELESS; VPN/TELEPORT not live-verified) — the Integration API client schema is minimal and identical across those types; it does NOT expose signal strength, channel, or per-port binding. Use for: who's online right now. Disconnected/historical clients are NOT in the Integration API; use unifi_list_client_history and unifi_list_client_sessions for controller-retained records.",
12
13
  inputSchema: {
13
- siteId: zod_1.z.string().describe("Site ID"),
14
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
14
15
  offset: zod_1.z
15
16
  .number()
16
17
  .int()
17
18
  .nonnegative()
18
19
  .optional()
19
- .describe("Number of records to skip (default: 0)"),
20
+ .describe("Number of records to skip"),
20
21
  limit: zod_1.z
21
22
  .number()
22
23
  .int()
23
24
  .min(0)
24
25
  .max(200)
25
26
  .optional()
26
- .describe("Number of records to return (default: 25, max: 200)"),
27
+ .describe("Number of records to return (API default: 25)"),
27
28
  filter: zod_1.z.string().optional().describe("Filter expression. Filterable: id, type, macAddress, ipAddress, connectedAt (timestamp), access.type, access.authorized (boolean). NOT filterable: name, uplinkDeviceId. Example: type.eq('WIRELESS')"),
28
29
  },
29
30
  outputSchema: output_schemas_js_1.listClientsOutputSchema,
@@ -41,8 +42,8 @@ function registerClientTools(server, client, readOnly = false) {
41
42
  server.registerTool("unifi_get_client", {
42
43
  description: "Get a specific connected client by ID. Returns same shape as unifi_list_clients entries.",
43
44
  inputSchema: {
44
- siteId: zod_1.z.string().describe("Site ID"),
45
- clientId: zod_1.z.string().describe("Client ID"),
45
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
46
+ clientId: (0, params_js_1.resourceId)().describe("Client ID"),
46
47
  },
47
48
  outputSchema: output_schemas_js_1.getClientOutputSchema,
48
49
  annotations: safety_js_1.READ_ONLY,
@@ -60,8 +61,8 @@ function registerClientTools(server, client, readOnly = false) {
60
61
  server.registerTool("unifi_authorize_guest", {
61
62
  description: "Authorize a guest client through a captive-portal hotspot. Optional limits override hotspot defaults. Returns (per 11.0.83 docs, not live-verified): action, grantedAuthorization and revokedAuthorization objects (authorizedAt, authorizationMethod, expiresAt, dataUsageLimitMBytes, rxRateLimitKbps, txRateLimitKbps, usage). Idempotency: not safe to retry — re-authorizing extends the session.",
62
63
  inputSchema: {
63
- siteId: zod_1.z.string().describe("Site ID"),
64
- clientId: zod_1.z.string().describe("Client ID"),
64
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
65
+ clientId: (0, params_js_1.resourceId)().describe("Client ID"),
65
66
  timeLimitMinutes: zod_1.z
66
67
  .number()
67
68
  .int()
@@ -118,8 +119,8 @@ function registerClientTools(server, client, readOnly = false) {
118
119
  server.registerTool("unifi_unauthorize_guest", {
119
120
  description: "Revoke guest authorization; the client returns to the captive portal on next request. Returns (per docs; the UNAUTHORIZE response sample is not rendered in 11.0.83 and not live-verified): action plus a revokedAuthorization object. Idempotent.",
120
121
  inputSchema: {
121
- siteId: zod_1.z.string().describe("Site ID"),
122
- clientId: zod_1.z.string().describe("Client ID"),
122
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
123
+ clientId: (0, params_js_1.resourceId)().describe("Client ID"),
123
124
  dryRun: zod_1.z.boolean().optional().describe("Preview this action without executing it"),
124
125
  },
125
126
  annotations: safety_js_1.WRITE,