@owine/unifi-network-mcp 2.14.2 → 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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +235 -80
  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 +126 -53
  15. package/dist/tools/client-history.js +20 -10
  16. package/dist/tools/clients.js +14 -13
  17. package/dist/tools/devices.js +30 -29
  18. package/dist/tools/dns-policies.js +116 -69
  19. package/dist/tools/firewall.d.ts +2 -1
  20. package/dist/tools/firewall.js +245 -80
  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 +152 -58
  26. package/dist/tools/sites.js +3 -3
  27. package/dist/tools/supporting.js +31 -30
  28. package/dist/tools/switching.js +22 -21
  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 +31 -28
  32. package/dist/tools/wifi.js +228 -311
  33. package/dist/utils/full-put.d.ts +32 -0
  34. package/dist/utils/full-put.js +74 -0
  35. package/dist/utils/output-schemas.d.ts +77 -9
  36. package/dist/utils/output-schemas.js +106 -54
  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.d.ts +24 -0
  44. package/dist/utils/secrets.js +92 -0
  45. package/dist/utils/wan-exposure.d.ts +69 -0
  46. package/dist/utils/wan-exposure.js +245 -0
  47. package/dist/write-log.d.ts +52 -0
  48. package/dist/write-log.js +178 -0
  49. package/dist/write-tools.d.ts +27 -0
  50. package/dist/write-tools.js +50 -0
  51. package/package.json +19 -15
@@ -1,58 +1,222 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerWifiTools = registerWifiTools;
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");
9
+ const full_put_js_1 = require("../utils/full-put.js");
10
+ const secrets_js_1 = require("../utils/secrets.js");
8
11
  const output_schemas_js_1 = require("../utils/output-schemas.js");
