@owine/unifi-network-mcp 2.15.0 → 3.0.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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +226 -73
  3. package/dist/client.d.ts +13 -1
  4. package/dist/client.js +142 -16
  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 +171 -0
  9. package/dist/index.js +15 -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/client-history.js +20 -10
  16. package/dist/tools/clients.js +10 -9
  17. package/dist/tools/devices.js +21 -20
  18. package/dist/tools/dns-policies.js +27 -27
  19. package/dist/tools/firewall.d.ts +2 -1
  20. package/dist/tools/firewall.js +200 -75
  21. package/dist/tools/hotspot.js +35 -28
  22. package/dist/tools/index.d.ts +13 -1
  23. package/dist/tools/index.js +63 -4
  24. package/dist/tools/networks.d.ts +2 -1
  25. package/dist/tools/networks.js +44 -31
  26. package/dist/tools/sites.js +2 -2
  27. package/dist/tools/supporting.js +22 -21
  28. package/dist/tools/switching.js +16 -15
  29. package/dist/tools/system.js +1 -1
  30. package/dist/tools/traffic-matching.d.ts +2 -1
  31. package/dist/tools/traffic-matching.js +22 -20
  32. package/dist/tools/wifi.js +76 -30
  33. package/dist/utils/full-put.d.ts +11 -2
  34. package/dist/utils/full-put.js +25 -2
  35. package/dist/utils/output-schemas.d.ts +26 -4
  36. package/dist/utils/output-schemas.js +80 -48
  37. package/dist/utils/params.d.ts +7 -0
  38. package/dist/utils/params.js +12 -0
  39. package/dist/utils/responses.d.ts +2 -0
  40. package/dist/utils/responses.js +28 -2
  41. package/dist/utils/safety.d.ts +35 -1
  42. package/dist/utils/safety.js +53 -3
  43. package/dist/utils/secrets.js +3 -0
  44. package/dist/utils/wan-exposure.d.ts +69 -0
  45. package/dist/utils/wan-exposure.js +245 -0
  46. package/dist/write-log.d.ts +52 -0
  47. package/dist/write-log.js +178 -0
  48. package/dist/write-tools.d.ts +27 -0
  49. package/dist/write-tools.js +50 -0
  50. package/package.json +17 -13
@@ -1,22 +1,27 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.listClientSessionsOutputSchema = exports.listClientHistoryOutputSchema = exports.ClientSessionEnvelope = exports.ClientSession = exports.HistoryClient = exports.listPendingDevicesOutputSchema = exports.listCountriesOutputSchema = exports.listDpiApplicationsOutputSchema = exports.listDpiCategoriesOutputSchema = exports.listDeviceTagsOutputSchema = exports.listRadiusProfilesOutputSchema = exports.listVpnTunnelsOutputSchema = exports.listVpnServersOutputSchema = exports.listWansOutputSchema = exports.getWifiOutputSchema = exports.listWifiOutputSchema = exports.getClientOutputSchema = exports.listClientsOutputSchema = exports.getDeviceStatisticsOutputSchema = exports.getDeviceOutputSchema = exports.listDevicesOutputSchema = exports.listTrafficMatchingListsOutputSchema = exports.trafficMatchingListOutputSchema = exports.listDnsPoliciesOutputSchema = exports.dnsPolicyOutputSchema = exports.listLagsOutputSchema = exports.lagOutputSchema = exports.listMcLagDomainsOutputSchema = exports.mcLagDomainOutputSchema = exports.listSwitchStacksOutputSchema = exports.switchStackOutputSchema = exports.aclRuleOrderingOutputSchema = exports.listAclRulesOutputSchema = exports.aclRuleOutputSchema = exports.firewallPolicyOrderingOutputSchema = exports.listFirewallPoliciesOutputSchema = exports.firewallPolicyOutputSchema = exports.listFirewallZonesOutputSchema = exports.firewallZoneOutputSchema = exports.createVouchersOutputSchema = exports.listVouchersOutputSchema = exports.voucherOutputSchema = exports.getNetworkReferencesOutputSchema = exports.listNetworksOutputSchema = exports.networkOutputSchema = exports.listSitesOutputSchema = exports.getInfoOutputSchema = void 0;
4
+ exports.looseOutput = looseOutput;
5
+ exports.writeResult = writeResult;
4
6
  /**
5
- * Output schemas for high-value read tools (loose strategy: every non-key
6
- * field optional, every nested object uses .passthrough() to allow
7
+ * Output schemas for every read tool, and for write tools whose response
8
+ * echoes the resource (loose strategy: every non-key
9
+ * field optional, every nested object uses .loose() to allow
7
10
  * firmware/hardware-specific fields to flow through unchanged).
8
11
  *
9
12
  * Verified against UniFi Network API 11.0.83. Where the docs collapse
10
13
  * nested arrays/objects (e.g. interfaces.ports[], radios[]), the schema
11
- * uses passthrough records so the contract doesn't lock to fields we
12
- * haven't verified.
14
+ * uses loose records so the contract doesn't lock to fields we
15
+ * haven't verified. Tools register these raw shapes, and registerAllTools()
16
+ * publishes each as a loose object (src/tools/index.ts), so fields a schema
17
+ * does not list never fail client-side validation.
13
18
  */
