@owine/unifi-network-mcp 2.14.1 → 2.15.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.
@@ -5,10 +5,135 @@ const zod_1 = require("zod");
5
5
  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
+ const full_put_js_1 = require("../utils/full-put.js");
9
+ const secrets_js_1 = require("../utils/secrets.js");
8
10
  const output_schemas_js_1 = require("../utils/output-schemas.js");
11
+ // Request-body fields per broadcast type, from the 11.0.83 POST/PUT schemas.
12
+ // IOT_OPTIMIZED accepts a strict subset of STANDARD.
13
+ const IOT_OPTIMIZED_FIELDS = [
14
+ "type",
15
+ "name",
16
+ "network",
17
+ "enabled",
18
+ "securityConfiguration",
19
+ "broadcastingDeviceFilter",
20
+ "mdnsProxyConfiguration",
21
+ "multicastFilteringPolicy",
22
+ "multicastToUnicastConversionEnabled",
23
+ "clientIsolationEnabled",
24
+ "hideName",
25
+ "uapsdEnabled",
26
+ "channel2gLockedTo6",
27
+ "dtimPeriod2gLockedTo3",
28
+ "basicDataRateKbpsByFrequencyGHz",
29
+ "clientFilteringPolicy",
30
+ "blackoutScheduleConfiguration",
31
+ ];
32
+ const STANDARD_FIELDS = [
33
+ ...IOT_OPTIMIZED_FIELDS,
34
+ "broadcastingFrequenciesGHz",
35
+ "hotspotConfiguration",
36
+ "mloEnabled",
37
+ "bandSteeringEnabled",
38
+ "arpProxyEnabled",
39
+ "bssTransitionEnabled",
40
+ "advertiseDeviceName",
41
+ "dtimPeriodByFrequencyGHzOverride",
42
+ "dnsAssistanceConfiguration",
43
+ "handoffSuggestionsConfiguration",
44
+ ];
45
+ const IOT_OPTIMIZED_REQUIRED = [
46
+ "type",
47
+ "name",
48
+ "enabled",
49
+ "securityConfiguration",
50
+ "multicastToUnicastConversionEnabled",
51
+ "clientIsolationEnabled",
52
+ "hideName",
53
+ "uapsdEnabled",
54
+ "channel2gLockedTo6",
55
+ "dtimPeriod2gLockedTo3",
56
+ ];
57
+ const STANDARD_REQUIRED = [
58
+ ...IOT_OPTIMIZED_REQUIRED,
59
+ "broadcastingFrequenciesGHz",
60
+ "arpProxyEnabled",
61
+ "bssTransitionEnabled",
62
+ "advertiseDeviceName",
63
+ ];
64
+ function fieldsForType(type) {
65
+ if (type === "STANDARD")
66
+ return { allowed: STANDARD_FIELDS, required: STANDARD_REQUIRED };
67
+ if (type === "IOT_OPTIMIZED")
68
+ return { allowed: IOT_OPTIMIZED_FIELDS, required: IOT_OPTIMIZED_REQUIRED };
69
+ throw new Error(`Unsupported WiFi broadcast type: ${String(type)}`);
70
+ }
71
+ const frequencies = zod_1.z
72
+ .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)]))
73
+ .min(1)
74
+ .describe("Frequencies to broadcast on: 2.4, 5, 6 (strings or numbers; sent as unique numbers). STANDARD only.");
75
+ const config = (description) => zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).nullable().optional().describe(description);
76
+ const flag = (description) => zod_1.z.boolean().optional().describe(description);
77
+ // Every body field as an optional input. Create tightens the core fields to
78
+ // required; update keeps them optional and fills gaps from the current SSID.
79
+ const wifiFields = {
80
+ name: zod_1.z.string().min(1).optional().describe("SSID name"),
81
+ enabled: flag("Enable the WiFi network"),
82
+ type: zod_1.z.enum(["STANDARD", "IOT_OPTIMIZED"]).optional().describe("WiFi type"),
83
+ broadcastingFrequenciesGHz: frequencies.optional(),
84
+ securityConfiguration: zod_1.z
85
+ .record(zod_1.z.string(), zod_1.z.unknown())
86
+ .optional()
87
+ .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.`),
88
+ multicastToUnicastConversionEnabled: flag("Enable multicast to unicast conversion"),
89
+ clientIsolationEnabled: flag("Enable client isolation"),
90
+ hideName: flag("Hide SSID name"),
91
+ uapsdEnabled: flag("Enable Unscheduled Automatic Power Save Delivery"),
92
+ channel2gLockedTo6: flag("Lock 2.4GHz channel to 6 on all broadcasting devices"),
93
+ dtimPeriod2gLockedTo3: flag("Lock 2.4GHz DTIM period to 3"),
94
+ arpProxyEnabled: flag("Enable ARP proxy. STANDARD only."),
95
+ bssTransitionEnabled: flag("Enable BSS transition. STANDARD only."),
96
+ advertiseDeviceName: flag("Advertise device name in beacon frames. STANDARD only."),
97
+ mloEnabled: flag("Enable MLO (Multi-Link Operation). STANDARD only."),
98
+ bandSteeringEnabled: flag("Enable band steering. STANDARD only."),
99
+ 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."),
100
+ broadcastingDeviceFilter: config("Custom scope of broadcasting devices. Null = broadcast from all AP-capable devices."),
101
+ mdnsProxyConfiguration: config("mDNS proxy configuration"),
102
+ multicastFilteringPolicy: config("Multicast filtering policy"),
103
+ basicDataRateKbpsByFrequencyGHz: config("Basic data rate in kbps by frequency"),
104
+ clientFilteringPolicy: config("Client MAC address filtering policy"),
105
+ blackoutScheduleConfiguration: config("Blackout schedule configuration"),
106
+ hotspotConfiguration: config("Hotspot configuration, e.g. {type:'CAPTIVE_PORTAL'}. STANDARD only."),
107
+ dtimPeriodByFrequencyGHzOverride: config("DTIM period override by frequency. STANDARD only."),
108
+ dnsAssistanceConfiguration: config("DNS assistance configuration. STANDARD only."),
109
+ handoffSuggestionsConfiguration: config("Handoff suggestions config (band5GHzRssiThreshold, band6GHzRssiThreshold). Null disables. STANDARD only."),
110
+ };
111
+ function toBodyFields(values) {
112
+ const { broadcastingFrequenciesGHz, ...rest } = values;
113
+ return (0, full_put_js_1.definedEntries)({
114
+ ...rest,
115
+ broadcastingFrequenciesGHz: broadcastingFrequenciesGHz && [...new Set(broadcastingFrequenciesGHz.map(Number))],
116
+ });
117
+ }
118
+ function checkBody(body, updates) {
119
+ const { allowed, required } = fieldsForType(body.type);
120
+ const rejected = (0, full_put_js_1.unsupportedFields)(updates, allowed);
121
+ if (rejected.length > 0) {
122
+ throw new Error(`Fields not accepted for type ${String(body.type)}: ${rejected.join(", ")}`);
123
+ }
124
+ const missing = (0, full_put_js_1.missingFields)(body, required);
125
+ if (missing.length > 0) {
126
+ throw new Error(`Missing required fields for type ${String(body.type)}: ${missing.join(", ")}`);
127
+ }
128
+ }
129
+ const includeSecrets = zod_1.z
130
+ .boolean()
131
+ .optional()
132
+ .describe(`Return WiFi passphrases in plaintext instead of "${secrets_js_1.REDACTED}" (default: false). Only set this when the user explicitly needs the key.`);
133
+ const readRedaction = `Passphrases (securityConfiguration.passphrase, presharedKeys[].passphrase) are returned as "${secrets_js_1.REDACTED}" unless includeSecrets is true.`;
9
134
  function registerWifiTools(server, client, readOnly = false) {
10
135
  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.",
136
+ 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
137
  inputSchema: {
13
138
  siteId: zod_1.z.string().describe("Site ID"),
14
139
  offset: zod_1.z
@@ -27,32 +152,34 @@ function registerWifiTools(server, client, readOnly = false) {
27
152
  filter: zod_1.z
28
153
  .string()
29
154
  .optional()
30
- .describe("Filter expression"),
155
+ .describe("Filter expression, e.g. enabled.eq(true)"),
156
+ includeSecrets,
31
157
  },
32
158
  outputSchema: output_schemas_js_1.listWifiOutputSchema,
33
159
  annotations: safety_js_1.READ_ONLY,
34
- }, async ({ siteId, offset, limit, filter }) => {
160
+ }, async ({ siteId, offset, limit, filter, includeSecrets }) => {
35
161
  try {
36
162
  const query = (0, query_js_1.buildQuery)({ offset, limit, filter });
37
163
  const data = await client.get(`/sites/${siteId}/wifi/broadcasts${query}`);
38
- return (0, responses_js_1.formatSuccess)(data, { structured: true });
164
+ return (0, responses_js_1.formatSuccess)(includeSecrets ? data : (0, secrets_js_1.redactSecrets)(data), { structured: true });
39
165
  }
40
166
  catch (err) {
41
167
  return (0, responses_js_1.formatError)(err);
42
168
  }
43
169
  });
44
170
  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.",
171
+ 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
172
  inputSchema: {
47
173
  siteId: zod_1.z.string().describe("Site ID"),
48
174
  wifiBroadcastId: zod_1.z.string().describe("WiFi Broadcast ID"),
175
+ includeSecrets,
49
176
  },
50
177
  outputSchema: output_schemas_js_1.getWifiOutputSchema,
51
178
  annotations: safety_js_1.READ_ONLY,
52
- }, async ({ siteId, wifiBroadcastId }) => {
179
+ }, async ({ siteId, wifiBroadcastId, includeSecrets }) => {
53
180
  try {
54
181
  const data = await client.get(`/sites/${siteId}/wifi/broadcasts/${wifiBroadcastId}`);
55
- return (0, responses_js_1.formatSuccess)(data, { structured: true });
182
+ return (0, responses_js_1.formatSuccess)(includeSecrets ? data : (0, secrets_js_1.redactSecrets)(data), { structured: true });
56
183
  }
57
184
  catch (err) {
58
185
  return (0, responses_js_1.formatError)(err);
@@ -61,93 +188,22 @@ function registerWifiTools(server, client, readOnly = false) {
61
188
  if (readOnly)
62
189
  return;
63
190
  server.registerTool("unifi_create_wifi", {
64
- description: "Create a new WiFi network (SSID)",
191
+ 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
192
  inputSchema: {
66
193
  siteId: zod_1.z.string().describe("Site ID"),
67
- name: zod_1.z.string().describe("SSID name"),
194
+ ...wifiFields,
195
+ name: zod_1.z.string().min(1).describe("SSID name"),
68
196
  enabled: zod_1.z.boolean().describe("Enable the WiFi network"),
69
197
  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
198
  securityConfiguration: zod_1.z
74
199
  .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"),
200
+ .describe("Security configuration object (type, passphrase, …)"),
201
+ multicastToUnicastConversionEnabled: zod_1.z.boolean().describe("Enable multicast to unicast conversion"),
202
+ clientIsolationEnabled: zod_1.z.boolean().describe("Enable client isolation"),
82
203
  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"),
204
+ uapsdEnabled: zod_1.z.boolean().describe("Enable Unscheduled Automatic Power Save Delivery"),
205
+ channel2gLockedTo6: zod_1.z.boolean().describe("Lock 2.4GHz channel to 6 on all broadcasting devices"),
206
+ dtimPeriod2gLockedTo3: zod_1.z.boolean().describe("Lock 2.4GHz DTIM period to 3"),
151
207
  dryRun: zod_1.z
152
208
  .boolean()
153
209
  .optional()
@@ -155,232 +211,47 @@ function registerWifiTools(server, client, readOnly = false) {
155
211
  },
156
212
  outputSchema: output_schemas_js_1.getWifiOutputSchema,
157
213
  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, }) => {
214
+ }, async ({ siteId, dryRun, ...fields }) => {
159
215
  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;
216
+ const body = toBodyFields(fields);
217
+ checkBody(body, body);
218
+ if ((0, secrets_js_1.containsRedacted)(body)) {
219
+ throw new Error(`securityConfiguration contains "${secrets_js_1.REDACTED}" — a new SSID needs the real passphrase`);
220
+ }
202
221
  if (dryRun)
203
- return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/wifi/broadcasts`, body);
222
+ return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/wifi/broadcasts`, (0, secrets_js_1.redactSecrets)(body));
204
223
  const data = await client.post(`/sites/${siteId}/wifi/broadcasts`, body);
205
- return (0, responses_js_1.formatSuccess)(data, { structured: true });
224
+ return (0, responses_js_1.formatSuccess)((0, secrets_js_1.redactSecrets)(data), { structured: true });
206
225
  }
207
226
  catch (err) {
208
227
  return (0, responses_js_1.formatError)(err);
209
228
  }
210
229
  });
211
230
  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.",
231
+ description: `Update an existing WiFi network. Pass only the fields to change. The API's PUT replaces the whole SSID (11.0.83 documents 14 required fields for STANDARD, 10 for IOT_OPTIMIZED), 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). Passphrases are kept unless you send a new one; "${secrets_js_1.REDACTED}" values are treated as "keep". dryRun shows the merged body (secrets redacted).`,
213
232
  inputSchema: {
214
233
  siteId: zod_1.z.string().describe("Site ID"),
215
234
  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"),
235
+ ...wifiFields,
315
236
  dryRun: zod_1.z
316
237
  .boolean()
317
238
  .optional()
318
- .describe("Preview this action without executing it"),
239
+ .describe("Preview the merged PUT body without executing it (still reads the current SSID)"),
319
240
  },
