@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.
package/README.md CHANGED
@@ -76,12 +76,14 @@ This server provides layered safety controls for responsible operation:
76
76
  - **Destructive tool warnings** — Tools that delete or irreversibly modify resources have descriptions prefixed with `DESTRUCTIVE:` to clearly signal risk
77
77
  - **Confirmation parameter** — Every tool marked `DESTRUCTIVE:` (all 10 of them, including `unifi_remove_device` and `unifi_bulk_delete_vouchers`) requires an explicit `confirm: true` parameter for the call to succeed
78
78
  - **Dry-run support** — All 33 write tools accept an optional `dryRun: true` parameter that returns a preview of the HTTP request (method, path, body) without making any changes
79
+ - **Safe partial updates** — The UniFi PUT endpoints for WiFi broadcasts, networks, firewall policies and DNS policies replace the whole resource, and some fields reset when omitted (a missing `dhcpGuarding` disables DHCP guarding). Their update tools read the current resource, keep every documented body field, and overlay only what you pass, so a one-field change never resets the rest. Pass `null` to clear an optional field. The dry-run preview shows the merged body (the read still happens)
80
+ - **Secret redaction** — WiFi passphrases (including per-VLAN PPSK keys) are returned as `[REDACTED]` unless the caller passes `includeSecrets: true` to `unifi_list_wifi` / `unifi_get_wifi`. Write-tool echoes and dry-run previews are always redacted, and an update that echoes back `[REDACTED]` keeps the stored passphrase
79
81
 
80
82
  ## Structured Output
81
83
 
82
84
  60 of the 76 tools (all 43 read tools, plus the 17 write tools whose API responses return the affected resource) declare an MCP `outputSchema` and return `structuredContent` alongside the usual text content. Clients that understand structured output get typed, machine-readable results instead of parsing JSON out of a text blob.
83
85
 
84
- The schemas live in `src/utils/output-schemas.ts` and are verified against UniFi Network API 10.6.106. They deliberately use a **loose strategy**: every non-key field is optional and nested objects use `.passthrough()`, so firmware- and hardware-specific fields flow through unchanged rather than being stripped or triggering a validation error. This keeps the contract stable across console versions and hardware models.
86
+ The schemas live in `src/utils/output-schemas.ts` and are verified against UniFi Network API 11.0.83. They deliberately use a **loose strategy**: every non-key field is optional and nested objects use `.passthrough()`, so firmware- and hardware-specific fields flow through unchanged rather than being stripped or triggering a validation error. This keeps the contract stable across console versions and hardware models.
85
87
 
86
88
  ## Tools (76 total)
87
89
 
@@ -127,17 +129,17 @@ The schemas live in `src/utils/output-schemas.ts` and are verified against UniFi
127
129
  | `unifi_list_networks` | List all networks at a site |
128
130
  | `unifi_get_network` | Get a specific network by ID |
129
131
  | `unifi_get_network_references` | Get references to a network (WiFi, firewall zones, etc.) |
130
- | `unifi_create_network` | Create a new network |
131
- | `unifi_update_network` | Update an existing network |
132
+ | `unifi_create_network` | Create a new network (UNMANAGED, GATEWAY or SWITCH body) |
133
+ | `unifi_update_network` | Update an existing network — partial changes are merged into the full PUT body |
132
134
  | `unifi_delete_network` | **DESTRUCTIVE:** Delete a network — disconnects all clients |
133
135
 
134
136
  ### WiFi (5)
135
137
  | Tool | Description |
136
138
  |---|---|
137
- | `unifi_list_wifi` | List all WiFi broadcasts (SSIDs) at a site |
138
- | `unifi_get_wifi` | Get a specific WiFi network by ID |
139
+ | `unifi_list_wifi` | List all WiFi broadcasts (SSIDs) at a site (passphrases redacted by default) |
140
+ | `unifi_get_wifi` | Get a specific WiFi network by ID (passphrases redacted unless `includeSecrets: true`) |
139
141
  | `unifi_create_wifi` | Create a new WiFi network (SSID) |
140
- | `unifi_update_wifi` | Update an existing WiFi network |
142
+ | `unifi_update_wifi` | Update an existing WiFi network — partial changes are merged into the full PUT body |
141
143
  | `unifi_delete_wifi` | **DESTRUCTIVE:** Delete a WiFi network — disconnects all clients |
142
144
 
143
145
  ### Hotspot Vouchers (5)
@@ -160,8 +162,8 @@ The schemas live in `src/utils/output-schemas.ts` and are verified against UniFi
160
162
  | `unifi_list_firewall_policies` | List all firewall policies at a site |
161
163
  | `unifi_get_firewall_policy` | Get a specific firewall policy by ID |
162
164
  | `unifi_create_firewall_policy` | Create a new firewall policy |
163
- | `unifi_update_firewall_policy` | Update a firewall policy |
164
- | `unifi_patch_firewall_policy` | Partially update a firewall policy (e.g. toggle logging) |
165
+ | `unifi_update_firewall_policy` | Update a firewall policy — partial changes are merged into the full PUT body |
166
+ | `unifi_patch_firewall_policy` | Partially update a firewall policy (toggle logging or Insights flow reporting) |
165
167
  | `unifi_delete_firewall_policy` | **DESTRUCTIVE:** Delete a firewall policy |
166
168
  | `unifi_get_firewall_policy_ordering` | Get user-defined firewall policy ordering for a zone pair |
167
169
  | `unifi_reorder_firewall_policies` | Reorder user-defined firewall policies for a zone pair |
@@ -192,8 +194,8 @@ The schemas live in `src/utils/output-schemas.ts` and are verified against UniFi
192
194
  |---|---|
193
195
  | `unifi_list_dns_policies` | List all DNS policies at a site |
194
196
  | `unifi_get_dns_policy` | Get a specific DNS policy by ID |