14
19
  const zod_1 = require("zod");
15
20
  // configurable is live-verified (11.0.83) on system-defined zones, policies,
16
21
  // networks and RADIUS profiles; it is absent on user-defined resources.
17
22
  const Metadata = zod_1.z
18
23
  .object({ origin: zod_1.z.string().optional(), configurable: zod_1.z.boolean().optional() })
19
- .passthrough();
24
+ .loose();
20
25
  const PaginationFields = {
21
26
  offset: zod_1.z.number().int().optional(),
22
27
  limit: zod_1.z.number().int().optional(),
@@ -45,7 +50,7 @@ const DeviceListItem = zod_1.z
45
50
  features: zod_1.z.array(zod_1.z.string()).optional(),
46
51
  interfaces: zod_1.z.array(zod_1.z.string()).optional(),
47
52
  })
48
- .passthrough();
53
+ .loose();
49
54
  const DevicePort = zod_1.z
50
55
  .object({
51
56
  idx: zod_1.z.number(),
@@ -61,10 +66,10 @@ const DevicePort = zod_1.z
61
66
  enabled: zod_1.z.boolean().optional(),
62
67
  state: zod_1.z.string().optional(),
63
68
  })
64
- .passthrough()
69
+ .loose()
65
70
  .optional(),
66
71
  })
67
- .passthrough();
72
+ .loose();
68
73
  const DeviceRadio = zod_1.z
69
74
  .object({
70
75
  wlanStandard: zod_1.z.string().optional(),
@@ -72,7 +77,7 @@ const DeviceRadio = zod_1.z
72
77
  channelWidthMHz: zod_1.z.number().optional(),
73
78
  channel: zod_1.z.number().optional(),
74
79
  })
75
- .passthrough();
80
+ .loose();
76
81
  const DeviceDetail = zod_1.z
77
82
  .object({
78
83
  ...DeviceCommon,
@@ -81,7 +86,7 @@ const DeviceDetail = zod_1.z
81
86
  configurationId: zod_1.z.string().optional(),
82
87
  uplink: zod_1.z
83
88
  .object({ deviceId: zod_1.z.string().optional() })
84
- .passthrough()
89
+ .loose()
85
90
  .optional(),
86
91
  // Detail view: object keyed by capability (switching/accessPoint).
87
92
  features: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
@@ -90,10 +95,10 @@ const DeviceDetail = zod_1.z
90
95
  ports: zod_1.z.array(DevicePort).optional(),
91
96
  radios: zod_1.z.array(DeviceRadio).optional(),
92
97
  })
93
- .passthrough()
98
+ .loose()
94
99
  .optional(),
95
100
  })
96
- .passthrough();
101
+ .loose();
97
102
  const DeviceStatistics = zod_1.z
98
103
  .object({
99
104
  uptimeSec: zod_1.z.number().optional(),
@@ -109,7 +114,7 @@ const DeviceStatistics = zod_1.z
109
114
  txRateBps: zod_1.z.number().optional(),
110
115
  rxRateBps: zod_1.z.number().optional(),
111
116
  })
