@owine/unifi-network-mcp 2.15.0 → 3.1.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -77
  3. package/dist/client.d.ts +26 -1
  4. package/dist/client.js +199 -24
  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 +180 -0
  9. package/dist/index.js +17 -4
  10. package/dist/instructions.d.ts +9 -0
  11. package/dist/instructions.js +32 -0
  12. package/dist/tls.d.ts +14 -0
  13. package/dist/tls.js +58 -0
  14. package/dist/tools/acl.js +123 -50
  15. package/dist/tools/classic.d.ts +3 -0
  16. package/dist/tools/classic.js +54 -0
  17. package/dist/tools/client-history.js +22 -12
  18. package/dist/tools/clients.js +10 -9
  19. package/dist/tools/devices.js +21 -20
  20. package/dist/tools/dns-policies.js +27 -27
  21. package/dist/tools/exposure.d.ts +3 -0
  22. package/dist/tools/exposure.js +118 -0
  23. package/dist/tools/firewall.d.ts +2 -1
  24. package/dist/tools/firewall.js +200 -75
  25. package/dist/tools/hotspot.js +35 -28
  26. package/dist/tools/index.d.ts +13 -1
  27. package/dist/tools/index.js +67 -4
  28. package/dist/tools/networks.d.ts +2 -1
  29. package/dist/tools/networks.js +44 -31
  30. package/dist/tools/sites.js +2 -2
  31. package/dist/tools/supporting.js +22 -21
  32. package/dist/tools/switching.js +16 -15
  33. package/dist/tools/system.js +1 -1
  34. package/dist/tools/traffic-matching.d.ts +2 -1
  35. package/dist/tools/traffic-matching.js +22 -20
  36. package/dist/tools/wifi.js +76 -30
  37. package/dist/utils/full-put.d.ts +11 -2
  38. package/dist/utils/full-put.js +25 -2
  39. package/dist/utils/output-schemas.d.ts +241 -4
  40. package/dist/utils/output-schemas.js +225 -49
  41. package/dist/utils/params.d.ts +12 -0
  42. package/dist/utils/params.js +21 -0
  43. package/dist/utils/pick.d.ts +6 -0
  44. package/dist/utils/pick.js +11 -0
  45. package/dist/utils/responses.d.ts +2 -0
  46. package/dist/utils/responses.js +28 -2
  47. package/dist/utils/safety.d.ts +35 -1
  48. package/dist/utils/safety.js +53 -3
  49. package/dist/utils/secrets.js +3 -0
  50. package/dist/utils/wan-exposure.d.ts +69 -0
  51. package/dist/utils/wan-exposure.js +245 -0
  52. package/dist/write-log.d.ts +52 -0
  53. package/dist/write-log.js +178 -0
  54. package/dist/write-tools.d.ts +27 -0
  55. package/dist/write-tools.js +50 -0
  56. 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,11 +1,18 @@
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");
7
12
  const clients_js_1 = require("./clients.js");
8
13
  const client_history_js_1 = require("./client-history.js");
14
+ const classic_js_1 = require("./classic.js");
15
+ const exposure_js_1 = require("./exposure.js");
9
16
  const networks_js_1 = require("./networks.js");
10
17
  const wifi_js_1 = require("./wifi.js");
11
18
  const hotspot_js_1 = require("./hotspot.js");
@@ -15,19 +22,75 @@ const dns_policies_js_1 = require("./dns-policies.js");
15
22
  const traffic_matching_js_1 = require("./traffic-matching.js");
16
23
  const switching_js_1 = require("./switching.js");
17
24
  const supporting_js_1 = require("./supporting.js");
