@owine/unifi-protect-mcp 2.13.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
@@ -48,6 +48,7 @@ claude mcp add-json unifi-protect '{"command":"node","args":["/path/to/unifi-pro
48
48
  | `UNIFI_PROTECT_API_KEY` | Yes | — | API key from Protect integration settings |
49
49
  | `UNIFI_PROTECT_VERIFY_SSL` | No | `true` | Set to `false` to skip TLS certificate verification (needed for self-signed certs) |
50
50
  | `UNIFI_PROTECT_READ_ONLY` | No | `true` | Set to `false` to enable write/mutating tools (default is monitoring-only mode) |
51
+ | `UNIFI_PROTECT_CONSOLE_ID` | No | — | Cloud Connector console ID. Only used when `UNIFI_PROTECT_HOST` is `api.ui.com`; ignored (with a warning on stderr) otherwise |
51
52
 
52
53
  ### Manual Configuration
53
54
 
@@ -74,6 +75,34 @@ Alternatively, add to your `~/.claude.json` under the top-level `"mcpServers"` k
74
75
  This server provides layered safety controls for responsible operation:
75
76
 
76
77
  - **Tool annotations** — Every tool declares `readOnlyHint` and `destructiveHint` so MCP clients (like Claude Code) can make informed confirmation decisions
78
+ ### Cloud Connector
79
+
80
+ To reach a console through Ubiquiti's Cloud Connector instead of over the local
81
+ network, set `UNIFI_PROTECT_HOST=api.ui.com` and `UNIFI_PROTECT_CONSOLE_ID` to
82
+ the console's ID, and supply a **Site Manager** API key (unifi.ui.com →
83
+ Settings → API) as `UNIFI_PROTECT_API_KEY`. That is a different credential
84
+ from a local console API key. Requests are routed through
85
+ `/v1/connector/consoles/{id}`. Keep TLS verification enabled.
86
+
87
+ Find your console ID with:
88
+
89
+ ```bash
90
+ curl -s https://api.ui.com/v1/hosts -H "X-API-KEY: $KEY" | jq -r '.data[].id'
91
+ ```
92
+
93
+ **WebSocket subscriptions do not work over Cloud Connector.** The connector
94
+ proxies REST calls but returns HTTP 404 on a WebSocket upgrade, so
95
+ `protect_subscribe_devices` and `protect_subscribe_events` fail fast with an
96
+ explanatory error. Every other tool, including snapshots and file uploads,
97
+ works. Connect directly to the console host if you need subscriptions.
98
+
99
+ Note that `/v1/hosts` reports `protect` under `controllers` for consoles where
100
+ Protect is merely available, not necessarily installed; only consoles actually
101
+ running Protect will answer.
102
+
103
+ Setting `UNIFI_PROTECT_CONSOLE_ID` against a local host is not fatal: the
104
+ server logs a warning and talks to that host directly.
105
+
77
106
  - **Read-only mode** — Enabled by default. Only read operations (list, get, snapshot) are registered. Set `UNIFI_PROTECT_READ_ONLY=false` to enable write/mutating tools
78
107
  - **Confirmation parameter** — The most dangerous tools (`protect_disable_mic`, `protect_trigger_alarm_webhook`) require an explicit `confirm: true` parameter that must be present for the call to succeed
79
108
  - **Dry-run support** — All write tools (except those with `confirm`) accept an optional `dryRun: true` parameter that returns a preview of what would happen without making any changes