112
- .passthrough()
117
+ .loose()
113
118
  .optional(),
114
119
  interfaces: zod_1.z
115
120
  .object({
@@ -119,13 +124,13 @@ const DeviceStatistics = zod_1.z
119
124
  frequencyGHz: zod_1.z.number().optional(),
120
125
  txRetriesPct: zod_1.z.number().optional(),
121
126
  })
122
- .passthrough())
127
+ .loose())
123
128
  .optional(),
124
129
  })
125
- .passthrough()
130
+ .loose()
126
131
  .optional(),
127
132
  })
128
- .passthrough();
133
+ .loose();
129
134
  const Client = zod_1.z
130
135
  .object({
131
136
  id: zod_1.z.string(),
@@ -137,10 +142,10 @@ const Client = zod_1.z
137
142
  uplinkDeviceId: zod_1.z.string().optional(),
138
143
  access: zod_1.z
139
144
  .object({ type: zod_1.z.string().optional() })
140
- .passthrough()
145
+ .loose()
141
146
  .optional(),
142
147
  })
143
- .passthrough();
148
+ .loose();
144
149
  const WifiBroadcast = zod_1.z
145
150
  .object({
146
151
  id: zod_1.z.string(),
@@ -159,20 +164,20 @@ const WifiBroadcast = zod_1.z
159
164
  broadcastingDeviceFilter: zod_1.z.unknown().optional(),
160
165
  metadata: Metadata.optional(),
161
166
  })
162
- .passthrough();
167
+ .loose();
163
168
  const ApplicationInfo = zod_1.z
164
169
  .object({
165
170
  // Live-verified (11.0.83): only applicationVersion is returned.
166
171
  applicationVersion: zod_1.z.string().optional(),
167
172
  })
168
- .passthrough();
173
+ .loose();
169
174
  const Site = zod_1.z
170
175
  .object({
171
176
  id: zod_1.z.string(),
172
177
  internalReference: zod_1.z.string().optional(),
173
178
  name: zod_1.z.string().optional(),
174
179
  })
175
- .passthrough();
180
+ .loose();
176
181
  const Network = zod_1.z
177
182
  .object({
178
183
  id: zod_1.z.string(),
@@ -192,12 +197,12 @@ const Network = zod_1.z
192
197
  ipv6Configuration: zod_1.z.unknown().optional(),
193
198
  metadata: Metadata.optional(),
194
199
  })
195
- .passthrough();
200
+ .loose();
196
201
  const NetworkReferences = zod_1.z
197
202
  .object({
198
203
  referenceResources: zod_1.z.array(zod_1.z.unknown()).optional(),
199
204
  })
200
- .passthrough();
205
+ .loose();
201
206
  const Voucher = zod_1.z
202
207
  .object({
203
208
  id: zod_1.z.string(),
@@ -214,7 +219,7 @@ const Voucher = zod_1.z
214
219
  authorizedGuestCount: zod_1.z.number().optional(),
215
220
  expired: zod_1.z.boolean().optional(),
216
221
  })
217
- .passthrough();
222
+ .loose();
218
223
  const FirewallZone = zod_1.z
219
224
  .object({
220
225
  id: zod_1.z.string(),
@@ -222,7 +227,7 @@ const FirewallZone = zod_1.z
222
227
  networkIds: zod_1.z.array(zod_1.z.string()).optional(),
223
228
  metadata: Metadata.optional(),
224
229
  })
225
- .passthrough();
230
+ .loose();
226
231
  const FirewallPolicy = zod_1.z
227
232
  .object({
228
233
  id: zod_1.z.string(),
@@ -244,7 +249,7 @@ const FirewallPolicy = zod_1.z
244
249
  description: zod_1.z.string().optional(),
245
250
  metadata: Metadata.optional(),
246
251
  })
247
- .passthrough();
252
+ .loose();
248
253
  const FirewallPolicyOrdering = zod_1.z