195
- | `unifi_create_dns_policy` | Create a new DNS policy |
196
- | `unifi_update_dns_policy` | Update a DNS policy |
197
+ | `unifi_create_dns_policy` | Create a DNS policy of any type (A, AAAA, CNAME, MX, TXT, SRV, FORWARD_DOMAIN) |
198
+ | `unifi_update_dns_policy` | Update a DNS policy — partial changes are merged into the full PUT body |
197
199
  | `unifi_delete_dns_policy` | **DESTRUCTIVE:** Delete a DNS policy |
198
200
 
199
201
  ### Traffic Matching (5)
package/dist/tools/acl.js CHANGED
@@ -59,7 +59,7 @@ function registerAclTools(server, client, readOnly = false) {
59
59
  }
60
60
  });
61
61
  server.registerTool("unifi_get_acl_rule_ordering", {
62
- description: "Get the evaluation order of user-defined ACL rules. Returns: orderedAclRuleIds[]. Rules higher in the list win.",
62
+ description: "Get the evaluation order of user-defined ACL rules. Returns: orderedAclRuleIds[] — rules are evaluated in list order.",
63
63
  inputSchema: {
64
64
  siteId: zod_1.z.string().describe("Site ID"),
65
65
  },
@@ -103,7 +103,7 @@ function registerAclTools(server, client, readOnly = false) {
103
103
  enforcingDeviceFilter: zod_1.z
104
104
  .unknown()
105
105
  .optional()
106
- .describe("IDs of the Switch-capable devices used to enforce the ACL rule. When null/omitted, the rule is provisioned to all switches on the site"),
106
+ .describe("Device filter object (with a type discriminator) selecting the switch-capable devices that enforce the rule. When null/omitted, the rule is provisioned to all switches on the site"),
107
107
  dryRun: zod_1.z
108
108
  .boolean()
109
109
  .optional()
@@ -161,7 +161,7 @@ function registerAclTools(server, client, readOnly = false) {
161
161
  enforcingDeviceFilter: zod_1.z
162
162
  .unknown()
163
163
  .optional()
164
- .describe("IDs of the Switch-capable devices used to enforce the ACL rule. When null/omitted, the rule is provisioned to all switches on the site"),
164
+ .describe("Device filter object (with a type discriminator) selecting the switch-capable devices that enforce the rule. When null/omitted, the rule is provisioned to all switches on the site"),
165
165
  dryRun: zod_1.z
166
166
  .boolean()
167
167
  .optional()
@@ -9,7 +9,7 @@ const siteReference = zod_1.z.string().regex(/^[A-Za-z0-9_-]{1,64}$/)
9
9
  .describe("Site internalReference from unifi_list_sites (often default), not the Integration API UUID");
10
10
  function registerClientHistoryTools(server, client) {
11
11
  server.registerTool("unifi_list_client_history", {
12
- description: "List historical/offline client inventory using the controller v2 API. Includes names, MACs, first_seen and last_seen epoch seconds when available. This is inventory, not session history; use unifi_list_client_sessions for past connections. Requires controller history API-key support; errors never mean zero activity. Retention depends on the controller.",
12
+ description: "List historical/offline client inventory using the controller v2 API. Returns OFFLINE clients only — currently connected clients are excluded, so merge with unifi_list_clients for a full inventory. Includes names, MACs, first_seen and last_seen epoch seconds when available. This is inventory, not session history; use unifi_list_client_sessions for past connections. Requires controller history API-key support; errors never mean zero activity. Retention depends on the controller.",
13
13
  inputSchema: { siteReference, withinHours: zod_1.z.number().int().min(1).max(8760).default(168).describe("Lookback in hours, up to one year; does not extend controller retention") },
14
14
  outputSchema: output_schemas_js_1.listClientHistoryOutputSchema,
15
15
  annotations: safety_js_1.READ_ONLY,
@@ -8,7 +8,7 @@ const safety_js_1 = require("../utils/safety.js");
8
8
  const output_schemas_js_1 = require("../utils/output-schemas.js");
9
9
  function registerClientTools(server, client, readOnly = false) {
10
10
  server.registerTool("unifi_list_clients", {
11
- description: "List currently connected clients at a site. Returns per client: id, name, type (WIRED/WIRELESS/VPN/TELEPORT), macAddress, ipAddress (frequently absent — do not rely on it being present), connectedAt, uplinkDeviceId (the switch/AP they're attached to), access.type. NOTE: verified against 10.6.106 — the Integration API client schema is minimal and identical across types; it does NOT expose signal strength, channel, or per-port binding. Use for: who's online right now. Disconnected/historical clients are NOT in the Integration API; use unifi_list_client_history and unifi_list_client_sessions for controller-retained records.",
11
+ description: "List currently connected clients at a site. Returns per client: id, name, type (WIRED/WIRELESS/VPN/TELEPORT), macAddress, ipAddress (may be absent), connectedAt, uplinkDeviceId (the switch/AP they're attached to), access.type. NOTE: verified against 11.0.83 (WIRED/WIRELESS; VPN/TELEPORT not live-verified) — the Integration API client schema is minimal and identical across those types; it does NOT expose signal strength, channel, or per-port binding. Use for: who's online right now. Disconnected/historical clients are NOT in the Integration API; use unifi_list_client_history and unifi_list_client_sessions for controller-retained records.",
12
12
  inputSchema: {
13
13
  siteId: zod_1.z.string().describe("Site ID"),
14
14
  offset: zod_1.z
@@ -24,7 +24,7 @@ function registerClientTools(server, client, readOnly = false) {
24
24
  .max(200)
25
25
  .optional()
26
26
  .describe("Number of records to return (default: 25, max: 200)"),
27
- filter: zod_1.z.string().optional().describe("Filter expression"),
27
+ filter: zod_1.z.string().optional().describe("Filter expression. Filterable: id, type, macAddress, ipAddress, connectedAt (timestamp), access.type, access.authorized (boolean). NOT filterable: name, uplinkDeviceId. Example: type.eq('WIRELESS')"),
28
28
  },
29
29
  outputSchema: output_schemas_js_1.listClientsOutputSchema,
30
30
  annotations: safety_js_1.READ_ONLY,
@@ -58,7 +58,7 @@ function registerClientTools(server, client, readOnly = false) {
58
58
  if (readOnly)
59
59
  return;
60
60
  server.registerTool("unifi_authorize_guest", {
61
- description: "Authorize a guest client through a captive-portal hotspot. Optional limits override hotspot defaults. Returns (per 10.6.106 docs, not live-verified): action, grantedAuthorization and revokedAuthorization objects (authorizedAt, authorizationMethod, expiresAt, dataUsageLimitMBytes, rxRateLimitKbps, txRateLimitKbps, usage). Idempotency: not safe to retry — re-authorizing extends the session.",
61
+ description: "Authorize a guest client through a captive-portal hotspot. Optional limits override hotspot defaults. Returns (per 11.0.83 docs, not live-verified): action, grantedAuthorization and revokedAuthorization objects (authorizedAt, authorizationMethod, expiresAt, dataUsageLimitMBytes, rxRateLimitKbps, txRateLimitKbps, usage). Idempotency: not safe to retry — re-authorizing extends the session.",
62
62
  inputSchema: {
63
63
  siteId: zod_1.z.string().describe("Site ID"),
64
64
  clientId: zod_1.z.string().describe("Client ID"),
@@ -116,7 +116,7 @@ function registerClientTools(server, client, readOnly = false) {
116
116
  }
117
117
  });
118
118
  server.registerTool("unifi_unauthorize_guest", {
119
- description: "Revoke guest authorization; the client returns to the captive portal on next request. Returns (per 10.6.106 docs, not live-verified): action plus a revokedAuthorization object. Idempotent.",
119
+ description: "Revoke guest authorization; the client returns to the captive portal on next request. Returns (per docs; the UNAUTHORIZE response sample is not rendered in 11.0.83 and not live-verified): action plus a revokedAuthorization object. Idempotent.",
120
120
  inputSchema: {
121
121
  siteId: zod_1.z.string().describe("Site ID"),
122
122
  clientId: zod_1.z.string().describe("Client ID"),
@@ -26,7 +26,7 @@ function registerDeviceTools(server, client, readOnly = false) {
26
26
  .max(200)
27
27
  .optional()
28
28
  .describe("Number of records to return (default: 25, max: 200)"),
29
- filter: zod_1.z.string().optional().describe("Filter expression"),
29
+ filter: zod_1.z.string().optional().describe("Filter expression. Filterable: name (supports like), model, state, macAddress, ipAddress, firmwareVersion, supported, firmwareUpdatable, features/interfaces (sets: contains/containsAny/containsAll). like is rejected on model. Example: features.contains('accessPoint')"),
30
30
  },
31
31
  outputSchema: output_schemas_js_1.listDevicesOutputSchema,
32
32
  annotations: safety_js_1.READ_ONLY,
@@ -41,7 +41,7 @@ function registerDeviceTools(server, client, readOnly = false) {
41
41
  }
42
42
  });
43
43
  server.registerTool("unifi_get_device", {
44
- description: "Get full configuration for a device. Returns (in addition to list fields): supported, firmwareUpdatable, adoptedAt, provisionedAt, configurationId, uplink.deviceId, features (object keyed by capability: switching {lags[]} / accessPoint {}), interfaces.ports[] for switches ({idx, state, connector, maxSpeedMbps, speedMbps, poe:{standard, type, enabled, state}}), interfaces.radios[] for APs ({wlanStandard, frequencyGHz, channelWidthMHz, channel}). NOTE: in the LIST endpoint, features/interfaces are capability-tag string arrays instead. Use for: switch port layout/PoE state, AP radio config, uplink topology. For live throughput/CPU/memory, use unifi_get_device_statistics.",
44
+ description: "Get full configuration for a device. Returns (in addition to list fields): adoptedAt, provisionedAt, configurationId, uplink.deviceId (absent on the ISP modem), features (object keyed by capability: switching {lags[]} / accessPoint {}), interfaces.ports[] for switches, gateways and modems ({idx, state, connector, maxSpeedMbps, speedMbps (may be omitted on DOWN ports), poe:{standard, type, enabled, state} on PoE ports only}), interfaces.radios[] for APs ({wlanStandard, frequencyGHz, channelWidthMHz, channel}). Some devices (e.g. 5G modems) return empty features/interfaces objects. NOTE: in the LIST endpoint, features/interfaces are capability-tag string arrays instead. Use for: switch port layout/PoE state, AP radio config, uplink topology. For live throughput/CPU/memory, use unifi_get_device_statistics.",
45
45
  inputSchema: {
46
46
  siteId: zod_1.z.string().describe("Site ID"),
47
47
  deviceId: zod_1.z.string().describe("Device ID"),
@@ -58,7 +58,7 @@ function registerDeviceTools(server, client, readOnly = false) {
58
58
  }
59
59
  });
60
60
  server.registerTool("unifi_get_device_statistics", {
61
- description: "Get latest live statistics for a device. Returns: uptimeSec, lastHeartbeatAt, nextHeartbeatAt, loadAverage1/5/15Min, cpuUtilizationPct, memoryUtilizationPct, uplink (txRateBps, rxRateBps), interfaces.radios[] for APs ({frequencyGHz, txRetriesPct}). NOTE: verified against 10.6.106 — the Integration API does NOT expose per-switch-port byte/error/PoE-power counters here; port-level live stats are unavailable. Use for: device health and AP radio metrics. For config (channel, power, port assignment), use unifi_get_device.",
61
+ description: "Get latest live statistics for a device. Returns: uptimeSec, lastHeartbeatAt, nextHeartbeatAt, loadAverage1/5/15Min, cpuUtilizationPct, memoryUtilizationPct, uplink (txRateBps, rxRateBps; absent on e.g. the ISP modem), interfaces.radios[] for APs ({frequencyGHz, txRetriesPct}; interfaces is {} for non-AP devices). NOTE: verified against 11.0.83 — the Integration API does NOT expose per-switch-port byte/error/PoE-power counters here; port-level live stats are unavailable. Use for: device health and AP radio metrics. For config (channel, power, port assignment), use unifi_get_device.",
62
62
  inputSchema: {
63
63
  siteId: zod_1.z.string().describe("Site ID"),
64
64
  deviceId: zod_1.z.string().describe("Device ID"),
@@ -75,7 +75,7 @@ function registerDeviceTools(server, client, readOnly = false) {
75
75
  }
76
76
  });
77
77
  server.registerTool("unifi_list_pending_devices", {
78
- description: "List devices pending adoption across all sites (global endpoint, not site-scoped). Returns: basic device info per pending device (macAddress, model, ipAddress, firmwareVersion, etc. — exact per-row schema is not rendered in the 10.6.106 docs). Use for: discovering new devices on the network before calling unifi_adopt_device.",
78
+ description: "List devices pending adoption across all sites (global endpoint, not site-scoped). Returns: basic device info per pending device (macAddress, model, ipAddress, firmwareVersion, etc. — exact per-row schema is not rendered in the 11.0.83 docs). Use for: discovering new devices on the network before calling unifi_adopt_device.",
79
79
  inputSchema: {
80
80
  offset: zod_1.z
81
81
  .number()
@@ -90,7 +90,7 @@ function registerDeviceTools(server, client, readOnly = false) {
90
90
  .max(200)
91
91
  .optional()
92
92
  .describe("Number of records to return (default: 25, max: 200)"),
93
- filter: zod_1.z.string().optional().describe("Filter expression"),
93
+ filter: zod_1.z.string().optional().describe("Filter expression, e.g. model.eq('U7 Pro')"),
94
94
  },
95
95
  outputSchema: output_schemas_js_1.listPendingDevicesOutputSchema,
96
96
  annotations: safety_js_1.READ_ONLY,
@@ -157,13 +157,13 @@ function registerDeviceTools(server, client, readOnly = false) {
157
157
  }
158
158
  });
159
159
  server.registerTool("unifi_restart_device", {
160
- description: "Restart (reboot) a device. The device will be unreachable for ~1–3 minutes. Idempotent: repeated calls trigger fresh reboots.",
160
+ description: "Restart (reboot) a device. The device will be unreachable for ~1–3 minutes. Not idempotent: each call triggers a fresh reboot.",
161
161
  inputSchema: {
162
162
  siteId: zod_1.z.string().describe("Site ID"),
163
163
  deviceId: zod_1.z.string().describe("Device ID"),
164
164
  dryRun: zod_1.z.boolean().optional().describe("Preview this action without executing it"),
165
165
  },
166
- annotations: safety_js_1.WRITE,
166
+ annotations: safety_js_1.WRITE_NOT_IDEMPOTENT,
167
167
  }, async ({ siteId, deviceId, dryRun }) => {
168
168
  const body = { action: "RESTART" };
169
169
  if (dryRun)
@@ -181,10 +181,10 @@ function registerDeviceTools(server, client, readOnly = false) {
181
181
  inputSchema: {
182
182
  siteId: zod_1.z.string().describe("Site ID"),
183
183
  deviceId: zod_1.z.string().describe("Device ID"),
184
- portIdx: zod_1.z.number().int().describe("Port index number"),
184
+ portIdx: zod_1.z.number().int().min(1).describe("Port index number (1-based)"),
185
185
  dryRun: zod_1.z.boolean().optional().describe("Preview this action without executing it"),
186
186
  },
187
- annotations: safety_js_1.WRITE,
187
+ annotations: safety_js_1.WRITE_NOT_IDEMPOTENT,
188
188
  }, async ({ siteId, deviceId, portIdx, dryRun }) => {
189
189
  const body = { action: "POWER_CYCLE" };
190
190
  if (dryRun)
@@ -5,10 +5,83 @@ 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");
8
9
  const output_schemas_js_1 = require("../utils/output-schemas.js");
10
+ // Type-specific request-body fields, from the 11.0.83 POST/PUT schemas (every
11
+ // type also takes type, enabled, domain). MX/TXT/SRV/FORWARD_DOMAIN have no TTL.
12
+ const DNS_TYPE_FIELDS = {
13
+ A_RECORD: ["ipv4Address", "ttlSeconds"],
14
+ AAAA_RECORD: ["ipv6Address", "ttlSeconds"],
15
+ CNAME_RECORD: ["targetDomain", "ttlSeconds"],
16
+ MX_RECORD: ["mailServerDomain", "priority"],
17
+ TXT_RECORD: ["text"],
18
+ SRV_RECORD: ["serverDomain", "service", "protocol", "port", "priority", "weight"],
19
+ FORWARD_DOMAIN: ["ipAddress"],
20
+ };
21
+ const DNS_TYPES_DOC = "A_RECORD: ipv4Address, ttlSeconds (0-86400). AAAA_RECORD: ipv6Address, ttlSeconds (0-86400). CNAME_RECORD: targetDomain, ttlSeconds (0-604800). MX_RECORD: mailServerDomain, priority. TXT_RECORD: text. SRV_RECORD: serverDomain, service (e.g. _ldap), protocol (e.g. _tcp), port, priority, weight. FORWARD_DOMAIN: ipAddress (upstream DNS server). All type-specific fields are required for their type.";
22
+ const dnsType = zod_1.z
23
+ .enum([
24
+ "A_RECORD",
25
+ "AAAA_RECORD",
26
+ "CNAME_RECORD",
27
+ "MX_RECORD",
28
+ "TXT_RECORD",
29
+ "SRV_RECORD",
30
+ "FORWARD_DOMAIN",
31
+ ])
32
+ .describe("DNS record type");
33
+ function fieldsForDnsType(type) {
34
+ if (typeof type !== "string" || !(type in DNS_TYPE_FIELDS)) {
35
+ throw new Error(`Unsupported DNS policy type: ${String(type)}`);
36
+ }
37
+ const specific = DNS_TYPE_FIELDS[type];
38
+ return {
39
+ allowed: ["type", "enabled", "domain", ...specific],
40
+ required: ["type", "enabled", "domain", ...specific],
41
+ };
42
+ }
43
+ function checkDnsBody(body, updates) {
44
+ const { allowed, required } = fieldsForDnsType(body.type);
45
+ const rejected = (0, full_put_js_1.unsupportedFields)(updates, allowed);
46
+ if (rejected.length > 0) {
47
+ throw new Error(`Fields not accepted for type ${String(body.type)}: ${rejected.join(", ")}`);
48
+ }
49
+ const missing = (0, full_put_js_1.missingFields)(body, required);
50
+ if (missing.length > 0) {
51
+ throw new Error(`Missing required fields for type ${String(body.type)}: ${missing.join(", ")}`);
52
+ }
53
+ if ((body.type === "A_RECORD" || body.type === "AAAA_RECORD") &&
54
+ typeof body.ttlSeconds === "number" &&
55
+ body.ttlSeconds > 86400) {
56
+ throw new Error(`ttlSeconds for ${body.type} must be 0-86400 (604800 is the CNAME_RECORD maximum)`);
57
+ }
58
+ }
59
+ const uint16 = (description) => zod_1.z.number().int().min(0).max(65535).optional().describe(description);
60
+ const hostname = (description) => zod_1.z.string().min(1).max(127).optional().describe(description);
61
+ const dnsFields = {
62
+ ipv4Address: zod_1.z.string().optional().describe("IPv4 address. A_RECORD."),
63
+ ipv6Address: zod_1.z.string().optional().describe("IPv6 address. AAAA_RECORD."),
64
+ targetDomain: hostname("Canonical name the alias points at. CNAME_RECORD."),
65
+ mailServerDomain: hostname("Mail server domain. MX_RECORD."),
66
+ text: zod_1.z.string().min(1).max(1024).optional().describe("TXT value: up to four 255-character strings; lines containing commas must be double-quoted. TXT_RECORD."),
67
+ serverDomain: hostname("Domain of the server running the service. SRV_RECORD."),
68
+ service: zod_1.z.string().optional().describe("Service, e.g. _ldap. SRV_RECORD."),
69
+ protocol: zod_1.z.string().optional().describe("Protocol, e.g. _tcp. SRV_RECORD."),
70
+ port: uint16("Service port. SRV_RECORD."),
71
+ priority: uint16("Priority; lower is preferred. MX_RECORD, SRV_RECORD."),
72
+ weight: uint16("Relative weight among records with the same priority. SRV_RECORD."),
73
+ ipAddress: zod_1.z.string().optional().describe("IP address of the DNS server queries are forwarded to. FORWARD_DOMAIN."),
74
+ ttlSeconds: zod_1.z
75
+ .number()
76
+ .int()
77
+ .min(0)
78
+ .max(604800)
79
+ .optional()
80
+ .describe("Time to live in seconds: 0-86400 for A/AAAA_RECORD, 0-604800 for CNAME_RECORD"),
81
+ };
9
82
  function registerDnsPolicyTools(server, client, readOnly = false) {
10
83
  server.registerTool("unifi_list_dns_policies", {
11
- description: "List DNS policies (local DNS records and forward rules served by the gateway) at a site. Returns: id, type (A_RECORD, AAAA_RECORD, CNAME_RECORD, MX_RECORD, TXT_RECORD, SRV_RECORD, FORWARD_DOMAIN), enabled, domain, ipv4Address, ttlSeconds, metadata.origin.",
84
+ description: "List DNS policies (local DNS records and forward rules served by the gateway) at a site. Returns: id, type (A_RECORD, AAAA_RECORD, CNAME_RECORD, MX_RECORD, TXT_RECORD, SRV_RECORD, FORWARD_DOMAIN), enabled, domain, metadata.origin, plus type-specific fields: A ipv4Address+ttlSeconds; AAAA ipv6Address+ttlSeconds; CNAME targetDomain+ttlSeconds; MX mailServerDomain+priority; TXT text; SRV serverDomain/service/protocol/port/priority/weight; FORWARD_DOMAIN ipAddress (per 11.0.83 response samples).",
12
85
  inputSchema: {
13
86
  siteId: zod_1.z.string().describe("Site ID"),
14
87
  offset: zod_1.z
@@ -27,7 +100,7 @@ function registerDnsPolicyTools(server, client, readOnly = false) {
27
100
  filter: zod_1.z
28
101
  .string()
29
102
  .optional()
30
- .describe("Filter expression"),
103
+ .describe("Filter expression, e.g. type.eq('A_RECORD')"),
31
104
  },
32
105
  outputSchema: output_schemas_js_1.listDnsPoliciesOutputSchema,
33
106
  annotations: safety_js_1.READ_ONLY,
@@ -61,29 +134,13 @@ function registerDnsPolicyTools(server, client, readOnly = false) {
61
134
  if (readOnly)
62
135
  return;
63
136
  server.registerTool("unifi_create_dns_policy", {
64
- description: "Create a new DNS policy",
137
+ description: `Create a new DNS policy. Every type takes type, enabled, domain; the rest depends on type (per 11.0.83 docs): ${DNS_TYPES_DOC} Fields belonging to other types are rejected. Idempotency: not safe to retry — re-running creates a duplicate record.`,
65
138
  inputSchema: {
66
139
  siteId: zod_1.z.string().describe("Site ID"),
67
- type: zod_1.z
68
- .enum([
69
- "A_RECORD",
70
- "AAAA_RECORD",
71
- "CNAME_RECORD",
72
- "MX_RECORD",
73
- "TXT_RECORD",
74
- "SRV_RECORD",
75
- "FORWARD_DOMAIN",
76
- ])
77
- .describe("DNS record type"),
140
+ ...dnsFields,
141
+ type: dnsType,
78
142
  enabled: zod_1.z.boolean().describe("Whether the DNS policy is enabled"),
79
143
  domain: zod_1.z.string().min(1).max(127).describe("Domain name"),
80
- ipv4Address: zod_1.z.string().describe("IPv4 address for the record"),
81
- ttlSeconds: zod_1.z
82
- .number()
83
- .int()
84
- .min(0)
85
- .max(86400)
86
- .describe("Time to live in seconds"),
87
144
  dryRun: zod_1.z
88
145
  .boolean()
89
146
  .optional()
@@ -91,9 +148,10 @@ function registerDnsPolicyTools(server, client, readOnly = false) {
91
148
  },
92
149
  outputSchema: output_schemas_js_1.dnsPolicyOutputSchema,
93
150
  annotations: safety_js_1.WRITE_NOT_IDEMPOTENT,
94
- }, async ({ siteId, type, enabled, domain, ipv4Address, ttlSeconds, dryRun }) => {
151
+ }, async ({ siteId, dryRun, ...fields }) => {
95
152
  try {
96
- const body = { type, enabled, domain, ipv4Address, ttlSeconds };
153
+ const body = (0, full_put_js_1.definedEntries)(fields);
154
+ checkDnsBody(body, body);
97
155
  if (dryRun)
98
156
  return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/dns/policies`, body);
99
157
  const data = await client.post(`/sites/${siteId}/dns/policies`, body);
@@ -104,43 +162,32 @@ function registerDnsPolicyTools(server, client, readOnly = false) {
104
162
  }
105
163
  });
106
164
  server.registerTool("unifi_update_dns_policy", {
107
- description: "Update a DNS policy",
165
+ description: `Update a DNS policy. Pass only the fields to change. The API's PUT replaces the whole policy, so this tool reads the current policy, keeps the documented fields for the (possibly new) type, overlays your changes and sends the full body. Changing type drops the old type's fields, so supply the new type's required fields: ${DNS_TYPES_DOC} dryRun shows the merged body.`,
108
166
  inputSchema: {
109
167
  siteId: zod_1.z.string().describe("Site ID"),
110
168
  dnsPolicyId: zod_1.z.string().describe("DNS policy ID"),
111
- type: zod_1.z
112
- .enum([
113
- "A_RECORD",
114
- "AAAA_RECORD",
115
- "CNAME_RECORD",
116
- "MX_RECORD",
117
- "TXT_RECORD",
118
- "SRV_RECORD",
119
- "FORWARD_DOMAIN",
120
- ])
121
- .describe("DNS record type"),
122
- enabled: zod_1.z.boolean().describe("Whether the DNS policy is enabled"),
123
- domain: zod_1.z.string().min(1).max(127).describe("Domain name"),
124
- ipv4Address: zod_1.z.string().describe("IPv4 address for the record"),
125
- ttlSeconds: zod_1.z
126
- .number()
127
- .int()
128
- .min(0)
129
- .max(86400)
130
- .describe("Time to live in seconds"),
169
+ ...dnsFields,
170
+ type: dnsType.optional(),
171
+ enabled: zod_1.z.boolean().optional().describe("Whether the DNS policy is enabled"),
172
+ domain: zod_1.z.string().min(1).max(127).optional().describe("Domain name"),
131
173
  dryRun: zod_1.z
132
174
  .boolean()
133
175
  .optional()
134
- .describe("Preview this action without executing it"),
176
+ .describe("Preview the merged PUT body without executing it (still reads the current policy)"),
135
177
  },
136
178
  outputSchema: output_schemas_js_1.dnsPolicyOutputSchema,
137
179
  annotations: safety_js_1.WRITE,
138
- }, async ({ siteId, dnsPolicyId, type, enabled, domain, ipv4Address, ttlSeconds, dryRun }) => {
180
+ }, async ({ siteId, dnsPolicyId, dryRun, ...fields }) => {
139
181
  try {
140
- const body = { type, enabled, domain, ipv4Address, ttlSeconds };
182
+ const path = `/sites/${siteId}/dns/policies/${dnsPolicyId}`;
183
+ const current = (0, full_put_js_1.asObject)(await client.get(path), "the current DNS policy");
184
+ const updates = (0, full_put_js_1.definedEntries)(fields);
185
+ const { allowed } = fieldsForDnsType(updates.type ?? current.type);
186
+ const body = (0, full_put_js_1.mergeForPut)(current, updates, allowed);
187
+ checkDnsBody(body, updates);
141
188
  if (dryRun)
142
- return (0, safety_js_1.formatDryRun)("PUT", `/sites/${siteId}/dns/policies/${dnsPolicyId}`, body);
143
- const data = await client.put(`/sites/${siteId}/dns/policies/${dnsPolicyId}`, body);
189
+ return (0, safety_js_1.formatDryRun)("PUT", path, body);
190
+ const data = await client.put(path, body);
144
191
  return (0, responses_js_1.formatSuccess)(data, { structured: true });
145
192
  }
146
193
  catch (err) {
@@ -5,7 +5,35 @@ 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");
8
9
  const output_schemas_js_1 = require("../utils/output-schemas.js");
10
+ // Firewall policy request-body fields, from the 11.0.83 POST/PUT schemas.
11
+ const FIREWALL_POLICY_FIELDS = [
12
+ "enabled",
13
+ "name",
14
+ "description",
15
+ "action",
16
+ "source",
17
+ "destination",
18
+ "ipProtocolScope",
19
+ "connectionStateFilter",
20
+ "ipsecFilter",
21
+ "loggingEnabled",
22
+ "reportFlowEnabled",
23
+ "schedule",
24
+ ];
25
+ const FIREWALL_POLICY_REQUIRED = [
26
+ "enabled",
27
+ "name",
28
+ "action",
29
+ "source",
30
+ "destination",
31
+ "ipProtocolScope",
32
+ "loggingEnabled",
33
+ ];
34
+ // Present in responses but not part of the request body.
35
+ const FIREWALL_POLICY_READ_ONLY = ["id", "index", "metadata"];
36
+ const FIREWALL_POLICY_BODY_DOC = "required enabled, name (non-empty), action ({type: ALLOW|BLOCK, allowReturnTraffic? on ALLOW}), source and destination ({zoneId, trafficFilter?}), ipProtocolScope ({ipVersion, protocolFilter?}), loggingEnabled; optional description, connectionStateFilter (array of NEW/INVALID/ESTABLISHED/RELATED), ipsecFilter (MATCH_ENCRYPTED/MATCH_NOT_ENCRYPTED), schedule (null = always active), reportFlowEnabled (report matches in Insights Flows; defaults to true when omitted).";
9
37
  function registerFirewallTools(server, client, readOnly = false) {
10
38
  server.registerTool("unifi_list_firewall_zones", {
11
39
  description: "List firewall zones (groupings of networks for zone-based firewalling) at a site. Returns: id, name, networkIds[], metadata.origin (indicates system-defined vs user-defined). Use for: zone inventory; pair with unifi_list_firewall_policies to see rules between zones.",
@@ -27,7 +55,7 @@ function registerFirewallTools(server, client, readOnly = false) {
27
55
  filter: zod_1.z
28
56
  .string()
29
57
  .optional()
30
- .describe("Filter expression"),
58
+ .describe("Filter expression, e.g. name.eq('Internal'). NOTE: totalCount ignores the filter on this endpoint (observed 11.0.83) — page by count"),
31
59
  },
32
60
  outputSchema: output_schemas_js_1.listFirewallZonesOutputSchema,
33
61
  annotations: safety_js_1.READ_ONLY,
@@ -59,7 +87,7 @@ function registerFirewallTools(server, client, readOnly = false) {
59
87
  }
60
88
  });
61
89
  server.registerTool("unifi_list_firewall_policies", {
62
- description: "List firewall policies (zone-based rules) at a site. Returns: id, name, enabled, action (object with type field), source/destination (zone reference + trafficFilter), ipProtocolScope, connectionStateFilter, ipsecFilter, schedule, loggingEnabled, index, description, metadata.origin. Protocols/ports are encoded inside source/destination.trafficFilter, not as top-level fields. Evaluation order within a zone pair comes from unifi_get_firewall_policy_ordering.",
90
+ description: "List firewall policies (zone-based rules) at a site. Returns: id, name, enabled, action (object with type field), source/destination (zone reference + trafficFilter), ipProtocolScope, connectionStateFilter, ipsecFilter, schedule, loggingEnabled, reportFlowEnabled (Insights Flows reporting; false on the predefined Allow Return Traffic and Block policies), index, description, metadata (origin SYSTEM_DEFINED/USER_DEFINED/DERIVED, configurable on system policies). action is {type: ALLOW|BLOCK, allowReturnTraffic on ALLOW}. The protocol lives in ipProtocolScope.protocolFilter (NAMED_PROTOCOL or PRESET); ports and addresses live in source/destination.trafficFilter, which is omitted when a side matches the whole zone. Unset optional fields (ipsecFilter, schedule, connectionStateFilter) are omitted, not null. Evaluation order within a zone pair comes from unifi_get_firewall_policy_ordering.",
63
91
  inputSchema: {
64
92
  siteId: zod_1.z.string().describe("Site ID"),
65
93
  offset: zod_1.z
@@ -78,7 +106,7 @@ function registerFirewallTools(server, client, readOnly = false) {
78
106
  filter: zod_1.z
79
107
  .string()
80
108
  .optional()
81
- .describe("Filter expression"),
109
+ .describe("Filter expression, e.g. metadata.origin.eq('USER_DEFINED') or name.eq('Allow Return Traffic'). Boolean fields (enabled, loggingEnabled, reportFlowEnabled) are not filterable"),
82
110
  },
83
111
  outputSchema: output_schemas_js_1.listFirewallPoliciesOutputSchema,
84
112
  annotations: safety_js_1.READ_ONLY,
@@ -93,7 +121,7 @@ function registerFirewallTools(server, client, readOnly = false) {
93
121
  }
94
122
  });
95
123
  server.registerTool("unifi_get_firewall_policy", {
96
- description: "Get a firewall policy by ID with full match criteria and action.",
124
+ description: "Get a firewall policy by ID with full match criteria and action. Returns the same fields as a unifi_list_firewall_policies entry, including loggingEnabled and reportFlowEnabled.",
97
125
  inputSchema: {
98
126
  siteId: zod_1.z.string().describe("Site ID"),
99
127
  firewallPolicyId: zod_1.z.string().describe("Firewall policy ID"),
@@ -110,7 +138,7 @@ function registerFirewallTools(server, client, readOnly = false) {
110
138
  }
111
139
  });
112
140
  server.registerTool("unifi_get_firewall_policy_ordering", {
113
- description: "Get the evaluation order of user-defined firewall policies for a specific (source zone, destination zone) pair. Returns: beforeSystemDefined[] and afterSystemDefined[] arrays of policy IDs. System-defined rules sit between these two arrays.",
141
+ description: "Get the evaluation order of user-defined firewall policies for a specific (source zone, destination zone) pair. Returns: orderedFirewallPolicyIds.beforeSystemDefined[] and orderedFirewallPolicyIds.afterSystemDefined[] arrays of policy IDs. System-defined rules sit between these two arrays.",
114
142
  inputSchema: {
115
143
  siteId: zod_1.z.string().describe("Site ID"),
116
144
  sourceFirewallZoneId: zod_1.z
@@ -219,7 +247,7 @@ function registerFirewallTools(server, client, readOnly = false) {
219
247
  }
220
248
  });
221
249
  server.registerTool("unifi_create_firewall_policy", {
222
- description: "Create a new firewall policy",
250
+ description: `Create a new firewall policy. Body fields (per 11.0.83 docs): ${FIREWALL_POLICY_BODY_DOC} Copy nested shapes (trafficFilter, protocolFilter) from an existing policy via unifi_get_firewall_policy. Idempotency: not safe to retry — re-running creates a duplicate policy.`,
223
251
  inputSchema: {
224
252
  siteId: zod_1.z.string().describe("Site ID"),
225
253
  policy: zod_1.z
@@ -244,25 +272,37 @@ function registerFirewallTools(server, client, readOnly = false) {
244
272
  }
245
273
  });
246
274
  server.registerTool("unifi_update_firewall_policy", {
247
- description: "Update a firewall policy",
275
+ description: `Update a firewall policy. Pass only the fields to change in policy. The API's PUT replaces the whole policy — and an omitted reportFlowEnabled reverts to true — so this tool reads the current policy, keeps every documented body field, overlays your fields and sends the full body. Top-level fields are replaced whole (e.g. send the complete source object). Read-only response fields (id, index, metadata) are ignored. Body fields: ${FIREWALL_POLICY_BODY_DOC} For just logging/flow-reporting toggles, prefer unifi_patch_firewall_policy. dryRun shows the merged body.`,
248
276
  inputSchema: {
249
277
  siteId: zod_1.z.string().describe("Site ID"),
250
278
  firewallPolicyId: zod_1.z.string().describe("Firewall policy ID"),
251
279
  policy: zod_1.z
252
280
  .record(zod_1.z.string(), zod_1.z.unknown())
253
- .describe("Firewall policy configuration (JSON object)"),
281
+ .describe("Firewall policy fields to change (JSON object); null clears an optional field such as schedule"),
254
282
  dryRun: zod_1.z
255
283
  .boolean()
256
284
  .optional()
257
- .describe("Preview this action without executing it"),
285
+ .describe("Preview the merged PUT body without executing it (still reads the current policy)"),
258
286
  },
259
287
  outputSchema: output_schemas_js_1.firewallPolicyOutputSchema,
260
288
  annotations: safety_js_1.WRITE,
261
289
  }, async ({ siteId, firewallPolicyId, policy, dryRun }) => {
262
290
  try {
291
+ const path = `/sites/${siteId}/firewall/policies/${firewallPolicyId}`;
292
+ const updates = Object.fromEntries(Object.entries(policy).filter(([field]) => !FIREWALL_POLICY_READ_ONLY.includes(field)));
293
+ const rejected = (0, full_put_js_1.unsupportedFields)(updates, FIREWALL_POLICY_FIELDS);
294
+ if (rejected.length > 0) {
295
+ throw new Error(`Fields not accepted by the firewall policy PUT body: ${rejected.join(", ")}`);
296
+ }
297
+ const current = (0, full_put_js_1.asObject)(await client.get(path), "the current firewall policy");
298
+ const body = (0, full_put_js_1.mergeForPut)(current, updates, FIREWALL_POLICY_FIELDS);
299
+ const missing = (0, full_put_js_1.missingFields)(body, FIREWALL_POLICY_REQUIRED);
300
+ if (missing.length > 0) {
301
+ throw new Error(`Missing required fields: ${missing.join(", ")}`);
302
+ }
263
303
  if (dryRun)
264
- return (0, safety_js_1.formatDryRun)("PUT", `/sites/${siteId}/firewall/policies/${firewallPolicyId}`, policy);
265
- const data = await client.put(`/sites/${siteId}/firewall/policies/${firewallPolicyId}`, policy);
304
+ return (0, safety_js_1.formatDryRun)("PUT", path, body);
305
+ const data = await client.put(path, body);
266
306
  return (0, responses_js_1.formatSuccess)(data, { structured: true });
267
307
  }
268
308
  catch (err) {
@@ -270,13 +310,13 @@ function registerFirewallTools(server, client, readOnly = false) {
270
310
  }
271
311
  });
272
312
  server.registerTool("unifi_patch_firewall_policy", {
273
- description: "Partially update a firewall policy without resending all fields. loggingEnabled is the only field documented as patchable in API 10.6.106; other fields may be rejected — use unifi_update_firewall_policy for a full update. Idempotent for fields supplied.",
313
+ description: "Partially update a firewall policy without resending all fields. loggingEnabled and reportFlowEnabled are the only fields documented as patchable in API 11.0.83 (reportFlowEnabled is ignored for the predefined Allow Return Traffic policies, which are never reported); other fields may be rejected — use unifi_update_firewall_policy for a full update. Idempotent for fields supplied.",
274
314
  inputSchema: {
275
315
  siteId: zod_1.z.string().describe("Site ID"),
276
316
  firewallPolicyId: zod_1.z.string().describe("Firewall policy ID"),
277
317
  policy: zod_1.z
278
318
  .record(zod_1.z.string(), zod_1.z.unknown())
279
- .describe("Partial firewall policy fields to update (e.g. { loggingEnabled: true })"),
319
+ .describe("Partial firewall policy fields to update (e.g. { loggingEnabled: true } or { reportFlowEnabled: false })"),
280
320
  dryRun: zod_1.z
281
321
  .boolean()
282
322
  .optional()