@mgcrea/mcp-unifi-protect 0.0.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.
@@ -0,0 +1,1743 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { readFileSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { z } from "zod";
6
+ import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
7
+ import { Agent, fetch as fetch$1 } from "undici";
8
+ //#region src/build-info.ts
9
+ const readPackageJson = () => {
10
+ try {
11
+ const pkgUrl = new URL("../package.json", import.meta.url);
12
+ return JSON.parse(readFileSync(pkgUrl, "utf8"));
13
+ } catch {
14
+ return {
15
+ name: "@mgcrea/mcp-unifi-protect",
16
+ version: "0.0.0"
17
+ };
18
+ }
19
+ };
20
+ const pkg = readPackageJson();
21
+ const BUILD_INFO = {
22
+ name: pkg.name,
23
+ version: pkg.version,
24
+ gitCommit: "b7fe70a",
25
+ gitCommitDate: "2026-08-30T10:17:29+02:00"
26
+ };
27
+ //#endregion
28
+ //#region src/config.ts
29
+ /**
30
+ * The private (undocumented) Protect API, mounted under UniFi OS's proxy. This
31
+ * server deliberately wraps this rather than the official Integration API at
32
+ * `/proxy/protect/integration/v1`: the official one has NO historical query
33
+ * capability at all — its only query parameters in the entire published OpenAPI
34
+ * spec are `channel`, `highQuality` and `qualities` — so "what happened at the
35
+ * front door last night" is unanswerable through it.
36
+ *
37
+ * The trade is real and accepted: nothing below is contractual, and Ubiquiti
38
+ * moves these endpoints between Protect releases. `unifi_protect_get_system_info`
39
+ * reports the running version so a mismatch is visible, and
40
+ * `unifi_protect_request` reaches anything that moved without a code change.
41
+ */
42
+ const PRIVATE_API_PATH = "/proxy/protect/api";
43
+ /** UniFi OS's own auth surface, which is NOT under the Protect proxy path. */
44
+ const LOGIN_PATH = "/api/auth/login";
45
+ /** The realtime channel. Not used yet — see the WebSocket note in the README. */
46
+ const UPDATES_WS_PATH = "/proxy/protect/ws/updates";
47
+ const ConfigSchema = z.object({
48
+ /** Console origin, e.g. `https://192.168.1.1` or `https://10.0.0.1:8443`. */
49
+ baseUrl: z.string().min(1).optional(),
50
+ username: z.string().min(1).optional(),
51
+ password: z.string().min(1).optional(),
52
+ /**
53
+ * A 2FA code is single-use and expires in ~30s, so it is only ever useful
54
+ * for a one-shot `unifi_protect_auth_login`, never for an unattended start.
55
+ */
56
+ totp: z.string().min(1).optional(),
57
+ /**
58
+ * On by default. Turning it off is scoped to this server's own requests via
59
+ * an undici dispatcher, not the whole process.
60
+ *
61
+ * Verifying takes two things together, and one alone does nothing: the
62
+ * console's certificate is self-signed (so NODE_EXTRA_CA_CERTS must point at
63
+ * it) AND it carries no IP SAN — `CN=unifi.local`, SANs for `unifi.local`,
64
+ * `localhost` and `127.0.0.1` — so UNIFI_PROTECT_HOST must be a host name
65
+ * that resolves to the console, never its IP address.
66
+ */
67
+ verifyTls: z.boolean().default(true),
68
+ allowWrites: z.boolean().default(false),
69
+ sessionFile: z.string().min(1),
70
+ snapshotDir: z.string().min(1),
71
+ maxRetries: z.number().int().nonnegative().max(10).default(3),
72
+ /** Guards against an unexpectedly huge video export blowing up the process. */
73
+ maxDownloadBytes: z.number().int().positive().default(2e8),
74
+ /** How long a cached camera id→name index stays fresh, in seconds. */
75
+ deviceCacheTtlSeconds: z.number().int().nonnegative().max(3600).default(60)
76
+ }).strict().superRefine((cfg, ctx) => {
77
+ if (cfg.baseUrl && !cfg.username) ctx.addIssue({
78
+ code: "custom",
79
+ path: ["username"],
80
+ message: "UNIFI_PROTECT_HOST is set but UNIFI_PROTECT_USERNAME is not. Both, plus UNIFI_PROTECT_PASSWORD, are needed to reach the console."
81
+ });
82
+ if (cfg.username && !cfg.password) ctx.addIssue({
83
+ code: "custom",
84
+ path: ["password"],
85
+ message: "UNIFI_PROTECT_USERNAME is set but UNIFI_PROTECT_PASSWORD is not."
86
+ });
87
+ });
88
+ /**
89
+ * The on-disk config document. Keys are camelCase to mirror `Config` rather
90
+ * than the env var names: this is a typed JSON file, not a shell.
91
+ *
92
+ * `.strict()` on purpose — a typo'd `userName` must be an error. Silently
93
+ * ignoring an unknown key looks exactly like "that setting had no effect",
94
+ * which is the worst way to learn your credentials came from somewhere else.
95
+ */
96
+ const FileConfigSchema = z.object({
97
+ host: z.string().min(1).optional(),
98
+ username: z.string().min(1).optional(),
99
+ password: z.string().min(1).optional(),
100
+ verifyTls: z.boolean().optional(),
101
+ allowWrites: z.boolean().optional(),
102
+ sessionFile: z.string().min(1).optional(),
103
+ snapshotDir: z.string().min(1).optional(),
104
+ maxRetries: z.number().int().nonnegative().max(10).optional(),
105
+ maxDownloadBytes: z.number().int().positive().optional(),
106
+ deviceCacheTtlSeconds: z.number().int().nonnegative().max(3600).optional()
107
+ }).strict();
108
+ /**
109
+ * Normalize whatever someone pasted into a console origin, preserving the port.
110
+ *
111
+ * "192.168.1.1" -> "https://192.168.1.1"
112
+ * "10.0.0.1:8443" -> "https://10.0.0.1:8443"
113
+ * "https://udm.lan/protect/" -> "https://udm.lan"
114
+ *
115
+ * A port must survive: consoles are commonly reached on a non-443 port, and the
116
+ * normalizers in mcp-keycloak and mcp-shopify both drop it. Everything after
117
+ * the origin is discarded — the API paths are this server's business, and a
118
+ * pasted `/protect/dashboard` URL would otherwise be prefixed onto every call.
119
+ *
120
+ * `https` is forced: UniFi OS redirects plain HTTP, and following that redirect
121
+ * would send the session cookie over cleartext on the first hop.
122
+ */
123
+ const normalizeBaseUrl = (raw) => {
124
+ const trimmedRaw = raw.trim().replace(/\/+$/, "");
125
+ const withScheme = /^https?:\/\//i.test(trimmedRaw) ? trimmedRaw : `https://${trimmedRaw}`;
126
+ return `https://${new URL(withScheme).host}`;
127
+ };
128
+ const parseBool = (value) => {
129
+ const t = trimmed(value);
130
+ if (t === void 0) return void 0;
131
+ return [
132
+ "1",
133
+ "true",
134
+ "yes",
135
+ "on"
136
+ ].includes(t.toLowerCase());
137
+ };
138
+ const parseIntOpt = (value) => {
139
+ if (value === void 0 || value.trim() === "") return void 0;
140
+ const n = Number(value);
141
+ return Number.isInteger(n) ? n : void 0;
142
+ };
143
+ /** Maps "" to undefined, so an empty env var means "unset" rather than "empty". */
144
+ const trimmed = (value) => {
145
+ const t = value?.trim();
146
+ return t ? t : void 0;
147
+ };
148
+ const message = (err) => err instanceof Error ? err.message : String(err);
149
+ /** `readFileSync` does not expand `~`, but it is the natural thing to write in a config file. */
150
+ const expandTilde = (path) => path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
151
+ /**
152
+ * Where the config file lives, most specific first: an explicit override, then
153
+ * the XDG location, then the conventional `~/.config`.
154
+ */
155
+ const resolveConfigPath = (env = process.env) => {
156
+ const explicit = trimmed(env.UNIFI_PROTECT_CONFIG);
157
+ if (explicit) return expandTilde(explicit);
158
+ const base = trimmed(env.XDG_CONFIG_HOME) ?? join(homedir(), ".config");
159
+ return join(expandTilde(base), "unifi-protect", "config.json");
160
+ };
161
+ /** The session file sits beside the config file unless told otherwise. */
162
+ const resolveSessionPath = (env = process.env) => join(dirname(resolveConfigPath(env)), "session.json");
163
+ /**
164
+ * The session file holds a live console cookie, so being readable by other
165
+ * users is worth saying out loud. A warning and not an error: refusing to start
166
+ * would be a worse trade for someone on a single-user machine.
167
+ */
168
+ const warnIfGroupReadable = (path) => {
169
+ if (process.platform === "win32") return;
170
+ try {
171
+ if (statSync(path).mode & 63) process.stderr.write(`[unifi-protect] ${path} is readable by other users. Run: chmod 600 ${path}\n`);
172
+ } catch {}
173
+ };
174
+ /**
175
+ * Read the config file, treating "absent" as "contributes nothing". Every other
176
+ * failure throws and names the path, so a malformed file is never mistaken for
177
+ * a missing one — that confusion sends you hunting for credentials that were
178
+ * sitting right there.
179
+ */
180
+ const readConfigFile = (path) => {
181
+ let raw;
182
+ try {
183
+ raw = readFileSync(path, "utf8");
184
+ } catch (err) {
185
+ if (err.code === "ENOENT") return {};
186
+ throw new Error(`Could not read the config file (${path}): ${message(err)}`, { cause: err });
187
+ }
188
+ warnIfGroupReadable(path);
189
+ let parsed;
190
+ try {
191
+ parsed = JSON.parse(raw);
192
+ } catch (err) {
193
+ throw new Error(`The config file (${path}) is not valid JSON: ${message(err)}`, { cause: err });
194
+ }
195
+ const result = FileConfigSchema.safeParse(parsed);
196
+ if (!result.success) {
197
+ const issues = result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
198
+ throw new Error(`The config file (${path}) is not valid: ${issues}`);
199
+ }
200
+ return result.data;
201
+ };
202
+ /**
203
+ * Environment first, config file second, **per field** — not whole-source.
204
+ * Docker and CI inject the environment and must keep working untouched, while a
205
+ * one-off `UNIFI_PROTECT_ALLOW_WRITES=0` still has to override a file that says
206
+ * `true`. Merging field by field is the only rule that gives both.
207
+ */
208
+ const loadConfig = (env = process.env, configPath = resolveConfigPath(env)) => {
209
+ const file = readConfigFile(configPath);
210
+ const host = trimmed(env.UNIFI_PROTECT_HOST) ?? file.host;
211
+ const sessionFile = trimmed(env.UNIFI_PROTECT_SESSION_FILE) ?? file.sessionFile ?? resolveSessionPath(env);
212
+ const snapshotDir = trimmed(env.UNIFI_PROTECT_SNAPSHOT_DIR) ?? file.snapshotDir ?? join(homedir(), ".cache", "unifi-protect");
213
+ return ConfigSchema.parse({
214
+ baseUrl: host ? normalizeBaseUrl(host) : void 0,
215
+ username: trimmed(env.UNIFI_PROTECT_USERNAME) ?? file.username,
216
+ password: trimmed(env.UNIFI_PROTECT_PASSWORD) ?? file.password,
217
+ totp: trimmed(env.UNIFI_PROTECT_TOTP),
218
+ verifyTls: parseBool(env.UNIFI_PROTECT_VERIFY_TLS) ?? file.verifyTls,
219
+ allowWrites: parseBool(env.UNIFI_PROTECT_ALLOW_WRITES) ?? file.allowWrites,
220
+ sessionFile: expandTilde(sessionFile),
221
+ snapshotDir: expandTilde(snapshotDir),
222
+ maxRetries: parseIntOpt(env.UNIFI_PROTECT_MAX_RETRIES) ?? file.maxRetries,
223
+ maxDownloadBytes: parseIntOpt(env.UNIFI_PROTECT_MAX_DOWNLOAD_BYTES) ?? file.maxDownloadBytes,
224
+ deviceCacheTtlSeconds: parseIntOpt(env.UNIFI_PROTECT_DEVICE_CACHE_TTL) ?? file.deviceCacheTtlSeconds
225
+ });
226
+ };
227
+ /** True once the server has everything it needs to reach a console. */
228
+ const isConfigured = (config) => Boolean(config.baseUrl && config.username && config.password);
229
+ /**
230
+ * Returned by unifi_protect_auth_status and printed to stderr at startup. Prose
231
+ * rather than a code, because this is the text someone acts on when nothing
232
+ * works — and the server can no longer signal it by refusing to start.
233
+ */
234
+ const setupInstructions = (config) => {
235
+ if (isConfigured(config)) return [];
236
+ return [
237
+ "No UniFi Protect console is configured, so only unifi_protect_auth_status is registered.",
238
+ "Set UNIFI_PROTECT_HOST to your console's address — the IP or hostname of the UDM Pro, UNVR or Cloud Key, e.g. 192.168.1.1 (https:// is assumed, and a :port is preserved).",
239
+ "Set UNIFI_PROTECT_USERNAME and UNIFI_PROTECT_PASSWORD to a console login.",
240
+ "Create a dedicated LOCAL user for this rather than reusing your own: UniFi OS → Settings → Admins & Users → Add User → Local Access Only, and give it Protect permissions only. A Ubiquiti cloud (SSO) account may fail local login entirely, and a local account keeps the blast radius to Protect.",
241
+ "Give that user View-Only rights unless you intend to set UNIFI_PROTECT_ALLOW_WRITES=1 — with writes off the mutating tools are not registered at all, so they cannot be called.",
242
+ "If the account has 2FA, pass a current code once via unifi_protect_auth_login; the session is then cached and reused. A code in UNIFI_PROTECT_TOTP expires in about 30 seconds, so it is no use for an unattended start.",
243
+ "Certificate verification is ON by default. A console reached by IP can never pass it: the certificate is self-signed AND has no IP SAN. Either set UNIFI_PROTECT_HOST to a host name that resolves to the console and point NODE_EXTRA_CA_CERTS at its certificate, or set UNIFI_PROTECT_VERIFY_TLS=false, which is scoped to this server's requests only. Set it to true only if you installed a certificate your machine already trusts."
244
+ ];
245
+ };
246
+ //#endregion
247
+ //#region src/client/errors.ts
248
+ /** A non-2xx answer from the console. */
249
+ var ProtectApiError = class extends Error {
250
+ name = "ProtectApiError";
251
+ status;
252
+ /** The request path, so an error names what failed without a stack trace. */
253
+ path;
254
+ errors;
255
+ constructor(message, opts) {
256
+ super(message);
257
+ this.status = opts.status;
258
+ this.path = opts.path;
259
+ this.errors = opts.errors;
260
+ }
261
+ };
262
+ /** The console rejected the credentials, or 2FA is required and was not supplied. */
263
+ var ProtectAuthError = class extends Error {
264
+ name = "ProtectAuthError";
265
+ /** True when the console asked for a 2FA code rather than refusing the password. */
266
+ needsTwoFactor;
267
+ constructor(message, opts = {}) {
268
+ super(message);
269
+ this.needsTwoFactor = opts.needsTwoFactor ?? false;
270
+ }
271
+ };
272
+ /** Thrown when a write path is reached while UNIFI_PROTECT_ALLOW_WRITES is off. */
273
+ var WritesDisabledError = class extends Error {
274
+ name = "WritesDisabledError";
275
+ constructor(what) {
276
+ super(`${what} is a write operation, but writes are disabled. Set UNIFI_PROTECT_ALLOW_WRITES=1 to register the mutating tools.`);
277
+ }
278
+ };
279
+ /** Thrown when the server is asked to reach a console it has no credentials for. */
280
+ var NotConfiguredError = class extends Error {
281
+ name = "NotConfiguredError";
282
+ constructor() {
283
+ super("No UniFi Protect console is configured. Call unifi_protect_auth_status for the setup steps.");
284
+ }
285
+ };
286
+ //#endregion
287
+ //#region src/client/session-store.ts
288
+ /** Load a persisted session, or undefined if none / unreadable / not JSON. */
289
+ const loadSession = async (path) => {
290
+ try {
291
+ const raw = await readFile(path, "utf8");
292
+ const parsed = JSON.parse(raw);
293
+ if (!parsed.cookie || !parsed.csrfToken || !parsed.baseUrl || !parsed.username) return;
294
+ return parsed;
295
+ } catch {
296
+ return;
297
+ }
298
+ };
299
+ /** Persist a session with owner-only permissions. */
300
+ const saveSession = async (path, session) => {
301
+ await mkdir(dirname(path), { recursive: true });
302
+ await writeFile(path, JSON.stringify(session, null, 2), { mode: 384 });
303
+ await chmod(path, 384);
304
+ };
305
+ /** Remove a persisted session. Absent is success — logout is idempotent. */
306
+ const clearSession = async (path) => {
307
+ try {
308
+ await unlink(path);
309
+ } catch (err) {
310
+ if (err.code !== "ENOENT") throw err;
311
+ }
312
+ };
313
+ //#endregion
314
+ //#region src/client/auth.ts
315
+ /**
316
+ * Read one header, collapsing undici's `string | string[] | undefined` shape.
317
+ * Multi-value headers (the Set-Cookie shape) collapse to their first entry,
318
+ * which is all the handshake needs — the session is a single cookie.
319
+ */
320
+ const header = (res, name) => res.headers.get(name) ?? void 0;
321
+ /**
322
+ * Keep only the `TOKEN=value` pair, dropping the cookie's attributes (Path,
323
+ * Expires, HttpOnly, …). Sending those attributes back on a request header is
324
+ * malformed and the console rejects the session.
325
+ */
326
+ const bareCookie = (setCookie) => {
327
+ const semicolon = setCookie.indexOf(";");
328
+ return semicolon === -1 ? setCookie : setCookie.slice(0, semicolon);
329
+ };
330
+ /**
331
+ * Whether a login response is asking for a second factor rather than refusing
332
+ * the password. Protect answers 499 for this; some firmwares use a 401 with a
333
+ * body naming the requirement, so both are probed.
334
+ */
335
+ const isTwoFactorChallenge = (status, body) => status === 499 || /2fa|two[- ]factor|mfa|otp/i.test(body);
336
+ const createSessionProvider = (opts) => {
337
+ const { config } = opts;
338
+ const fetchImpl = opts.fetch ?? fetch;
339
+ const logger = opts.logger;
340
+ let session;
341
+ let source = "none";
342
+ let inflight;
343
+ let restored = false;
344
+ const requireCredentials = () => {
345
+ if (!config.baseUrl || !config.username || !config.password) throw new ProtectAuthError("No console credentials are configured. Call unifi_protect_auth_status for the setup steps.");
346
+ return {
347
+ baseUrl: config.baseUrl,
348
+ username: config.username,
349
+ password: config.password
350
+ };
351
+ };
352
+ /**
353
+ * Fetch a CSRF token from the console root. UniFi OS gates login behind CSRF
354
+ * protection, so a first login on a cold start has nothing to present until
355
+ * this runs.
356
+ */
357
+ const fetchCsrfToken = async (baseUrl) => {
358
+ try {
359
+ const res = await fetchImpl(baseUrl, {
360
+ method: "GET",
361
+ redirect: "manual"
362
+ });
363
+ return header(res, "x-csrf-token");
364
+ } catch (err) {
365
+ logger?.debug?.(`could not prefetch a CSRF token: ${String(err)}`);
366
+ return;
367
+ }
368
+ };
369
+ /** Run the UniFi OS handshake and return a complete session. */
370
+ const handshake = async (totp) => {
371
+ const { baseUrl, username, password } = requireCredentials();
372
+ const url = `${baseUrl}${LOGIN_PATH}`;
373
+ const code = totp ?? config.totp;
374
+ const attempt = async (csrf) => fetchImpl(url, {
375
+ method: "POST",
376
+ headers: {
377
+ "Content-Type": "application/json",
378
+ Accept: "application/json",
379
+ ...csrf ? { "x-csrf-token": csrf } : {}
380
+ },
381
+ body: JSON.stringify({
382
+ username,
383
+ password,
384
+ ...code ? { token: code } : {},
385
+ rememberMe: true
386
+ }),
387
+ redirect: "manual"
388
+ });
389
+ logger?.debug?.(`logging in to ${baseUrl} as ${username}`);
390
+ let res = await attempt(session?.csrfToken);
391
+ if (!res.ok && !session?.csrfToken) {
392
+ const csrf = await fetchCsrfToken(baseUrl);
393
+ if (csrf) {
394
+ logger?.debug?.("retrying login with a freshly fetched CSRF token");
395
+ res = await attempt(csrf);
396
+ }
397
+ }
398
+ if (!res.ok) {
399
+ const body = await res.text().catch(() => "");
400
+ if (isTwoFactorChallenge(res.status, body)) throw new ProtectAuthError("The console requires a two-factor code. Call unifi_protect_auth_login with a current code from your authenticator app — the session is cached afterwards, so this is a one-off.", { needsTwoFactor: true });
401
+ throw new ProtectAuthError(`Login to ${baseUrl} as ${username} failed: HTTP ${res.status} ${res.statusText}. Check the username and password, and note that a Ubiquiti cloud (SSO) account often cannot log in locally — create a Local Access Only user instead.`);
402
+ }
403
+ const csrfToken = header(res, "x-updated-csrf-token") ?? header(res, "x-csrf-token");
404
+ const setCookie = res.headers.getSetCookie()[0] ?? header(res, "set-cookie");
405
+ if (!csrfToken || !setCookie) throw new ProtectAuthError(`Login to ${baseUrl} succeeded (HTTP ${res.status}) but returned no ${!setCookie ? "session cookie" : "CSRF token"}. This usually means the host is not a UniFi OS console — check UNIFI_PROTECT_HOST points at the console itself, not at a reverse proxy in front of it.`);
406
+ const next = {
407
+ cookie: bareCookie(setCookie),
408
+ csrfToken,
409
+ baseUrl,
410
+ username,
411
+ savedAt: new Date(opts.now?.() ?? Date.now()).toISOString()
412
+ };
413
+ await saveSession(config.sessionFile, next).catch((err) => {
414
+ logger?.warn?.(`could not persist the session to ${config.sessionFile}: ${String(err)}`);
415
+ });
416
+ return next;
417
+ };
418
+ /**
419
+ * Reuse a session from disk when it belongs to the console and account we are
420
+ * configured for. A session for a different host or user is not merely
421
+ * useless, it would authenticate as the wrong identity.
422
+ */
423
+ const restore = async () => {
424
+ if (restored) return void 0;
425
+ restored = true;
426
+ const persisted = await loadSession(config.sessionFile);
427
+ if (!persisted) return void 0;
428
+ if (persisted.baseUrl !== config.baseUrl || persisted.username !== config.username) {
429
+ logger?.debug?.("ignoring a persisted session issued for a different console or user");
430
+ return;
431
+ }
432
+ logger?.debug?.(`restored a session from ${config.sessionFile}`);
433
+ return persisted;
434
+ };
435
+ const ensure = async (totp) => {
436
+ if (session) return session;
437
+ const fromDisk = await restore();
438
+ if (fromDisk) {
439
+ session = fromDisk;
440
+ source = "restored";
441
+ return session;
442
+ }
443
+ if (!inflight) inflight = handshake(totp).finally(() => {
444
+ inflight = void 0;
445
+ });
446
+ session = await inflight;
447
+ source = "login";
448
+ return session;
449
+ };
450
+ return {
451
+ async headers() {
452
+ const live = await ensure();
453
+ return {
454
+ cookie: live.cookie,
455
+ "x-csrf-token": live.csrfToken
456
+ };
457
+ },
458
+ invalidate() {
459
+ session = void 0;
460
+ restored = true;
461
+ },
462
+ async login(totp) {
463
+ session = void 0;
464
+ restored = true;
465
+ session = await handshake(totp);
466
+ source = "login";
467
+ return this.describe();
468
+ },
469
+ async logout() {
470
+ session = void 0;
471
+ restored = true;
472
+ source = "none";
473
+ await clearSession(config.sessionFile);
474
+ },
475
+ describe() {
476
+ return {
477
+ authenticated: session !== void 0,
478
+ source,
479
+ username: session?.username ?? config.username,
480
+ savedAt: session?.savedAt
481
+ };
482
+ }
483
+ };
484
+ };
485
+ /** For tests: fixed headers, no network, no disk. */
486
+ const staticSessionProvider = (headers = {
487
+ cookie: "TOKEN=test",
488
+ "x-csrf-token": "csrf-test"
489
+ }) => ({
490
+ headers: async () => headers,
491
+ invalidate: () => {},
492
+ login: async () => ({
493
+ authenticated: true,
494
+ source: "login",
495
+ username: "test",
496
+ savedAt: void 0
497
+ }),
498
+ logout: async () => {},
499
+ describe: () => ({
500
+ authenticated: true,
501
+ source: "login",
502
+ username: "test",
503
+ savedAt: void 0
504
+ })
505
+ });
506
+ //#endregion
507
+ //#region src/client/shape.ts
508
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
509
+ const str = (value) => typeof value === "string" && value.length > 0 ? value : void 0;
510
+ /** Apply a summarizer across an array, passing non-arrays through untouched. */
511
+ const summarizeEach = (value, fn) => Array.isArray(value) ? value.filter(isRecord).map(fn) : value;
512
+ /**
513
+ * Protect timestamps are milliseconds since the Unix epoch. Rendering them as
514
+ * ISO 8601 costs a few characters and saves the model from having to reason
515
+ * about a bare 13-digit integer — which it does get wrong, usually by reading
516
+ * it as seconds and landing in 1970.
517
+ */
518
+ const isoTime = (value) => {
519
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return void 0;
520
+ return new Date(value).toISOString();
521
+ };
522
+ const buildNameIndex = (devices) => {
523
+ const index = /* @__PURE__ */ new Map();
524
+ if (!Array.isArray(devices)) return index;
525
+ for (const device of devices) {
526
+ if (!isRecord(device)) continue;
527
+ const id = str(device.id);
528
+ const name = str(device.name);
529
+ if (id && name) index.set(id, name);
530
+ }
531
+ return index;
532
+ };
533
+ const summarizeCamera = (camera) => {
534
+ const flags = isRecord(camera.featureFlags) ? camera.featureFlags : {};
535
+ const recording = isRecord(camera.recordingSettings) ? camera.recordingSettings : {};
536
+ const smart = isRecord(camera.smartDetectSettings) ? camera.smartDetectSettings : {};
537
+ return {
538
+ id: camera.id,
539
+ name: camera.name,
540
+ type: camera.type,
541
+ mac: camera.mac,
542
+ host: camera.host,
543
+ isConnected: camera.isConnected,
544
+ state: camera.state,
545
+ isRecording: camera.isRecording,
546
+ recordingMode: recording.mode,
547
+ hasPtz: flags.canOpticalZoom ?? flags.hasPtz ?? false,
548
+ hasPackageCamera: flags.hasPackageCamera ?? false,
549
+ hasSmartDetect: flags.hasSmartDetect ?? false,
550
+ smartDetectTypes: smart.objectTypes ?? [],
551
+ firmwareVersion: camera.firmwareVersion,
552
+ isUpdating: camera.isUpdating,
553
+ lastSeen: isoTime(camera.lastSeen)
554
+ };
555
+ };
556
+ /**
557
+ * Events reference their camera by id. Resolving that to a name server-side is
558
+ * the single most useful thing this layer does: it removes a join the model
559
+ * would otherwise have to perform against a separate camera list, and get
560
+ * silently wrong. The id is kept too, since the write and snapshot tools need it.
561
+ */
562
+ const summarizeEvent = (event, cameras) => {
563
+ const cameraId = str(event.camera);
564
+ const metadata = isRecord(event.metadata) ? event.metadata : void 0;
565
+ const plate = metadata && isRecord(metadata.licensePlate) ? str(metadata.licensePlate.name) : void 0;
566
+ return {
567
+ id: event.id,
568
+ type: event.type,
569
+ start: isoTime(event.start),
570
+ end: isoTime(event.end),
571
+ ...cameraId ? { cameraId } : {},
572
+ ...cameraId && cameras?.get(cameraId) ? { camera: cameras.get(cameraId) } : {},
573
+ ...Array.isArray(event.smartDetectTypes) && event.smartDetectTypes.length > 0 ? { smartDetectTypes: event.smartDetectTypes } : {},
574
+ ...plate ? { licensePlate: plate } : {},
575
+ ...typeof event.score === "number" ? { score: event.score } : {},
576
+ ...str(event.thumbnail) ? { hasThumbnail: true } : {}
577
+ };
578
+ };
579
+ const summarizeLight = (light) => {
580
+ const settings = isRecord(light.lightDeviceSettings) ? light.lightDeviceSettings : {};
581
+ return {
582
+ id: light.id,
583
+ name: light.name,
584
+ isConnected: light.isConnected,
585
+ isLightOn: light.isLightOn,
586
+ isPirMotionDetected: light.isPirMotionDetected,
587
+ ledLevel: settings.ledLevel,
588
+ isIndicatorEnabled: settings.isIndicatorEnabled,
589
+ firmwareVersion: light.firmwareVersion,
590
+ lastSeen: isoTime(light.lastSeen)
591
+ };
592
+ };
593
+ const summarizeSensor = (sensor) => {
594
+ const stats = isRecord(sensor.stats) ? sensor.stats : {};
595
+ const reading = (key) => {
596
+ const block = stats[key];
597
+ return isRecord(block) ? block.value : void 0;
598
+ };
599
+ return {
600
+ id: sensor.id,
601
+ name: sensor.name,
602
+ type: sensor.type,
603
+ isConnected: sensor.isConnected,
604
+ mountType: sensor.mountType,
605
+ batteryStatus: isRecord(sensor.batteryStatus) ? sensor.batteryStatus.percentage : void 0,
606
+ isOpened: sensor.isOpened,
607
+ isMotionDetected: sensor.isMotionDetected,
608
+ temperature: reading("temperature"),
609
+ humidity: reading("humidity"),
610
+ light: reading("light"),
611
+ firmwareVersion: sensor.firmwareVersion,
612
+ lastSeen: isoTime(sensor.lastSeen)
613
+ };
614
+ };
615
+ const summarizeViewer = (viewer) => ({
616
+ id: viewer.id,
617
+ name: viewer.name,
618
+ isConnected: viewer.isConnected,
619
+ liveview: viewer.liveview,
620
+ streamLimit: viewer.streamLimit,
621
+ firmwareVersion: viewer.firmwareVersion,
622
+ lastSeen: isoTime(viewer.lastSeen)
623
+ });
624
+ const summarizeChime = (chime) => ({
625
+ id: chime.id,
626
+ name: chime.name,
627
+ isConnected: chime.isConnected,
628
+ volume: chime.volume,
629
+ cameraIds: chime.cameraIds,
630
+ firmwareVersion: chime.firmwareVersion,
631
+ lastSeen: isoTime(chime.lastSeen)
632
+ });
633
+ const summarizeLiveview = (liveview) => ({
634
+ id: liveview.id,
635
+ name: liveview.name,
636
+ isDefault: liveview.isDefault,
637
+ owner: liveview.owner,
638
+ slotCount: Array.isArray(liveview.slots) ? liveview.slots.length : void 0
639
+ });
640
+ const summarizeUser = (user) => ({
641
+ id: user.id,
642
+ name: user.name,
643
+ firstName: user.firstName,
644
+ lastName: user.lastName,
645
+ email: user.email,
646
+ role: user.role,
647
+ isOwner: user.isOwner,
648
+ lastLoginTime: isoTime(user.lastLoginTime)
649
+ });
650
+ const num = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
651
+ const rec = (value) => isRecord(value) ? value : void 0;
652
+ /**
653
+ * Storage moved between Protect releases, so both layouts are read.
654
+ *
655
+ * Protect 6.x exposed `nvr.storageInfo` with `totalSize` / `totalSpaceUsed` and a
656
+ * `devices[]` array carrying full SMART tables. By 7.2 that key is gone entirely:
657
+ * the same numbers live under `nvr.systemInfo.storage` as `size` / `used` /
658
+ * `available`, the per-disk detail moved to `systemInfo.ustorage.disks`, and a
659
+ * separate `nvr.storageStats` carries utilization. Reading only one shape gives
660
+ * an empty `storage: {}` on the other, which is how this was found.
661
+ */
662
+ const summarizeStorage = (nvr) => {
663
+ const system = rec(nvr.systemInfo);
664
+ const modern = rec(system?.storage);
665
+ const legacy = rec(nvr.storageInfo);
666
+ const stats = rec(nvr.storageStats);
667
+ const ustorage = rec(system?.ustorage);
668
+ const disks = Array.isArray(ustorage?.disks) ? ustorage.disks.filter(isRecord) : void 0;
669
+ const total = num(modern?.size) ?? num(legacy?.totalSize);
670
+ const used = num(modern?.used) ?? num(legacy?.totalSpaceUsed);
671
+ const available = num(modern?.available) ?? (total !== void 0 && used !== void 0 ? total - used : void 0);
672
+ return {
673
+ ...total !== void 0 ? { totalBytes: total } : {},
674
+ ...used !== void 0 ? { usedBytes: used } : {},
675
+ ...available !== void 0 ? { availableBytes: available } : {},
676
+ ...num(stats?.utilization) !== void 0 ? { utilizationPercent: Math.round(stats.utilization * 10) / 10 } : {},
677
+ ...str(modern?.type) ? { type: modern.type } : {},
678
+ ...str(ustorage?.raid) ? { raid: ustorage.raid } : {},
679
+ ...str(modern?.capability) ?? str(nvr.hardDriveState) ? { health: modern?.capability ?? nvr.hardDriveState } : {},
680
+ ...disks ? {
681
+ disks: disks.length,
682
+ disksUnhealthy: disks.filter((d) => d.healthy !== void 0 && d.healthy !== "good").length
683
+ } : {},
684
+ ...nvr.isRecycling === true ? {
685
+ isRecycling: true,
686
+ note: "Near-full with recycling on is normal: the console overwrites the oldest footage continuously rather than stopping."
687
+ } : {}
688
+ };
689
+ };
690
+ const summarizeNvr = (nvr) => {
691
+ const system = rec(nvr.systemInfo);
692
+ const memory = rec(system?.memory);
693
+ const cpu = rec(system?.cpu);
694
+ const upSince = num(nvr.upSince);
695
+ return {
696
+ id: nvr.id,
697
+ name: nvr.name,
698
+ host: nvr.host,
699
+ type: nvr.type,
700
+ version: nvr.version,
701
+ firmwareVersion: nvr.firmwareVersion,
702
+ ...str(nvr.marketName) ? { model: nvr.marketName } : {},
703
+ timezone: nvr.timezone,
704
+ isRecordingDisabled: nvr.isRecordingDisabled,
705
+ storage: summarizeStorage(nvr),
706
+ ...memory ? { memory: {
707
+ available: memory.available,
708
+ total: memory.total
709
+ } } : {},
710
+ ...cpu ? { cpu: {
711
+ load: cpu.averageLoad,
712
+ temperature: cpu.temperature
713
+ } } : {},
714
+ ...upSince !== void 0 ? {
715
+ upSince: new Date(upSince).toISOString(),
716
+ uptimeSeconds: Math.round((Date.now() - upSince) / 1e3)
717
+ } : num(nvr.uptime) !== void 0 ? { uptimeSeconds: Math.round(nvr.uptime / 1e3) } : {},
718
+ lastSeen: isoTime(nvr.lastSeen)
719
+ };
720
+ };
721
+ /**
722
+ * The bootstrap document is the entire console state and must never be returned
723
+ * raw — it is the single largest context bomb this API offers. This reduces it
724
+ * to the NVR summary plus per-type counts, which is what "tell me about my
725
+ * system" actually wants.
726
+ */
727
+ const summarizeBootstrap = (bootstrap) => {
728
+ const count = (key) => Array.isArray(bootstrap[key]) ? bootstrap[key].length : 0;
729
+ return {
730
+ nvr: isRecord(bootstrap.nvr) ? summarizeNvr(bootstrap.nvr) : void 0,
731
+ devices: {
732
+ cameras: count("cameras"),
733
+ lights: count("lights"),
734
+ sensors: count("sensors"),
735
+ viewers: count("viewers"),
736
+ chimes: count("chimes"),
737
+ bridges: count("bridges"),
738
+ doorlocks: count("doorlocks")
739
+ },
740
+ liveviews: count("liveviews"),
741
+ users: count("users"),
742
+ lastUpdateId: bootstrap.lastUpdateId
743
+ };
744
+ };
745
+ //#endregion
746
+ //#region src/client/device-cache.ts
747
+ const createDeviceCache = (opts) => {
748
+ const now = opts.now ?? Date.now;
749
+ const ttlMs = opts.ttlSeconds * 1e3;
750
+ let cached;
751
+ let inflight;
752
+ const fetchIndex = async () => {
753
+ const cameras = await opts.client.get("cameras");
754
+ const index = buildNameIndex(cameras);
755
+ cached = {
756
+ index,
757
+ expiresAt: now() + ttlMs
758
+ };
759
+ return index;
760
+ };
761
+ return {
762
+ async cameras() {
763
+ if (cached && now() < cached.expiresAt) return cached.index;
764
+ if (!inflight) inflight = fetchIndex().finally(() => {
765
+ inflight = void 0;
766
+ });
767
+ try {
768
+ return await inflight;
769
+ } catch {
770
+ return /* @__PURE__ */ new Map();
771
+ }
772
+ },
773
+ invalidate() {
774
+ cached = void 0;
775
+ }
776
+ };
777
+ };
778
+ //#endregion
779
+ //#region src/client/protect.ts
780
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
781
+ const backoffMs = (attempt) => Math.min(1e3 * 2 ** attempt, 8e3);
782
+ const retryAfterMs = (res) => {
783
+ const value = res.headers.get("Retry-After");
784
+ if (value === null) return void 0;
785
+ const seconds = Number(value);
786
+ return Number.isFinite(seconds) ? Math.max(seconds, 0) * 1e3 : void 0;
787
+ };
788
+ const safeJsonParse = (text) => {
789
+ try {
790
+ return JSON.parse(text);
791
+ } catch {
792
+ return text;
793
+ }
794
+ };
795
+ const buildQuery = (query) => {
796
+ if (!query) return "";
797
+ const params = new URLSearchParams();
798
+ for (const [key, value] of Object.entries(query)) {
799
+ if (value === void 0) continue;
800
+ if (Array.isArray(value)) for (const item of value) params.append(key, item);
801
+ else params.append(key, String(value));
802
+ }
803
+ const qs = params.toString();
804
+ return qs ? `?${qs}` : "";
805
+ };
806
+ /**
807
+ * The private Protect API client. Every path passed in is relative to
808
+ * `/proxy/protect/api` — callers write `cameras/abc123`, not the full path.
809
+ */
810
+ var ProtectClient = class {
811
+ baseUrl;
812
+ session;
813
+ maxRetries;
814
+ userAgent;
815
+ maxDownloadBytes;
816
+ fetchImpl;
817
+ logger;
818
+ constructor(opts) {
819
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
820
+ this.session = opts.session;
821
+ this.maxRetries = opts.maxRetries;
822
+ this.userAgent = opts.userAgent;
823
+ this.maxDownloadBytes = opts.maxDownloadBytes;
824
+ this.fetchImpl = opts.fetch ?? fetch;
825
+ this.logger = opts.logger;
826
+ }
827
+ /** Absolute URL for a path relative to the private API root. */
828
+ url(path, query) {
829
+ const clean = path.replace(/^\/+/, "");
830
+ return `${this.baseUrl}${PRIVATE_API_PATH}/${clean}${buildQuery(query)}`;
831
+ }
832
+ /**
833
+ * Perform one authenticated request, retrying on 401 (re-login), 429 and 5xx.
834
+ * Returns the raw Response so callers can decide between JSON and bytes.
835
+ */
836
+ async send(method, url, opts = {}) {
837
+ const hasBody = opts.body !== void 0;
838
+ const bodyText = hasBody ? JSON.stringify(opts.body) : void 0;
839
+ let attempt = 0;
840
+ for (;;) {
841
+ const auth = await this.session.headers();
842
+ this.logger?.debug?.(`${method} ${url} (attempt ${attempt + 1})`);
843
+ const res = await this.fetchImpl(url, {
844
+ method,
845
+ headers: {
846
+ ...auth,
847
+ Accept: opts.accept ?? "application/json",
848
+ "User-Agent": this.userAgent,
849
+ ...hasBody ? { "Content-Type": "application/json" } : {}
850
+ },
851
+ ...bodyText !== void 0 ? { body: bodyText } : {},
852
+ redirect: "manual"
853
+ });
854
+ if (res.status === 401 && attempt < this.maxRetries) {
855
+ this.logger?.warn?.("HTTP 401 — re-authenticating and retrying");
856
+ this.session.invalidate();
857
+ attempt += 1;
858
+ continue;
859
+ }
860
+ if ((res.status === 429 || res.status >= 500) && attempt < this.maxRetries) {
861
+ const delay = retryAfterMs(res) ?? backoffMs(attempt);
862
+ this.logger?.warn?.(`HTTP ${res.status} — retrying in ${delay}ms`);
863
+ await sleep(delay);
864
+ attempt += 1;
865
+ continue;
866
+ }
867
+ return res;
868
+ }
869
+ }
870
+ /** A JSON request against the private API. */
871
+ async request(method, path, opts = {}) {
872
+ const url = this.url(path, opts.query);
873
+ const res = await this.send(method, url, opts.body !== void 0 ? { body: opts.body } : {});
874
+ const text = await res.text();
875
+ if (!res.ok) throw new ProtectApiError(this.errorMessage(res, method, path, text), {
876
+ status: res.status,
877
+ path,
878
+ errors: safeJsonParse(text)
879
+ });
880
+ if (res.status === 204 || text.trim() === "") return null;
881
+ return safeJsonParse(text);
882
+ }
883
+ get(path, query) {
884
+ return this.request("GET", path, query ? { query } : {});
885
+ }
886
+ post(path, body, query) {
887
+ return this.request("POST", path, {
888
+ ...body !== void 0 ? { body } : {},
889
+ ...query ? { query } : {}
890
+ });
891
+ }
892
+ patch(path, body, query) {
893
+ return this.request("PATCH", path, {
894
+ ...body !== void 0 ? { body } : {},
895
+ ...query ? { query } : {}
896
+ });
897
+ }
898
+ del(path, query) {
899
+ return this.request("DELETE", path, query ? { query } : {});
900
+ }
901
+ /**
902
+ * Fetch a binary asset — a snapshot JPEG, an event thumbnail, an exported
903
+ * MP4. Size is checked against `maxDownloadBytes` from Content-Length where
904
+ * the console supplies one, and again after reading where it does not, so an
905
+ * unexpectedly huge export fails with a clear message rather than by
906
+ * exhausting the heap.
907
+ */
908
+ async requestBytes(path, opts = {}) {
909
+ const url = this.url(path, opts.query);
910
+ const res = await this.send("GET", url, { accept: opts.accept ?? "application/octet-stream" });
911
+ if (!res.ok) {
912
+ const text = await res.text().catch(() => "");
913
+ throw new ProtectApiError(this.errorMessage(res, "GET", path, text), {
914
+ status: res.status,
915
+ path,
916
+ errors: safeJsonParse(text)
917
+ });
918
+ }
919
+ const declared = Number(res.headers.get("Content-Length"));
920
+ if (Number.isFinite(declared) && declared > this.maxDownloadBytes) throw new ProtectApiError(`${path} would return ${declared} bytes, over the ${this.maxDownloadBytes}-byte limit. Narrow the time range, or raise UNIFI_PROTECT_MAX_DOWNLOAD_BYTES.`, {
921
+ status: res.status,
922
+ path
923
+ });
924
+ const bytes = new Uint8Array(await res.arrayBuffer());
925
+ if (bytes.byteLength > this.maxDownloadBytes) throw new ProtectApiError(`${path} returned ${bytes.byteLength} bytes, over the ${this.maxDownloadBytes}-byte limit. Narrow the time range, or raise UNIFI_PROTECT_MAX_DOWNLOAD_BYTES.`, {
926
+ status: res.status,
927
+ path
928
+ });
929
+ return {
930
+ bytes,
931
+ contentType: res.headers.get("Content-Type") ?? "application/octet-stream"
932
+ };
933
+ }
934
+ /** Status-aware prose, because this is the text someone acts on. */
935
+ errorMessage(res, method, path, body) {
936
+ const detail = safeJsonParse(body);
937
+ const upstream = typeof detail === "object" && detail !== null && "error" in detail ? String(detail.error) : typeof detail === "string" && detail.length > 0 && detail.length < 200 ? detail : void 0;
938
+ const base = `Protect ${method} ${path} failed: HTTP ${res.status} ${res.statusText}`.trim() + (upstream ? ` — ${upstream}` : "");
939
+ if (res.status === 401 || res.status === 403) return `${base}. The account may lack Protect permissions for this device, or may be view-only while this call needs write access.`;
940
+ if (res.status === 404) return `${base}. Check the id came from a list tool on THIS console. Note also that the private Protect API is undocumented and Ubiquiti moves endpoints between releases — unifi_protect_get_system_info reports the version actually running.`;
941
+ if (res.status >= 300 && res.status < 400) return `${base}. The console redirected the request, which usually means the session was rejected.`;
942
+ return base;
943
+ }
944
+ };
945
+ //#endregion
946
+ //#region src/client/tls.ts
947
+ /**
948
+ * The one place TLS is decided.
949
+ *
950
+ * UniFi consoles ship a self-signed certificate, and Node's native `fetch`
951
+ * ignores a `node:https` Agent — the only scoped way to relax verification is an
952
+ * undici dispatcher. That is why this server carries a third runtime dependency:
953
+ * the alternative, NODE_TLS_REJECT_UNAUTHORIZED=0, is process-global and
954
+ * disables verification for every other request the process ever makes.
955
+ *
956
+ * This replaced exactly that process-wide switch, which had been adopted on the
957
+ * belief that undici could not be imported and a dispatcher therefore could not
958
+ * be scoped. It can — see below.
959
+ *
960
+ * **The `fetch` here must be undici's own, not the global one.** Node's built-in
961
+ * fetch is a *bundled copy* of undici, and it rejects a dispatcher constructed
962
+ * from the separately-installed package with a bare `UND_ERR_INVALID_ARG`,
963
+ * surfacing as an unexplained "fetch failed". Passing undici's own fetch keeps
964
+ * both sides on one class. Verification is on by default, so the platform fetch
965
+ * remains the common path.
966
+ *
967
+ * Note what a pinned certificate can and cannot fix. These consoles present
968
+ * `CN=unifi.local` with SANs for `unifi.local`, `localhost` and `127.0.0.1` —
969
+ * and **no IP SAN**. Reached by IP, verification fails on the host name however
970
+ * the certificate is trusted. Measured with `curl --cacert`: by host name
971
+ * `ssl_verify=0`, by IP `ssl_verify=1`. So verifying requires BOTH
972
+ * NODE_EXTRA_CA_CERTS and a host name that resolves to the console.
973
+ */
974
+ const createHttpFetch = (opts) => {
975
+ if (!opts.insecureTls) return fetch;
976
+ opts.logger?.warn?.("TLS certificate verification is DISABLED for this server's requests (UNIFI_PROTECT_VERIFY_TLS=false). To verify instead, address the console by a host name that resolves to it — its certificate has no IP SAN — and point NODE_EXTRA_CA_CERTS at the console's certificate.");
977
+ const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
978
+ return ((input, init) => fetch$1(input, {
979
+ ...init,
980
+ dispatcher
981
+ }));
982
+ };
983
+ //#endregion
984
+ //#region src/tools/util.ts
985
+ const ok = (data) => ({ content: [{
986
+ type: "text",
987
+ text: JSON.stringify(data ?? { ok: true }, null, 2)
988
+ }] });
989
+ const fail = (message, extra) => ({
990
+ content: [{
991
+ type: "text",
992
+ text: JSON.stringify({
993
+ error: message,
994
+ ...extra ? { details: extra } : {}
995
+ }, null, 2)
996
+ }],
997
+ isError: true
998
+ });
999
+ /** Render a thrown value as a tool error, preserving upstream detail. */
1000
+ const toFailure = (err) => {
1001
+ if (err instanceof ProtectApiError) return fail(err.message, {
1002
+ status: err.status,
1003
+ ...err.path ? { path: err.path } : {},
1004
+ errors: err.errors
1005
+ });
1006
+ if (err instanceof ProtectAuthError) return fail(err.message, err.needsTwoFactor ? { needsTwoFactor: true } : void 0);
1007
+ if (err instanceof WritesDisabledError || err instanceof NotConfiguredError) return fail(err.message);
1008
+ if (err instanceof Error) return fail(err.message);
1009
+ return fail("Unknown error", err);
1010
+ };
1011
+ /** Run a tool body, JSON-formatting the result and turning errors into a tool error. */
1012
+ const wrap = async (fn) => {
1013
+ try {
1014
+ return ok(await fn());
1015
+ } catch (err) {
1016
+ return toFailure(err);
1017
+ }
1018
+ };
1019
+ /** Like `wrap`, but the body chooses its own result shape (e.g. an image block). */
1020
+ const wrapResult = async (fn) => {
1021
+ try {
1022
+ return await fn();
1023
+ } catch (err) {
1024
+ return toFailure(err);
1025
+ }
1026
+ };
1027
+ /** Drop undefined values so we never send `{"name": undefined}` to the console. */
1028
+ const compact = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
1029
+ /** `{}` is not a no-op PATCH body — send undefined instead. */
1030
+ const compactOrUndefined = (obj) => {
1031
+ const out = compact(obj);
1032
+ return Object.keys(out).length > 0 ? out : void 0;
1033
+ };
1034
+ const RELATIVE = /^(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?|w|weeks?)(?:\s+ago)?$/i;
1035
+ const UNIT_MS = {
1036
+ s: 1e3,
1037
+ m: 6e4,
1038
+ h: 36e5,
1039
+ d: 864e5,
1040
+ w: 6048e5
1041
+ };
1042
+ /**
1043
+ * Convert a time expression to milliseconds since the Unix epoch.
1044
+ *
1045
+ * This exists because the console's `/events` endpoint takes JavaScript
1046
+ * millisecond timestamps — not ISO 8601, and NOT Unix seconds. A seconds value
1047
+ * is not rejected: it is interpreted as a moment in January 1970, so the query
1048
+ * succeeds and returns an empty list. That reads as "nothing happened last
1049
+ * night", which is the most expensive possible failure for this server.
1050
+ *
1051
+ * Accepts ISO 8601, a relative expression like "2h ago" or "30m", the literal
1052
+ * "now", or a raw millisecond number.
1053
+ */
1054
+ const toEpochMs = (value, now = Date.now()) => {
1055
+ if (typeof value === "number") return Math.round(value);
1056
+ const text = value.trim();
1057
+ if (text === "" || text.toLowerCase() === "now") return now;
1058
+ const relative = RELATIVE.exec(text);
1059
+ if (relative) {
1060
+ const amount = Number(relative[1]);
1061
+ const unit = relative[2].toLowerCase()[0];
1062
+ const scale = UNIT_MS[unit];
1063
+ if (scale === void 0) throw new Error(`Unrecognised time unit in "${value}".`);
1064
+ return Math.round(now - amount * scale);
1065
+ }
1066
+ if (/^\d+$/.test(text)) {
1067
+ const n = Number(text);
1068
+ if (text.length <= 10) throw new Error(`"${value}" looks like Unix SECONDS. This API takes milliseconds — pass ${n * 1e3}, or better, an ISO 8601 timestamp or a relative expression like "2h ago".`);
1069
+ return n;
1070
+ }
1071
+ const parsed = Date.parse(text);
1072
+ if (Number.isNaN(parsed)) throw new Error(`Could not read "${value}" as a time. Use ISO 8601 (2026-08-29T22:00:00Z), a relative expression ("2h ago", "30m", "7d"), or "now".`);
1073
+ return parsed;
1074
+ };
1075
+ const confirmArg = z.literal(true).describe("Must be true. Explicit acknowledgement that this changes the console's state.");
1076
+ const cameraIdArg = z.string().min(1).describe("Camera id — the `id` from unifi_protect_list_cameras, a 24-character hex string. Not the camera's name and not its MAC address.");
1077
+ /** The shared prose for every time argument, so the trap is stated everywhere it applies. */
1078
+ const timeHelp = (role) => `${role} Accepts ISO 8601 ("2026-08-29T22:00:00Z"), a relative expression ("2h ago", "30m", "7d"), or "now". Converted to the millisecond epoch the console requires — do not pass Unix seconds, which would silently query 1970 and return nothing.`;
1079
+ const timeArg = (role) => z.string().optional().describe(timeHelp(role));
1080
+ const requiredTimeArg = (role) => z.string().min(1).describe(timeHelp(role));
1081
+ const limitArg = z.number().int().min(1).max(500).default(50).describe("Maximum number of items to return (1-500). Defaults to 50. A busy system logs thousands of motion events a day, so raise this deliberately.");
1082
+ //#endregion
1083
+ //#region src/tools/cameras.ts
1084
+ const RECORDING_MODES = [
1085
+ "always",
1086
+ "never",
1087
+ "detections",
1088
+ "schedule"
1089
+ ];
1090
+ /** Filesystem-safe stem for a snapshot file, derived from the camera name. */
1091
+ const slug = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "camera";
1092
+ const registerCameraTools = (server, client, ctx) => {
1093
+ server.registerTool("unifi_protect_list_cameras", {
1094
+ description: "List every camera on the console with its id, name, connection state, recording mode, firmware and what it can do (PTZ, package camera, smart detection, and which object types it detects). Returns a summary rather than the console's full camera record, which runs to thousands of fields across encoder profiles, zones and feature flags — use unifi_protect_get_camera when you need all of it for one camera.",
1095
+ inputSchema: {},
1096
+ annotations: { readOnlyHint: true }
1097
+ }, async () => wrap(async () => summarizeEach(await client.get("cameras"), summarizeCamera)));
1098
+ server.registerTool("unifi_protect_get_camera", {
1099
+ description: "Get one camera's complete record — every setting the console holds, including encoder channels, motion and smart-detection zones, privacy masks, OSD and LED settings, ISP tuning and live statistics. This is large (roughly 8-15 KB of JSON). Prefer unifi_protect_list_cameras unless you specifically need a field it does not carry.",
1100
+ inputSchema: { cameraId: cameraIdArg },
1101
+ annotations: { readOnlyHint: true }
1102
+ }, async ({ cameraId }) => wrap(() => client.get(`cameras/${encodeURIComponent(cameraId)}`)));
1103
+ server.registerTool("unifi_protect_get_camera_snapshot", {
1104
+ description: "Capture a still frame from a camera as it looks right now. Writes the JPEG to disk and returns its path, size and content type by default. Set output=\"image\" to get the frame inline instead so a vision model can actually look at it — that costs roughly 300,000 to 700,000 characters of context per call, so choose it deliberately rather than by default. A fresh capture is forced; without that the console can hand back a cached frame that is minutes old.",
1105
+ inputSchema: {
1106
+ cameraId: cameraIdArg,
1107
+ output: z.enum(["file", "image"]).default("file").describe("Where the frame goes. \"file\" writes it to disk and returns the path — cheap, and you can read the file later if it turns out to matter. \"image\" returns it inline for a model to look at, at a large cost in context."),
1108
+ highQuality: z.boolean().default(false).describe("Request the camera's full resolution rather than a scaled frame. Larger and slower; with output=\"image\" it multiplies an already expensive call."),
1109
+ savePath: z.string().optional().describe("Absolute path to write the JPEG to. Defaults to a timestamped file under UNIFI_PROTECT_SNAPSHOT_DIR. Parent directories are created.")
1110
+ },
1111
+ annotations: { readOnlyHint: true }
1112
+ }, async ({ cameraId, output, highQuality, savePath }) => wrapResult(async () => {
1113
+ const { bytes, contentType } = await client.requestBytes(`cameras/${encodeURIComponent(cameraId)}/snapshot`, {
1114
+ query: {
1115
+ force: "true",
1116
+ ...highQuality ? { highQuality: "true" } : {},
1117
+ ts: Date.now()
1118
+ },
1119
+ accept: "image/jpeg"
1120
+ });
1121
+ if (output === "image") return { content: [{
1122
+ type: "image",
1123
+ data: Buffer.from(bytes).toString("base64"),
1124
+ mimeType: contentType.startsWith("image/") ? contentType : "image/jpeg"
1125
+ }] };
1126
+ const name = (await ctx.devices.cameras()).get(cameraId) ?? cameraId;
1127
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1128
+ const path = savePath ?? join(ctx.config.snapshotDir, `${slug(name)}-${stamp}.jpg`);
1129
+ await mkdir(join(path, ".."), { recursive: true });
1130
+ await writeFile(path, bytes);
1131
+ return ok({
1132
+ path,
1133
+ bytes: bytes.byteLength,
1134
+ contentType,
1135
+ camera: name,
1136
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
1137
+ note: "Call again with output=\"image\" if a model needs to see the frame itself."
1138
+ });
1139
+ }));
1140
+ server.registerTool("unifi_protect_list_ptz_presets", {
1141
+ description: "List a PTZ camera's saved preset positions, with the slot number each one lives at. Only meaningful for cameras reporting hasPtz: true in unifi_protect_list_cameras. There is no tool to MOVE a PTZ camera or run a patrol: those commands exist only on Ubiquiti's official Integration API (a separate X-API-KEY auth this server does not use), not on the private API this server wraps — presets are created and driven from the Protect app itself.",
1142
+ inputSchema: { cameraId: cameraIdArg },
1143
+ annotations: { readOnlyHint: true }
1144
+ }, async ({ cameraId }) => wrap(() => client.get(`cameras/${encodeURIComponent(cameraId)}/ptz/preset`)));
1145
+ server.registerTool("unifi_protect_list_ptz_patrols", {
1146
+ description: "List a PTZ camera's saved patrol routes. See unifi_protect_list_ptz_presets for why there is no tool to start or stop one.",
1147
+ inputSchema: { cameraId: cameraIdArg },
1148
+ annotations: { readOnlyHint: true }
1149
+ }, async ({ cameraId }) => wrap(() => client.get(`cameras/${encodeURIComponent(cameraId)}/ptz/patrol`)));
1150
+ if (!ctx.allowWrites) return;
1151
+ server.registerTool("unifi_protect_update_camera", {
1152
+ description: "Change a camera's settings in place. Only the fields you pass are sent, and the console merges them — sibling settings inside the same block are preserved, so setting osdDate alone does not clear osdName. Use unifi_protect_set_camera_recording_mode for recording mode alone — it is the setting people mean most often and it is easy to send wrongly here.",
1153
+ inputSchema: {
1154
+ cameraId: cameraIdArg,
1155
+ name: z.string().min(1).optional().describe("Display name shown throughout Protect."),
1156
+ micVolume: z.number().int().min(0).max(100).optional().describe("Microphone sensitivity, 0-100. 0 mutes the microphone."),
1157
+ isMicEnabled: z.boolean().optional().describe("Whether the microphone records at all."),
1158
+ statusLedEnabled: z.boolean().optional().describe("Whether the camera's status LED lights up at all."),
1159
+ osdName: z.boolean().optional().describe("Overlay the camera name on the video."),
1160
+ osdDate: z.boolean().optional().describe("Overlay the date and time on the video.")
1161
+ },
1162
+ annotations: {
1163
+ readOnlyHint: false,
1164
+ destructiveHint: false,
1165
+ idempotentHint: true
1166
+ }
1167
+ }, async ({ cameraId, name, micVolume, isMicEnabled, statusLedEnabled, osdName, osdDate }) => wrap(async () => {
1168
+ const ledSettings = compactOrUndefined({ isEnabled: statusLedEnabled });
1169
+ const osdSettings = compactOrUndefined({
1170
+ isNameEnabled: osdName,
1171
+ isDateEnabled: osdDate
1172
+ });
1173
+ const body = compactOrUndefined({
1174
+ name,
1175
+ micVolume,
1176
+ isMicEnabled,
1177
+ ledSettings,
1178
+ osdSettings
1179
+ });
1180
+ if (!body) throw new Error("Nothing to update — pass at least one of name, micVolume, isMicEnabled, statusLedEnabled, osdName, osdDate.");
1181
+ return summarizeCamera(await client.patch(`cameras/${encodeURIComponent(cameraId)}`, body));
1182
+ }));
1183
+ server.registerTool("unifi_protect_set_camera_recording_mode", {
1184
+ description: "Set what a camera records. `never` stops recording entirely — the camera stays online and streams live, but nothing is written, so there will be no footage to search later. `detections` records only motion and smart detections; `always` records continuously; `schedule` follows the schedule configured in Protect.",
1185
+ inputSchema: {
1186
+ cameraId: cameraIdArg,
1187
+ mode: z.enum(RECORDING_MODES).describe("Recording mode. `never` means no footage is kept from now on — this is the one worth pausing over.")
1188
+ },
1189
+ annotations: {
1190
+ readOnlyHint: false,
1191
+ destructiveHint: false,
1192
+ idempotentHint: true
1193
+ }
1194
+ }, async ({ cameraId, mode }) => wrap(async () => summarizeCamera(await client.patch(`cameras/${encodeURIComponent(cameraId)}`, { recordingSettings: { mode } }))));
1195
+ server.registerTool("unifi_protect_reboot_camera", {
1196
+ description: "Reboot a camera. It stops recording and goes offline for roughly a minute, and any footage during that window is lost. Useful for a camera that has stopped responding.",
1197
+ inputSchema: {
1198
+ cameraId: cameraIdArg,
1199
+ confirm: confirmArg
1200
+ },
1201
+ annotations: {
1202
+ readOnlyHint: false,
1203
+ destructiveHint: true,
1204
+ idempotentHint: false
1205
+ }
1206
+ }, async ({ cameraId }) => wrap(() => client.post(`cameras/${encodeURIComponent(cameraId)}/reboot`)));
1207
+ };
1208
+ //#endregion
1209
+ //#region src/tools/devices.ts
1210
+ const idArg = (kind, listTool) => z.string().min(1).describe(`${kind} id — the \`id\` from ${listTool}.`);
1211
+ const registerDeviceTools = (server, client, ctx) => {
1212
+ server.registerTool("unifi_protect_list_lights", {
1213
+ description: "List UniFi Protect floodlights with their connection state, whether the light is currently on, whether PIR motion is being detected, and brightness.",
1214
+ inputSchema: {},
1215
+ annotations: { readOnlyHint: true }
1216
+ }, async () => wrap(async () => summarizeEach(await client.get("lights"), summarizeLight)));
1217
+ server.registerTool("unifi_protect_list_sensors", {
1218
+ description: "List UniFi Protect sensors with their current readings — temperature, humidity, light level — plus open/closed state, motion, and battery percentage. The readings are lifted out of the console's per-metric history arrays, which are far larger than the values themselves.",
1219
+ inputSchema: {},
1220
+ annotations: { readOnlyHint: true }
1221
+ }, async () => wrap(async () => summarizeEach(await client.get("sensors"), summarizeSensor)));
1222
+ server.registerTool("unifi_protect_list_viewers", {
1223
+ description: "List UniFi Protect Viewport devices and which live view each is currently displaying.",
1224
+ inputSchema: {},
1225
+ annotations: { readOnlyHint: true }
1226
+ }, async () => wrap(async () => summarizeEach(await client.get("viewers"), summarizeViewer)));
1227
+ server.registerTool("unifi_protect_list_chimes", {
1228
+ description: "List UniFi Protect chimes, their volume, and which doorbell cameras each is paired to.",
1229
+ inputSchema: {},
1230
+ annotations: { readOnlyHint: true }
1231
+ }, async () => wrap(async () => summarizeEach(await client.get("chimes"), summarizeChime)));
1232
+ if (!ctx.allowWrites) return;
1233
+ server.registerTool("unifi_protect_update_light", {
1234
+ description: "Change a floodlight's settings — brightness, whether the light is on, and the PIR sensitivity that decides when it triggers. Only the fields you pass are sent, and the console merges them, so the PIR duration and lux sensitivity you do not pass survive.",
1235
+ inputSchema: {
1236
+ lightId: idArg("Light", "unifi_protect_list_lights"),
1237
+ isLightOn: z.boolean().optional().describe("Turn the light on or off right now."),
1238
+ ledLevel: z.number().int().min(1).max(6).optional().describe("Brightness, 1 (dimmest) to 6 (brightest)."),
1239
+ pirSensitivity: z.number().int().min(0).max(100).optional().describe("Motion sensitivity, 0-100. Higher triggers on smaller movement.")
1240
+ },
1241
+ annotations: {
1242
+ readOnlyHint: false,
1243
+ destructiveHint: false,
1244
+ idempotentHint: true
1245
+ }
1246
+ }, async ({ lightId, isLightOn, ledLevel, pirSensitivity }) => wrap(async () => {
1247
+ const lightOnSettings = compactOrUndefined({ isLedForceOn: isLightOn });
1248
+ const lightDeviceSettings = compactOrUndefined({
1249
+ ledLevel,
1250
+ pirSensitivity
1251
+ });
1252
+ const body = compactOrUndefined({
1253
+ lightOnSettings,
1254
+ lightDeviceSettings
1255
+ });
1256
+ if (!body) throw new Error("Nothing to update — pass at least one of isLightOn, ledLevel, pirSensitivity.");
1257
+ return summarizeLight(await client.patch(`lights/${encodeURIComponent(lightId)}`, body));
1258
+ }));
1259
+ server.registerTool("unifi_protect_update_sensor", {
1260
+ description: "Rename a sensor or change which of its capabilities are enabled. Only the fields you pass are sent.",
1261
+ inputSchema: {
1262
+ sensorId: idArg("Sensor", "unifi_protect_list_sensors"),
1263
+ name: z.string().min(1).optional().describe("Display name for the sensor."),
1264
+ motionEnabled: z.boolean().optional().describe("Whether motion detection reports events."),
1265
+ temperatureEnabled: z.boolean().optional().describe("Whether temperature is reported."),
1266
+ humidityEnabled: z.boolean().optional().describe("Whether humidity is reported."),
1267
+ lightEnabled: z.boolean().optional().describe("Whether the light level is reported.")
1268
+ },
1269
+ annotations: {
1270
+ readOnlyHint: false,
1271
+ destructiveHint: false,
1272
+ idempotentHint: true
1273
+ }
1274
+ }, async ({ sensorId, name, motionEnabled, temperatureEnabled, humidityEnabled, lightEnabled }) => wrap(async () => {
1275
+ const body = compactOrUndefined({
1276
+ name,
1277
+ motionSettings: compactOrUndefined({ isEnabled: motionEnabled }),
1278
+ temperatureSettings: compactOrUndefined({ isEnabled: temperatureEnabled }),
1279
+ humiditySettings: compactOrUndefined({ isEnabled: humidityEnabled }),
1280
+ lightSettings: compactOrUndefined({ isEnabled: lightEnabled })
1281
+ });
1282
+ if (!body) throw new Error("Nothing to update — pass at least one of name, motionEnabled, temperatureEnabled, humidityEnabled, lightEnabled.");
1283
+ return summarizeSensor(await client.patch(`sensors/${encodeURIComponent(sensorId)}`, body));
1284
+ }));
1285
+ server.registerTool("unifi_protect_update_viewer", {
1286
+ description: "Put a saved live view on a Viewport screen, or rename the viewer. The liveview id comes from unifi_protect_list_liveviews — this changes what is displayed on a physical screen, so someone watching will see it switch.",
1287
+ inputSchema: {
1288
+ viewerId: idArg("Viewer", "unifi_protect_list_viewers"),
1289
+ liveview: z.string().min(1).optional().describe("Live view id from unifi_protect_list_liveviews — the layout to display."),
1290
+ name: z.string().min(1).optional().describe("Display name for the viewer.")
1291
+ },
1292
+ annotations: {
1293
+ readOnlyHint: false,
1294
+ destructiveHint: false,
1295
+ idempotentHint: true
1296
+ }
1297
+ }, async ({ viewerId, liveview, name }) => wrap(async () => {
1298
+ const body = compactOrUndefined({
1299
+ liveview,
1300
+ name
1301
+ });
1302
+ if (!body) throw new Error("Nothing to update — pass liveview or name.");
1303
+ return summarizeViewer(await client.patch(`viewers/${encodeURIComponent(viewerId)}`, body));
1304
+ }));
1305
+ server.registerTool("unifi_protect_update_chime", {
1306
+ description: "Change a chime's volume or rename it. Volume 0 silences it, so a doorbell press will make no sound.",
1307
+ inputSchema: {
1308
+ chimeId: idArg("Chime", "unifi_protect_list_chimes"),
1309
+ volume: z.number().int().min(0).max(100).optional().describe("Volume, 0-100. 0 means the chime stays silent when the doorbell is pressed."),
1310
+ name: z.string().min(1).optional().describe("Display name for the chime.")
1311
+ },
1312
+ annotations: {
1313
+ readOnlyHint: false,
1314
+ destructiveHint: false,
1315
+ idempotentHint: true
1316
+ }
1317
+ }, async ({ chimeId, volume, name }) => wrap(async () => {
1318
+ const body = compactOrUndefined({
1319
+ volume,
1320
+ name
1321
+ });
1322
+ if (!body) throw new Error("Nothing to update — pass volume or name.");
1323
+ return summarizeChime(await client.patch(`chimes/${encodeURIComponent(chimeId)}`, body));
1324
+ }));
1325
+ };
1326
+ //#endregion
1327
+ //#region src/tools/events.ts
1328
+ /**
1329
+ * The event types worth searching. Protect emits far more — disk health,
1330
+ * firmware updates, adoption, connection churn — but they are noise in an
1331
+ * event search and swamp the interesting ones.
1332
+ */
1333
+ const EVENT_TYPES = [
1334
+ "motion",
1335
+ "smartDetectZone",
1336
+ "smartDetectLine",
1337
+ "smartAudioDetect",
1338
+ "ring",
1339
+ "doorAccess",
1340
+ "nfcCardScanned",
1341
+ "fingerprintIdentified",
1342
+ "disconnect",
1343
+ "cameraConnected",
1344
+ "cameraDisconnected",
1345
+ "recordingDeleted"
1346
+ ];
1347
+ /**
1348
+ * The default set: what a person means by "what happened". Motion, anything the
1349
+ * camera classified, and doorbell rings — with the device-health chatter left out.
1350
+ */
1351
+ const DEFAULT_EVENT_TYPES = [
1352
+ "motion",
1353
+ "smartDetectZone",
1354
+ "smartDetectLine",
1355
+ "smartAudioDetect",
1356
+ "ring"
1357
+ ];
1358
+ const SMART_DETECT_TYPES = [
1359
+ "person",
1360
+ "vehicle",
1361
+ "animal",
1362
+ "package",
1363
+ "licensePlate",
1364
+ "face",
1365
+ "alrmSmoke",
1366
+ "alrmCmonx",
1367
+ "alrmSiren",
1368
+ "alrmBabyCry",
1369
+ "alrmSpeak",
1370
+ "alrmBark",
1371
+ "alrmBurglar",
1372
+ "alrmCarHorn",
1373
+ "alrmGlassBreak"
1374
+ ];
1375
+ const registerEventTools = (server, client, ctx) => {
1376
+ server.registerTool("unifi_protect_list_events", {
1377
+ description: "Search recorded events over any time range — motion, smart detections (person, vehicle, animal, package, licence plate), doorbell rings, and camera connection changes. This is the tool for questions like \"what happened at the front door last night\". Each result carries its camera's NAME as well as its id, so no second lookup is needed. Narrow with `types`, `smartDetectTypes` and `cameraId` wherever you can: a busy system logs thousands of motion events a day, and an unfiltered query returns the newest slice of that rather than the interesting part.",
1378
+ inputSchema: {
1379
+ start: timeArg("Beginning of the search window."),
1380
+ end: timeArg("End of the search window."),
1381
+ types: z.array(z.enum(EVENT_TYPES)).optional().describe("Event types to include. Defaults to motion, smart detections and rings. `smartDetectZone` is the object-detection type — pair it with smartDetectTypes to ask for people or vehicles specifically."),
1382
+ smartDetectTypes: z.array(z.enum(SMART_DETECT_TYPES)).optional().describe("What the camera classified, e.g. [\"person\"] or [\"vehicle\",\"licensePlate\"]. Only meaningful for smartDetectZone / smartDetectLine events; the alrm* values are audio detections. Cameras without smart detection never produce these."),
1383
+ cameraId: cameraIdArg.optional().describe("Restrict to one camera — the `id` from unifi_protect_list_cameras. Omit for all cameras."),
1384
+ limit: limitArg,
1385
+ order: z.enum(["newest", "oldest"]).default("newest").describe("Which end of the window to return first.")
1386
+ },
1387
+ annotations: { readOnlyHint: true }
1388
+ }, async ({ start, end, types, smartDetectTypes, cameraId, limit, order }) => wrap(async () => {
1389
+ const endMs = end === void 0 ? Date.now() : toEpochMs(end);
1390
+ const startMs = start === void 0 ? endMs - 864e5 : toEpochMs(start);
1391
+ if (startMs >= endMs) throw new Error(`The window is empty: start (${new Date(startMs).toISOString()}) is not before end (${new Date(endMs).toISOString()}).`);
1392
+ const requestedTypes = types && types.length > 0 ? types : [...DEFAULT_EVENT_TYPES];
1393
+ const raw = await client.get("events", {
1394
+ start: startMs,
1395
+ end: endMs,
1396
+ limit,
1397
+ orderDirection: order === "newest" ? "DESC" : "ASC",
1398
+ types: [...requestedTypes],
1399
+ ...smartDetectTypes && smartDetectTypes.length > 0 ? { smartDetectTypes: [...smartDetectTypes] } : {},
1400
+ withoutDescriptions: "true"
1401
+ });
1402
+ const cameras = await ctx.devices.cameras();
1403
+ const events = Array.isArray(raw) ? raw : [];
1404
+ const shaped = events.filter((e) => typeof e === "object" && e !== null).filter((e) => cameraId === void 0 || e.camera === cameraId).map((e) => summarizeEvent(e, cameras));
1405
+ return {
1406
+ window: {
1407
+ start: new Date(startMs).toISOString(),
1408
+ end: new Date(endMs).toISOString()
1409
+ },
1410
+ types: requestedTypes,
1411
+ count: shaped.length,
1412
+ ...events.length >= limit ? {
1413
+ truncated: true,
1414
+ note: `Returned the ${order === "newest" ? "newest" : "oldest"} ${limit} events; more exist in this window. Narrow the range or raise limit.`
1415
+ } : {},
1416
+ events: shaped
1417
+ };
1418
+ }));
1419
+ server.registerTool("unifi_protect_get_event", {
1420
+ description: "Get one event's full record, including detection metadata the search results leave out — per-object tracking, detected zones, licence plate text and vehicle attributes where the camera captured them. Use the `id` from unifi_protect_list_events.",
1421
+ inputSchema: { eventId: z.string().min(1).describe("Event id — the `id` from unifi_protect_list_events.") },
1422
+ annotations: { readOnlyHint: true }
1423
+ }, async ({ eventId }) => wrap(() => client.get(`events/${encodeURIComponent(eventId)}`)));
1424
+ server.registerTool("unifi_protect_get_event_thumbnail", {
1425
+ description: "Fetch the still image Protect captured for an event — the frame that triggered the detection. Writes it to disk and returns the path by default; set output=\"image\" to return it inline for a vision model to look at, which costs a large amount of context. Pass the event's `id` from unifi_protect_list_events; results showing `hasThumbnail: true` have one.",
1426
+ inputSchema: {
1427
+ eventId: z.string().min(1).describe("Event id — the `id` from unifi_protect_list_events. Results with `hasThumbnail: true` have an image; others return 404. A raw `e-…` value from the console's own payload is also accepted."),
1428
+ output: z.enum(["file", "image"]).default("file").describe("\"file\" writes it to disk and returns the path; \"image\" returns it inline."),
1429
+ savePath: z.string().optional().describe("Absolute path to write the JPEG to. Defaults to a file under UNIFI_PROTECT_SNAPSHOT_DIR. Parent directories are created.")
1430
+ },
1431
+ annotations: { readOnlyHint: true }
1432
+ }, async ({ eventId, output, savePath }) => wrapResult(async () => {
1433
+ const id = eventId.startsWith("e-") ? eventId.slice(2) : eventId;
1434
+ const { bytes, contentType } = await client.requestBytes(`events/${encodeURIComponent(id)}/thumbnail`, { accept: "image/jpeg" });
1435
+ if (output === "image") return { content: [{
1436
+ type: "image",
1437
+ data: Buffer.from(bytes).toString("base64"),
1438
+ mimeType: contentType.startsWith("image/") ? contentType : "image/jpeg"
1439
+ }] };
1440
+ const path = savePath ?? join(ctx.config.snapshotDir, `event-${slugId(id)}.jpg`);
1441
+ await mkdir(join(path, ".."), { recursive: true });
1442
+ await writeFile(path, bytes);
1443
+ return ok({
1444
+ path,
1445
+ bytes: bytes.byteLength,
1446
+ contentType
1447
+ });
1448
+ }));
1449
+ server.registerTool("unifi_protect_export_video", {
1450
+ description: "Export recorded footage from one camera over a time range as an MP4 file on disk. Always writes to a file and returns the path — video is never returned inline. Size grows quickly with the window: expect tens of megabytes per minute at full quality, and the call fails rather than exhausting memory if the export exceeds UNIFI_PROTECT_MAX_DOWNLOAD_BYTES. Footage only exists if the camera was recording at the time, so check the recording mode before concluding that nothing happened.",
1451
+ inputSchema: {
1452
+ cameraId: cameraIdArg,
1453
+ start: requiredTimeArg("Beginning of the footage to export."),
1454
+ end: requiredTimeArg("End of the footage to export."),
1455
+ savePath: z.string().optional().describe("Absolute path to write the MP4 to. Defaults to a timestamped file under UNIFI_PROTECT_SNAPSHOT_DIR. Parent directories are created."),
1456
+ channel: z.number().int().min(0).max(3).default(0).describe("Encoder channel: 0 is the high-quality stream, higher numbers are progressively lower bitrate. Use a higher channel to keep a long export manageable.")
1457
+ },
1458
+ annotations: { readOnlyHint: true }
1459
+ }, async ({ cameraId, start, end, savePath, channel }) => wrap(async () => {
1460
+ const startMs = toEpochMs(start);
1461
+ const endMs = toEpochMs(end);
1462
+ if (startMs >= endMs) throw new Error(`The window is empty: start (${new Date(startMs).toISOString()}) is not before end (${new Date(endMs).toISOString()}).`);
1463
+ const { bytes, contentType } = await client.requestBytes("video/export", {
1464
+ query: {
1465
+ camera: cameraId,
1466
+ start: startMs,
1467
+ end: endMs,
1468
+ channel
1469
+ },
1470
+ accept: "video/mp4"
1471
+ });
1472
+ const name = (await ctx.devices.cameras()).get(cameraId) ?? cameraId;
1473
+ const stamp = new Date(startMs).toISOString().replace(/[:.]/g, "-");
1474
+ const path = savePath ?? join(ctx.config.snapshotDir, `${slugId(name)}-${stamp}.mp4`);
1475
+ await mkdir(join(path, ".."), { recursive: true });
1476
+ await writeFile(path, bytes);
1477
+ return {
1478
+ path,
1479
+ bytes: bytes.byteLength,
1480
+ contentType,
1481
+ camera: name,
1482
+ window: {
1483
+ start: new Date(startMs).toISOString(),
1484
+ end: new Date(endMs).toISOString()
1485
+ }
1486
+ };
1487
+ }));
1488
+ };
1489
+ const slugId = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "export";
1490
+ //#endregion
1491
+ //#region src/tools/request.ts
1492
+ /**
1493
+ * Reject anything that is not a plain relative path. The server decides the
1494
+ * host and the `/proxy/protect/api` prefix; letting a caller supply either
1495
+ * would send the console session somewhere it does not belong.
1496
+ */
1497
+ const assertSafePath = (path) => {
1498
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) throw new Error("`path` must be a path, not an absolute URL — the server sets the host.");
1499
+ if (path.split("/").includes("..")) throw new Error("`path` must not contain `..` segments.");
1500
+ };
1501
+ const registerRequestTool = (server, client, allowWrites) => {
1502
+ const methods = allowWrites ? [
1503
+ "GET",
1504
+ "POST",
1505
+ "PATCH",
1506
+ "DELETE"
1507
+ ] : ["GET"];
1508
+ server.registerTool("unifi_protect_request", {
1509
+ description: "Escape hatch: call any private Protect API endpoint directly, relative to /proxy/protect/api. This exists because the private API is undocumented and Ubiquiti moves endpoints between Protect releases — when a wrapped tool starts returning 404, this reaches the replacement without waiting for a new version of this server. Responses are returned RAW and unshaped, so a broad endpoint like `bootstrap` can return hundreds of kilobytes; prefer the wrapped tools, which summarize. " + (allowWrites ? "Writes are ENABLED, so POST, PATCH and DELETE are permitted — there is no confirmation step here, so check the path before you call it." : "Writes are DISABLED: only GET is permitted. Set UNIFI_PROTECT_ALLOW_WRITES=1 to allow mutations."),
1510
+ inputSchema: {
1511
+ path: z.string().min(1).describe("Path relative to /proxy/protect/api, without a leading slash, e.g. \"cameras\", \"nvr\", \"events/abc123\". Not an absolute URL."),
1512
+ method: z.enum(methods).default("GET").describe("HTTP method."),
1513
+ query: z.record(z.string(), z.string()).optional().describe("Query parameters as a flat string map, e.g. {\"start\":\"1756500000000\"}. Remember that Protect times are milliseconds since the epoch."),
1514
+ body: z.record(z.string(), z.unknown()).optional().describe("JSON request body, for POST and PATCH.")
1515
+ },
1516
+ annotations: {
1517
+ readOnlyHint: !allowWrites,
1518
+ destructiveHint: allowWrites
1519
+ }
1520
+ }, async ({ path, method, query, body }) => wrap(async () => {
1521
+ if (!allowWrites && method !== "GET") throw new WritesDisabledError(`unifi_protect_request with method ${method}`);
1522
+ assertSafePath(path);
1523
+ return client.request(method, path, {
1524
+ ...query ? { query } : {},
1525
+ ...body ? { body } : {}
1526
+ });
1527
+ }));
1528
+ };
1529
+ //#endregion
1530
+ //#region src/tools/status.ts
1531
+ const fileMode = (path) => {
1532
+ try {
1533
+ return statSync(path).mode & 511;
1534
+ } catch {
1535
+ return;
1536
+ }
1537
+ };
1538
+ const registerStatusTools = (server, client, ctx) => {
1539
+ server.registerTool("unifi_protect_auth_status", {
1540
+ description: "Check whether this server can actually reach your UniFi Protect console. By default it logs in and makes a real call, so the answer reflects the console rather than cached state — this is the tool to run when something is not working. Reports the host, the account, the Protect version, whether TLS is verified, and whether writes are enabled; when nothing is configured it returns the exact setup steps instead. Call this first when a tool you expected is not listed: an absent tool means missing configuration or writes being off, not a bug.",
1541
+ inputSchema: { probe: z.boolean().default(true).describe("Actually contact the console (logging in if needed) rather than only reporting what is already cached. Set false for a fast, purely local answer.") },
1542
+ annotations: { readOnlyHint: true }
1543
+ }, async ({ probe }) => wrap(async () => {
1544
+ if (!isConfigured(ctx.config)) return {
1545
+ configured: false,
1546
+ host: ctx.config.baseUrl ?? null,
1547
+ available_without_credentials: ["unifi_protect_auth_status"],
1548
+ setup: setupInstructions(ctx.config)
1549
+ };
1550
+ let reachable;
1551
+ let version;
1552
+ let failure;
1553
+ if (probe) try {
1554
+ const nvr = (await client.get("bootstrap"))?.nvr;
1555
+ version = typeof nvr === "object" && nvr !== null ? nvr.version : void 0;
1556
+ reachable = true;
1557
+ } catch (err) {
1558
+ reachable = false;
1559
+ failure = err instanceof Error ? err.message : String(err);
1560
+ }
1561
+ const status = ctx.session.describe();
1562
+ const mode = fileMode(ctx.config.sessionFile);
1563
+ return {
1564
+ configured: true,
1565
+ host: ctx.config.baseUrl,
1566
+ username: status.username ?? null,
1567
+ ...probe ? {
1568
+ reachable,
1569
+ ...version !== void 0 ? { protectVersion: version } : {},
1570
+ ...failure ? { failure } : {}
1571
+ } : {},
1572
+ session: {
1573
+ authenticated: status.authenticated,
1574
+ source: status.source,
1575
+ established_at: status.savedAt ?? null,
1576
+ file: ctx.config.sessionFile,
1577
+ file_mode: mode === void 0 ? "absent" : `0${mode.toString(8)}`,
1578
+ ...mode !== void 0 && (mode & 63) !== 0 ? { warning: `Readable by other users. Run: chmod 600 ${ctx.config.sessionFile}` } : {}
1579
+ },
1580
+ tls: ctx.config.verifyTls ? "verified" : "UNVERIFIED — certificate checks are off for this server's requests only. Verifying needs both NODE_EXTRA_CA_CERTS pointing at the console certificate and UNIFI_PROTECT_HOST set to a host name: the certificate carries no IP SAN.",
1581
+ writes: ctx.allowWrites ? "enabled" : "disabled",
1582
+ api: "private (undocumented). Ubiquiti moves these endpoints between Protect releases, so the Protect version reported above is the first thing to check if a tool that used to work starts returning 404."
1583
+ };
1584
+ }));
1585
+ if (!isConfigured(ctx.config)) return;
1586
+ server.registerTool("unifi_protect_auth_login", {
1587
+ description: "Force a fresh login to the console, replacing any cached session. Normally unnecessary — the server logs in on demand and re-authenticates automatically on a 401. Use it to supply a two-factor code, which cannot be done unattended: the code is single-use and expires in about 30 seconds, so it is passed here once and the resulting session is then cached and reused.",
1588
+ inputSchema: { totp: z.string().regex(/^\d{6,8}$/, "A two-factor code is 6 to 8 digits.").optional().describe("Current code from your authenticator app. Omit if the account has no 2FA.") },
1589
+ annotations: {
1590
+ readOnlyHint: false,
1591
+ destructiveHint: false,
1592
+ idempotentHint: false
1593
+ }
1594
+ }, async ({ totp }) => wrap(async () => {
1595
+ const status = await ctx.session.login(totp);
1596
+ return {
1597
+ authenticated: status.authenticated,
1598
+ username: status.username,
1599
+ established_at: status.savedAt,
1600
+ session_file: ctx.config.sessionFile,
1601
+ note: "The session is stored with mode 600 and reused until the console rejects it."
1602
+ };
1603
+ }));
1604
+ server.registerTool("unifi_protect_auth_logout", {
1605
+ description: "Drop the cached session and delete the session file. The next call logs in again from the configured username and password, so this does not lock anything out — use it to clear a session after changing accounts, or to remove the cookie from disk.",
1606
+ inputSchema: { confirm: confirmArg },
1607
+ annotations: {
1608
+ readOnlyHint: false,
1609
+ destructiveHint: true,
1610
+ idempotentHint: true
1611
+ }
1612
+ }, async () => wrap(async () => {
1613
+ await ctx.session.logout();
1614
+ return {
1615
+ logged_out: true,
1616
+ note: "Credentials are unchanged; the next tool call will log in again."
1617
+ };
1618
+ }));
1619
+ };
1620
+ //#endregion
1621
+ //#region src/tools/system.ts
1622
+ const registerSystemTools = (server, client, ctx) => {
1623
+ server.registerTool("unifi_protect_get_system_info", {
1624
+ description: "Overview of the console: model, Protect version, firmware, timezone, uptime, storage use and how many devices of each type are adopted. Worth calling first on an unfamiliar system. The reported Protect version matters: this server talks to Protect's private API, which Ubiquiti changes between releases, so a version that differs from the one in the README is the first thing to check if a tool starts returning 404.",
1625
+ inputSchema: {},
1626
+ annotations: { readOnlyHint: true }
1627
+ }, async () => wrap(async () => summarizeBootstrap(await client.get("bootstrap"))));
1628
+ server.registerTool("unifi_protect_list_users", {
1629
+ description: "List the accounts that can sign in to Protect, with their role and last login. Useful for auditing who has access to the cameras.",
1630
+ inputSchema: {},
1631
+ annotations: { readOnlyHint: true }
1632
+ }, async () => wrap(async () => summarizeEach(await client.get("users"), summarizeUser)));
1633
+ server.registerTool("unifi_protect_list_liveviews", {
1634
+ description: "List the saved live views — the named camera grid layouts shown on viewers and in the Protect app. The returned id is what unifi_protect_update_viewer needs to put a layout on a screen.",
1635
+ inputSchema: {},
1636
+ annotations: { readOnlyHint: true }
1637
+ }, async () => wrap(async () => summarizeEach(await client.get("liveviews"), summarizeLiveview)));
1638
+ if (!ctx.allowWrites) return;
1639
+ server.registerTool("unifi_protect_update_nvr_settings", {
1640
+ description: "Change console-wide settings. `isRecordingDisabled` is the significant one: turning it on stops recording on EVERY camera at once, so nothing is written until it is turned back off. Only the fields you pass are sent.",
1641
+ inputSchema: {
1642
+ name: z.string().min(1).optional().describe("Display name for the console."),
1643
+ timezone: z.string().min(1).optional().describe("IANA timezone, e.g. \"Europe/Paris\". Affects event timestamps and schedules."),
1644
+ isRecordingDisabled: z.boolean().optional().describe("Disable recording across every camera. True means no footage is kept, system-wide.")
1645
+ },
1646
+ annotations: {
1647
+ readOnlyHint: false,
1648
+ destructiveHint: false,
1649
+ idempotentHint: true
1650
+ }
1651
+ }, async ({ name, timezone, isRecordingDisabled }) => wrap(async () => {
1652
+ const body = compactOrUndefined({
1653
+ name,
1654
+ timezone,
1655
+ isRecordingDisabled
1656
+ });
1657
+ if (!body) throw new Error("Nothing to update — pass at least one of name, timezone, isRecordingDisabled.");
1658
+ return client.patch("nvr", body);
1659
+ }));
1660
+ server.registerTool("unifi_protect_reboot_nvr", {
1661
+ description: "REBOOT THE CONSOLE. Every camera stops recording for the two to five minutes it takes to come back, and footage from that window is lost permanently. This also drops the network if the console is your router (a UDM or UDM Pro), taking down everything behind it. Reboot a single unresponsive camera with unifi_protect_reboot_camera instead wherever that would do.",
1662
+ inputSchema: { confirm: confirmArg },
1663
+ annotations: {
1664
+ readOnlyHint: false,
1665
+ destructiveHint: true,
1666
+ idempotentHint: false
1667
+ }
1668
+ }, async () => wrap(() => client.post("nvr/reboot")));
1669
+ };
1670
+ //#endregion
1671
+ //#region src/tools/index.ts
1672
+ /**
1673
+ * Register the UniFi Protect tools.
1674
+ *
1675
+ * unifi_protect_auth_status comes first and unconditionally, so a server with no
1676
+ * console configured is still a useful one — it can say what to set — rather
1677
+ * than a connection that closes with its own error message swallowed.
1678
+ *
1679
+ * Read tools are then always registered; the write tools only when
1680
+ * `allowWrites` is set, so with the flag off they are not merely refused — they
1681
+ * are absent from tools/list and cannot be called at all. A refusal still lets a
1682
+ * model try, retry, and reason about how to get around it; a tool that does not
1683
+ * exist ends the conversation.
1684
+ */
1685
+ const registerTools = (server, client, ctx) => {
1686
+ registerStatusTools(server, client, ctx);
1687
+ if (!isConfigured(ctx.config)) return;
1688
+ registerSystemTools(server, client, ctx);
1689
+ registerCameraTools(server, client, ctx);
1690
+ registerEventTools(server, client, ctx);
1691
+ registerDeviceTools(server, client, ctx);
1692
+ registerRequestTool(server, client, ctx.allowWrites);
1693
+ };
1694
+ //#endregion
1695
+ //#region src/server.ts
1696
+ const SERVER_NAME = BUILD_INFO.name;
1697
+ const SERVER_VERSION = BUILD_INFO.version;
1698
+ const USER_AGENT = `mcp-unifi-protect-js/${BUILD_INFO.version}`;
1699
+ const createServer = (opts) => {
1700
+ const { config } = opts;
1701
+ const server = new McpServer({
1702
+ name: SERVER_NAME,
1703
+ version: SERVER_VERSION
1704
+ });
1705
+ const fetchImpl = opts.fetch ?? createHttpFetch({
1706
+ insecureTls: !config.verifyTls,
1707
+ ...opts.logger ? { logger: opts.logger } : {}
1708
+ });
1709
+ const session = opts.session ?? createSessionProvider({
1710
+ config,
1711
+ fetch: fetchImpl,
1712
+ ...opts.logger ? { logger: opts.logger } : {}
1713
+ });
1714
+ const client = new ProtectClient({
1715
+ baseUrl: config.baseUrl ?? "https://unconfigured.invalid",
1716
+ session,
1717
+ maxRetries: config.maxRetries,
1718
+ userAgent: USER_AGENT,
1719
+ maxDownloadBytes: config.maxDownloadBytes,
1720
+ fetch: fetchImpl,
1721
+ ...opts.logger ? { logger: opts.logger } : {}
1722
+ });
1723
+ const devices = createDeviceCache({
1724
+ client,
1725
+ ttlSeconds: config.deviceCacheTtlSeconds
1726
+ });
1727
+ registerTools(server, client, {
1728
+ config,
1729
+ allowWrites: config.allowWrites,
1730
+ session,
1731
+ devices
1732
+ });
1733
+ return {
1734
+ server,
1735
+ client,
1736
+ session,
1737
+ devices
1738
+ };
1739
+ };
1740
+ //#endregion
1741
+ export { saveSession as A, loadConfig as B, summarizeSensor as C, staticSessionProvider as D, createSessionProvider as E, LOGIN_PATH as F, BUILD_INFO as G, resolveConfigPath as H, PRIVATE_API_PATH as I, UPDATES_WS_PATH as L, ProtectApiError as M, ProtectAuthError as N, clearSession as O, WritesDisabledError as P, expandTilde as R, summarizeNvr as S, summarizeViewer as T, resolveSessionPath as U, normalizeBaseUrl as V, setupInstructions as W, summarizeChime as _, registerTools as a, summarizeLight as b, ProtectClient as c, retryAfterMs as d, createDeviceCache as f, summarizeCamera as g, summarizeBootstrap as h, createServer as i, NotConfiguredError as j, loadSession as k, backoffMs as l, isoTime as m, SERVER_VERSION as n, assertSafePath as o, buildNameIndex as p, USER_AGENT as r, toEpochMs as s, SERVER_NAME as t, buildQuery as u, summarizeEach as v, summarizeUser as w, summarizeLiveview as x, summarizeEvent as y, isConfigured as z };
1742
+
1743
+ //# sourceMappingURL=server-iu_3JECB.js.map