249
254
  .object({
250
255
  orderedFirewallPolicyIds: zod_1.z
@@ -252,10 +257,10 @@ const FirewallPolicyOrdering = zod_1.z
252
257
  beforeSystemDefined: zod_1.z.array(zod_1.z.string()).optional(),
253
258
  afterSystemDefined: zod_1.z.array(zod_1.z.string()).optional(),
254
259
  })
255
- .passthrough()
260
+ .loose()
256
261
  .optional(),
257
262
  })
258
- .passthrough();
263
+ .loose();
259
264
  const AclRule = zod_1.z
260
265
  .object({
261
266
  id: zod_1.z.string(),
@@ -276,12 +281,12 @@ const AclRule = zod_1.z
276
281
  index: zod_1.z.number().int().optional(),
277
282
  metadata: Metadata.optional(),
278
283
  })
279
- .passthrough();
284
+ .loose();
280
285
  const AclRuleOrdering = zod_1.z
281
286
  .object({
282
287
  orderedAclRuleIds: zod_1.z.array(zod_1.z.string()).optional(),
283
288
  })
284
- .passthrough();
289
+ .loose();
285
290
  const SwitchStack = zod_1.z
286
291
  .object({
287
292
  id: zod_1.z.string(),
@@ -296,7 +301,7 @@ const SwitchStack = zod_1.z
296
301
  lags: zod_1.z.array(zod_1.z.unknown()).optional(),
297
302
  metadata: Metadata.optional(),
298
303
  })
299
- .passthrough();
304
+ .loose();
300
305
  const McLagDomain = zod_1.z
301
306
  .object({
302
307
  id: zod_1.z.string(),
@@ -305,7 +310,7 @@ const McLagDomain = zod_1.z
305
310
  lags: zod_1.z.array(zod_1.z.unknown()).optional(),
306
311
  metadata: Metadata.optional(),
307
312
  })
308
- .passthrough();
313
+ .loose();
309
314
  const Lag = zod_1.z
310
315
  .object({
311
316
  id: zod_1.z.string(),
@@ -316,11 +321,11 @@ const Lag = zod_1.z
316
321
  deviceId: zod_1.z.string().optional(),
317
322
  portIdxs: zod_1.z.array(zod_1.z.number()).optional(),
318
323
  })
319
- .passthrough())
324
+ .loose())
320
325
  .optional(),
321
326
  metadata: Metadata.optional(),
322
327
  })
323
- .passthrough();
328
+ .loose();
324
329
  const DnsPolicy = zod_1.z
325
330
  .object({
326
331
  id: zod_1.z.string(),
@@ -344,7 +349,7 @@ const DnsPolicy = zod_1.z
344
349
  ipAddress: zod_1.z.string().nullable().optional(),
345
350
  metadata: Metadata.optional(),
346
351
  })
347
- .passthrough();
352
+ .loose();
348
353
  const TrafficMatchingList = zod_1.z
349
354
  .object({
350
355
  id: zod_1.z.string(),
@@ -353,7 +358,7 @@ const TrafficMatchingList = zod_1.z
353
358
  items: zod_1.z.array(zod_1.z.unknown()).optional(),
354
359
  metadata: Metadata.optional(),
355
360
  })
356
- .passthrough();
361
+ .loose();
357
362
  // Verified against the live Integration API (11.0.83). The WAN and
358
363
  // site-to-site-tunnel list rows are intentionally minimal in the API.
359
364
  const Wan = zod_1.z
@@ -361,7 +366,7 @@ const Wan = zod_1.z
361
366
  id: zod_1.z.string(),
362
367
  name: zod_1.z.string().optional(),
363
368
  })
364
- .passthrough();
369
+ .loose();
365
370
  const VpnServer = zod_1.z
366
371
  .object({
367
372
  id: zod_1.z.string(),
@@ -370,21 +375,21 @@ const VpnServer = zod_1.z
370
375
  enabled: zod_1.z.boolean().optional(),
371
376
  metadata: Metadata.optional(),
372
377
  })
373
- .passthrough();
378
+ .loose();
374
379
  // No tunnels present in the verification environment — keep loose.
375
380
  const VpnTunnel = zod_1.z
