@owine/unifi-network-mcp 2.13.0 → 2.14.1

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
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
  });
@@ -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 (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.",
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
@@ -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);
@@ -615,3 +615,73 @@ export declare const listPendingDevicesOutputSchema: {
615
615
  firmwareVersion: z.ZodOptional<z.ZodString>;
616
616
  }, z.core.$loose>>;
617
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,6 +1,6 @@
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
@@ -519,3 +519,50 @@ exports.listPendingDevicesOutputSchema = {
519
519
  ...PaginationFields,
520
520
  data: zod_1.z.array(PendingDevice),
521
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.13.0",
3
+ "version": "2.14.1",
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.4.1",
25
+ "packageManager": "pnpm@12.6.0",
26
26
  "repository": {
27
27
  "type": "git",
28
28
  "url": "git+https://github.com/owine/unifi-network-mcp.git"
@@ -37,18 +37,18 @@
37
37
  "author": "owine",
38
38
  "license": "MIT",
39
39
  "dependencies": {
40
- "@modelcontextprotocol/sdk": "1.30.0",
41
- "zod": "4.6.2"
40
+ "@modelcontextprotocol/sdk": "1.30.1",
41
+ "zod": "4.6.5"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@eslint/js": "10.0.1",
45
- "@types/node": "24.13.4",
45
+ "@types/node": "24.19.0",
46
46
  "@typescript/native": "npm:typescript@7.0.2",
47
- "@vitest/coverage-v8": "5.0.0",
48
- "eslint": "10.10.0",
49
- "lefthook": "2.1.12",
47
+ "@vitest/coverage-v8": "5.0.2",
48
+ "eslint": "10.11.0",
49
+ "lefthook": "2.1.14",
50
50
  "typescript": "npm:@typescript/typescript6@6.0.2",
51
- "typescript-eslint": "8.70.0",
52
- "vitest": "5.0.0"
51
+ "typescript-eslint": "8.70.1",
52
+ "vitest": "5.0.2"
53
53
  }
54
54
  }