@owine/unifi-network-mcp 2.12.0 → 2.14.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
@@ -1,6 +1,6 @@
1
1
  # UniFi Network MCP Server
2
2
 
3
- An MCP (Model Context Protocol) server that exposes the UniFi Network Integration API as tools for Claude Code and other MCP clients. Provides 74 tools for managing sites, devices, clients, networks, WiFi, firewalls, ACLs, switching, DNS policies, hotspot vouchers, VPNs, and more.
3
+ An MCP (Model Context Protocol) server that exposes the UniFi Network Integration API as tools for Claude Code and other MCP clients. Provides 76 tools for managing sites, devices, clients, networks, WiFi, firewalls, ACLs, switching, DNS policies, hotspot vouchers, VPNs, and more.
4
4
 
5
5
  ## Prerequisites
6
6
 
@@ -79,11 +79,11 @@ This server provides layered safety controls for responsible operation:
79
79
 
80
80
  ## Structured Output
81
81
 
82
- 58 of the 74 tools (all 41 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.
82
+ 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
83
 
84
- The schemas live in `src/utils/output-schemas.ts` and are verified against UniFi Network API 10.5.43. 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.
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.
85
85
 
86
- ## Tools (74 total)
86
+ ## Tools (76 total)
87
87
 
88
88
  ### System (1)
89
89
  | Tool | Description |
@@ -115,6 +115,12 @@ The schemas live in `src/utils/output-schemas.ts` and are verified against UniFi
115
115
  | `unifi_authorize_guest` | Authorize a guest client on a hotspot network |
116
116
  | `unifi_unauthorize_guest` | Unauthorize a guest client |
117
117
 
118
+ ### Client history (2)
119
+ | Tool | Description |
120
+ |---|---|
121
+ | `unifi_list_client_history` | Historical/offline client inventory and first/last seen times |
122
+ | `unifi_list_client_sessions` | Retained connection sessions, duration, byte counters and roaming records |
123
+
118
124
  ### Networks (6)
119
125
  | Tool | Description |
120
126
  |---|---|
@@ -245,3 +251,23 @@ git commit --allow-empty -m "chore: release 2.0.0" -m "Release-As: 2.0.0"
245
251
  ## License
246
252
 
247
253
  MIT
254
+
255
+ ## Retained client connections
256
+
257
+ The Integration API only lists connected clients. The two history tools use the controller APIs instead: GET `/proxy/network/v2/api/site/{siteReference}/clients/history` and POST `/proxy/network/api/s/{siteReference}/stat/session`. The POST is a statistics query and does not change the network. Both tools remain available in read-only mode.
258
+
259
+ Use `internalReference` from `unifi_list_sites` (often `default`), not its UUID. Session queries require `start` and `end` in Unix **seconds**, with a maximum 31-day window. The controller ignores `_start`/offset on this endpoint. A full result sets `mayBeTruncated=true`; split the time window or filter by MAC and query again. Overlap split boundaries and deduplicate session IDs. A result below the requested limit means the query was not capped, not that all dates were retained.
260
+
261
+ ```json
262
+ {"siteReference":"default","start":1789272000,"end":1789876800,"limit":1000}
263
+ ```
264
+
265
+ Results preserve controller field names, including `assoc_time`, `duration`, `rx_bytes`, `tx_bytes` and `roaming_sessions`. RX/TX are the controller's counters, not relabeled client download/upload. Inventory is separate from session history. Retention varies; a non-truncated result does not prove all requested dates were retained, and ongoing sessions may not be present. Neither association nor traffic counters establish end-to-end Internet success. The `is_guest` flag does not establish device ownership.
266
+
267
+ These controller routes are not part of the published Integration API contract. Their availability and API-key permissions vary by controller version. Unsupported, unauthorized, HTML login, legacy `meta.rc` error and malformed responses fail explicitly instead of becoming an empty history. History requests have a 25-second timeout, reject redirects, and cap responses at 10 MB. No browser cookies are required where API-key access is supported.
268
+
269
+ ### Cloud Connector
270
+
271
+ To use a Site Manager API key with a console, set `UNIFI_NETWORK_HOST=api.ui.com` and `UNIFI_NETWORK_CONSOLE_ID` to its console ID. The existing `UNIFI_NETWORK_API_KEY` supplies the key. This works for current Integration API tools and the controller history routes when permitted by the console/key. Cloud Connector requires eligible console ownership/access and firmware 5.0.3 or later. Keep TLS verification enabled.
272
+
273
+ References: [official Cloud Connector API](https://developer.ui.com/site-manager/v1.0.0) and the [Art-of-WiFi controller client](https://github.com/Art-of-WiFi/UniFi-API-client/blob/master/src/Client.php), whose session queries use epoch seconds.
package/dist/client.d.ts CHANGED
@@ -2,6 +2,7 @@ import { Config } from "./config.js";
2
2
  export declare class NetworkClient {
3
3
  private baseUrl;
4
4
  private headers;
5
+ private networkUrl;
5
6
  constructor(config: Config);
6
7
  private request;
7
8
  get(path: string): Promise<unknown>;
@@ -9,4 +10,14 @@ export declare class NetworkClient {
9
10
  put(path: string, body: unknown): Promise<unknown>;
10
11
  patch(path: string, body: unknown): Promise<unknown>;
11
12
  delete(path: string): Promise<unknown>;
13
+ /** These controller endpoints are read queries, including stat/session's POST. */
14
+ getClientHistory(siteReference: string, withinHours: number): Promise<unknown>;
15
+ getClientSessions(siteReference: string, query: {
16
+ start: number;
17
+ end: number;
18
+ limit: number;
19
+ mac?: string;
20
+ }): Promise<unknown>;
21
+ private validateSiteReference;
22
+ private historyRequest;
12
23
  }
package/dist/client.js CHANGED
@@ -4,8 +4,18 @@ exports.NetworkClient = void 0;
4
4
  class NetworkClient {
5
5
  baseUrl;
6
6
  headers;
7
+ networkUrl;
7
8
  constructor(config) {
8
- this.baseUrl = `https://${config.host}/proxy/network/integration/v1`;
9
+ // Cloud Connector only exists behind api.ui.com. A console ID set against a
10
+ // local controller is ignored rather than fatal: the other tools work fine
11
+ // against that controller, and taking the whole server down helps nobody.
12
+ const useConnector = config.consoleId !== undefined && config.host === "api.ui.com";
13
+ if (config.consoleId !== undefined && !useConnector) {
14
+ console.error(`Ignoring UNIFI_NETWORK_CONSOLE_ID: Cloud Connector requires host api.ui.com, but host is ${config.host}.`);
15
+ }
16
+ const consolePath = useConnector ? `/v1/connector/consoles/${config.consoleId}` : "";
17
+ this.networkUrl = `https://${config.host}${consolePath}/proxy/network`;
18
+ this.baseUrl = `${this.networkUrl}/integration/v1`;
9
19
  this.headers = {
10
20
  "X-API-KEY": config.apiKey,
11
21
  "Content-Type": "application/json",
@@ -49,5 +59,84 @@ class NetworkClient {
49
59
  async delete(path) {
50
60
  return this.request("DELETE", path);
51
61
  }
62
+ /** These controller endpoints are read queries, including stat/session's POST. */
63
+ async getClientHistory(siteReference, withinHours) {
64
+ this.validateSiteReference(siteReference);
65
+ if (!Number.isInteger(withinHours) || withinHours < 1 || withinHours > 8760) {
66
+ throw new Error("withinHours must be an integer from 1 to 8760");
67
+ }
68
+ return this.historyRequest(`/v2/api/site/${siteReference}/clients/history?withinHours=${withinHours}`);
69
+ }
70
+ async getClientSessions(siteReference, query) {
71
+ this.validateSiteReference(siteReference);
72
+ const { start, end, limit, mac } = query;
73
+ if (![start, end, limit].every(Number.isSafeInteger) || start < 0 || end > 4102444800 ||
74
+ end <= start || end - start > 31 * 86400 || limit < 1 || limit > 1000 ||
75
+ (mac !== undefined && !/^([\da-f]{2}:){5}[\da-f]{2}$/i.test(mac))) {
76
+ throw new Error("Invalid session query: use epoch seconds, an increasing window of at most 31 days, a valid limit and MAC");
77
+ }
78
+ return this.historyRequest(`/api/s/${siteReference}/stat/session`, {
79
+ type: "all", start, end, _limit: limit, _sort: "-assoc_time",
80
+ ...(mac ? { mac: mac.toLowerCase() } : {}),
81
+ });
82
+ }
83
+ validateSiteReference(siteReference) {
84
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(siteReference)) {
85
+ throw new Error("Use the site's internalReference from unifi_list_sites (for example default)");
86
+ }
87
+ }
88
+ async historyRequest(path, body) {
89
+ const response = await fetch(`${this.networkUrl}${path}`, {
90
+ method: body === undefined ? "GET" : "POST", headers: this.headers,
91
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
92
+ redirect: "error", signal: AbortSignal.timeout(25000),
93
+ });
94
+ if (!response.ok) {
95
+ // Drain before throwing: an unread body pins the socket until GC, so a
96
+ // run of failures (say, a mis-scoped API key) would leak connections.
97
+ await response.body?.cancel();
98
+ throw new Error(`Controller history returned HTTP ${response.status}; verify API-key access and controller support. This is not an empty history result.`);
99
+ }
100
+ if (!response.headers.get("content-type")?.includes("application/json")) {
101
+ await response.body?.cancel();
102
+ throw new Error("Controller history returned non-JSON data; verify controller authentication");
103
+ }
104
+ // Bound the body before parsing: controller session records can be large.
105
+ const reader = response.body?.getReader();
106
+ if (!reader)
107
+ throw new Error("Controller history returned no response body");
108
+ const chunks = [];
109
+ let bytes = 0;
110
+ try {
111
+ while (true) {
112
+ const { done, value } = await reader.read();
113
+ if (done)
114
+ break;
115
+ bytes += value.length;
116
+ if (bytes > 10000000) {
117
+ await reader.cancel();
118
+ throw new Error("Controller history exceeds 10 MB; reduce the page size or time window");
119
+ }
120
+ chunks.push(value);
121
+ }
122
+ }
123
+ finally {
124
+ reader.releaseLock();
125
+ }
126
+ const text = Buffer.concat(chunks).toString("utf8");
127
+ if (text.trim() === "") {
128
+ // A bare JSON.parse would surface "Unexpected end of JSON input", which
129
+ // reads as a broken tool rather than an unusable controller response.
130
+ throw new Error("Controller history returned an empty body; verify controller support. This is not an empty history result.");
131
+ }
132
+ const result = JSON.parse(text);
133
+ if (result && typeof result === "object" && "meta" in result) {
134
+ const meta = result.meta;
135
+ if (meta && typeof meta === "object" && "rc" in meta && meta.rc !== "ok") {
136
+ throw new Error("Controller rejected the history query (meta.rc is not ok)");
137
+ }
138
+ }
139
+ return result;
140
+ }
52
141
  }
53
142
  exports.NetworkClient = NetworkClient;
package/dist/config.d.ts CHANGED
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  declare const configSchema: z.ZodObject<{
3
3
  host: z.ZodString;
4
4
  apiKey: z.ZodString;
5
+ consoleId: z.ZodOptional<z.ZodString>;
5
6
  verifySsl: z.ZodDefault<z.ZodBoolean>;
6
7
  readOnly: z.ZodDefault<z.ZodBoolean>;
7
8
  }, z.core.$strip>;
package/dist/config.js CHANGED
@@ -5,6 +5,11 @@ const zod_1 = require("zod");
5
5
  const configSchema = zod_1.z.object({
6
6
  host: zod_1.z.string().min(1).describe("UniFi Network host (IP or hostname)"),
7
7
  apiKey: zod_1.z.string().min(1).describe("UniFi Network API key"),
8
+ // Console/host IDs are hex, UUID- or colon-shaped depending on vintage, so
9
+ // hyphens and underscores are allowed; the charset still excludes anything
10
+ // that could escape the URL path segment it is interpolated into.
11
+ consoleId: zod_1.z.string().regex(/^[A-Za-z0-9:_-]{1,128}$/).optional()
12
+ .describe("Optional Cloud Connector console ID; only used when host is api.ui.com"),
8
13
  verifySsl: zod_1.z
9
14
  .boolean()
10
15
  .default(true)
@@ -18,6 +23,8 @@ function loadConfig() {
18
23
  const result = configSchema.safeParse({
19
24
  host: process.env.UNIFI_NETWORK_HOST,
20
25
  apiKey: process.env.UNIFI_NETWORK_API_KEY,
26
+ ...(process.env.UNIFI_NETWORK_CONSOLE_ID
27
+ ? { consoleId: process.env.UNIFI_NETWORK_CONSOLE_ID } : {}),
21
28
  verifySsl: process.env.UNIFI_NETWORK_VERIFY_SSL?.toLowerCase() !== "false",
22
29
  readOnly: process.env.UNIFI_NETWORK_READ_ONLY?.toLowerCase() !== "false",
23
30
  });
package/dist/tools/acl.js CHANGED
@@ -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 registerAclTools(server, client, readOnly = false) {
10
10
  server.registerTool("unifi_list_acl_rules", {
11
- description: "List ACL rules (switch/AP-level access control lists, distinct from zone-based firewall policies) at a site. Returns: id, type (IPV4/MAC), name, enabled, action (ALLOW/BLOCK), description, protocolFilter, source/destination matchers. ACLs apply earlier in the path than firewall policies.",
11
+ description: "List ACL rules (switch/AP-level access control lists, distinct from zone-based firewall policies) at a site. Returns: id, type (IPV4/MAC), name, enabled, action (ALLOW/BLOCK), description, protocolFilter, sourceFilter/destinationFilter matchers, enforcingDeviceFilter (which devices apply the rule), index (evaluation order — read-only; change it with unifi_reorder_acl_rules), metadata.origin. ACLs apply earlier in the path than firewall policies.",
12
12
  inputSchema: {
13
13
  siteId: zod_1.z.string().describe("Site ID"),
14
14
  offset: zod_1.z
@@ -20,7 +20,7 @@ function registerAclTools(server, client, readOnly = false) {
20
20
  limit: zod_1.z
21
21
  .number()
22
22
  .int()
23
- .min(1)
23
+ .min(0)
24
24
  .max(200)
25
25
  .optional()
26
26
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { NetworkClient } from "../client.js";
3
+ export declare function registerClientHistoryTools(server: McpServer, client: NetworkClient): void;
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerClientHistoryTools = registerClientHistoryTools;
4
+ const zod_1 = require("zod");
5
+ const responses_js_1 = require("../utils/responses.js");
6
+ const safety_js_1 = require("../utils/safety.js");
7
+ const output_schemas_js_1 = require("../utils/output-schemas.js");
8
+ const siteReference = zod_1.z.string().regex(/^[A-Za-z0-9_-]{1,64}$/)
9
+ .describe("Site internalReference from unifi_list_sites (often default), not the Integration API UUID");
10
+ function registerClientHistoryTools(server, client) {
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.",
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
+ outputSchema: output_schemas_js_1.listClientHistoryOutputSchema,
15
+ annotations: safety_js_1.READ_ONLY,
16
+ }, async ({ siteReference, withinHours }) => {
17
+ try {
18
+ const data = zod_1.z.array(output_schemas_js_1.HistoryClient).parse(await client.getClientHistory(siteReference, withinHours));
19
+ return (0, responses_js_1.formatSuccess)({ data, count: data.length, withinHours, coverage: "Historical client inventory returned by the controller; not a complete session log." }, { structured: true });
20
+ }
21
+ catch (err) {
22
+ return (0, responses_js_1.formatError)(err);
23
+ }
24
+ });
25
+ server.registerTool("unifi_list_client_sessions", {
26
+ description: "Read retained past client connections, including disconnected clients, from the controller stat/session query endpoint. POST here only reads statistics. Returns controller session IDs, MACs, assoc_time (epoch seconds), duration (seconds), rx_bytes/tx_bytes (controller perspective), and roaming_sessions when available. Use an explicit UTC epoch-second window (max 31 days). If mayBeTruncated is true, split the time window (overlap boundaries and deduplicate IDs) or filter by MAC. The controller ignores offset pagination. Records reflect controller retention and may omit ongoing sessions. Wi-Fi association/bytes do not establish end-to-end Internet success. Do not interpret controller is_guest as proof of a person's identity or ownership.",
27
+ inputSchema: {
28
+ siteReference,
29
+ start: zod_1.z.number().int().min(0).max(4102444800).describe("Start Unix epoch seconds, not milliseconds"),
30
+ end: zod_1.z.number().int().min(1).max(4102444800).describe("End Unix epoch seconds; after start and at most 31 days later"),
31
+ mac: zod_1.z.string().regex(/^([\da-f]{2}:){5}[\da-f]{2}$/i).optional().describe("Optional client MAC address"),
32
+ limit: zod_1.z.number().int().min(1).max(1000).default(1000).describe("Maximum records; a full result requires a narrower window or MAC filter"),
33
+ },
34
+ outputSchema: output_schemas_js_1.listClientSessionsOutputSchema,
35
+ annotations: safety_js_1.READ_ONLY,
36
+ }, async ({ siteReference, start, end, limit, mac }) => {
37
+ try {
38
+ if (end <= start || end - start > 31 * 86400)
39
+ throw new Error("Use an increasing time window of at most 31 days");
40
+ const { data: rows } = output_schemas_js_1.ClientSessionEnvelope.parse(await client.getClientSessions(siteReference, { start, end, limit, mac }));
41
+ // The controller honours neither _start nor _limit reliably, and can
42
+ // repeat a session ID across roaming records. Normalise instead of
43
+ // rejecting: a usable page is better than an error with no data.
44
+ const seen = new Set();
45
+ const deduped = [];
46
+ for (const row of rows) {
47
+ if (seen.has(row._id))
48
+ continue;
49
+ seen.add(row._id);
50
+ deduped.push(row);
51
+ }
52
+ const data = deduped.slice(0, limit);
53
+ const mayBeTruncated = deduped.length >= limit;
54
+ return (0, responses_js_1.formatSuccess)({ data, count: data.length, limit, mayBeTruncated, start, end,
55
+ coverage: "Retained controller sessions; A non-truncated response does not prove retention coverage or include every ongoing connection." }, { structured: true });
56
+ }
57
+ catch (err) {
58
+ return (0, responses_js_1.formatError)(err);
59
+ }
60
+ });
61
+ }
@@ -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, connectedAt, uplinkDeviceId (the switch/AP they're attached to), access.type. NOTE: verified against 10.5.43 — 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.",
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.",
12
12
  inputSchema: {
13
13
  siteId: zod_1.z.string().describe("Site ID"),
14
14
  offset: zod_1.z
@@ -20,7 +20,7 @@ function registerClientTools(server, client, readOnly = false) {
20
20
  limit: zod_1.z
21
21
  .number()
22
22
  .int()
23
- .min(1)
23
+ .min(0)
24
24
  .max(200)
25
25
  .optional()
26
26
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -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. 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 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.",
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. Idempotent.",
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.",
120
120
  inputSchema: {
121
121
  siteId: zod_1.z.string().describe("Site ID"),
122
122
  clientId: zod_1.z.string().describe("Client ID"),
@@ -22,7 +22,7 @@ function registerDeviceTools(server, client, readOnly = false) {
22
22
  limit: zod_1.z
23
23
  .number()
24
24
  .int()
25
- .min(1)
25
+ .min(0)
26
26
  .max(200)
27
27
  .optional()
28
28
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -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, 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): 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.",
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.5.43 — 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), 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.",
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.5.43 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 10.6.106 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()
@@ -86,7 +86,7 @@ function registerDeviceTools(server, client, readOnly = false) {
86
86
  limit: zod_1.z
87
87
  .number()
88
88
  .int()
89
- .min(1)
89
+ .min(0)
90
90
  .max(200)
91
91
  .optional()
92
92
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -111,7 +111,9 @@ function registerDeviceTools(server, client, readOnly = false) {
111
111
  inputSchema: {
112
112
  siteId: zod_1.z.string().describe("Site ID"),
113
113
  macAddress: zod_1.z.string().describe("MAC address of the device"),
114
- ignoreDeviceLimit: zod_1.z.boolean().optional().describe("Ignore device limit when adopting (default: false)"),
114
+ // Required by the API; kept optional here and defaulted to false below
115
+ // so callers need not pass it explicitly.
116
+ ignoreDeviceLimit: zod_1.z.boolean().optional().describe("Ignore device limit when adopting (required by the API; sent as false when omitted)"),
115
117
  dryRun: zod_1.z.boolean().optional().describe("Preview this action without executing it"),
116
118
  },
117
119
  outputSchema: adoptDeviceOutputSchema,
@@ -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 registerDnsPolicyTools(server, client, readOnly = false) {
10
10
  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.",
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.",
12
12
  inputSchema: {
13
13
  siteId: zod_1.z.string().describe("Site ID"),
14
14
  offset: zod_1.z
@@ -20,7 +20,7 @@ function registerDnsPolicyTools(server, client, readOnly = false) {
20
20
  limit: zod_1.z
21
21
  .number()
22
22
  .int()
23
- .min(1)
23
+ .min(0)
24
24
  .max(200)
25
25
  .optional()
26
26
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -20,7 +20,7 @@ function registerFirewallTools(server, client, readOnly = false) {
20
20
  limit: zod_1.z
21
21
  .number()
22
22
  .int()
23
- .min(1)
23
+ .min(0)
24
24
  .max(200)
25
25
  .optional()
26
26
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -71,7 +71,7 @@ function registerFirewallTools(server, client, readOnly = false) {
71
71
  limit: zod_1.z
72
72
  .number()
73
73
  .int()
74
- .min(1)
74
+ .min(0)
75
75
  .max(200)
76
76
  .optional()
77
77
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -270,7 +270,7 @@ function registerFirewallTools(server, client, readOnly = false) {
270
270
  }
271
271
  });
272
272
  server.registerTool("unifi_patch_firewall_policy", {
273
- description: "Partially update a firewall policy without resending all fields. Common use: toggle loggingEnabled or enabled. Idempotent for fields supplied.",
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.",
274
274
  inputSchema: {
275
275
  siteId: zod_1.z.string().describe("Site ID"),
276
276
  firewallPolicyId: zod_1.z.string().describe("Firewall policy ID"),
@@ -21,7 +21,7 @@ function registerHotspotTools(server, client, readOnly = false) {
21
21
  limit: zod_1.z
22
22
  .number()
23
23
  .int()
24
- .min(1)
24
+ .min(0)
25
25
  .max(1000)
26
26
  .optional()
27
27
  .default(100)
@@ -143,7 +143,7 @@ function registerHotspotTools(server, client, readOnly = false) {
143
143
  }
144
144
  });
145
145
  server.registerTool("unifi_delete_voucher", {
146
- description: "DESTRUCTIVE: Delete a hotspot voucher",
146
+ description: "DESTRUCTIVE: Delete a hotspot voucher. Returns: vouchersDeleted (count of vouchers removed).",
147
147
  inputSchema: {
148
148
  siteId: zod_1.z.string().describe("Site ID"),
149
149
  voucherId: zod_1.z.string().describe("Voucher ID"),
@@ -173,12 +173,12 @@ function registerHotspotTools(server, client, readOnly = false) {
173
173
  }
174
174
  });
175
175
  server.registerTool("unifi_bulk_delete_vouchers", {
176
- description: "DESTRUCTIVE: Bulk delete hotspot vouchers based on filter criteria",
176
+ description: "DESTRUCTIVE: Bulk delete hotspot vouchers based on filter criteria. Returns: vouchersDeleted (count of vouchers removed).",
177
177
  inputSchema: {
178
178
  siteId: zod_1.z.string().describe("Site ID"),
179
179
  filter: zod_1.z
180
180
  .string()
181
- .describe("Required filter expression (e.g., 'expired.eq(true)', 'name.like(guest*)')"),
181
+ .describe("Required filter expression (e.g., 'expired.eq(true)', name.like('guest*'))"),
182
182
  confirm: zod_1.z
183
183
  .boolean()
184
184
  .optional()
@@ -5,6 +5,7 @@ const system_js_1 = require("./system.js");
5
5
  const sites_js_1 = require("./sites.js");
6
6
  const devices_js_1 = require("./devices.js");
7
7
  const clients_js_1 = require("./clients.js");
8
+ const client_history_js_1 = require("./client-history.js");
8
9
  const networks_js_1 = require("./networks.js");
9
10
  const wifi_js_1 = require("./wifi.js");
10
11
  const hotspot_js_1 = require("./hotspot.js");
@@ -19,6 +20,7 @@ function registerAllTools(server, client, readOnly = false) {
19
20
  (0, sites_js_1.registerSiteTools)(server, client);
20
21
  (0, devices_js_1.registerDeviceTools)(server, client, readOnly);
21
22
  (0, clients_js_1.registerClientTools)(server, client, readOnly);
23
+ (0, client_history_js_1.registerClientHistoryTools)(server, client);
22
24
  (0, networks_js_1.registerNetworkTools)(server, client, readOnly);
23
25
  (0, wifi_js_1.registerWifiTools)(server, client, readOnly);
24
26
  (0, hotspot_js_1.registerHotspotTools)(server, client, readOnly);
@@ -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 registerNetworkTools(server, client, readOnly = false) {
10
10
  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), dhcpGuarding, metadata.origin. NOTE: the list view is sparse — for subnet/DHCP/NTP detail (ipv4Configuration), call unifi_get_network on a specific id. Use for: VLAN inventory; pair with unifi_get_network_references to find what consumes a network.",
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.",
12
12
  inputSchema: {
13
13
  siteId: zod_1.z.string().describe("Site ID"),
14
14
  offset: zod_1.z
@@ -20,7 +20,7 @@ function registerNetworkTools(server, client, readOnly = false) {
20
20
  limit: zod_1.z
21
21
  .number()
22
22
  .int()
23
- .min(1)
23
+ .min(0)
24
24
  .max(200)
25
25
  .optional()
26
26
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -42,7 +42,7 @@ function registerNetworkTools(server, client, readOnly = false) {
42
42
  }
43
43
  });
44
44
  server.registerTool("unifi_get_network", {
45
- description: "Get a network/VLAN by ID. Returns the list fields PLUS (live-verified) zoneId, isolationEnabled, internetAccessEnabled, mdnsForwardingEnabled, cellularBackupEnabled, and a full ipv4Configuration object (hostIpAddress, prefixLength, dhcpConfiguration with ipAddressRange/leaseTimeSeconds/domainName/ntpServerIpAddresses). NOTE: subnet/DHCP detail appears here at get-by-id but NOT in unifi_list_networks (sparse list view).",
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).",
46
46
  inputSchema: {
47
47
  siteId: zod_1.z.string().describe("Site ID"),
48
48
  networkId: zod_1.z.string().describe("Network ID"),
@@ -19,14 +19,14 @@ function registerSiteTools(server, client) {
19
19
  limit: zod_1.z
20
20
  .number()
21
21
  .int()
22
- .min(1)
22
+ .min(0)
23
23
  .max(200)
24
24
  .optional()
25
25
  .describe("Number of records to return (default: 25, max: 200)"),
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 (e.g., name.like('office*'))"),
30
30
  },
31
31
  outputSchema: output_schemas_js_1.listSitesOutputSchema,
32
32
  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 registerSupportingTools(server, client) {
10
10
  server.registerTool("unifi_list_wans", {
11
- description: "List WAN interface definitions at a site. Returns: id, name only (verified against 10.5.43 — the Integration API exposes no live link status or throughput rates here). Use for: WAN inventory, multi-WAN topology.",
11
+ description: "List WAN interface definitions at a site. Returns: id, name only (verified against 10.6.106 — the Integration API exposes no live link status or throughput rates here). Use for: WAN inventory, multi-WAN topology.",
12
12
  inputSchema: {
13
13
  siteId: zod_1.z.string().describe("Site ID"),
14
14
  offset: zod_1.z
@@ -20,7 +20,7 @@ function registerSupportingTools(server, client) {
20
20
  limit: zod_1.z
21
21
  .number()
22
22
  .int()
23
- .min(1)
23
+ .min(0)
24
24
  .max(200)
25
25
  .optional()
26
26
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -38,7 +38,7 @@ function registerSupportingTools(server, client) {
38
38
  }
39
39
  });
40
40
  server.registerTool("unifi_list_vpn_tunnels", {
41
- description: "List site-to-site VPN tunnels (IPsec, WireGuard, OpenVPN site-to-site) at a site. Returns: tunnel definitions per row (per-row schema not rendered in 10.5.43 docs — call to inspect). For roaming client VPN servers, see unifi_list_vpn_servers.",
41
+ description: "List site-to-site VPN tunnels (IPsec, WireGuard, OpenVPN site-to-site) at a site. Returns: tunnel definitions per row (per-row schema not rendered in 10.6.106 docs — call to inspect). For roaming client VPN servers, see unifi_list_vpn_servers.",
42
42
  inputSchema: {
43
43
  siteId: zod_1.z.string().describe("Site ID"),
44
44
  offset: zod_1.z
@@ -50,7 +50,7 @@ function registerSupportingTools(server, client) {
50
50
  limit: zod_1.z
51
51
  .number()
52
52
  .int()
53
- .min(1)
53
+ .min(0)
54
54
  .max(200)
55
55
  .optional()
56
56
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -84,7 +84,7 @@ function registerSupportingTools(server, client) {
84
84
  limit: zod_1.z
85
85
  .number()
86
86
  .int()
87
- .min(1)
87
+ .min(0)
88
88
  .max(200)
89
89
  .optional()
90
90
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -118,7 +118,7 @@ function registerSupportingTools(server, client) {
118
118
  limit: zod_1.z
119
119
  .number()
120
120
  .int()
121
- .min(1)
121
+ .min(0)
122
122
  .max(200)
123
123
  .optional()
124
124
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -152,7 +152,7 @@ function registerSupportingTools(server, client) {
152
152
  limit: zod_1.z
153
153
  .number()
154
154
  .int()
155
- .min(1)
155
+ .min(0)
156
156
  .max(200)
157
157
  .optional()
158
158
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -185,7 +185,7 @@ function registerSupportingTools(server, client) {
185
185
  limit: zod_1.z
186
186
  .number()
187
187
  .int()
188
- .min(1)
188
+ .min(0)
189
189
  .max(200)
190
190
  .optional()
191
191
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -218,7 +218,7 @@ function registerSupportingTools(server, client) {
218
218
  limit: zod_1.z
219
219
  .number()
220
220
  .int()
221
- .min(1)
221
+ .min(0)
222
222
  .max(200)
223
223
  .optional()
224
224
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -251,14 +251,14 @@ function registerSupportingTools(server, client) {
251
251
  limit: zod_1.z
252
252
  .number()
253
253
  .int()
254
- .min(1)
254
+ .min(0)
255
255
  .max(200)
256
256
  .optional()
257
257
  .describe("Number of records to return (default: 25, max: 200)"),
258
258
  filter: zod_1.z
259
259
  .string()
260
260
  .optional()
261
- .describe("Filter expression (e.g., 'name.like(United*)')"),
261
+ .describe("Filter expression (e.g., name.like('United*'))"),
262
262
  },
263
263
  outputSchema: output_schemas_js_1.listCountriesOutputSchema,
264
264
  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 registerSwitchingTools(server, client) {
10
10
  server.registerTool("unifi_list_switch_stacks", {
11
- description: "List Switch Stacks (multiple physical switches managed as one logical unit) at a site. Returns: id, name, members[], lags[] (LAGs spanning the stack), metadata.origin. Use for: identifying stacked switches; individual member configs/stats still come from unifi_get_device.",
11
+ description: "List Switch Stacks (multiple physical switches managed as one logical unit) at a site. Returns: id, deviceId (the stack's primary device), name, units[] (the stacked switches — named `members` on consoles older than 10.6.106), lags[] (LAGs spanning the stack), metadata.origin. Per-unit schema is not rendered in the 10.6.106 docs — call unifi_get_switch_stack to inspect. Use for: identifying stacked switches; individual member configs/stats still come from unifi_get_device.",
12
12
  inputSchema: {
13
13
  siteId: zod_1.z.string().describe("Site ID"),
14
14
  offset: zod_1.z
@@ -20,7 +20,7 @@ function registerSwitchingTools(server, client) {
20
20
  limit: zod_1.z
21
21
  .number()
22
22
  .int()
23
- .min(1)
23
+ .min(0)
24
24
  .max(200)
25
25
  .optional()
26
26
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -42,7 +42,7 @@ function registerSwitchingTools(server, client) {
42
42
  }
43
43
  });
44
44
  server.registerTool("unifi_get_switch_stack", {
45
- description: "Get full details of a Switch Stack including all members and stacking topology. Returns the same fields as the list response but for a single stack.",
45
+ description: "Get full details of a Switch Stack including all units and stacking topology. Returns the same fields as the list response but for a single stack: id, deviceId, name, units[] (named `members` before 10.6.106), lags[], metadata.origin.",
46
46
  inputSchema: {
47
47
  siteId: zod_1.z.string().describe("Site ID"),
48
48
  switchStackId: zod_1.z.string().describe("Switch Stack ID"),
@@ -71,7 +71,7 @@ function registerSwitchingTools(server, client) {
71
71
  limit: zod_1.z
72
72
  .number()
73
73
  .int()
74
- .min(1)
74
+ .min(0)
75
75
  .max(200)
76
76
  .optional()
77
77
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -122,7 +122,7 @@ function registerSwitchingTools(server, client) {
122
122
  limit: zod_1.z
123
123
  .number()
124
124
  .int()
125
- .min(1)
125
+ .min(0)
126
126
  .max(200)
127
127
  .optional()
128
128
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -6,7 +6,7 @@ const safety_js_1 = require("../utils/safety.js");
6
6
  const output_schemas_js_1 = require("../utils/output-schemas.js");
7
7
  function registerSystemTools(server, client) {
8
8
  server.registerTool("unifi_get_info", {
9
- description: "Get UniFi Network application info. Returns: applicationVersion. NOTE: verified against 10.5.43 on a UniFi OS console — the Integration API returns ONLY applicationVersion here; there is no isUniFiOSConsole or other field. Use for: version checks before calling version-gated tools.",
9
+ description: "Get UniFi Network application info. Returns: applicationVersion. NOTE: verified against 10.6.106 on a UniFi OS console — the Integration API returns ONLY applicationVersion here; there is no isUniFiOSConsole or other field. Use for: version checks before calling version-gated tools.",
10
10
  inputSchema: {},
11
11
  outputSchema: output_schemas_js_1.getInfoOutputSchema,
12
12
  annotations: safety_js_1.READ_ONLY,
@@ -20,7 +20,7 @@ function registerTrafficMatchingTools(server, client, readOnly = false) {
20
20
  limit: zod_1.z
21
21
  .number()
22
22
  .int()
23
- .min(1)
23
+ .min(0)
24
24
  .max(200)
25
25
  .optional()
26
26
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -20,7 +20,7 @@ function registerWifiTools(server, client, readOnly = false) {
20
20
  limit: zod_1.z
21
21
  .number()
22
22
  .int()
23
- .min(1)
23
+ .min(0)
24
24
  .max(200)
25
25
  .optional()
26
26
  .describe("Number of records to return (default: 25, max: 200)"),
@@ -68,8 +68,8 @@ function registerWifiTools(server, client, readOnly = false) {
68
68
  enabled: zod_1.z.boolean().describe("Enable the WiFi network"),
69
69
  type: zod_1.z.enum(["STANDARD", "IOT_OPTIMIZED"]).describe("WiFi type"),
70
70
  broadcastingFrequenciesGHz: zod_1.z
71
- .array(zod_1.z.string())
72
- .describe("Frequencies: 2.4, 5, 6"),
71
+ .array(zod_1.z.enum(["2.4", "5", "6"]))
72
+ .describe("Frequencies to broadcast on. Accepted as strings and sent to the API as numbers (2.4, 5, 6)."),
73
73
  securityConfiguration: zod_1.z
74
74
  .record(zod_1.z.string(), zod_1.z.unknown())
75
75
  .describe("Security configuration object"),
@@ -209,7 +209,7 @@ function registerWifiTools(server, client, readOnly = false) {
209
209
  }
210
210
  });
211
211
  server.registerTool("unifi_update_wifi", {
212
- description: "Update an existing WiFi network",
212
+ description: "Update an existing WiFi network. NOTE: this is a full PUT — API 10.6.106 documents 14 of these fields as required (type, name, enabled, securityConfiguration, multicastToUnicastConversionEnabled, clientIsolationEnabled, hideName, uapsdEnabled, channel2gLockedTo6, dtimPeriod2gLockedTo3, broadcastingFrequenciesGHz, arpProxyEnabled, bssTransitionEnabled, advertiseDeviceName). They are optional here for convenience, but a sparse update may be rejected by the API — read the current values with unifi_get_wifi and resend the full set if you get a 400.",
213
213
  inputSchema: {
214
214
  siteId: zod_1.z.string().describe("Site ID"),
215
215
  wifiBroadcastId: zod_1.z.string().describe("WiFi Broadcast ID"),
@@ -220,9 +220,9 @@ function registerWifiTools(server, client, readOnly = false) {
220
220
  .optional()
221
221
  .describe("WiFi type"),
222
222
  broadcastingFrequenciesGHz: zod_1.z
223
- .array(zod_1.z.string())
223
+ .array(zod_1.z.enum(["2.4", "5", "6"]))
224
224
  .optional()
225
- .describe("Frequencies: 2.4, 5, 6"),
225
+ .describe("Frequencies to broadcast on. Accepted as strings and sent to the API as numbers (2.4, 5, 6)."),
226
226
  securityConfiguration: zod_1.z
227
227
  .record(zod_1.z.string(), zod_1.z.unknown())
228
228
  .optional()
@@ -3,7 +3,7 @@
3
3
  * field optional, every nested object uses .passthrough() to allow
4
4
  * firmware/hardware-specific fields to flow through unchanged).
5
5
  *
6
- * Verified against UniFi Network API 10.5.43. Where the docs collapse
6
+ * Verified against UniFi Network API 10.6.106. Where the docs collapse
7
7
  * nested arrays/objects (e.g. interfaces.ports[], radios[]), the schema
8
8
  * uses passthrough records so the contract doesn't lock to fields we
9
9
  * haven't verified.
@@ -37,6 +37,7 @@ export declare const networkOutputSchema: {
37
37
  cellularBackupEnabled: z.ZodOptional<z.ZodBoolean>;
38
38
  dhcpGuarding: z.ZodOptional<z.ZodUnknown>;
39
39
  ipv4Configuration: z.ZodOptional<z.ZodUnknown>;
40
+ ipv6Configuration: z.ZodOptional<z.ZodUnknown>;
40
41
  metadata: z.ZodOptional<z.ZodObject<{
41
42
  origin: z.ZodOptional<z.ZodString>;
42
43
  }, z.core.$loose>>;
@@ -60,6 +61,7 @@ export declare const listNetworksOutputSchema: {
60
61
  cellularBackupEnabled: z.ZodOptional<z.ZodBoolean>;
61
62
  dhcpGuarding: z.ZodOptional<z.ZodUnknown>;
62
63
  ipv4Configuration: z.ZodOptional<z.ZodUnknown>;
64
+ ipv6Configuration: z.ZodOptional<z.ZodUnknown>;
63
65
  metadata: z.ZodOptional<z.ZodObject<{
64
66
  origin: z.ZodOptional<z.ZodString>;
65
67
  }, z.core.$loose>>;
@@ -202,6 +204,7 @@ export declare const aclRuleOutputSchema: {
202
204
  sourceFilter: z.ZodOptional<z.ZodUnknown>;
203
205
  destinationFilter: z.ZodOptional<z.ZodUnknown>;
204
206
  enforcingDeviceFilter: z.ZodOptional<z.ZodUnknown>;
207
+ index: z.ZodOptional<z.ZodNumber>;
205
208
  metadata: z.ZodOptional<z.ZodObject<{
206
209
  origin: z.ZodOptional<z.ZodString>;
207
210
  }, z.core.$loose>>;
@@ -222,6 +225,7 @@ export declare const listAclRulesOutputSchema: {
222
225
  sourceFilter: z.ZodOptional<z.ZodUnknown>;
223
226
  destinationFilter: z.ZodOptional<z.ZodUnknown>;
224
227
  enforcingDeviceFilter: z.ZodOptional<z.ZodUnknown>;
228
+ index: z.ZodOptional<z.ZodNumber>;
225
229
  metadata: z.ZodOptional<z.ZodObject<{
226
230
  origin: z.ZodOptional<z.ZodString>;
227
231
  }, z.core.$loose>>;
@@ -233,6 +237,8 @@ export declare const aclRuleOrderingOutputSchema: {
233
237
  export declare const switchStackOutputSchema: {
234
238
  id: z.ZodString;
235
239
  name: z.ZodOptional<z.ZodString>;
240
+ deviceId: z.ZodOptional<z.ZodString>;
241
+ units: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
236
242
  members: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
237
243
  lags: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
238
244
  metadata: z.ZodOptional<z.ZodObject<{
@@ -247,6 +253,8 @@ export declare const listSwitchStacksOutputSchema: {
247
253
  readonly data: z.ZodArray<z.ZodObject<{
248
254
  id: z.ZodString;
249
255
  name: z.ZodOptional<z.ZodString>;
256
+ deviceId: z.ZodOptional<z.ZodString>;
257
+ units: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
250
258
  members: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
251
259
  lags: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
252
260
  metadata: z.ZodOptional<z.ZodObject<{
@@ -607,3 +615,73 @@ export declare const listPendingDevicesOutputSchema: {
607
615
  firmwareVersion: z.ZodOptional<z.ZodString>;
608
616
  }, z.core.$loose>>;
609
617
  };
618
+ export declare const HistoryClient: z.ZodObject<{
619
+ mac: z.ZodString;
620
+ hostname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
621
+ display_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
622
+ first_seen: z.ZodOptional<z.ZodNumber>;
623
+ last_seen: z.ZodOptional<z.ZodNumber>;
624
+ is_wired: z.ZodOptional<z.ZodBoolean>;
625
+ status: z.ZodOptional<z.ZodString>;
626
+ }, z.core.$loose>;
627
+ export declare const ClientSession: z.ZodObject<{
628
+ _id: z.ZodString;
629
+ mac: z.ZodString;
630
+ assoc_time: z.ZodNumber;
631
+ duration: z.ZodOptional<z.ZodNumber>;
632
+ rx_bytes: z.ZodOptional<z.ZodNumber>;
633
+ tx_bytes: z.ZodOptional<z.ZodNumber>;
634
+ hostname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
635
+ is_wired: z.ZodOptional<z.ZodBoolean>;
636
+ roaming_sessions: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
637
+ }, z.core.$loose>;
638
+ /** Envelope returned by the controller's stat/session endpoint. */
639
+ export declare const ClientSessionEnvelope: z.ZodObject<{
640
+ meta: z.ZodObject<{
641
+ rc: z.ZodLiteral<"ok">;
642
+ }, z.core.$loose>;
643
+ data: z.ZodArray<z.ZodObject<{
644
+ _id: z.ZodString;
645
+ mac: z.ZodString;
646
+ assoc_time: z.ZodNumber;
647
+ duration: z.ZodOptional<z.ZodNumber>;
648
+ rx_bytes: z.ZodOptional<z.ZodNumber>;
649
+ tx_bytes: z.ZodOptional<z.ZodNumber>;
650
+ hostname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
651
+ is_wired: z.ZodOptional<z.ZodBoolean>;
652
+ roaming_sessions: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
653
+ }, z.core.$loose>>;
654
+ }, z.core.$strip>;
655
+ export declare const listClientHistoryOutputSchema: {
656
+ readonly data: z.ZodArray<z.ZodObject<{
657
+ mac: z.ZodString;
658
+ hostname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
659
+ display_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
660
+ first_seen: z.ZodOptional<z.ZodNumber>;
661
+ last_seen: z.ZodOptional<z.ZodNumber>;
662
+ is_wired: z.ZodOptional<z.ZodBoolean>;
663
+ status: z.ZodOptional<z.ZodString>;
664
+ }, z.core.$loose>>;
665
+ readonly count: z.ZodNumber;
666
+ readonly withinHours: z.ZodNumber;
667
+ readonly coverage: z.ZodString;
668
+ };
669
+ export declare const listClientSessionsOutputSchema: {
670
+ readonly data: z.ZodArray<z.ZodObject<{
671
+ _id: z.ZodString;
672
+ mac: z.ZodString;
673
+ assoc_time: z.ZodNumber;
674
+ duration: z.ZodOptional<z.ZodNumber>;
675
+ rx_bytes: z.ZodOptional<z.ZodNumber>;
676
+ tx_bytes: z.ZodOptional<z.ZodNumber>;
677
+ hostname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
678
+ is_wired: z.ZodOptional<z.ZodBoolean>;
679
+ roaming_sessions: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
680
+ }, z.core.$loose>>;
681
+ readonly count: z.ZodNumber;
682
+ readonly limit: z.ZodNumber;
683
+ readonly mayBeTruncated: z.ZodBoolean;
684
+ readonly start: z.ZodNumber;
685
+ readonly end: z.ZodNumber;
686
+ readonly coverage: z.ZodString;
687
+ };
@@ -1,12 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.listPendingDevicesOutputSchema = exports.listCountriesOutputSchema = exports.listDpiApplicationsOutputSchema = exports.listDpiCategoriesOutputSchema = exports.listDeviceTagsOutputSchema = exports.listRadiusProfilesOutputSchema = exports.listVpnTunnelsOutputSchema = exports.listVpnServersOutputSchema = exports.listWansOutputSchema = exports.getWifiOutputSchema = exports.listWifiOutputSchema = exports.getClientOutputSchema = exports.listClientsOutputSchema = exports.getDeviceStatisticsOutputSchema = exports.getDeviceOutputSchema = exports.listDevicesOutputSchema = exports.listTrafficMatchingListsOutputSchema = exports.trafficMatchingListOutputSchema = exports.listDnsPoliciesOutputSchema = exports.dnsPolicyOutputSchema = exports.listLagsOutputSchema = exports.lagOutputSchema = exports.listMcLagDomainsOutputSchema = exports.mcLagDomainOutputSchema = exports.listSwitchStacksOutputSchema = exports.switchStackOutputSchema = exports.aclRuleOrderingOutputSchema = exports.listAclRulesOutputSchema = exports.aclRuleOutputSchema = exports.firewallPolicyOrderingOutputSchema = exports.listFirewallPoliciesOutputSchema = exports.firewallPolicyOutputSchema = exports.listFirewallZonesOutputSchema = exports.firewallZoneOutputSchema = exports.createVouchersOutputSchema = exports.listVouchersOutputSchema = exports.voucherOutputSchema = exports.getNetworkReferencesOutputSchema = exports.listNetworksOutputSchema = exports.networkOutputSchema = exports.listSitesOutputSchema = exports.getInfoOutputSchema = void 0;
3
+ exports.listClientSessionsOutputSchema = exports.listClientHistoryOutputSchema = exports.ClientSessionEnvelope = exports.ClientSession = exports.HistoryClient = exports.listPendingDevicesOutputSchema = exports.listCountriesOutputSchema = exports.listDpiApplicationsOutputSchema = exports.listDpiCategoriesOutputSchema = exports.listDeviceTagsOutputSchema = exports.listRadiusProfilesOutputSchema = exports.listVpnTunnelsOutputSchema = exports.listVpnServersOutputSchema = exports.listWansOutputSchema = exports.getWifiOutputSchema = exports.listWifiOutputSchema = exports.getClientOutputSchema = exports.listClientsOutputSchema = exports.getDeviceStatisticsOutputSchema = exports.getDeviceOutputSchema = exports.listDevicesOutputSchema = exports.listTrafficMatchingListsOutputSchema = exports.trafficMatchingListOutputSchema = exports.listDnsPoliciesOutputSchema = exports.dnsPolicyOutputSchema = exports.listLagsOutputSchema = exports.lagOutputSchema = exports.listMcLagDomainsOutputSchema = exports.mcLagDomainOutputSchema = exports.listSwitchStacksOutputSchema = exports.switchStackOutputSchema = exports.aclRuleOrderingOutputSchema = exports.listAclRulesOutputSchema = exports.aclRuleOutputSchema = exports.firewallPolicyOrderingOutputSchema = exports.listFirewallPoliciesOutputSchema = exports.firewallPolicyOutputSchema = exports.listFirewallZonesOutputSchema = exports.firewallZoneOutputSchema = exports.createVouchersOutputSchema = exports.listVouchersOutputSchema = exports.voucherOutputSchema = exports.getNetworkReferencesOutputSchema = exports.listNetworksOutputSchema = exports.networkOutputSchema = exports.listSitesOutputSchema = exports.getInfoOutputSchema = void 0;
4
4
  /**
5
5
  * Output schemas for high-value read tools (loose strategy: every non-key
6
6
  * field optional, every nested object uses .passthrough() to allow
7
7
  * firmware/hardware-specific fields to flow through unchanged).
8
8
  *
9
- * Verified against UniFi Network API 10.5.43. Where the docs collapse
9
+ * Verified against UniFi Network API 10.6.106. Where the docs collapse
10
10
  * nested arrays/objects (e.g. interfaces.ports[], radios[]), the schema
11
11
  * uses passthrough records so the contract doesn't lock to fields we
12
12
  * haven't verified.
@@ -160,7 +160,7 @@ const WifiBroadcast = zod_1.z
160
160
  .passthrough();
161
161
  const ApplicationInfo = zod_1.z
162
162
  .object({
163
- // Live-verified (10.5.43): only applicationVersion is returned.
163
+ // Live-verified (10.6.106): only applicationVersion is returned.
164
164
  applicationVersion: zod_1.z.string().optional(),
165
165
  })
166
166
  .passthrough();
@@ -185,8 +185,9 @@ const Network = zod_1.z
185
185
  mdnsForwardingEnabled: zod_1.z.boolean().optional(),
186
186
  cellularBackupEnabled: zod_1.z.boolean().optional(),
187
187
  dhcpGuarding: zod_1.z.unknown().optional(),
188
- // get-by-id only (absent in list view); nested shape left loose.
188
+ // get-by-id only (absent in list view); nested shapes left loose.
189
189
  ipv4Configuration: zod_1.z.unknown().optional(),
190
+ ipv6Configuration: zod_1.z.unknown().optional(),
190
191
  metadata: Metadata.optional(),
191
192
  })
192
193
  .passthrough();
@@ -263,6 +264,9 @@ const AclRule = zod_1.z
263
264
  sourceFilter: zod_1.z.unknown().optional(),
264
265
  destinationFilter: zod_1.z.unknown().optional(),
265
266
  enforcingDeviceFilter: zod_1.z.unknown().optional(),
267
+ // Read-only in the response. Deprecated as a *request* field — ordering
268
+ // is set via the dedicated reorder endpoint, so no write tool accepts it.
269
+ index: zod_1.z.number().int().optional(),
266
270
  metadata: Metadata.optional(),
267
271
  })
268
272
  .passthrough();
@@ -275,6 +279,12 @@ const SwitchStack = zod_1.z
275
279
  .object({
276
280
  id: zod_1.z.string(),
277
281
  name: zod_1.z.string().optional(),
282
+ // The stack's primary device. Added in 10.6.106.
283
+ deviceId: zod_1.z.string().optional(),
284
+ // Renamed from `members` in 10.6.106 (was `members` through 10.4.57).
285
+ // `members` stays declared so responses from older consoles still
286
+ // describe themselves; both are optional, so either shape validates.
287
+ units: zod_1.z.array(zod_1.z.unknown()).optional(),
278
288
  members: zod_1.z.array(zod_1.z.unknown()).optional(),
279
289
  lags: zod_1.z.array(zod_1.z.unknown()).optional(),
280
290
  metadata: Metadata.optional(),
@@ -324,7 +334,7 @@ const TrafficMatchingList = zod_1.z
324
334
  metadata: Metadata.optional(),
325
335
  })
326
336
  .passthrough();
327
- // Verified against the live Integration API (10.5.43). The WAN and
337
+ // Verified against the live Integration API (10.6.106). The WAN and
328
338
  // site-to-site-tunnel list rows are intentionally minimal in the API.
329
339
  const Wan = zod_1.z
330
340
  .object({
@@ -509,3 +519,50 @@ exports.listPendingDevicesOutputSchema = {
509
519
  ...PaginationFields,
510
520
  data: zod_1.z.array(PendingDevice),
511
521
  };
522
+ // Controller history endpoints (v2 clients/history and stat/session) are not
523
+ // part of the Integration API, so these follow the same loose strategy against
524
+ // the controller's own response shape: only the identity fields are required.
525
+ exports.HistoryClient = zod_1.z
526
+ .object({
527
+ mac: zod_1.z.string(),
528
+ hostname: zod_1.z.string().nullish(),
529
+ display_name: zod_1.z.string().nullish(),
530
+ first_seen: zod_1.z.number().optional(),
531
+ last_seen: zod_1.z.number().optional(),
532
+ is_wired: zod_1.z.boolean().optional(),
533
+ status: zod_1.z.string().optional(),
534
+ })
535
+ .passthrough();
536
+ exports.ClientSession = zod_1.z
537
+ .object({
538
+ _id: zod_1.z.string(),
539
+ mac: zod_1.z.string(),
540
+ assoc_time: zod_1.z.number(),
541
+ duration: zod_1.z.number().optional(),
542
+ rx_bytes: zod_1.z.number().optional(),
543
+ tx_bytes: zod_1.z.number().optional(),
544
+ hostname: zod_1.z.string().nullish(),
545
+ is_wired: zod_1.z.boolean().optional(),
546
+ roaming_sessions: zod_1.z.array(zod_1.z.unknown()).optional(),
547
+ })
548
+ .passthrough();
549
+ /** Envelope returned by the controller's stat/session endpoint. */
550
+ exports.ClientSessionEnvelope = zod_1.z.object({
551
+ meta: zod_1.z.object({ rc: zod_1.z.literal("ok") }).passthrough(),
552
+ data: zod_1.z.array(exports.ClientSession),
553
+ });
554
+ exports.listClientHistoryOutputSchema = {
555
+ data: zod_1.z.array(exports.HistoryClient),
556
+ count: zod_1.z.number(),
557
+ withinHours: zod_1.z.number(),
558
+ coverage: zod_1.z.string(),
559
+ };
560
+ exports.listClientSessionsOutputSchema = {
561
+ data: zod_1.z.array(exports.ClientSession),
562
+ count: zod_1.z.number(),
563
+ limit: zod_1.z.number(),
564
+ mayBeTruncated: zod_1.z.boolean(),
565
+ start: zod_1.z.number(),
566
+ end: zod_1.z.number(),
567
+ coverage: zod_1.z.string(),
568
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owine/unifi-network-mcp",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "description": "MCP server for the UniFi Network API",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "engines": {
23
23
  "node": "^22.13.0 || ^24.0.0"
24
24
  },
25
- "packageManager": "pnpm@12.3.4",
25
+ "packageManager": "pnpm@12.5.1",
26
26
  "repository": {
27
27
  "type": "git",
28
28
  "url": "git+https://github.com/owine/unifi-network-mcp.git"
@@ -38,17 +38,17 @@
38
38
  "license": "MIT",
39
39
  "dependencies": {
40
40
  "@modelcontextprotocol/sdk": "1.30.0",
41
- "zod": "4.5.4"
41
+ "zod": "4.6.5"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@eslint/js": "10.0.1",
45
- "@types/node": "24.13.3",
45
+ "@types/node": "24.13.6",
46
46
  "@typescript/native": "npm:typescript@7.0.2",
47
- "@vitest/coverage-v8": "4.1.11",
48
- "eslint": "10.10.0",
49
- "lefthook": "2.1.12",
47
+ "@vitest/coverage-v8": "5.0.1",
48
+ "eslint": "10.11.0",
49
+ "lefthook": "2.1.14",
50
50
  "typescript": "npm:@typescript/typescript6@6.0.2",
51
- "typescript-eslint": "8.69.0",
52
- "vitest": "4.1.11"
51
+ "typescript-eslint": "8.70.0",
52
+ "vitest": "5.0.1"
53
53
  }
54
54
  }