376
381
  .object({
377
382
  id: zod_1.z.string(),
378
383
  name: zod_1.z.string().optional(),
379
384
  })
380
- .passthrough();
385
+ .loose();
381
386
  const RadiusProfile = zod_1.z
382
387
  .object({
383
388
  id: zod_1.z.string(),
384
389
  name: zod_1.z.string().optional(),
385
390
  metadata: Metadata.optional(),
386
391
  })
387
- .passthrough();
392
+ .loose();
388
393
  // No tags present in the verification environment — documented shape.
389
394
  const DeviceTag = zod_1.z
390
395
  .object({
@@ -392,27 +397,27 @@ const DeviceTag = zod_1.z
392
397
  name: zod_1.z.string().optional(),
393
398
  deviceIds: zod_1.z.array(zod_1.z.string()).optional(),
394
399
  })
395
- .passthrough();
400
+ .loose();
396
401
  // DPI category/application ids are NUMERIC (verified live).
397
402
  const DpiCategory = zod_1.z
398
403
  .object({
399
404
  id: zod_1.z.number(),
400
405
  name: zod_1.z.string().optional(),
401
406
  })
402
- .passthrough();
407
+ .loose();
403
408
  const DpiApplication = zod_1.z
404
409
  .object({
405
410
  id: zod_1.z.number(),
406
411
  name: zod_1.z.string().optional(),
407
412
  })
408
- .passthrough();
413
+ .loose();
409
414
  // Countries key on `code` (ISO alpha-2), NOT `id` (verified live).
410
415
  const Country = zod_1.z
411
416
  .object({
412
417
  code: zod_1.z.string(),
413
418
  name: zod_1.z.string().optional(),
414
419
  })
415
- .passthrough();
420
+ .loose();
416
421
  // No pending devices in the verification environment — keep loose.
417
422
  const PendingDevice = zod_1.z
418
423
  .object({
@@ -421,7 +426,31 @@ const PendingDevice = zod_1.z
421
426
  ipAddress: zod_1.z.string().optional(),
422
427
  firmwareVersion: zod_1.z.string().optional(),
423
428
  })
424
- .passthrough();
429
+ .loose();
430
+ // A write tool returns either the API's echo of the resource or, with dryRun,
431
+ // a preview of the request it would send. The SDK validates both against the
432
+ // one outputSchema, so the resource's key fields relax to optional alongside
433
+ // the preview fields; read tools keep their stricter schemas.
434
+ const DryRunPreview = {
435
+ dryRun: zod_1.z.literal(true).optional(),
436
+ wouldExecute: zod_1.z
437
+ .object({ method: zod_1.z.string(), path: zod_1.z.string(), body: zod_1.z.unknown().optional() })
438
+ .loose()
439
+ .optional(),
440
+ confirmationRequired: zod_1.z.string().optional(),
441
+ };
442
+ /**
443
+ * The outputSchema to publish for a tool: always a loose object, whether the
444
+ * tool passed a raw shape or a Zod object (the SDK accepts both). Wrapping a
445
+ * Zod object in z.looseObject() would build a broken schema that makes
446
+ * tools/list throw for every tool.
447
+ */
448
+ function looseOutput(schema) {
449
+ return schema instanceof zod_1.z.ZodObject ? schema.loose() : zod_1.z.looseObject(schema);
450
+ }
451
+ function writeResult(shape) {
452
+ return { ...zod_1.z.object(shape).partial().shape, ...DryRunPreview };
453
+ }
425
454
  exports.getInfoOutputSchema = ApplicationInfo.shape;
426
455
  exports.listSitesOutputSchema = {
427
456
  ...PaginationFields,
@@ -552,7 +581,7 @@ exports.HistoryClient = zod_1.z
552
581
  is_wired: zod_1.z.boolean().optional(),
553
582
  status: zod_1.z.string().optional(),
554
583
  })
555
- .passthrough();
584
+ .loose();
556
585
  exports.ClientSession = zod_1.z