package/dist/client.d.ts CHANGED
@@ -3,6 +3,7 @@ import { Config } from "./config.js";
3
3
  export declare class ProtectClient {
4
4
  private baseUrl;
5
5
  private headers;
6
+ private usingConnector;
6
7
  constructor(config: Config);
7
8
  private request;
8
9
  get(path: string): Promise<unknown>;
package/dist/client.js CHANGED
@@ -5,11 +5,59 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.ProtectClient = void 0;
7
7
  const ws_1 = __importDefault(require("ws"));
8
+ /** Protect answers JSON calls quickly; a longer wait means something is wrong. */
9
+ const REQUEST_TIMEOUT_MS = 25_000;
10
+ /** Snapshots and file uploads are larger, so they get a longer budget. */
11
+ const BINARY_TIMEOUT_MS = 60_000;
12
+ const MAX_JSON_BYTES = 10_000_000;
13
+ const MAX_BINARY_BYTES = 100_000_000;
14
+ /** Error text is forwarded to the MCP client, so keep it context-sized. */
15
+ const MAX_ERROR_BODY_CHARS = 2_000;
16
+ /**
17
+ * Best-effort size guard. Protect sets content-length on the responses that
18
+ * can actually get large (snapshots, exports); a chunked response without the
19
+ * header is not caught here.
20
+ */
21
+ function enforceSizeLimit(response, limit) {
22
+ const declared = Number(response.headers.get("content-length"));
23
+ if (Number.isFinite(declared) && declared > limit) {
24
+ throw new Error(`Response exceeds the ${limit.toString()}-byte limit (content-length ${declared.toString()})`);
25
+ }
26
+ }
27
+ /**
28
+ * Read an error body for inclusion in a thrown message. Error responses bypass
29
+ * the caller's size check, so bound them here too: an oversized body is left
30
+ * unread, and a merely long one is truncated before it reaches an MCP client's
31
+ * context window.
32
+ */
33
+ async function readErrorBody(response, limit) {
34
+ const declared = Number(response.headers.get("content-length"));
35
+ if (Number.isFinite(declared) && declared > limit) {
36
+ await response.body?.cancel();
37
+ return `<error body exceeds the ${limit.toString()}-byte limit and was not read>`;
38
+ }
39
+ const text = await response.text();
40
+ return text.length > MAX_ERROR_BODY_CHARS
41
+ ? `${text.slice(0, MAX_ERROR_BODY_CHARS)}… (truncated)`
42
+ : text;
43
+ }
8
44
  class ProtectClient {
9
45
  baseUrl;
10
46
  headers;
47
+ usingConnector;
11
48
  constructor(config) {
12
- this.baseUrl = `https://${config.host}/proxy/protect/integration/v1`;
49
+ // Cloud Connector only exists behind api.ui.com. A console ID set against
50
+ // a local console is ignored rather than fatal: every tool still works
51
+ // against that console, and refusing to start helps nobody.
52
+ const useConnector = config.consoleId !== undefined && config.host === "api.ui.com";
53
+ if (config.consoleId !== undefined && !useConnector) {
54
+ console.error(`Ignoring UNIFI_PROTECT_CONSOLE_ID: Cloud Connector requires host api.ui.com, but host is ${config.host}.`);
55
+ }
56
+ const consolePath = useConnector
57
+ ? `/v1/connector/consoles/${config.consoleId ?? ""}`
58
+ : "";
59
+ this.usingConnector = useConnector;
60
+ this.baseUrl = `https://${config.host}${consolePath}/proxy/protect/integration/v1`;
13
61
  this.headers = {
14
62
  "X-API-KEY": config.apiKey,
15
63
  "Content-Type": "application/json",
@@ -27,16 +75,24 @@ class ProtectClient {
27
75
  if (body !== undefined) {
28
76
  options.body = JSON.stringify(body);
29
77
  }
78
+ options.redirect = "error";
79
+ options.signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
30
80
  const response = await fetch(url, options);
31
81
  if (!response.ok) {
32
- const text = await response.text();
33
- throw new Error(`HTTP ${response.status}: ${text}`);
82
+ throw new Error(`HTTP ${response.status}: ${await readErrorBody(response, MAX_JSON_BYTES)}`);
34
83
  }
84
+ enforceSizeLimit(response, MAX_JSON_BYTES);
35
85
  const contentType = response.headers.get("content-type") ?? "";
86
+ const text = await response.text();
36
87
  if (contentType.includes("application/json")) {
37
- return response.json();
88
+ if (text.trim() === "") {
89
+ // A bare JSON.parse would report "Unexpected end of JSON input", which
90
+ // reads as a broken tool rather than an unusable Protect response.
91
+ throw new Error(`Protect returned an empty body for ${method} ${path} with a JSON content type`);
92
+ }
93
+ return JSON.parse(text);
38
94
  }
39
- return response.text();
95
+ return text;
40
96
  }
41
97
  async get(path) {
42
98
  return this.request("GET", path);
@@ -55,11 +111,13 @@ class ProtectClient {
55
111
  const response = await fetch(url, {
56
112
  method: "GET",
57
113
  headers: { "X-API-KEY": this.headers["X-API-KEY"] },
114
+ redirect: "error",
115
+ signal: AbortSignal.timeout(BINARY_TIMEOUT_MS),
58
116
  });
59
117
  if (!response.ok) {
60
- const text = await response.text();
61
- throw new Error(`HTTP ${response.status}: ${text}`);
118
+ throw new Error(`HTTP ${response.status}: ${await readErrorBody(response, MAX_BINARY_BYTES)}`);
62
119
  }
120
+ enforceSizeLimit(response, MAX_BINARY_BYTES);
63
121
  const mimeType = response.headers.get("content-type") ?? "application/octet-stream";
64
122
  const arrayBuffer = await response.arrayBuffer();
65
123
  return { data: Buffer.from(arrayBuffer), mimeType };
@@ -73,10 +131,11 @@ class ProtectClient {
73
131
  "Content-Type": contentType,
74
132
  },
75
133
  body: new Uint8Array(data),
134
+ redirect: "error",
135
+ signal: AbortSignal.timeout(BINARY_TIMEOUT_MS),
76
136
  });
77
137
  if (!response.ok) {
78
- const text = await response.text();
79
- throw new Error(`HTTP ${response.status}: ${text}`);
138
+ throw new Error(`HTTP ${response.status}: ${await readErrorBody(response, MAX_BINARY_BYTES)}`);
80
139
  }
81
140
  const ct = response.headers.get("content-type") ?? "";
82
141
  if (ct.includes("application/json")) {
@@ -85,6 +144,12 @@ class ProtectClient {
85
144
  return response.text();
86
145
  }
87
146
  connectWebSocket(path) {
147
+ if (this.usingConnector) {
148
+ // Verified against a live console: the connector proxies REST calls but
149
+ // answers 404 to a WebSocket upgrade on the same path. Say so plainly
150
+ // rather than letting an opaque 404 reach the MCP client.
151
+ throw new Error("WebSocket subscriptions are not available over Cloud Connector; connect directly to the console host instead");
152
+ }
88
153
  const url = `wss://${this.baseUrl.replace(/^https?:\/\//, "")}${path}`;
89
154
  return new ws_1.default(url, {
90
155
  headers: { "X-API-KEY": this.headers["X-API-KEY"] },
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.ZodBoolean;
6
7
  readOnly: z.ZodBoolean;
7
8
  }, z.core.$strip>;
package/dist/config.js CHANGED
@@ -5,6 +5,13 @@ const zod_1 = require("zod");
5
5
  const ConfigSchema = zod_1.z.object({
6
6
  host: zod_1.z.string().min(1, "UNIFI_PROTECT_HOST is required"),
7
7
  apiKey: zod_1.z.string().min(1, "UNIFI_PROTECT_API_KEY is required"),
8
+ // Console IDs are hex, UUID- or colon-shaped depending on vintage. The
9
+ // charset excludes anything that could escape the URL path segment it is
10
+ // interpolated into.
11
+ consoleId: zod_1.z
12
+ .string()
13
+ .regex(/^[A-Za-z0-9:_-]{1,128}$/, "UNIFI_PROTECT_CONSOLE_ID must be alphanumeric with : _ -")
14
+ .optional(),
8
15
  verifySsl: zod_1.z.boolean(),
9
16
  readOnly: zod_1.z.boolean(),
10
17
  });
@@ -12,6 +19,9 @@ function loadConfig() {
12
19
  const result = ConfigSchema.safeParse({
13
20
  host: process.env.UNIFI_PROTECT_HOST,
14
21
  apiKey: process.env.UNIFI_PROTECT_API_KEY,
22
+ ...(process.env.UNIFI_PROTECT_CONSOLE_ID
23
+ ? { consoleId: process.env.UNIFI_PROTECT_CONSOLE_ID }
24
+ : {}),
15
25
  verifySsl: process.env.UNIFI_PROTECT_VERIFY_SSL !== "false",
16
26
  readOnly: process.env.UNIFI_PROTECT_READ_ONLY !== "false",
17
27
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owine/unifi-protect-mcp",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "MCP server for UniFi Protect Integration 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.5.1",
26
26
  "repository": {
27
27
  "type": "git",
28
28
  "url": "git+https://github.com/owine/unifi-protect-mcp.git"
@@ -39,18 +39,18 @@
39
39
  "dependencies": {
40
40
  "@modelcontextprotocol/sdk": "1.30.0",
41
41
  "ws": "8.21.3",
42
- "zod": "4.6.2"
42
+ "zod": "4.6.5"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@eslint/js": "10.0.1",
46
- "@types/node": "24.13.4",
46
+ "@types/node": "24.13.6",
47
47
  "@types/ws": "8.18.1",
48
48
  "@typescript/native": "npm:typescript@7.0.2",
49
- "@vitest/coverage-v8": "5.0.0",
50
- "eslint": "10.10.0",
51
- "lefthook": "2.1.12",
49
+ "@vitest/coverage-v8": "5.0.1",
50
+ "eslint": "10.11.0",
51
+ "lefthook": "2.1.14",
52
52
  "typescript": "npm:@typescript/typescript6@6.0.2",
53
53
  "typescript-eslint": "8.70.0",
54
- "vitest": "5.0.0"
54
+ "vitest": "5.0.1"
55
55
  }
56
56
  }