12
+ const params_js_1 = require("../utils/params.js");
13
+ // Request-body fields per broadcast type, from the 11.0.83 POST/PUT schemas.
14
+ // IOT_OPTIMIZED accepts a strict subset of STANDARD.
15
+ const IOT_OPTIMIZED_FIELDS = [
16
+ "type",
17
+ "name",
18
+ "network",
19
+ "enabled",
20
+ "securityConfiguration",
21
+ "broadcastingDeviceFilter",
22
+ "mdnsProxyConfiguration",
23
+ "multicastFilteringPolicy",
24
+ "multicastToUnicastConversionEnabled",
25
+ "clientIsolationEnabled",
26
+ "hideName",
27
+ "uapsdEnabled",
28
+ "channel2gLockedTo6",
29
+ "dtimPeriod2gLockedTo3",
30
+ "basicDataRateKbpsByFrequencyGHz",
31
+ "clientFilteringPolicy",
32
+ "blackoutScheduleConfiguration",
33
+ ];
34
+ const STANDARD_FIELDS = [
35
+ ...IOT_OPTIMIZED_FIELDS,
36
+ "broadcastingFrequenciesGHz",
37
+ "hotspotConfiguration",
38
+ "mloEnabled",
39
+ "bandSteeringEnabled",
40
+ "arpProxyEnabled",
41
+ "bssTransitionEnabled",
42
+ "advertiseDeviceName",
43
+ "dtimPeriodByFrequencyGHzOverride",
44
+ "dnsAssistanceConfiguration",
45
+ "handoffSuggestionsConfiguration",
46
+ ];
47
+ const IOT_OPTIMIZED_REQUIRED = [
48
+ "type",
49
+ "name",
50
+ "enabled",
51
+ "securityConfiguration",
52
+ "multicastToUnicastConversionEnabled",
53
+ "clientIsolationEnabled",
54
+ "hideName",
55
+ "uapsdEnabled",
56
+ "channel2gLockedTo6",
57
+ "dtimPeriod2gLockedTo3",
58
+ ];
59
+ const STANDARD_REQUIRED = [
60
+ ...IOT_OPTIMIZED_REQUIRED,
61
+ "broadcastingFrequenciesGHz",
62
+ "arpProxyEnabled",
63
+ "bssTransitionEnabled",
64
+ "advertiseDeviceName",
65
+ ];
66
+ function fieldsForType(type) {
67
+ if (type === "STANDARD")
68
+ return { allowed: STANDARD_FIELDS, required: STANDARD_REQUIRED };
69
+ if (type === "IOT_OPTIMIZED")
70
+ return { allowed: IOT_OPTIMIZED_FIELDS, required: IOT_OPTIMIZED_REQUIRED };
71
+ throw new Error(`Unsupported WiFi broadcast type: ${String(type)}`);
72
+ }
73
+ const frequencies = zod_1.z
74
+ .array(zod_1.z.union([zod_1.z.enum(["2.4", "5", "6"]), zod_1.z.literal(2.4), zod_1.z.literal(5), zod_1.z.literal(6)]))
75
+ .min(1)
76
+ .describe("Frequencies to broadcast on: 2.4, 5, 6 (strings or numbers; sent as unique numbers). STANDARD only.");
77
+ const config = (description) => zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).nullable().optional().describe(description);
78
+ const flag = (description) => zod_1.z.boolean().optional().describe(description);
79
+ // Every body field as an optional input. Create tightens the core fields to
80
+ // required; update keeps them optional and fills gaps from the current SSID.
81
+ const wifiFields = {
82
+ name: zod_1.z.string().min(1).optional().describe("SSID name"),
83
+ enabled: flag("Enable the WiFi network"),
84
+ type: zod_1.z.enum(["STANDARD", "IOT_OPTIMIZED"]).optional().describe("WiFi type"),
85
+ broadcastingFrequenciesGHz: frequencies.optional(),
86
+ securityConfiguration: zod_1.z
87
+ .record(zod_1.z.string(), zod_1.z.unknown())
88
+ .optional()
89
+ .describe(`Security configuration object (type, passphrase, presharedKeys[], pmfMode, …). A "${secrets_js_1.REDACTED}" value read from unifi_get_wifi is kept as the existing secret on update.`),
90
+ multicastToUnicastConversionEnabled: flag("Enable multicast to unicast conversion"),
91
+ clientIsolationEnabled: flag("Enable client isolation"),
92
+ hideName: flag("Hide SSID name"),
93
+ uapsdEnabled: flag("Enable Unscheduled Automatic Power Save Delivery"),
94
+ channel2gLockedTo6: flag("Lock 2.4GHz channel to 6 on all broadcasting devices"),
95
+ dtimPeriod2gLockedTo3: flag("Lock 2.4GHz DTIM period to 3"),
96
+ arpProxyEnabled: flag("Enable ARP proxy. STANDARD only."),
97
+ bssTransitionEnabled: flag("Enable BSS transition. STANDARD only."),
98
+ advertiseDeviceName: flag("Advertise device name in beacon frames. STANDARD only."),
99
+ mloEnabled: flag("Enable MLO (Multi-Link Operation). STANDARD only."),
100
+ bandSteeringEnabled: flag("Enable band steering. STANDARD only."),
101
+ network: config("Network reference, e.g. {type:'NATIVE'} or {type:'SPECIFIC', networkId:'<uuid>'} (live-observed). Omitted on PPSK SSIDs, where each presharedKeys[] entry carries its own network."),
102
+ broadcastingDeviceFilter: config("Custom scope of broadcasting devices. Null = broadcast from all AP-capable devices."),
103
+ mdnsProxyConfiguration: config("mDNS proxy configuration"),
104
+ multicastFilteringPolicy: config("Multicast filtering policy"),
105
+ basicDataRateKbpsByFrequencyGHz: config("Basic data rate in kbps by frequency"),
106
+ clientFilteringPolicy: config("Client MAC address filtering policy"),
107
+ blackoutScheduleConfiguration: config("Blackout schedule configuration"),
108
+ hotspotConfiguration: config("Hotspot configuration, e.g. {type:'CAPTIVE_PORTAL'}. STANDARD only."),
109
+ dtimPeriodByFrequencyGHzOverride: config("DTIM period override by frequency. STANDARD only."),
110
+ dnsAssistanceConfiguration: config("DNS assistance configuration. STANDARD only."),
111
+ handoffSuggestionsConfiguration: config("Handoff suggestions config (band5GHzRssiThreshold, band6GHzRssiThreshold). Null disables. STANDARD only."),
112
+ };
113
+ function toBodyFields(values) {
114
+ const { broadcastingFrequenciesGHz, ...rest } = values;
115
+ return (0, full_put_js_1.definedEntries)({
116
+ ...rest,
117
+ broadcastingFrequenciesGHz: broadcastingFrequenciesGHz && [...new Set(broadcastingFrequenciesGHz.map(Number))],
118
+ });
119
+ }
120
+ function checkBody(body, updates) {
121
+ const { allowed, required } = fieldsForType(body.type);
122
+ const rejected = (0, full_put_js_1.unsupportedFields)(updates, allowed);
123
+ if (rejected.length > 0) {
124
+ throw new Error(`Fields not accepted for type ${String(body.type)}: ${rejected.join(", ")}`);
125
+ }
126
+ const missing = (0, full_put_js_1.missingFields)(body, required);
127
+ if (missing.length > 0) {
128
+ throw new Error(`Missing required fields for type ${String(body.type)}: ${missing.join(", ")}`);
129
+ }
130
+ }
131
+ function securityType(ssid) {
132
+ const security = ssid.securityConfiguration;
133
+ return security !== null && typeof security === "object" && "type" in security ? security.type : undefined;
134
+ }
135
+ function isOpen(ssid) {
136
+ const type = securityType(ssid);
137
+ return typeof type === "string" && type.toUpperCase() === "OPEN";
138
+ }
139
+ // On an open SSID, these decide who can join and what joining reaches.
140
+ const OPEN_ACCESS_FIELDS = ["network", "hotspotConfiguration", "clientIsolationEnabled", "clientFilteringPolicy"];
141
+ /**
142
+ * What a WiFi write would open, if anything. before is undefined on create.
143
+ * An open SSID lets anyone in range join, so switching one on, or changing
144
+ * what it leads to or how joining is gated, counts as well as the security
145
+ * type itself.
146
+ */
147
+ function wifiOpening(before, after) {
148
+ if (before === undefined)
149
+ return isOpen(after) ? "This creates an open SSID with no encryption" : undefined;
150
+ const [from, to] = [securityType(before), securityType(after)];
151
+ if (!(0, node_util_1.isDeepStrictEqual)(from, to))
152
+ return `This changes the SSID's security from ${String(from)} to ${String(to)}`;
153
+ if (!isOpen(after) || after.enabled === false)
154
+ return undefined;
155
+ if (before.enabled === false)
156
+ return "This enables an open SSID with no encryption";
157
+ const changed = OPEN_ACCESS_FIELDS.filter((field) => !(0, node_util_1.isDeepStrictEqual)(before[field], after[field]));
158
+ return changed.length > 0
159
+ ? `This changes who can join an open SSID, or what joining reaches (${changed.join(", ")})`
160
+ : undefined;
161
+ }
162
+ const confirmSecurity = zod_1.z
163
+ .boolean()
164
+ .optional()
165
+ .describe("Set true to confirm creating or enabling an open SSID, changing an SSID's security type, or changing an open SSID's network, captive portal (hotspotConfiguration), client isolation or client filtering; not needed with dryRun: true");
166
+ const includeSecrets = zod_1.z
167
+ .boolean()
168
+ .optional()
169
+ .describe(`Return WiFi passphrases in plaintext instead of "${secrets_js_1.REDACTED}" (default: false). Only set this when the user explicitly needs the key.`);
170
+ const readRedaction = `Passphrases (securityConfiguration.passphrase, presharedKeys[].passphrase) are returned as "${secrets_js_1.REDACTED}" unless includeSecrets is true.`;
9
171
  function registerWifiTools(server, client, readOnly = false) {
10
172
  server.registerTool("unifi_list_wifi", {
11
- description: "List all WiFi broadcasts (SSIDs) at a site. Returns: id, name (SSID), enabled, type (STANDARD/IOT_OPTIMIZED), broadcastingFrequenciesGHz (2.4/5/6), securityConfiguration, hideName, bandSteeringEnabled, mloEnabled, network reference, broadcastingDeviceFilter (which APs broadcast it). Use for: SSID inventory. For per-AP radio state (channel, txPower), use unifi_get_device on the AP.",
173
+ description: `List all WiFi broadcasts (SSIDs) at a site. Returns (sparse list view, live-verified on 11.0.83): id, name (SSID), type (STANDARD/IOT_OPTIMIZED), enabled, metadata.origin, network reference (absent on PPSK SSIDs), securityConfiguration.type, broadcastingFrequenciesGHz, hotspotConfiguration (empty object when set). Booleans such as hideName/bandSteeringEnabled/clientIsolationEnabled, the full security configuration and optional configs are only returned by unifi_get_wifi. ${readRedaction} Use for: SSID inventory. For per-AP radio state (channel, txPower), use unifi_get_device on the AP.`,
12
174
  inputSchema: {
13
- siteId: zod_1.z.string().describe("Site ID"),
175
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
14
176
  offset: zod_1.z
15
177
  .number()
16
178
  .int()
17
179
  .nonnegative()
18
180
  .optional()
19
- .describe("Number of records to skip (default: 0)"),
181
+ .describe("Number of records to skip"),
20
182
  limit: zod_1.z
21
183
  .number()
22
184
  .int()
23
185
  .min(0)
24
186
  .max(200)
25
187
  .optional()
26
- .describe("Number of records to return (default: 25, max: 200)"),
188
+ .describe("Number of records to return (API default: 25)"),
27
189
  filter: zod_1.z
28
190
  .string()
29
191
  .optional()
30
- .describe("Filter expression"),
192
+ .describe("Filter expression, e.g. enabled.eq(true)"),
193
+ includeSecrets,
31
194
  },
32
195
  outputSchema: output_schemas_js_1.listWifiOutputSchema,
33
196
  annotations: safety_js_1.READ_ONLY,
34
- }, async ({ siteId, offset, limit, filter }) => {
197
+ }, async ({ siteId, offset, limit, filter, includeSecrets }) => {
35
198
  try {
36
199
  const query = (0, query_js_1.buildQuery)({ offset, limit, filter });
37
200
  const data = await client.get(`/sites/${siteId}/wifi/broadcasts${query}`);
38
- return (0, responses_js_1.formatSuccess)(data, { structured: true });
201
+ return (0, responses_js_1.formatSuccess)(includeSecrets ? data : (0, secrets_js_1.redactSecrets)(data), { structured: true });
39
202
  }
40
203
  catch (err) {
41
204
  return (0, responses_js_1.formatError)(err);
42
205
  }
43
206
  });
44
207
  server.registerTool("unifi_get_wifi", {
45
- description: "Get full configuration for a WiFi broadcast (SSID), including all optional fields not always returned by list: clientFilteringPolicy, blackoutScheduleConfiguration, hotspotConfiguration, mdnsProxyConfiguration, handoffSuggestionsConfiguration, dtimPeriodByFrequencyGHzOverride, etc.",
208
+ description: `Get full configuration for a WiFi broadcast (SSID). Returns the list fields plus the booleans (hideName, clientIsolationEnabled, uapsdEnabled, multicastToUnicastConversionEnabled, bandSteeringEnabled, arpProxyEnabled, bssTransitionEnabled, advertiseDeviceName, channel2gLockedTo6, dtimPeriod2gLockedTo3), the full securityConfiguration (type, pmfMode, fastRoamingEnabled, saeConfiguration, presharedKeys[] on PPSK SSIDs, …), and optional configs when configured (omitted otherwise): clientFilteringPolicy, blackoutScheduleConfiguration, hotspotConfiguration, mdnsProxyConfiguration, handoffSuggestionsConfiguration, dtimPeriodByFrequencyGHzOverride, broadcastingDeviceFilter, mloEnabled. ${readRedaction}`,
46
209
  inputSchema: {
47
- siteId: zod_1.z.string().describe("Site ID"),
48
- wifiBroadcastId: zod_1.z.string().describe("WiFi Broadcast ID"),
210
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
211
+ wifiBroadcastId: (0, params_js_1.resourceId)().describe("WiFi Broadcast ID"),
212
+ includeSecrets,
49
213
  },
50
214
  outputSchema: output_schemas_js_1.getWifiOutputSchema,
51
215
  annotations: safety_js_1.READ_ONLY,
52
- }, async ({ siteId, wifiBroadcastId }) => {
216
+ }, async ({ siteId, wifiBroadcastId, includeSecrets }) => {
53
217
  try {
54
218
  const data = await client.get(`/sites/${siteId}/wifi/broadcasts/${wifiBroadcastId}`);
55
- return (0, responses_js_1.formatSuccess)(data, { structured: true });
219
+ return (0, responses_js_1.formatSuccess)(includeSecrets ? data : (0, secrets_js_1.redactSecrets)(data), { structured: true });
56
220
  }
57
221
  catch (err) {
58
222
  return (0, responses_js_1.formatError)(err);
@@ -61,326 +225,82 @@ function registerWifiTools(server, client, readOnly = false) {
61
225
  if (readOnly)
62
226
  return;
63
227
  server.registerTool("unifi_create_wifi", {
64
- description: "Create a new WiFi network (SSID)",
228
+ description: `Create a new WiFi network (SSID). Required for both types: type, name, enabled, securityConfiguration, multicastToUnicastConversionEnabled, clientIsolationEnabled, hideName, uapsdEnabled, channel2gLockedTo6, dtimPeriod2gLockedTo3. STANDARD additionally requires broadcastingFrequenciesGHz, arpProxyEnabled, bssTransitionEnabled, advertiseDeviceName; fields marked "STANDARD only" are rejected for IOT_OPTIMIZED (per 11.0.83 docs). The response echo has passphrases redacted. Idempotency: not safe to retry — re-running creates a duplicate SSID.`,
65
229
  inputSchema: {
66
- siteId: zod_1.z.string().describe("Site ID"),
67
- name: zod_1.z.string().describe("SSID name"),
230
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
231
+ ...wifiFields,
232
+ name: zod_1.z.string().min(1).describe("SSID name"),
68
233
  enabled: zod_1.z.boolean().describe("Enable the WiFi network"),
69
234
  type: zod_1.z.enum(["STANDARD", "IOT_OPTIMIZED"]).describe("WiFi type"),
70
- broadcastingFrequenciesGHz: zod_1.z
71
- .array(zod_1.z.enum(["2.4", "5", "6"]))
72
- .describe("Frequencies to broadcast on. Accepted as strings and sent to the API as numbers (2.4, 5, 6)."),
73
235
  securityConfiguration: zod_1.z
74
236
  .record(zod_1.z.string(), zod_1.z.unknown())
75
- .describe("Security configuration object"),
76
- multicastToUnicastConversionEnabled: zod_1.z
77
- .boolean()
78
- .describe("Enable multicast to unicast conversion"),
79
- clientIsolationEnabled: zod_1.z
80
- .boolean()
81
- .describe("Enable client isolation"),
237
+ .describe("Security configuration object (type, passphrase, …)"),
238
+ multicastToUnicastConversionEnabled: zod_1.z.boolean().describe("Enable multicast to unicast conversion"),
239
+ clientIsolationEnabled: zod_1.z.boolean().describe("Enable client isolation"),
82
240
  hideName: zod_1.z.boolean().describe("Hide SSID name"),
83
- uapsdEnabled: zod_1.z
84
- .boolean()
85
- .describe("Enable Unscheduled Automatic Power Save Delivery"),
86
- arpProxyEnabled: zod_1.z.boolean().describe("Enable ARP proxy"),
87
- bssTransitionEnabled: zod_1.z
88
- .boolean()
89
- .describe("Enable BSS transition"),
90
- advertiseDeviceName: zod_1.z
91
- .boolean()
92
- .describe("Advertise device name in beacon frames"),
93
- channel2gLockedTo6: zod_1.z
94
- .boolean()
95
- .describe("Lock 2.4GHz channel to 6 on all broadcasting devices"),
96
- dtimPeriod2gLockedTo3: zod_1.z
97
- .boolean()
98
- .describe("Lock 2.4GHz DTIM period to 3"),
99
- network: zod_1.z
100
- .record(zod_1.z.string(), zod_1.z.unknown())
101
- .optional()
102
- .describe("Network reference"),
103
- broadcastingDeviceFilter: zod_1.z
104
- .record(zod_1.z.string(), zod_1.z.unknown())
105
- .optional()
106
- .describe("Custom scope of broadcasting devices"),
107
- mdnsProxyConfiguration: zod_1.z
108
- .record(zod_1.z.string(), zod_1.z.unknown())
109
- .optional()
110
- .describe("mDNS proxy configuration"),
111
- multicastFilteringPolicy: zod_1.z
112
- .record(zod_1.z.string(), zod_1.z.unknown())
113
- .optional()
114
- .describe("Multicast filtering policy"),
115
- basicDataRateKbpsByFrequencyGHz: zod_1.z
116
- .record(zod_1.z.string(), zod_1.z.unknown())
117
- .optional()
118
- .describe("Basic data rate in kbps by frequency"),
119
- clientFilteringPolicy: zod_1.z
120
- .record(zod_1.z.string(), zod_1.z.unknown())
121
- .optional()
122
- .describe("Client MAC address filtering policy"),
123
- blackoutScheduleConfiguration: zod_1.z
124
- .record(zod_1.z.string(), zod_1.z.unknown())
125
- .optional()
126
- .describe("Blackout schedule configuration"),
127
- hotspotConfiguration: zod_1.z
128
- .record(zod_1.z.string(), zod_1.z.unknown())
129
- .optional()
130
- .describe("Hotspot configuration"),
131
- mloEnabled: zod_1.z
132
- .boolean()
133
- .optional()
134
- .describe("Enable MLO (Multi-Link Operation)"),
135
- bandSteeringEnabled: zod_1.z
136
- .boolean()
137
- .optional()
138
- .describe("Enable band steering"),
139
- dtimPeriodByFrequencyGHzOverride: zod_1.z
140
- .record(zod_1.z.string(), zod_1.z.unknown())
141
- .optional()
142
- .describe("DTIM period override by frequency"),
143
- dnsAssistanceConfiguration: zod_1.z
144
- .record(zod_1.z.string(), zod_1.z.unknown())
145
- .optional()
146
- .describe("DNS assistance configuration"),
147
- handoffSuggestionsConfiguration: zod_1.z
148
- .record(zod_1.z.string(), zod_1.z.unknown())
149
- .optional()
150
- .describe("Handoff suggestions config (band5GHzRssiThreshold, band6GHzRssiThreshold). Null disables"),
241
+ uapsdEnabled: zod_1.z.boolean().describe("Enable Unscheduled Automatic Power Save Delivery"),
242
+ channel2gLockedTo6: zod_1.z.boolean().describe("Lock 2.4GHz channel to 6 on all broadcasting devices"),
243
+ dtimPeriod2gLockedTo3: zod_1.z.boolean().describe("Lock 2.4GHz DTIM period to 3"),
244
+ confirm: confirmSecurity,
151
245
  dryRun: zod_1.z
152
246
  .boolean()
153
247
  .optional()
154
248
  .describe("Preview this action without executing it"),
155
249
  },
156
- outputSchema: output_schemas_js_1.getWifiOutputSchema,
250
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.getWifiOutputSchema),
157
251
  annotations: safety_js_1.WRITE_NOT_IDEMPOTENT,
158
- }, async ({ siteId, name, enabled, type, broadcastingFrequenciesGHz, securityConfiguration, multicastToUnicastConversionEnabled, clientIsolationEnabled, hideName, uapsdEnabled, arpProxyEnabled, bssTransitionEnabled, advertiseDeviceName, channel2gLockedTo6, dtimPeriod2gLockedTo3, network, broadcastingDeviceFilter, mdnsProxyConfiguration, multicastFilteringPolicy, basicDataRateKbpsByFrequencyGHz, clientFilteringPolicy, blackoutScheduleConfiguration, hotspotConfiguration, mloEnabled, bandSteeringEnabled, dtimPeriodByFrequencyGHzOverride, dnsAssistanceConfiguration, handoffSuggestionsConfiguration, dryRun, }) => {
252
+ }, async ({ siteId, confirm, dryRun, ...fields }) => {
159
253
  try {
160
- const body = {
161
- name,
162
- enabled,
163
- type,
164
- broadcastingFrequenciesGHz: broadcastingFrequenciesGHz.map(Number),
165
- securityConfiguration,
166
- multicastToUnicastConversionEnabled,
167
- clientIsolationEnabled,
168
- hideName,
169
- uapsdEnabled,
170
- arpProxyEnabled,
171
- bssTransitionEnabled,
172
- advertiseDeviceName,
173
- channel2gLockedTo6,
174
- dtimPeriod2gLockedTo3,
175
- };
176
- if (network !== undefined)
177
- body.network = network;
178
- if (broadcastingDeviceFilter !== undefined)
179
- body.broadcastingDeviceFilter = broadcastingDeviceFilter;
180
- if (mdnsProxyConfiguration !== undefined)
181
- body.mdnsProxyConfiguration = mdnsProxyConfiguration;
182
- if (multicastFilteringPolicy !== undefined)
183
- body.multicastFilteringPolicy = multicastFilteringPolicy;
184
- if (basicDataRateKbpsByFrequencyGHz !== undefined)
185
- body.basicDataRateKbpsByFrequencyGHz = basicDataRateKbpsByFrequencyGHz;
186
- if (clientFilteringPolicy !== undefined)
187
- body.clientFilteringPolicy = clientFilteringPolicy;
188
- if (blackoutScheduleConfiguration !== undefined)
189
- body.blackoutScheduleConfiguration = blackoutScheduleConfiguration;
190
- if (hotspotConfiguration !== undefined)
191
- body.hotspotConfiguration = hotspotConfiguration;
192
- if (mloEnabled !== undefined)
193
- body.mloEnabled = mloEnabled;
194
- if (bandSteeringEnabled !== undefined)
195
- body.bandSteeringEnabled = bandSteeringEnabled;
196
- if (dtimPeriodByFrequencyGHzOverride !== undefined)
197
- body.dtimPeriodByFrequencyGHzOverride = dtimPeriodByFrequencyGHzOverride;
198
- if (dnsAssistanceConfiguration !== undefined)
199
- body.dnsAssistanceConfiguration = dnsAssistanceConfiguration;
200
- if (handoffSuggestionsConfiguration !== undefined)
201
- body.handoffSuggestionsConfiguration = handoffSuggestionsConfiguration;
254
+ const body = toBodyFields(fields);
255
+ checkBody(body, body);
256
+ const opening = wifiOpening(undefined, body);
257
+ const guard = (0, safety_js_1.requireConfirmationIf)(opening, confirm, dryRun);
258
+ if (guard)
259
+ return guard;
260
+ if ((0, secrets_js_1.containsRedacted)(body)) {
261
+ throw new Error(`securityConfiguration contains "${secrets_js_1.REDACTED}" — a new SSID needs the real passphrase`);
262
+ }
202
263
  if (dryRun)
203
- return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/wifi/broadcasts`, body);
264
+ return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/wifi/broadcasts`, (0, secrets_js_1.redactSecrets)(body), opening);
204
265
  const data = await client.post(`/sites/${siteId}/wifi/broadcasts`, body);
205
- return (0, responses_js_1.formatSuccess)(data, { structured: true });
266
+ return (0, responses_js_1.formatSuccess)((0, secrets_js_1.redactSecrets)(data), { structured: true });
206
267
  }
207
268
  catch (err) {
208
269
  return (0, responses_js_1.formatError)(err);
209
270
  }
210
271
  });
211
272
  server.registerTool("unifi_update_wifi", {
212
- description: "Update an existing WiFi network. NOTE: this is a full PUT — API 10.6.106 documents 14 of these fields as required (type, name, enabled, securityConfiguration, multicastToUnicastConversionEnabled, clientIsolationEnabled, hideName, uapsdEnabled, channel2gLockedTo6, dtimPeriod2gLockedTo3, broadcastingFrequenciesGHz, arpProxyEnabled, bssTransitionEnabled, advertiseDeviceName). They are optional here for convenience, but a sparse update may be rejected by the API — read the current values with unifi_get_wifi and resend the full set if you get a 400.",
273
+ description: `Update an existing WiFi network. Pass only the fields to change. The API's PUT replaces the whole SSID and requires every documented required field, so this tool reads the current SSID, keeps every documented body field, overlays your changes and sends the full body — omitted fields keep their current values instead of being reset. Pass null to clear an optional config (e.g. broadcastingDeviceFilter: null broadcasts from all APs). Top-level objects are replaced whole: to change one security setting, start from the securityConfiguration unifi_get_wifi returns and edit it, since a partial object drops the passphrase or presharedKeys it omits. Passphrases are kept if you omit securityConfiguration or echo its "${secrets_js_1.REDACTED}" values; send a new one to change it. dryRun shows the merged body (secrets redacted).`,
213
274
  inputSchema: {
214
- siteId: zod_1.z.string().describe("Site ID"),
215
- wifiBroadcastId: zod_1.z.string().describe("WiFi Broadcast ID"),
216
- name: zod_1.z.string().optional().describe("SSID name"),
217
- enabled: zod_1.z.boolean().optional().describe("Enable the WiFi network"),
218
- type: zod_1.z
219
- .enum(["STANDARD", "IOT_OPTIMIZED"])
220
- .optional()
221
- .describe("WiFi type"),
222
- broadcastingFrequenciesGHz: zod_1.z
223
- .array(zod_1.z.enum(["2.4", "5", "6"]))
224
- .optional()
225
- .describe("Frequencies to broadcast on. Accepted as strings and sent to the API as numbers (2.4, 5, 6)."),
226
- securityConfiguration: zod_1.z
227
- .record(zod_1.z.string(), zod_1.z.unknown())
228
- .optional()
229
- .describe("Security configuration object"),
230
- multicastToUnicastConversionEnabled: zod_1.z
231
- .boolean()
232
- .optional()
233
- .describe("Enable multicast to unicast conversion"),
234
- clientIsolationEnabled: zod_1.z
235
- .boolean()
236
- .optional()
237
- .describe("Enable client isolation"),
238
- hideName: zod_1.z.boolean().optional().describe("Hide SSID name"),
239
- uapsdEnabled: zod_1.z
240
- .boolean()
241
- .optional()
242
- .describe("Enable Unscheduled Automatic Power Save Delivery"),
243
- arpProxyEnabled: zod_1.z
244
- .boolean()
245
- .optional()
246
- .describe("Enable ARP proxy"),
247
- bssTransitionEnabled: zod_1.z
248
- .boolean()
249
- .optional()
250
- .describe("Enable BSS transition"),
251
- advertiseDeviceName: zod_1.z
252
- .boolean()
253
- .optional()
254
- .describe("Advertise device name in beacon frames"),
255
- channel2gLockedTo6: zod_1.z
256
- .boolean()
257
- .optional()
258
- .describe("Lock 2.4GHz channel to 6 on all broadcasting devices"),
259
- dtimPeriod2gLockedTo3: zod_1.z
260
- .boolean()
261
- .optional()
262
- .describe("Lock 2.4GHz DTIM period to 3"),
263
- network: zod_1.z
264
- .record(zod_1.z.string(), zod_1.z.unknown())
265
- .optional()
266
- .describe("Network reference"),
267
- broadcastingDeviceFilter: zod_1.z
268
- .record(zod_1.z.string(), zod_1.z.unknown())
269
- .optional()
270
- .describe("Custom scope of broadcasting devices"),
271
- mdnsProxyConfiguration: zod_1.z
272
- .record(zod_1.z.string(), zod_1.z.unknown())
273
- .optional()
274
- .describe("mDNS proxy configuration"),
275
- multicastFilteringPolicy: zod_1.z
276
- .record(zod_1.z.string(), zod_1.z.unknown())
277
- .optional()
278
- .describe("Multicast filtering policy"),
279
- basicDataRateKbpsByFrequencyGHz: zod_1.z
280
- .record(zod_1.z.string(), zod_1.z.unknown())
281
- .optional()
282
- .describe("Basic data rate in kbps by frequency"),
283
- clientFilteringPolicy: zod_1.z
284
- .record(zod_1.z.string(), zod_1.z.unknown())
285
- .optional()
286
- .describe("Client MAC address filtering policy"),
287
- blackoutScheduleConfiguration: zod_1.z
288
- .record(zod_1.z.string(), zod_1.z.unknown())
289
- .optional()
290
- .describe("Blackout schedule configuration"),
291
- hotspotConfiguration: zod_1.z
292
- .record(zod_1.z.string(), zod_1.z.unknown())
293
- .optional()
294
- .describe("Hotspot configuration"),
295
- mloEnabled: zod_1.z
296
- .boolean()
297
- .optional()
298
- .describe("Enable MLO (Multi-Link Operation)"),
299
- bandSteeringEnabled: zod_1.z
300
- .boolean()
301
- .optional()
302
- .describe("Enable band steering"),
303
- dtimPeriodByFrequencyGHzOverride: zod_1.z
304
- .record(zod_1.z.string(), zod_1.z.unknown())
305
- .optional()
306
- .describe("DTIM period override by frequency"),
307
- dnsAssistanceConfiguration: zod_1.z
308
- .record(zod_1.z.string(), zod_1.z.unknown())
309
- .optional()
310
- .describe("DNS assistance configuration"),
311
- handoffSuggestionsConfiguration: zod_1.z
312
- .record(zod_1.z.string(), zod_1.z.unknown())
313
- .optional()
314
- .describe("Handoff suggestions config (band5GHzRssiThreshold, band6GHzRssiThreshold). Null disables"),
275
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
276
+ wifiBroadcastId: (0, params_js_1.resourceId)().describe("WiFi Broadcast ID"),
277
+ ...wifiFields,
278
+ confirm: confirmSecurity,
315
279
  dryRun: zod_1.z
316
280
  .boolean()
317
281
  .optional()
318
- .describe("Preview this action without executing it"),
282
+ .describe("Preview the merged PUT body without executing it (still reads the current SSID)"),
319
283
  },
320
- outputSchema: output_schemas_js_1.getWifiOutputSchema,
284
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.getWifiOutputSchema),
321
285
  annotations: safety_js_1.WRITE,
322
- }, async ({ siteId, wifiBroadcastId, name, enabled, type, broadcastingFrequenciesGHz, securityConfiguration, multicastToUnicastConversionEnabled, clientIsolationEnabled, hideName, uapsdEnabled, arpProxyEnabled, bssTransitionEnabled, advertiseDeviceName, channel2gLockedTo6, dtimPeriod2gLockedTo3, network, broadcastingDeviceFilter, mdnsProxyConfiguration, multicastFilteringPolicy, basicDataRateKbpsByFrequencyGHz, clientFilteringPolicy, blackoutScheduleConfiguration, hotspotConfiguration, mloEnabled, bandSteeringEnabled, dtimPeriodByFrequencyGHzOverride, dnsAssistanceConfiguration, handoffSuggestionsConfiguration, dryRun, }) => {
286
+ }, async ({ siteId, wifiBroadcastId, confirm, dryRun, ...fields }) => {
323
287
  try {
324
- const body = {};
325
- if (name !== undefined)
326
- body.name = name;
327
- if (enabled !== undefined)
328
- body.enabled = enabled;
329
- if (type !== undefined)
330
- body.type = type;
331
- if (broadcastingFrequenciesGHz !== undefined)
332
- body.broadcastingFrequenciesGHz = broadcastingFrequenciesGHz.map(Number);
333
- if (securityConfiguration !== undefined)
334
- body.securityConfiguration = securityConfiguration;
335
- if (multicastToUnicastConversionEnabled !== undefined)
336
- body.multicastToUnicastConversionEnabled =
337
- multicastToUnicastConversionEnabled;
338
- if (clientIsolationEnabled !== undefined)
339
- body.clientIsolationEnabled = clientIsolationEnabled;
340
- if (hideName !== undefined)
341
- body.hideName = hideName;
342
- if (uapsdEnabled !== undefined)
343
- body.uapsdEnabled = uapsdEnabled;
344
- if (arpProxyEnabled !== undefined)
345
- body.arpProxyEnabled = arpProxyEnabled;
346
- if (bssTransitionEnabled !== undefined)
347
- body.bssTransitionEnabled = bssTransitionEnabled;
348
- if (advertiseDeviceName !== undefined)
349
- body.advertiseDeviceName = advertiseDeviceName;
350
- if (channel2gLockedTo6 !== undefined)
351
- body.channel2gLockedTo6 = channel2gLockedTo6;
352
- if (dtimPeriod2gLockedTo3 !== undefined)
353
- body.dtimPeriod2gLockedTo3 = dtimPeriod2gLockedTo3;
354
- if (network !== undefined)
355
- body.network = network;
356
- if (broadcastingDeviceFilter !== undefined)
357
- body.broadcastingDeviceFilter = broadcastingDeviceFilter;
358
- if (mdnsProxyConfiguration !== undefined)
359
- body.mdnsProxyConfiguration = mdnsProxyConfiguration;
360
- if (multicastFilteringPolicy !== undefined)
361
- body.multicastFilteringPolicy = multicastFilteringPolicy;
362
- if (basicDataRateKbpsByFrequencyGHz !== undefined)
363
- body.basicDataRateKbpsByFrequencyGHz = basicDataRateKbpsByFrequencyGHz;
364
- if (clientFilteringPolicy !== undefined)
365
- body.clientFilteringPolicy = clientFilteringPolicy;
366
- if (blackoutScheduleConfiguration !== undefined)
367
- body.blackoutScheduleConfiguration = blackoutScheduleConfiguration;
368
- if (hotspotConfiguration !== undefined)
369
- body.hotspotConfiguration = hotspotConfiguration;
370
- if (mloEnabled !== undefined)
371
- body.mloEnabled = mloEnabled;
372
- if (bandSteeringEnabled !== undefined)
373
- body.bandSteeringEnabled = bandSteeringEnabled;
374
- if (dtimPeriodByFrequencyGHzOverride !== undefined)
375
- body.dtimPeriodByFrequencyGHzOverride = dtimPeriodByFrequencyGHzOverride;
376
- if (dnsAssistanceConfiguration !== undefined)
377
- body.dnsAssistanceConfiguration = dnsAssistanceConfiguration;
378
- if (handoffSuggestionsConfiguration !== undefined)
379
- body.handoffSuggestionsConfiguration = handoffSuggestionsConfiguration;
380
- if (dryRun)
381
- return (0, safety_js_1.formatDryRun)("PUT", `/sites/${siteId}/wifi/broadcasts/${wifiBroadcastId}`, body);
382
- const data = await client.put(`/sites/${siteId}/wifi/broadcasts/${wifiBroadcastId}`, body);
383
- return (0, responses_js_1.formatSuccess)(data, { structured: true });
288
+ const path = `/sites/${siteId}/wifi/broadcasts/${wifiBroadcastId}`;
289
+ return await (0, full_put_js_1.withResourceLock)(path, async () => {
290
+ const current = (0, full_put_js_1.asObject)(await client.get(path), "the current WiFi broadcast");
291
+ const updates = (0, secrets_js_1.restoreRedacted)(toBodyFields(fields), current);
292
+ const { allowed } = fieldsForType(updates.type ?? current.type);
293
+ const body = (0, full_put_js_1.mergeForPut)(current, updates, allowed);
294
+ checkBody(body, updates);
295
+ const opening = wifiOpening(current, body);
296
+ const guard = (0, safety_js_1.requireConfirmationIf)(opening, confirm, dryRun);
297
+ if (guard)
298
+ return guard;
299
+ if (dryRun)
300
+ return (0, safety_js_1.formatDryRun)("PUT", path, (0, secrets_js_1.redactSecrets)(body), opening);
301
+ const data = await client.put(path, body);
302
+ return (0, responses_js_1.formatSuccess)((0, secrets_js_1.redactSecrets)(data), { structured: true });
303
+ });
384
304
  }
385
305
  catch (err) {
386
306
  return (0, responses_js_1.formatError)(err);
@@ -389,17 +309,14 @@ function registerWifiTools(server, client, readOnly = false) {
389
309
  server.registerTool("unifi_delete_wifi", {
390
310
  description: "DESTRUCTIVE: Delete a WiFi network — all clients on this SSID will be disconnected",
391
311
  inputSchema: {
392
- siteId: zod_1.z.string().describe("Site ID"),
393
- wifiBroadcastId: zod_1.z.string().describe("WiFi Broadcast ID"),
312
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
313
+ wifiBroadcastId: (0, params_js_1.resourceId)().describe("WiFi Broadcast ID"),
394
314
  force: zod_1.z
395
315
  .boolean()
396
316
  .optional()
397
317
  .default(false)
398
318
  .describe("Force delete (default: false)"),
399
- confirm: zod_1.z
400
- .boolean()
401
- .optional()
402
- .describe("Must be true to execute this destructive action"),
319
+ confirm: (0, safety_js_1.confirmDestructive)(),
403
320
  dryRun: zod_1.z
404
321
  .boolean()
405
322
  .optional()
@@ -407,7 +324,7 @@ function registerWifiTools(server, client, readOnly = false) {
407
324
  },
408
325
  annotations: safety_js_1.DESTRUCTIVE,
409
326
  }, async ({ siteId, wifiBroadcastId, force, confirm, dryRun }) => {
410
- const guard = (0, safety_js_1.requireConfirmation)(confirm, "This will delete the WiFi network and disconnect all clients on this SSID");
327
+ const guard = dryRun ? null : (0, safety_js_1.requireConfirmation)(confirm, "This will delete the WiFi network and disconnect all clients on this SSID");
411
328
  if (guard)
412
329
  return guard;
413
330
  try {