557
586
  .object({
558
587
  _id: zod_1.z.string(),
@@ -565,15 +594,18 @@ exports.ClientSession = zod_1.z
565
594
  is_wired: zod_1.z.boolean().optional(),
566
595
  roaming_sessions: zod_1.z.array(zod_1.z.unknown()).optional(),
567
596
  })
568
- .passthrough();
597
+ .loose();
569
598
  /** Envelope returned by the controller's stat/session endpoint. */
570
599
  exports.ClientSessionEnvelope = zod_1.z.object({
571
- meta: zod_1.z.object({ rc: zod_1.z.literal("ok") }).passthrough(),
600
+ meta: zod_1.z.object({ rc: zod_1.z.literal("ok") }).loose(),
572
601
  data: zod_1.z.array(exports.ClientSession),
573
602
  });
574
603
  exports.listClientHistoryOutputSchema = {
575
604
  data: zod_1.z.array(exports.HistoryClient),
576
605
  count: zod_1.z.number(),
606
+ totalCount: zod_1.z.number(),
607
+ limit: zod_1.z.number(),
608
+ truncated: zod_1.z.boolean(),
577
609
  withinHours: zod_1.z.number(),
578
610
  coverage: zod_1.z.string(),
579
611
  };
@@ -0,0 +1,7 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * An Integration API resource ID. IDs are interpolated into request paths, so
4
+ * only the plain ID charset is accepted — anything else could smuggle a query
5
+ * (`?force=true`), a fragment, or extra path segments into the request.
6
+ */
7
+ export declare function resourceId(): z.ZodString;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resourceId = resourceId;
4
+ const zod_1 = require("zod");
5
+ /**
6
+ * An Integration API resource ID. IDs are interpolated into request paths, so
7
+ * only the plain ID charset is accepted — anything else could smuggle a query
8
+ * (`?force=true`), a fragment, or extra path segments into the request.
9
+ */
10
+ function resourceId() {
11
+ return zod_1.z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, "Use an ID returned by a list or get tool");
12
+ }
@@ -7,6 +7,8 @@ export declare function formatSuccess(data: unknown, opts?: {
7
7
  }[];
8
8
  structuredContent?: Record<string, unknown>;
9
9
  };
