@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
@@ -6,26 +6,28 @@ const responses_js_1 = require("../utils/responses.js");
6
6
  const query_js_1 = require("../utils/query.js");
7
7
  const safety_js_1 = require("../utils/safety.js");
8
8
  const output_schemas_js_1 = require("../utils/output-schemas.js");
9
+ const params_js_1 = require("../utils/params.js");
10
+ const VOUCHER_BATCH_WITHOUT_CONFIRM = 10;
9
11
  function registerHotspotTools(server, client, readOnly = false) {
10
12
  server.registerTool("unifi_list_vouchers", {
11
13
  description: "List hotspot/guest-portal vouchers at a site. Returns: id, code, name, createdAt, activatedAt (when first guest used it), expiresAt, timeLimitMinutes, dataUsageLimitMBytes, rxRateLimitKbps, txRateLimitKbps, authorizedGuestLimit, authorizedGuestCount, expired. Use filter like 'expired.eq(true)' to bulk-find stale vouchers.",
12
14
  inputSchema: {
13
- siteId: zod_1.z.string().describe("Site ID"),
15
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
14
16
  offset: zod_1.z
15
17
  .number()
16
18
  .int()
17
19
  .nonnegative()
18
20
  .optional()
19
21
  .default(0)
20
- .describe("Number of records to skip (default: 0)"),
22
+ .describe("Number of records to skip"),
21
23
  limit: zod_1.z
22
24
  .number()
23
25
  .int()
24
26
  .min(0)
25
27
  .max(1000)
26
28
  .optional()
27
- .default(100)
28
- .describe("Number of records to return (default: 100, max: 1000)"),
29
+ .default(25)
30
+ .describe("Number of records to return"),
29
31
  filter: zod_1.z
30
32
  .string()
31
33
  .optional()
@@ -46,8 +48,8 @@ function registerHotspotTools(server, client, readOnly = false) {
46
48
  server.registerTool("unifi_get_voucher", {
47
49
  description: "Get a specific hotspot voucher by ID (same fields as the list entry).",
48
50
  inputSchema: {
49
- siteId: zod_1.z.string().describe("Site ID"),
50
- voucherId: zod_1.z.string().describe("Voucher ID"),
51
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
52
+ voucherId: (0, params_js_1.resourceId)().describe("Voucher ID"),
51
53
  },
52
54
  outputSchema: output_schemas_js_1.voucherOutputSchema,
53
55
  annotations: safety_js_1.READ_ONLY,
@@ -63,9 +65,9 @@ function registerHotspotTools(server, client, readOnly = false) {
63
65
  if (readOnly)
64
66
  return;
65
67
  server.registerTool("unifi_create_voucher", {
66
- description: "Create one or more hotspot vouchers (use count for batch creation, up to 1000 at once). Returns: array of created vouchers with their generated codes. Idempotency: not safe to retry — each call mints fresh codes.",
68
+ description: `Create one or more hotspot vouchers (use count for batch creation; more than ${VOUCHER_BATCH_WITHOUT_CONFIRM} at once needs confirm: true). Returns: vouchers[] — the created vouchers with their generated codes. Idempotency: not safe to retry — each call mints fresh codes.`,
67
69
  inputSchema: {
68
- siteId: zod_1.z.string().describe("Site ID"),
70
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
69
71
  name: zod_1.z
70
72
  .string()
71
73
  .describe("Voucher note/name (duplicated across all generated vouchers)"),
@@ -110,21 +112,30 @@ function registerHotspotTools(server, client, readOnly = false) {
110
112
  .max(100000)
111
113
  .optional()
112
114
  .describe("Upload rate limit in kbps (2-100000)"),
115
+ confirm: zod_1.z
116
+ .boolean()
117
+ .optional()
118
+ .describe(`Set true to confirm creating more than ${VOUCHER_BATCH_WITHOUT_CONFIRM} vouchers at once; not needed with dryRun: true`),
113
119
  dryRun: zod_1.z
114
120
  .boolean()
115
121
  .optional()
116
122
  .describe("Preview this action without executing it"),
117
123
  },
118
- outputSchema: output_schemas_js_1.createVouchersOutputSchema,
124
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.createVouchersOutputSchema),
119
125
  annotations: safety_js_1.WRITE_NOT_IDEMPOTENT,
120
- }, async ({ siteId, name, timeLimitMinutes, count, authorizedGuestLimit, dataUsageLimitMBytes, rxRateLimitKbps, txRateLimitKbps, dryRun, }) => {
126
+ }, async ({ siteId, name, timeLimitMinutes, count, authorizedGuestLimit, dataUsageLimitMBytes, rxRateLimitKbps, txRateLimitKbps, confirm, dryRun, }) => {
127
+ // Each voucher is a guest credential; a large batch is a lot of access
128
+ // to hand out on one unchecked call.
129
+ const opening = count > VOUCHER_BATCH_WITHOUT_CONFIRM ? `This creates ${count} guest access codes` : undefined;
130
+ const guard = (0, safety_js_1.requireConfirmationIf)(opening, confirm, dryRun);
131
+ if (guard)
132
+ return guard;
121
133
  try {
122
134
  const body = {
123
135
  name,
124
136
  timeLimitMinutes,
137
+ count,
125
138
  };
126
- if (count !== undefined)
127
- body.count = count;
128
139
  if (authorizedGuestLimit !== undefined)
129
140
  body.authorizedGuestLimit = authorizedGuestLimit;
130
141
  if (dataUsageLimitMBytes !== undefined)
@@ -134,7 +145,7 @@ function registerHotspotTools(server, client, readOnly = false) {
134
145
  if (txRateLimitKbps !== undefined)
135
146
  body.txRateLimitKbps = txRateLimitKbps;
136
147
  if (dryRun)
137
- return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/hotspot/vouchers`, body);
148
+ return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/hotspot/vouchers`, body, opening);
138
149
  const data = await client.post(`/sites/${siteId}/hotspot/vouchers`, body);
139
150
  return (0, responses_js_1.formatSuccess)(data, { structured: true });
140
151
  }
@@ -145,12 +156,9 @@ function registerHotspotTools(server, client, readOnly = false) {
145
156
  server.registerTool("unifi_delete_voucher", {
146
157
  description: "DESTRUCTIVE: Delete a hotspot voucher. Returns: vouchersDeleted (count of vouchers removed).",
147
158
  inputSchema: {
148
- siteId: zod_1.z.string().describe("Site ID"),
149
- voucherId: zod_1.z.string().describe("Voucher ID"),
150
- confirm: zod_1.z
151
- .boolean()
152
- .optional()
153
- .describe("Must be true to execute this destructive action"),
159
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
160
+ voucherId: (0, params_js_1.resourceId)().describe("Voucher ID"),
161
+ confirm: (0, safety_js_1.confirmDestructive)(),
154
162
  dryRun: zod_1.z
155
163
  .boolean()
156
164
  .optional()
@@ -158,7 +166,7 @@ function registerHotspotTools(server, client, readOnly = false) {
158
166
  },
159
167
  annotations: safety_js_1.DESTRUCTIVE,
160
168
  }, async ({ siteId, voucherId, confirm, dryRun }) => {
161
- const guard = (0, safety_js_1.requireConfirmation)(confirm, "This will permanently delete the voucher");
169
+ const guard = dryRun ? null : (0, safety_js_1.requireConfirmation)(confirm, "This will permanently delete the voucher");
162
170
  if (guard)
163
171
  return guard;
164
172
  try {
@@ -173,16 +181,15 @@ function registerHotspotTools(server, client, readOnly = false) {
173
181
  }
174
182
  });
175
183
  server.registerTool("unifi_bulk_delete_vouchers", {
176
- description: "DESTRUCTIVE: Bulk delete hotspot vouchers based on filter criteria. Returns: vouchersDeleted (count of vouchers removed).",
184
+ description: "DESTRUCTIVE: Bulk delete hotspot vouchers matching a filter. The dry run shows only the request, not which vouchers match: call unifi_list_vouchers with the same filter first. Returns: vouchersDeleted (count of vouchers removed).",
177
185
  inputSchema: {
178
- siteId: zod_1.z.string().describe("Site ID"),
186
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
179
187
  filter: zod_1.z
180
188
  .string()
181
- .describe("Required filter expression (e.g., 'expired.eq(true)', name.like('guest*'))"),
182
- confirm: zod_1.z
183
- .boolean()
184
- .optional()
185
- .describe("Must be true to execute this destructive action"),
189
+ .trim()
190
+ .min(1)
191
+ .describe("Required filter expression, e.g. expired.eq(true) or name.like('guest*')"),
192
+ confirm: (0, safety_js_1.confirmDestructive)(),
186
193
  dryRun: zod_1.z
187
194
  .boolean()
188
195
  .optional()
@@ -191,7 +198,7 @@ function registerHotspotTools(server, client, readOnly = false) {
191
198
  annotations: safety_js_1.DESTRUCTIVE,
192
199
  }, async ({ siteId, filter, confirm, dryRun }) => {
193
200
  try {
194
- const guard = (0, safety_js_1.requireConfirmation)(confirm, "This will delete all vouchers matching the filter");
201
+ const guard = dryRun ? null : (0, safety_js_1.requireConfirmation)(confirm, "This will delete all vouchers matching the filter");
195
202
  if (guard)
196
203
  return guard;
197
204
  const query = (0, query_js_1.buildQuery)({ filter });
@@ -1,3 +1,15 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { NetworkClient } from "../client.js";
3
- export declare function registerAllTools(server: McpServer, client: NetworkClient, readOnly?: boolean): void;
3
+ import { type WanPolicy } from "../utils/wan-exposure.js";
4
+ import type { WriteLog } from "../write-log.js";
5
+ export interface ToolOptions {
6
+ /** The write tools to register (UNIFI_NETWORK_WRITE_TOOLS); undefined registers them all. */
7
+ writeTools?: readonly string[];
8
+ /** WAN-exposure refusals (UNIFI_NETWORK_ALLOW_WAN_EXPOSURE, UNIFI_NETWORK_WAN_ZONE_IDS); on by default. */
9
+ wan?: WanPolicy;
10
+ /** UNIFI_NETWORK_WRITE_LOG; every write tool call appends a line. */
11
+ writeLog?: WriteLog;
12
+ }
13
+ export declare function registerAllTools(server: McpServer, client: NetworkClient, readOnly?: boolean, options?: ToolOptions): void;
14
+ /** "unifi_list_mc_lag_domains" → "List MC-LAG domains". */
15
+ export declare function toolTitle(name: string): string;
@@ -1,6 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerAllTools = registerAllTools;
4
+ exports.toolTitle = toolTitle;
5
+ const client_js_1 = require("../client.js");
6
+ const output_schemas_js_1 = require("../utils/output-schemas.js");
7
+ const write_tools_js_1 = require("../write-tools.js");
8
+ const wan_exposure_js_1 = require("../utils/wan-exposure.js");
4
9
  const system_js_1 = require("./system.js");
5
10
  const sites_js_1 = require("./sites.js");
6
11
  const devices_js_1 = require("./devices.js");
@@ -15,19 +20,73 @@ const dns_policies_js_1 = require("./dns-policies.js");
15
20
  const traffic_matching_js_1 = require("./traffic-matching.js");
16
21
  const switching_js_1 = require("./switching.js");
17
22
  const supporting_js_1 = require("./supporting.js");
18
- function registerAllTools(server, client, readOnly = false) {
23
+ function registerAllTools(server, client, readOnly = false, options = {}) {
24
+ server = instrumentTools(server, options);
25
+ const wan = options.wan ?? wan_exposure_js_1.DEFAULT_WAN_POLICY;
19
26
  (0, system_js_1.registerSystemTools)(server, client);
20
27
  (0, sites_js_1.registerSiteTools)(server, client);
21
28
  (0, devices_js_1.registerDeviceTools)(server, client, readOnly);
22
29
  (0, clients_js_1.registerClientTools)(server, client, readOnly);
23
30
  (0, client_history_js_1.registerClientHistoryTools)(server, client);
24
- (0, networks_js_1.registerNetworkTools)(server, client, readOnly);
31
+ (0, networks_js_1.registerNetworkTools)(server, client, readOnly, wan);
25
32
  (0, wifi_js_1.registerWifiTools)(server, client, readOnly);
26
33
  (0, hotspot_js_1.registerHotspotTools)(server, client, readOnly);
27
- (0, firewall_js_1.registerFirewallTools)(server, client, readOnly);
34
+ (0, firewall_js_1.registerFirewallTools)(server, client, readOnly, wan);
28
35
  (0, acl_js_1.registerAclTools)(server, client, readOnly);
29
36
  (0, dns_policies_js_1.registerDnsPolicyTools)(server, client, readOnly);
30
- (0, traffic_matching_js_1.registerTrafficMatchingTools)(server, client, readOnly);
37
+ (0, traffic_matching_js_1.registerTrafficMatchingTools)(server, client, readOnly, wan);
31
38
  (0, switching_js_1.registerSwitchingTools)(server, client);
32
39
  (0, supporting_js_1.registerSupportingTools)(server, client);
33
40
  }
41
+ /**
42
+ * A view of server that applies these rules to every tool it registers, so no
43
+ * tool module can forget them:
44
+ *
45
+ * - Handlers run with their call's abort signal in scope, so NetworkClient
46
+ * aborts the controller request when the MCP client cancels the call. The
47
+ * SDK passes that signal in `extra`, the handler's last argument.
48
+ * - Each tool gets a human-readable title derived from its name.
49
+ * - A write tool the operator did not enable (UNIFI_NETWORK_WRITE_TOOLS) is
50
+ * not registered at all. A write tool missing from WRITE_TOOL_GROUPS, or a
51
+ * read tool listed there, is a bug: registration throws.
52
+ * - With a write log, every write tool call is recorded in it.
53
+ * - The outputSchema is published as a loose object. The SDK client validates
54
+ * structuredContent against the published JSON Schema, and tools pass the
55
+ * controller's response through; a raw shape publishes
56
+ * additionalProperties: false, so the first field a firmware update added
57
+ * would make the tool fail for every client that validates.
58
+ */
59
+ function instrumentTools(server, { writeTools, writeLog }) {
60
+ const registerTool = (name, config, handler) => {
61
+ const write = config.annotations?.readOnlyHint === false;
62
+ if (write !== write_tools_js_1.WRITE_TOOLS.includes(name)) {
63
+ throw new Error(`${name} is ${write ? "a write tool missing from" : "a read tool listed in"} WRITE_TOOL_GROUPS (src/write-tools.ts)`);
64
+ }
65
+ if (write && writeTools !== undefined && !writeTools.includes(name))
66
+ return undefined;
67
+ return server.registerTool(name, {
68
+ ...config,
69
+ title: config.title ?? toolTitle(name),
70
+ ...(config.outputSchema ? { outputSchema: (0, output_schemas_js_1.looseOutput)(config.outputSchema) } : {}),
71
+ }, ((...args) => {
72
+ const extra = args.at(-1);
73
+ const signal = extra?.signal instanceof AbortSignal ? extra.signal : undefined;
74
+ const run = () => (0, client_js_1.withCallSignal)(signal, () => handler(...args));
75
+ return write && writeLog ? writeLog.record(name, args[0], run) : run();
76
+ }));
77
+ };
78
+ return new Proxy(server, {
79
+ get: (target, prop, receiver) => prop === "registerTool" ? registerTool : Reflect.get(target, prop, receiver),
80
+ });
81
+ }
82
+ // Words in tool names that read wrongly in plain sentence case.
83
+ const TITLE_WORDS = {
84
+ acl: "ACL", dns: "DNS", dpi: "DPI", vpn: "VPN", radius: "RADIUS",
85
+ wifi: "WiFi", wan: "WAN", wans: "WANs", lag: "LAG", lags: "LAGs", mc: "MC",
86
+ };
87
+ /** "unifi_list_mc_lag_domains" → "List MC-LAG domains". */
88
+ function toolTitle(name) {
89
+ const words = name.replace(/^unifi_/, "").split("_").map((word) => TITLE_WORDS[word] ?? word);
90
+ const title = words.join(" ").replace("MC LAG", "MC-LAG");
91
+ return title.charAt(0).toUpperCase() + title.slice(1);
92
+ }
@@ -1,3 +1,4 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { NetworkClient } from "../client.js";
3
- export declare function registerNetworkTools(server: McpServer, client: NetworkClient, readOnly?: boolean): void;
3
+ import { type WanPolicy } from "../utils/wan-exposure.js";
4
+ export declare function registerNetworkTools(server: McpServer, client: NetworkClient, readOnly?: boolean, wan?: WanPolicy): void;
@@ -5,29 +5,127 @@ 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");
9
- function registerNetworkTools(server, client, readOnly = false) {
10
+ const params_js_1 = require("../utils/params.js");
11
+ const wan_exposure_js_1 = require("../utils/wan-exposure.js");
12
+ // Request-body fields per management type, from the 11.0.83 POST/PUT schemas.
13
+ const COMMON_FIELDS = ["management", "name", "enabled", "vlanId", "dhcpGuarding"];
14
+ const COMMON_REQUIRED = ["management", "name", "enabled", "vlanId"];
15
+ const NETWORK_VARIANTS = {
16
+ UNMANAGED: { allowed: COMMON_FIELDS, required: COMMON_REQUIRED },
17
+ GATEWAY: {
18
+ allowed: [
19
+ ...COMMON_FIELDS,
20
+ "isolationEnabled",
21
+ "cellularBackupEnabled",
22
+ "zoneId",
23
+ "internetAccessEnabled",
24
+ "mdnsForwardingEnabled",
25
+ "ipv4Configuration",
26
+ "ipv6Configuration",
27
+ ],
28
+ required: [
29
+ ...COMMON_REQUIRED,
30
+ "isolationEnabled",
31
+ "cellularBackupEnabled",
32
+ "internetAccessEnabled",
33
+ "ipv4Configuration",
34
+ ],
35
+ },
36
+ SWITCH: {
37
+ allowed: [
38
+ ...COMMON_FIELDS,
39
+ "isolationEnabled",
40
+ "cellularBackupEnabled",
41
+ "deviceId",
42
+ "ipv4Configuration",
43
+ ],
44
+ required: [
45
+ ...COMMON_REQUIRED,
46
+ "isolationEnabled",
47
+ "cellularBackupEnabled",
48
+ "deviceId",
49
+ "ipv4Configuration",
50
+ ],
51
+ },
52
+ };
53
+ const NETWORK_VARIANTS_DOC = "all types take name, enabled, vlanId, optional dhcpGuarding (omitted or null = disabled). GATEWAY (routed by the UniFi gateway) also requires isolationEnabled, cellularBackupEnabled, internetAccessEnabled, ipv4Configuration (gateway-managed subnet + DHCP), optional zoneId, mdnsForwardingEnabled, ipv6Configuration. SWITCH (routed by an L3 switch) also requires isolationEnabled, cellularBackupEnabled, deviceId (the L3 switch), ipv4Configuration (switch-managed).";
54
+ function fieldsForManagement(management) {
55
+ if (management === "UNMANAGED" || management === "GATEWAY" || management === "SWITCH") {
56
+ return NETWORK_VARIANTS[management];
57
+ }
58
+ throw new Error(`Unsupported network management type: ${String(management)}`);
59
+ }
60
+ function checkNetworkBody(body, updates) {
61
+ const { allowed, required } = fieldsForManagement(body.management);
62
+ const rejected = (0, full_put_js_1.unsupportedFields)(updates, allowed);
63
+ if (rejected.length > 0) {
64
+ throw new Error(`Fields not accepted for management ${String(body.management)}: ${rejected.join(", ")}`);
65
+ }
66
+ const missing = (0, full_put_js_1.missingFields)(body, required);
67
+ if (missing.length > 0) {
68
+ throw new Error(`Missing required fields for management ${String(body.management)}: ${missing.join(", ")}`);
69
+ }
70
+ }
71
+ const config = (description) => zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).describe(description);
72
+ // Every body field as an optional input. Create tightens the common fields to
73
+ // required; update keeps them optional and fills gaps from the current network.
74
+ const networkFields = {
75
+ name: zod_1.z.string().optional().describe("Network name"),
76
+ management: zod_1.z
77
+ .enum(["UNMANAGED", "GATEWAY", "SWITCH"])
78
+ .optional()
79
+ .describe("Network management type"),
80
+ enabled: zod_1.z.boolean().optional().describe("Enable the network"),
81
+ vlanId: zod_1.z
82
+ .number()
83
+ .int()
84
+ .min(1)
85
+ .max(4009)
86
+ .optional()
87
+ .describe("VLAN ID (1 for the default network, 2-4009 for additional networks)"),
88
+ dhcpGuarding: config("DHCP Guarding settings (trustedDhcpServerIpAddresses). Omitted or null = disabled.")
89
+ .nullable()
90
+ .optional(),
91
+ isolationEnabled: zod_1.z.boolean().optional().describe("Isolate this network from all other networks. GATEWAY/SWITCH."),
92
+ cellularBackupEnabled: zod_1.z.boolean().optional().describe("Allow cellular data when WAN connection(s) are down. GATEWAY/SWITCH."),
93
+ internetAccessEnabled: zod_1.z.boolean().optional().describe("Allow internet access for devices on this network. GATEWAY only."),
94
+ mdnsForwardingEnabled: zod_1.z
95
+ .boolean()
96
+ .nullable()
97
+ .optional()
98
+ .describe("Participate in mDNS forwarding; null = use the site mDNS default. GATEWAY only."),
99
+ zoneId: (0, params_js_1.resourceId)().optional().describe("Firewall zone ID for this network. GATEWAY only."),
100
+ deviceId: (0, params_js_1.resourceId)().optional().describe("ID of the L3 switching capable device that manages this network. SWITCH only."),
101
+ ipv4Configuration: config("IPv4 configuration (hostIpAddress, prefixLength, dhcpConfiguration, …) — copy the shape from unifi_get_network. GATEWAY/SWITCH.")
102
+ .optional(),
103
+ ipv6Configuration: config("IPv6 configuration (interfaceType, clientAddressAssignment, routerAdvertisement, …); omitted or null = IPv6 not configured. GATEWAY only.")
104
+ .nullable()
105
+ .optional(),
106
+ };
107
+ function registerNetworkTools(server, client, readOnly = false, wan = wan_exposure_js_1.DEFAULT_WAN_POLICY) {
10
108
  server.registerTool("unifi_list_networks", {
11
- description: "List all networks (VLANs/LAN segments) at a site. Returns: id, name, management (UNMANAGED/GATEWAY/SWITCH), enabled, vlanId, default (true for the default network), zoneId (the firewall zone the network sits in), dhcpGuarding, metadata.origin. NOTE: the list view is sparse — for subnet/DHCP/NTP detail (ipv4Configuration/ipv6Configuration), call unifi_get_network on a specific id. Use for: VLAN inventory; pair with unifi_get_network_references to find what consumes a network.",
109
+ description: "List all networks (VLANs/LAN segments) at a site. Returns: id, name, management (UNMANAGED/GATEWAY/SWITCH), enabled, vlanId, default (true for the default network), zoneId (the firewall zone the network sits in), dhcpGuarding (only present when enabled), metadata.origin (+ metadata.configurable on the system-defined default network). NOTE: the list view is sparse — for subnet/DHCP/NTP detail (ipv4Configuration/ipv6Configuration), call unifi_get_network on a specific id. Use for: VLAN inventory; pair with unifi_get_network_references to find what consumes a network.",
12
110
  inputSchema: {
13
- siteId: zod_1.z.string().describe("Site ID"),
111
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
14
112
  offset: zod_1.z
15
113
  .number()
16
114
  .int()
17
115
  .nonnegative()
18
116
  .optional()
19
- .describe("Number of records to skip (default: 0)"),
117
+ .describe("Number of records to skip"),
20
118
  limit: zod_1.z
21
119
  .number()
22
120
  .int()
23
121
  .min(0)
24
122
  .max(200)
25
123
  .optional()
26
- .describe("Number of records to return (default: 25, max: 200)"),
124
+ .describe("Number of records to return (API default: 25)"),
27
125
  filter: zod_1.z
28
126
  .string()
29
127
  .optional()
30
- .describe("Filter expression"),
128
+ .describe("Filter expression, e.g. vlanId.eq(20)"),
31
129
  },
32
130
  outputSchema: output_schemas_js_1.listNetworksOutputSchema,
33
131
  annotations: safety_js_1.READ_ONLY,
@@ -42,10 +140,10 @@ function registerNetworkTools(server, client, readOnly = false) {
42
140
  }
43
141
  });
44
142
  server.registerTool("unifi_get_network", {
45
- description: "Get a network/VLAN by ID. Returns the list fields PLUS (live-verified) isolationEnabled, internetAccessEnabled, mdnsForwardingEnabled, cellularBackupEnabled, a full ipv4Configuration object (autoScaleEnabled, hostIpAddress, prefixLength, dhcpConfiguration with mode/ipAddressRange/leaseTimeSeconds/domainName/pingConflictDetectionEnabled/ntpServerIpAddresses), and an ipv6Configuration object (interfaceType, clientAddressAssignment, routerAdvertisement, hostIpAddress, prefixLength). NOTE: subnet/DHCP detail appears here at get-by-id but NOT in unifi_list_networks (sparse list view).",
143
+ description: "Get a network/VLAN by ID. Returns the list fields PLUS (live-verified) isolationEnabled, internetAccessEnabled, mdnsForwardingEnabled, cellularBackupEnabled, a full ipv4Configuration object (autoScaleEnabled, hostIpAddress, prefixLength, dhcpConfiguration with mode/ipAddressRange/leaseTimeSeconds/domainName/pingConflictDetectionEnabled/ntpServerIpAddresses — which may contain empty-string entries), and an ipv6Configuration object when IPv6 is configured (interfaceType, clientAddressAssignment, routerAdvertisement, hostIpAddress, prefixLength). NOTE: subnet/DHCP detail appears here at get-by-id but NOT in unifi_list_networks (sparse list view).",
46
144
  inputSchema: {
47
- siteId: zod_1.z.string().describe("Site ID"),
48
- networkId: zod_1.z.string().describe("Network ID"),
145
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
146
+ networkId: (0, params_js_1.resourceId)().describe("Network ID"),
49
147
  },
50
148
  outputSchema: output_schemas_js_1.networkOutputSchema,
51
149
  annotations: safety_js_1.READ_ONLY,
@@ -59,10 +157,10 @@ function registerNetworkTools(server, client, readOnly = false) {
59
157
  }
60
158
  });
61
159
  server.registerTool("unifi_get_network_references", {
62
- description: "Get all objects that reference this network (WiFi broadcasts, firewall zones, etc.). Returns: { referenceResources: [...] }. Use before deleting a network to find dependencies that need to be re-pointed or removed.",
160
+ description: "Get all objects that reference this network. Returns: { referenceResources: [{ resourceType (e.g. WIFI), referenceCount, references: [{ referenceId }] }] }. Use before deleting a network to find dependencies that need to be re-pointed or removed. NOTE: on 11.0.83 the controller returns HTTP 500 (api.unexpected-error) for many networks — observed on the default network and on networks used by PPSK keys. On a 500, fall back to unifi_list_wifi + unifi_get_wifi (network.networkId and securityConfiguration.presharedKeys[].network.networkId) and unifi_list_firewall_zones (networkIds).",
63
161
  inputSchema: {
64
- siteId: zod_1.z.string().describe("Site ID"),
65
- networkId: zod_1.z.string().describe("Network ID"),
162
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
163
+ networkId: (0, params_js_1.resourceId)().describe("Network ID"),
66
164
  },
67
165
  outputSchema: output_schemas_js_1.getNetworkReferencesOutputSchema,
68
166
  annotations: safety_js_1.READ_ONLY,
@@ -78,9 +176,10 @@ function registerNetworkTools(server, client, readOnly = false) {
78
176
  if (readOnly)
79
177
  return;
80
178
  server.registerTool("unifi_create_network", {
81
- description: "Create a new VLAN/network. management=GATEWAY means routed via UDM/UXG; SWITCH means VLAN-only; UNMANAGED means external. Idempotency: not safe to retry — re-running creates duplicates.",
179
+ description: `Create a new VLAN/network. The body depends on management (per 11.0.83 docs): ${NETWORK_VARIANTS_DOC} Idempotency: not safe to retry — re-running creates duplicates.`,
82
180
  inputSchema: {
83
- siteId: zod_1.z.string().describe("Site ID"),
181
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
182
+ ...networkFields,
84
183
  name: zod_1.z.string().describe("Network name"),
85
184
  management: zod_1.z
86
185
  .enum(["UNMANAGED", "GATEWAY", "SWITCH"])
@@ -92,22 +191,22 @@ function registerNetworkTools(server, client, readOnly = false) {
92
191
  .min(1)
93
192
  .max(4009)
94
193
  .describe("VLAN ID (1 for default, 2+ for additional)"),
95
- dhcpGuarding: zod_1.z
96
- .record(zod_1.z.string(), zod_1.z.unknown())
97
- .optional()
98
- .describe("DHCP Guarding settings (trustedDhcpServerIpAddresses)"),
99
194
  dryRun: zod_1.z
100
195
  .boolean()
101
196
  .optional()
102
197
  .describe("Preview this action without executing it"),
103
198
  },
104
- outputSchema: output_schemas_js_1.networkOutputSchema,
199
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.networkOutputSchema),
105
200
  annotations: safety_js_1.WRITE_NOT_IDEMPOTENT,
106
- }, async ({ siteId, name, management, enabled, vlanId, dhcpGuarding, dryRun }) => {
201
+ }, async ({ siteId, dryRun, ...fields }) => {
107
202
  try {
108
- const body = { name, management, enabled, vlanId };
109
- if (dhcpGuarding !== undefined)
110
- body.dhcpGuarding = dhcpGuarding;
203
+ const body = (0, full_put_js_1.definedEntries)(fields);
204
+ checkNetworkBody(body, body);
205
+ if (typeof body.zoneId === "string") {
206
+ const exposure = await new wan_exposure_js_1.WanExposure(client, wan, siteId).networkZone(body.zoneId);
207
+ if (exposure)
208
+ return (0, wan_exposure_js_1.wanRefusal)(exposure, "POST", `/sites/${siteId}/networks`);
209
+ }
111
210
  if (dryRun)
112
211
  return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/networks`, body);
113
212
  const data = await client.post(`/sites/${siteId}/networks`, body);
@@ -118,41 +217,39 @@ function registerNetworkTools(server, client, readOnly = false) {
118
217
  }
119
218
  });
120
219
  server.registerTool("unifi_update_network", {
121
- description: "Update an existing network",
220
+ description: `Update an existing network. Pass only the fields to change. The API's PUT replaces the whole network — omitting dhcpGuarding disables DHCP guarding, and GATEWAY networks must resend their full ipv4Configuration — so this tool reads the current network, keeps every documented body field for its management type, overlays your changes and sends the full body. Pass null to clear an optional field (e.g. ipv6Configuration: null removes IPv6). Nested objects such as ipv4Configuration are replaced whole, not deep-merged: read them with unifi_get_network and send the complete object. Variants: ${NETWORK_VARIANTS_DOC} dryRun shows the merged body.`,
122
221
  inputSchema: {
123
- siteId: zod_1.z.string().describe("Site ID"),
124
- networkId: zod_1.z.string().describe("Network ID"),
125
- name: zod_1.z.string().describe("Network name"),
126
- management: zod_1.z
127
- .enum(["UNMANAGED", "GATEWAY", "SWITCH"])
128
- .describe("Network management type"),
129
- enabled: zod_1.z.boolean().describe("Enable the network"),
130
- vlanId: zod_1.z
131
- .number()
132
- .int()
133
- .min(1)
134
- .max(4009)
135
- .describe("VLAN ID (1-4009)"),
136
- dhcpGuarding: zod_1.z
137
- .record(zod_1.z.string(), zod_1.z.unknown())
138
- .optional()
139
- .describe("DHCP Guarding settings (trustedDhcpServerIpAddresses)"),
222
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
223
+ networkId: (0, params_js_1.resourceId)().describe("Network ID"),
224
+ ...networkFields,
140
225
  dryRun: zod_1.z
141
226
  .boolean()
142
227
  .optional()
143
- .describe("Preview this action without executing it"),
228
+ .describe("Preview the merged PUT body without executing it (still reads the current network)"),
144
229
  },
145
- outputSchema: output_schemas_js_1.networkOutputSchema,
230
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.networkOutputSchema),
146
231
  annotations: safety_js_1.WRITE,
147
- }, async ({ siteId, networkId, name, management, enabled, vlanId, dhcpGuarding, dryRun }) => {
232
+ }, async ({ siteId, networkId, dryRun, ...fields }) => {
148
233
  try {
149
- const body = { name, management, enabled, vlanId };
150
- if (dhcpGuarding !== undefined)
151
- body.dhcpGuarding = dhcpGuarding;
152
- if (dryRun)
153
- return (0, safety_js_1.formatDryRun)("PUT", `/sites/${siteId}/networks/${networkId}`, body);
154
- const data = await client.put(`/sites/${siteId}/networks/${networkId}`, body);
155
- return (0, responses_js_1.formatSuccess)(data, { structured: true });
234
+ const path = `/sites/${siteId}/networks/${networkId}`;
235
+ return await (0, full_put_js_1.withResourceLock)(path, async () => {
236
+ const current = (0, full_put_js_1.asObject)(await client.get(path), "the current network");
237
+ const updates = (0, full_put_js_1.definedEntries)(fields);
238
+ const { allowed } = fieldsForManagement(updates.management ?? current.management);
239
+ const body = (0, full_put_js_1.mergeForPut)(current, updates, allowed);
240
+ checkNetworkBody(body, updates);
241
+ const currentZoneId = typeof current.zoneId === "string" ? current.zoneId : undefined;
242
+ // Zone IDs are compared without case, as the WAN check compares them.
243
+ if (typeof body.zoneId === "string" && body.zoneId.toLowerCase() !== currentZoneId?.toLowerCase()) {
244
+ const exposure = await new wan_exposure_js_1.WanExposure(client, wan, siteId).networkZone(body.zoneId, currentZoneId);
245
+ if (exposure)
246
+ return (0, wan_exposure_js_1.wanRefusal)(exposure, "PUT", path);
247
+ }
248
+ if (dryRun)
249
+ return (0, safety_js_1.formatDryRun)("PUT", path, body);
250
+ const data = await client.put(path, body);
251
+ return (0, responses_js_1.formatSuccess)(data, { structured: true });
252
+ });
156
253
  }
157
254
  catch (err) {
158
255
  return (0, responses_js_1.formatError)(err);
@@ -161,17 +258,14 @@ function registerNetworkTools(server, client, readOnly = false) {
161
258
  server.registerTool("unifi_delete_network", {
162
259
  description: "DESTRUCTIVE: Delete a network — all clients on this network will be disconnected",
163
260
  inputSchema: {
164
- siteId: zod_1.z.string().describe("Site ID"),
165
- networkId: zod_1.z.string().describe("Network ID"),
261
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
262
+ networkId: (0, params_js_1.resourceId)().describe("Network ID"),
166
263
  force: zod_1.z
167
264
  .boolean()
168
265
  .optional()
169
266
  .default(false)
170
267
  .describe("Force delete (default: false)"),
171
- confirm: zod_1.z
172
- .boolean()
173
- .optional()
174
- .describe("Must be true to execute this destructive action"),
268
+ confirm: (0, safety_js_1.confirmDestructive)(),
175
269
  dryRun: zod_1.z
176
270
  .boolean()
177
271
  .optional()
@@ -179,7 +273,7 @@ function registerNetworkTools(server, client, readOnly = false) {
179
273
  },
180
274
  annotations: safety_js_1.DESTRUCTIVE,
181
275
  }, async ({ siteId, networkId, force, confirm, dryRun }) => {
182
- const guard = (0, safety_js_1.requireConfirmation)(confirm, "This will delete the network and disconnect all clients on it");
276
+ const guard = dryRun ? null : (0, safety_js_1.requireConfirmation)(confirm, "This will delete the network and disconnect all clients on it");
183
277
  if (guard)
184
278
  return guard;
185
279
  try {
@@ -15,18 +15,18 @@ function registerSiteTools(server, client) {
15
15
  .int()
16
16
  .nonnegative()
17
17
  .optional()
18
- .describe("Number of records to skip (default: 0)"),
18
+ .describe("Number of records to skip"),
19
19
  limit: zod_1.z
20
20
  .number()
21
21
  .int()
22
22
  .min(0)
23
23
  .max(200)
24
24
  .optional()
25
- .describe("Number of records to return (default: 25, max: 200)"),
25
+ .describe("Number of records to return (API default: 25)"),
26
26
  filter: zod_1.z
27
27
  .string()
28
28
  .optional()
29
- .describe("Filter expression (e.g., name.like('office*'))"),
29
+ .describe("Filter expression. Filterable: id, internalReference, name — eq/ne/in/notIn only (like is rejected). Example: name.eq('Default')"),
30
30
  },
31
31
  outputSchema: output_schemas_js_1.listSitesOutputSchema,
32
32
  annotations: safety_js_1.READ_ONLY,