@owine/unifi-protect-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 +29 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +74 -9
- package/dist/config.d.ts +1 -0
- package/dist/config.js +10 -0
- package/package.json +10 -10
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
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.
|
|
3
|
+
"version": "2.14.1",
|
|
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.
|
|
25
|
+
"packageManager": "pnpm@12.6.0",
|
|
26
26
|
"repository": {
|
|
27
27
|
"type": "git",
|
|
28
28
|
"url": "git+https://github.com/owine/unifi-protect-mcp.git"
|
|
@@ -37,20 +37,20 @@
|
|
|
37
37
|
"author": "owine",
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@modelcontextprotocol/sdk": "1.30.
|
|
40
|
+
"@modelcontextprotocol/sdk": "1.30.1",
|
|
41
41
|
"ws": "8.21.3",
|
|
42
|
-
"zod": "4.6.
|
|
42
|
+
"zod": "4.6.5"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@eslint/js": "10.0.1",
|
|
46
|
-
"@types/node": "24.
|
|
46
|
+
"@types/node": "24.19.0",
|
|
47
47
|
"@types/ws": "8.18.1",
|
|
48
48
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
49
|
-
"@vitest/coverage-v8": "5.0.
|
|
50
|
-
"eslint": "10.
|
|
51
|
-
"lefthook": "2.1.
|
|
49
|
+
"@vitest/coverage-v8": "5.0.2",
|
|
50
|
+
"eslint": "10.11.0",
|
|
51
|
+
"lefthook": "2.1.14",
|
|
52
52
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
53
|
-
"typescript-eslint": "8.70.
|
|
54
|
-
"vitest": "5.0.
|
|
53
|
+
"typescript-eslint": "8.70.1",
|
|
54
|
+
"vitest": "5.0.2"
|
|
55
55
|
}
|
|
56
56
|
}
|