18
- function registerAllTools(server, client, readOnly = false) {
25
+ function registerAllTools(server, client, readOnly = false, options = {}) {
26
+ server = instrumentTools(server, options);
27
+ const wan = options.wan ?? wan_exposure_js_1.DEFAULT_WAN_POLICY;
19
28
  (0, system_js_1.registerSystemTools)(server, client);
20
29
  (0, sites_js_1.registerSiteTools)(server, client);
21
30
  (0, devices_js_1.registerDeviceTools)(server, client, readOnly);
22
31
  (0, clients_js_1.registerClientTools)(server, client, readOnly);
23
32
  (0, client_history_js_1.registerClientHistoryTools)(server, client);
24
- (0, networks_js_1.registerNetworkTools)(server, client, readOnly);
33
+ (0, classic_js_1.registerClassicTools)(server, client);
34
+ (0, exposure_js_1.registerExposureTools)(server, client);
35
+ (0, networks_js_1.registerNetworkTools)(server, client, readOnly, wan);
25
36
  (0, wifi_js_1.registerWifiTools)(server, client, readOnly);
26
37
  (0, hotspot_js_1.registerHotspotTools)(server, client, readOnly);
27
- (0, firewall_js_1.registerFirewallTools)(server, client, readOnly);
38
+ (0, firewall_js_1.registerFirewallTools)(server, client, readOnly, wan);
28
39
  (0, acl_js_1.registerAclTools)(server, client, readOnly);
29
40
  (0, dns_policies_js_1.registerDnsPolicyTools)(server, client, readOnly);
30
- (0, traffic_matching_js_1.registerTrafficMatchingTools)(server, client, readOnly);
41
+ (0, traffic_matching_js_1.registerTrafficMatchingTools)(server, client, readOnly, wan);
31
42
  (0, switching_js_1.registerSwitchingTools)(server, client);
32
43
  (0, supporting_js_1.registerSupportingTools)(server, client);
33
44
  }
45
+ /**
46
+ * A view of server that applies these rules to every tool it registers, so no
47
+ * tool module can forget them:
48
+ *
49
+ * - Handlers run with their call's abort signal in scope, so NetworkClient
50
+ * aborts the controller request when the MCP client cancels the call. The
51
+ * SDK passes that signal in `extra`, the handler's last argument.
52
+ * - Each tool gets a human-readable title derived from its name.
53
+ * - A write tool the operator did not enable (UNIFI_NETWORK_WRITE_TOOLS) is
54
+ * not registered at all. A write tool missing from WRITE_TOOL_GROUPS, or a
55
+ * read tool listed there, is a bug: registration throws.
56
+ * - With a write log, every write tool call is recorded in it.
57
+ * - The outputSchema is published as a loose object. The SDK client validates
58
+ * structuredContent against the published JSON Schema, and tools pass the
59
+ * controller's response through; a raw shape publishes
60
+ * additionalProperties: false, so the first field a firmware update added
61
+ * would make the tool fail for every client that validates.
62
+ */
63
+ function instrumentTools(server, { writeTools, writeLog }) {
64
+ const registerTool = (name, config, handler) => {
65
+ const write = config.annotations?.readOnlyHint === false;
66
+ if (write !== write_tools_js_1.WRITE_TOOLS.includes(name)) {
67
+ throw new Error(`${name} is ${write ? "a write tool missing from" : "a read tool listed in"} WRITE_TOOL_GROUPS (src/write-tools.ts)`);
68
+ }
69
+ if (write && writeTools !== undefined && !writeTools.includes(name))
70
+ return undefined;
71
+ return server.registerTool(name, {
72
+ ...config,
73
+ title: config.title ?? toolTitle(name),
74
+ ...(config.outputSchema ? { outputSchema: (0, output_schemas_js_1.looseOutput)(config.outputSchema) } : {}),
75
+ }, ((...args) => {
76
+ const extra = args.at(-1);
77
+ const signal = extra?.signal instanceof AbortSignal ? extra.signal : undefined;
78
+ const run = () => (0, client_js_1.withCallSignal)(signal, () => handler(...args));
79
+ return write && writeLog ? writeLog.record(name, args[0], run) : run();
80
+ }));
81
+ };
82
+ return new Proxy(server, {
83
+ get: (target, prop, receiver) => prop === "registerTool" ? registerTool : Reflect.get(target, prop, receiver),
84
+ });
85
+ }
86
+ // Words in tool names that read wrongly in plain sentence case.
87
+ const TITLE_WORDS = {
88
+ acl: "ACL", dns: "DNS", dpi: "DPI", vpn: "VPN", radius: "RADIUS",
89
+ wifi: "WiFi", wan: "WAN", wans: "WANs", lag: "LAG", lags: "LAGs", mc: "MC", upnp: "UPnP", ssh: "SSH",
90
+ };
91
+ /** "unifi_list_mc_lag_domains" → "List MC-LAG domains". */
92
+ function toolTitle(name) {
93
+ const words = name.replace(/^unifi_/, "").split("_").map((word) => TITLE_WORDS[word] ?? word);
94
+ const title = words.join(" ").replace("MC LAG", "MC-LAG");
95
+ return title.charAt(0).toUpperCase() + title.slice(1);
96
+ }
@@ -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;
@@ -7,6 +7,8 @@ const query_js_1 = require("../utils/query.js");
7
7
  const safety_js_1 = require("../utils/safety.js");
8
8
  const full_put_js_1 = require("../utils/full-put.js");
9
9
  const output_schemas_js_1 = require("../utils/output-schemas.js");
10
+ const params_js_1 = require("../utils/params.js");
11
+ const wan_exposure_js_1 = require("../utils/wan-exposure.js");
10
12
  // Request-body fields per management type, from the 11.0.83 POST/PUT schemas.
11
13
  const COMMON_FIELDS = ["management", "name", "enabled", "vlanId", "dhcpGuarding"];
12
14
  const COMMON_REQUIRED = ["management", "name", "enabled", "vlanId"];
@@ -94,32 +96,32 @@ const networkFields = {
94
96
  .nullable()
95
97
  .optional()
96
98
  .describe("Participate in mDNS forwarding; null = use the site mDNS default. GATEWAY only."),
97
- zoneId: zod_1.z.string().optional().describe("Firewall zone ID for this network. GATEWAY only."),
98
- deviceId: zod_1.z.string().optional().describe("ID of the L3 switching capable device that manages this network. SWITCH 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."),
99
101
  ipv4Configuration: config("IPv4 configuration (hostIpAddress, prefixLength, dhcpConfiguration, …) — copy the shape from unifi_get_network. GATEWAY/SWITCH.")
100
102
  .optional(),
101
103
  ipv6Configuration: config("IPv6 configuration (interfaceType, clientAddressAssignment, routerAdvertisement, …); omitted or null = IPv6 not configured. GATEWAY only.")
102
104
  .nullable()
103
105
  .optional(),
104
106
  };
105
- function registerNetworkTools(server, client, readOnly = false) {
107
+ function registerNetworkTools(server, client, readOnly = false, wan = wan_exposure_js_1.DEFAULT_WAN_POLICY) {
106
108
  server.registerTool("unifi_list_networks", {
107
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.",
108
110
  inputSchema: {
109
- siteId: zod_1.z.string().describe("Site ID"),
111
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
110
112
  offset: zod_1.z
111
113
  .number()
112
114
  .int()
113
115
  .nonnegative()
114
116
  .optional()
115
- .describe("Number of records to skip (default: 0)"),
117
+ .describe("Number of records to skip"),
116
118
  limit: zod_1.z
117
119
  .number()
118
120
  .int()
119
121
  .min(0)
120
122
  .max(200)
121
123
  .optional()
122
- .describe("Number of records to return (default: 25, max: 200)"),
124
+ .describe("Number of records to return (API default: 25)"),
123
125
  filter: zod_1.z
124
126
  .string()
125
127
  .optional()
@@ -140,8 +142,8 @@ function registerNetworkTools(server, client, readOnly = false) {
140
142
  server.registerTool("unifi_get_network", {
141
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).",
142
144
  inputSchema: {
143
- siteId: zod_1.z.string().describe("Site ID"),
144
- 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"),
145
147
  },
146
148
  outputSchema: output_schemas_js_1.networkOutputSchema,
147
149
  annotations: safety_js_1.READ_ONLY,
@@ -157,8 +159,8 @@ function registerNetworkTools(server, client, readOnly = false) {
157
159
  server.registerTool("unifi_get_network_references", {
158
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).",
159
161
  inputSchema: {
160
- siteId: zod_1.z.string().describe("Site ID"),
161
- 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"),
162
164
  },
163
165
  outputSchema: output_schemas_js_1.getNetworkReferencesOutputSchema,
164
166
  annotations: safety_js_1.READ_ONLY,
@@ -176,7 +178,7 @@ function registerNetworkTools(server, client, readOnly = false) {
176
178
  server.registerTool("unifi_create_network", {
177
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.`,
178
180
  inputSchema: {
179
- siteId: zod_1.z.string().describe("Site ID"),
181
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
180
182
  ...networkFields,
181
183
  name: zod_1.z.string().describe("Network name"),
182
184
  management: zod_1.z
@@ -194,12 +196,17 @@ function registerNetworkTools(server, client, readOnly = false) {
194
196
  .optional()
195
197
  .describe("Preview this action without executing it"),
196
198
  },
197
- outputSchema: output_schemas_js_1.networkOutputSchema,
199
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.networkOutputSchema),
198
200
  annotations: safety_js_1.WRITE_NOT_IDEMPOTENT,
199
201
  }, async ({ siteId, dryRun, ...fields }) => {
200
202
  try {
201
203
  const body = (0, full_put_js_1.definedEntries)(fields);
202
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
+ }
203
210
  if (dryRun)
204
211
  return (0, safety_js_1.formatDryRun)("POST", `/sites/${siteId}/networks`, body);
205
212
  const data = await client.post(`/sites/${siteId}/networks`, body);
@@ -212,28 +219,37 @@ function registerNetworkTools(server, client, readOnly = false) {
212
219
  server.registerTool("unifi_update_network", {
213
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.`,
214
221
  inputSchema: {
215
- siteId: zod_1.z.string().describe("Site ID"),
216
- networkId: zod_1.z.string().describe("Network ID"),
222
+ siteId: (0, params_js_1.resourceId)().describe("Site ID"),
223
+ networkId: (0, params_js_1.resourceId)().describe("Network ID"),
217
224
  ...networkFields,
218
225
  dryRun: zod_1.z
219
226
  .boolean()
220
227
  .optional()
221
228
  .describe("Preview the merged PUT body without executing it (still reads the current network)"),
222
229
  },
223
- outputSchema: output_schemas_js_1.networkOutputSchema,
230
+ outputSchema: (0, output_schemas_js_1.writeResult)(output_schemas_js_1.networkOutputSchema),
224
231
  annotations: safety_js_1.WRITE,
225
232
  }, async ({ siteId, networkId, dryRun, ...fields }) => {
226
233
  try {
227
234
  const path = `/sites/${siteId}/networks/${networkId}`;
228
- const current = (0, full_put_js_1.asObject)(await client.get(path), "the current network");
229
- const updates = (0, full_put_js_1.definedEntries)(fields);
230
- const { allowed } = fieldsForManagement(updates.management ?? current.management);
231
- const body = (0, full_put_js_1.mergeForPut)(current, updates, allowed);
232
- checkNetworkBody(body, updates);
233
- if (dryRun)
234
- return (0, safety_js_1.formatDryRun)("PUT", path, body);
235
- const data = await client.put(path, body);
236
- return (0, responses_js_1.formatSuccess)(data, { structured: true });
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
+ });
237
253
  }
238
254
  catch (err) {
239
255
  return (0, responses_js_1.formatError)(err);
@@ -242,17 +258,14 @@ function registerNetworkTools(server, client, readOnly = false) {
242
258
  server.registerTool("unifi_delete_network", {
243
259
  description: "DESTRUCTIVE: Delete a network — all clients on this network will be disconnected",
244
260
  inputSchema: {
245
- siteId: zod_1.z.string().describe("Site ID"),
246
- 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"),
247
263
  force: zod_1.z
248
264
  .boolean()
249
265
  .optional()
250
266
  .default(false)
251
267
  .describe("Force delete (default: false)"),
252
- confirm: zod_1.z
253
- .boolean()
254
- .optional()
255
- .describe("Must be true to execute this destructive action"),
268
+ confirm: (0, safety_js_1.confirmDestructive)(),
256
269
  dryRun: zod_1.z
257
270
  .boolean()
258
271
  .optional()
@@ -260,7 +273,7 @@ function registerNetworkTools(server, client, readOnly = false) {
260
273
  },
261
274
  annotations: safety_js_1.DESTRUCTIVE,
262
275
  }, async ({ siteId, networkId, force, confirm, dryRun }) => {
263
- 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");
264
277
  if (guard)
265
278
  return guard;
266
279
  try {
@@ -15,14 +15,14 @@ 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()