10
+ /** The message of a thrown value, which need not be an Error. */
11
+ export declare function errorMessage(err: unknown): string;
10
12
  export declare function formatError(err: unknown): {
11
13
  content: {
12
14
  type: "text";
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatSuccess = formatSuccess;
4
+ exports.errorMessage = errorMessage;
4
5
  exports.formatError = formatError;
5
6
  function formatSuccess(data, opts) {
6
7
  const result = {
7
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
8
+ content: [{ type: "text", text: JSON.stringify(data) }],
8
9
  };
9
10
  if (opts?.structured &&
10
11
  data !== null &&
@@ -14,10 +15,35 @@ function formatSuccess(data, opts) {
14
15
  }
15
16
  return result;
16
17
  }
18
+ /** The message of a thrown value, which need not be an Error. */
19
+ function errorMessage(err) {
20
+ return err instanceof Error ? err.message : String(err);
21
+ }
17
22
  function formatError(err) {
18
- const message = err instanceof Error ? err.message : String(err);
23
+ 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
+ const cause = err instanceof Error ? err.cause : undefined;
27
+ const code = cause instanceof Error && "code" in cause && typeof cause.code === "string" ? cause.code : undefined;
28
+ if (code) {
29
+ message += ` (${code})`;
30
+ message += tlsHint(code);
31
+ }
19
32
  return {
20
33
  content: [{ type: "text", text: `Error: ${message}` }],
21
34
  isError: true,
22
35
  };
23
36
  }
37
+ /**
38
+ * What to do about a certificate that did not verify. Pinning makes a
39
+ * console's own certificate trusted, but it does not lift the validity dates,
40
+ * so an expired certificate gets its own advice.
41
+ */
42
+ function tlsHint(code) {
43
+ if (code === "CERT_HAS_EXPIRED" || code === "CERT_NOT_YET_VALID") {
44
+ return ". The console's TLS certificate is outside its validity dates; check the clocks on the console and this machine, and renew the certificate if it has expired (pinning it with UNIFI_NETWORK_CA_CERT does not override the dates)";
45
+ }
46
+ if (!/CERT|SELF_SIGNED|LEAF_SIGNATURE/.test(code))
47
+ return "";
48
+ return ". The console's TLS certificate did not verify. For a local console with a self-signed certificate, save the certificate and point UNIFI_NETWORK_CA_CERT at it; if it is already set, the console has a new certificate, so save it again. Against api.ui.com, something between you and Ubiquiti presented this certificate: do not work around it";
49
+ }
@@ -1,14 +1,48 @@
1
+ import { z } from "zod";
1
2
  import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
3
+ /** The confirm input of a DESTRUCTIVE tool. */
4
+ export declare const confirmDestructive: () => z.ZodOptional<z.ZodBoolean>;
2
5
  export declare const READ_ONLY: ToolAnnotations;
3
6
  export declare const WRITE: ToolAnnotations;
4
7
  export declare const WRITE_NOT_IDEMPOTENT: ToolAnnotations;
5
8
  export declare const DESTRUCTIVE: ToolAnnotations;
6
- export declare function formatDryRun(method: string, path: string, body?: unknown): {
9
+ /**
10
+ * A dry run's result. confirmationRequired is the reason the real call needs
11
+ * confirm, if it does: the preview is what the user approves, so it has to
12
+ * say that the change opens access before they approve it, not after.
13
+ */
14
+ export declare function formatDryRun(method: string, path: string, body?: unknown, confirmationRequired?: string): {
7
15
  content: {
8
16
  type: "text";
9
17
  text: string;
10
18
  }[];
19
+ structuredContent: Record<string, unknown>;
11
20
  };
21
+ /**
22
+ * Confirmation for a write that is routine in general but, with these inputs,
23
+ * opens access — an ALLOW rule, an open SSID, a batch of guest codes. reason
24
+ * says what opens, or is undefined when nothing does; a dry run changes
25
+ * nothing, so it never needs confirming.
26
+ */
27
+ export declare function requireConfirmationIf(reason: string | undefined, confirm: boolean | undefined, dryRun: boolean | undefined): {
28
+ content: {
29
+ type: "text";
30
+ text: string;
31
+ }[];
32
+ isError: true;
33
+ } | null;
34
+ /** How a rule in an evaluation order treats the traffic it matches. */
35
+ export interface RuleEffect {
36
+ letsThrough: boolean;
37
+ blocks: boolean;
38
+ }
39
+ /**
40
+ * Whether a reorder opens access: whether it moves a rule that lets traffic
41
+ * through ahead of one that blocks, which it used to follow. Only rules in
42
+ * both orders are compared, and effect is asked only about the rules a
43
+ * reorder actually moves past one another. IDs are compared without case.
44
+ */
45
+ export declare function reorderOpens(before: readonly string[], after: readonly string[], effect: (id: string) => Promise<RuleEffect>): Promise<boolean>;
12
46
  export declare function requireConfirmation(confirm: boolean | undefined, action: string): {
13
47
  content: {
14
48
  type: "text";
@@ -1,38 +1,88 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DESTRUCTIVE = exports.WRITE_NOT_IDEMPOTENT = exports.WRITE = exports.READ_ONLY = void 0;
3
+ exports.DESTRUCTIVE = exports.WRITE_NOT_IDEMPOTENT = exports.WRITE = exports.READ_ONLY = exports.confirmDestructive = void 0;
4
4
  exports.formatDryRun = formatDryRun;
5
+ exports.requireConfirmationIf = requireConfirmationIf;
6
+ exports.reorderOpens = reorderOpens;
5
7
  exports.requireConfirmation = requireConfirmation;
8
+ const zod_1 = require("zod");
9
+ const write_log_js_1 = require("../write-log.js");
10
+ /** The confirm input of a DESTRUCTIVE tool. */
11
+ const confirmDestructive = () => zod_1.z.boolean().optional().describe("Must be true to execute this destructive action; not needed with dryRun: true");
12
+ exports.confirmDestructive = confirmDestructive;
6
13
  exports.READ_ONLY = {
7
14
  readOnlyHint: true,
8
15
  destructiveHint: false,
16
+ openWorldHint: false,
9
17
  };
10
18
  exports.WRITE = {
11
19
  readOnlyHint: false,
12
20
  destructiveHint: false,
13
21
  idempotentHint: true,
22
+ openWorldHint: false,
14
23
  };
15
24
  exports.WRITE_NOT_IDEMPOTENT = {
16
25
  readOnlyHint: false,
17
26
  destructiveHint: false,
18
27
  idempotentHint: false,
28
+ openWorldHint: false,
19
29
  };
20
30
  exports.DESTRUCTIVE = {
21
31
  readOnlyHint: false,
22
32
  destructiveHint: true,
23
33
  idempotentHint: false,
34
+ openWorldHint: false,
24
35
  };
25
- function formatDryRun(method, path, body) {
36
+ /**
37
+ * A dry run's result. confirmationRequired is the reason the real call needs
38
+ * confirm, if it does: the preview is what the user approves, so it has to
39
+ * say that the change opens access before they approve it, not after.
40
+ */
41
+ function formatDryRun(method, path, body, confirmationRequired) {
26
42
  const preview = {
27
43
  dryRun: true,
28
44
  wouldExecute: { method, path, ...(body !== undefined ? { body } : {}) },
45
+ ...(confirmationRequired !== undefined ? { confirmationRequired } : {}),
29
46
  };
47
+ // structuredContent is required: the SDK rejects a result without it from
48
+ // any tool that declares an outputSchema.
30
49
  return {
31
- content: [{ type: "text", text: JSON.stringify(preview, null, 2) }],
50
+ content: [{ type: "text", text: JSON.stringify(preview) }],
51
+ structuredContent: preview,
32
52
  };
33
53
  }
54
+ /**
55
+ * Confirmation for a write that is routine in general but, with these inputs,
56
+ * opens access — an ALLOW rule, an open SSID, a batch of guest codes. reason
57
+ * says what opens, or is undefined when nothing does; a dry run changes
58
+ * nothing, so it never needs confirming.
59
+ */
60
+ function requireConfirmationIf(reason, confirm, dryRun) {
61
+ return reason === undefined || dryRun ? null : requireConfirmation(confirm, reason);
62
+ }
63
+ /**
64
+ * Whether a reorder opens access: whether it moves a rule that lets traffic
65
+ * through ahead of one that blocks, which it used to follow. Only rules in
66
+ * both orders are compared, and effect is asked only about the rules a
67
+ * reorder actually moves past one another. IDs are compared without case.
68
+ */
69
+ async function reorderOpens(before, after, effect) {
70
+ const rank = new Map(before.map((id, index) => [id.toLowerCase(), index]));
71
+ const passes = [];
72
+ after.forEach((ahead, i) => {
73
+ for (const behind of after.slice(i + 1)) {
74
+ const [was, other] = [rank.get(ahead.toLowerCase()), rank.get(behind.toLowerCase())];
75
+ if (was !== undefined && other !== undefined && was > other)
76
+ passes.push([ahead, behind]);
77
+ }
78
+ });
79
+ const ids = [...new Set(passes.flat())];
80
+ const effects = new Map(await Promise.all(ids.map(async (id) => [id, await effect(id)])));
81
+ return passes.some(([ahead, behind]) => effects.get(ahead)?.letsThrough === true && effects.get(behind)?.blocks === true);
82
+ }
34
83
  function requireConfirmation(confirm, action) {
35
84
  if (confirm !== true) {
85
+ (0, write_log_js_1.noteRefusal)("confirmation_required");
36
86
  return {
37
87
  content: [
38
88
  {
@@ -79,6 +79,9 @@ function containsRedacted(value) {
79
79
  function stableStringify(value) {
80
80
  if (Array.isArray(value))
81
81
  return `[${value.map(stableStringify).join(",")}]`;
82
+ // JSON.stringify returns undefined for undefined and functions, whatever
83
+ // its lib.d.ts signature says.
84
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
82
85
  if (value === null || typeof value !== "object")
83
86
  return JSON.stringify(value) ?? "undefined";
84
87
  const entries = Object.entries(value)