320
241
  outputSchema: output_schemas_js_1.getWifiOutputSchema,
321
242
  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, }) => {
243
+ }, async ({ siteId, wifiBroadcastId, dryRun, ...fields }) => {
323
244
  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;
245
+ const path = `/sites/${siteId}/wifi/broadcasts/${wifiBroadcastId}`;
246
+ const current = (0, full_put_js_1.asObject)(await client.get(path), "the current WiFi broadcast");
247
+ const updates = (0, secrets_js_1.restoreRedacted)(toBodyFields(fields), current);
248
+ const { allowed } = fieldsForType(updates.type ?? current.type);
249
+ const body = (0, full_put_js_1.mergeForPut)(current, updates, allowed);
250
+ checkBody(body, updates);
380
251
  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 });
252
+ return (0, safety_js_1.formatDryRun)("PUT", path, (0, secrets_js_1.redactSecrets)(body));
253
+ const data = await client.put(path, body);
254
+ return (0, responses_js_1.formatSuccess)((0, secrets_js_1.redactSecrets)(data), { structured: true });
384
255
  }
385
256
  catch (err) {
386
257
  return (0, responses_js_1.formatError)(err);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Read-merge-write support for UniFi's full-replacement PUT endpoints.
3
+ *
4
+ * Several Integration API PUTs (WiFi broadcasts, networks, firewall policies)
5
+ * replace the whole resource: required fields must all be present, and some
6
+ * optional fields carry meaning when omitted (a missing `dhcpGuarding`
7
+ * disables DHCP guarding; a missing `reportFlowEnabled` reverts to true). A
8
+ * sparse body therefore either fails or silently resets settings.
9
+ *
10
+ * Instead, tools GET the current resource, keep only the fields the PUT body
11
+ * documents (so read-only response fields like id/metadata never leak into the
12
+ * request), and overlay the caller's changes. `undefined` keeps the current
13
+ * value; `null` is sent as an explicit null, which the API uses to clear.
14
+ */
15
+ export type JsonObject = Record<string, unknown>;
16
+ export declare function asObject(value: unknown, what: string): JsonObject;
17
+ export declare function mergeForPut(current: JsonObject, updates: JsonObject, allowedFields: readonly string[]): JsonObject;
18
+ /** Fields the caller set that the target variant's PUT body does not accept. */
19
+ export declare function unsupportedFields(updates: JsonObject, allowedFields: readonly string[]): string[];
20
+ /** Required fields that are absent (undefined or null) from a request body. */
21
+ export declare function missingFields(body: JsonObject, requiredFields: readonly string[]): string[];
22
+ /** Copy only the defined entries, so optional inputs the caller skipped are not sent. */
23
+ export declare function definedEntries(values: JsonObject): JsonObject;
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ /**
3
+ * Read-merge-write support for UniFi's full-replacement PUT endpoints.
4
+ *
5
+ * Several Integration API PUTs (WiFi broadcasts, networks, firewall policies)
6
+ * replace the whole resource: required fields must all be present, and some
7
+ * optional fields carry meaning when omitted (a missing `dhcpGuarding`
8
+ * disables DHCP guarding; a missing `reportFlowEnabled` reverts to true). A
9
+ * sparse body therefore either fails or silently resets settings.
10
+ *
11
+ * Instead, tools GET the current resource, keep only the fields the PUT body
12
+ * documents (so read-only response fields like id/metadata never leak into the
13
+ * request), and overlay the caller's changes. `undefined` keeps the current
14
+ * value; `null` is sent as an explicit null, which the API uses to clear.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.asObject = asObject;
18
+ exports.mergeForPut = mergeForPut;
19
+ exports.unsupportedFields = unsupportedFields;
20
+ exports.missingFields = missingFields;
21
+ exports.definedEntries = definedEntries;
22
+ function asObject(value, what) {
23
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
24
+ throw new Error(`Expected ${what} to be a JSON object`);
25
+ }
26
+ return value;
27
+ }
28
+ function mergeForPut(current, updates, allowedFields) {
29
+ const body = {};
30
+ for (const field of allowedFields) {
31
+ if (current[field] !== undefined)
32
+ body[field] = current[field];
33
+ }
34
+ for (const [field, value] of Object.entries(updates)) {
35
+ if (value !== undefined && allowedFields.includes(field))
36
+ body[field] = value;
37
+ }
38
+ return body;
39
+ }
40
+ /** Fields the caller set that the target variant's PUT body does not accept. */
41
+ function unsupportedFields(updates, allowedFields) {
42
+ return Object.keys(updates).filter((field) => updates[field] !== undefined && !allowedFields.includes(field));
43
+ }
44
+ /** Required fields that are absent (undefined or null) from a request body. */
45
+ function missingFields(body, requiredFields) {
46
+ return requiredFields.filter((field) => body[field] === undefined || body[field] === null);
47
+ }
48
+ /** Copy only the defined entries, so optional inputs the caller skipped are not sent. */
49
+ function definedEntries(values) {
50
+ return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
51
+ }