@mgcrea/mcp-unifi-protect 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/{server-B1wSHfi7.js → server-9inOsyBi.js} +137 -79
- package/dist/server-9inOsyBi.js.map +1 -0
- package/package.json +3 -2
- package/dist/server-B1wSHfi7.js.map +0 -1
package/README.md
CHANGED
|
@@ -261,7 +261,11 @@ Two prompts carry the procedure, which is the part a tool list cannot express:
|
|
|
261
261
|
- **`check_camera_settings`** — run the audit and interpret it, changing nothing. Several
|
|
262
262
|
findings have two valid opposite fixes, and which is right depends on what the camera is for.
|
|
263
263
|
- **`who_passed`** — find who was present in a window, and **fall back to motion frames on any
|
|
264
|
-
camera whose detector is off** rather than reporting a zero count as an absence.
|
|
264
|
+
camera whose detector is off** rather than reporting a zero count as an absence. It takes the
|
|
265
|
+
question as one free-text argument, so **quote it**: slash-command arguments are split
|
|
266
|
+
shell-style and mapped positionally, so `who_passed in front of the house last night?` arrives
|
|
267
|
+
as just `"in"`, while `who_passed "in front of the house last night?"` arrives whole. A
|
|
268
|
+
single-word question is treated as that truncation and refused rather than answered.
|
|
265
269
|
|
|
266
270
|
## Worked example: what happened at the front door last night
|
|
267
271
|
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { G as BUILD_INFO, L as setupInstructions, M as isConfigured, N as loadConfig, i as createServer } from "./server-
|
|
2
|
+
import { G as BUILD_INFO, L as setupInstructions, M as isConfigured, N as loadConfig, i as createServer } from "./server-9inOsyBi.js";
|
|
3
3
|
import { ZodError } from "zod";
|
|
4
|
-
import { StdioServerTransport } from "@modelcontextprotocol/
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
5
5
|
//#region src/cli.ts
|
|
6
6
|
const stderrLogger = {
|
|
7
7
|
debug: (...args) => {
|
package/dist/cli.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { StdioServerTransport } from \"@modelcontextprotocol/
|
|
1
|
+
{"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { StdioServerTransport } from \"@modelcontextprotocol/server/stdio\";\nimport { ZodError } from \"zod\";\n\nimport { BUILD_INFO } from \"#/build-info\";\nimport { isConfigured, loadConfig, setupInstructions } from \"#/config\";\nimport { createServer } from \"#/server\";\n\n// Everything goes to stderr: stdout is the MCP protocol channel, and a stray\n// log line there corrupts the JSON-RPC stream — usually failing the client's\n// next parse, far from the cause.\n// oxlint-disable no-console -- this is the process entry point; stderr is the log channel.\nconst stderrLogger = {\n debug: (...args: unknown[]) => {\n if (process.env.UNIFI_PROTECT_DEBUG) console.error(\"[unifi-protect-mcp]\", ...args);\n },\n warn: (...args: unknown[]) => console.error(\"[unifi-protect-mcp]\", ...args),\n error: (...args: unknown[]) => console.error(\"[unifi-protect-mcp]\", ...args),\n};\n\n/** Show a config mistake as its field messages, not 40 frames of zod internals. */\nconst describeFatal = (err: unknown): string => {\n if (err instanceof ZodError) {\n return err.issues\n .map((issue) => {\n const path = issue.path.join(\".\");\n return path ? `${path}: ${issue.message}` : issue.message;\n })\n .join(\"\\n\");\n }\n return err instanceof Error ? err.message : String(err);\n};\n\nconst main = async (): Promise<void> => {\n stderrLogger.warn(\n `${BUILD_INFO.name}@${BUILD_INFO.version} (git ${BUILD_INFO.gitCommit} ${BUILD_INFO.gitCommitDate}, node ${process.version})`,\n );\n\n const config = loadConfig();\n // Before anything can open a socket.\n\n const { server } = createServer({ config, logger: stderrLogger });\n const transport = new StdioServerTransport();\n await server.connect(transport);\n\n stderrLogger.warn(\n `unifi-protect-mcp connected (mode=${config.mode}, ` +\n (config.mode === \"cloud\"\n ? `console=${config.consoleId ?? \"MISSING\"}, auth=${config.apiKey ? \"api-key\" : \"MISSING\"}, `\n : `host=${config.baseUrl ?? \"MISSING\"}, user=${config.username ?? \"MISSING\"}, `) +\n `writes=${config.allowWrites ? \"ENABLED\" : \"disabled\"}, ` +\n `tls=${config.mode === \"cloud\" || config.verifyTls ? \"verified\" : \"UNVERIFIED\"})`,\n );\n\n // Connecting successfully but exposing one tool is confusing unless we say\n // why. The server no longer refuses to start over this, so the banner and\n // unifi_protect_auth_status are the only channels left.\n for (const issue of config.issues) stderrLogger.warn(` ${issue}`);\n\n if (!isConfigured(config)) {\n stderrLogger.warn(\" not configured — only unifi_protect_auth_status is available:\");\n for (const line of setupInstructions(config)) stderrLogger.warn(` ${line}`);\n stderrLogger.warn(\" Call unifi_protect_auth_status for this same guidance in your client.\");\n }\n\n const shutdown = (signal: string): void => {\n stderrLogger.warn(`received ${signal}, shutting down`);\n process.exit(0);\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n};\n\nmain().catch((err: unknown) => {\n console.error(`[unifi-protect-mcp] fatal: ${describeFatal(err)}`);\n if (process.env.UNIFI_PROTECT_DEBUG && err instanceof Error) console.error(err.stack);\n process.exit(1);\n});\n"],"mappings":";;;;;AAYA,MAAM,eAAe;CACnB,QAAQ,GAAG,SAAoB;EAC7B,IAAI,QAAQ,IAAI,qBAAqB,QAAQ,MAAM,uBAAuB,GAAG,IAAI;CACnF;CACA,OAAO,GAAG,SAAoB,QAAQ,MAAM,uBAAuB,GAAG,IAAI;CAC1E,QAAQ,GAAG,SAAoB,QAAQ,MAAM,uBAAuB,GAAG,IAAI;AAC7E;;AAGA,MAAM,iBAAiB,QAAyB;CAC9C,IAAI,eAAe,UACjB,OAAO,IAAI,OACR,KAAK,UAAU;EACd,MAAM,OAAO,MAAM,KAAK,KAAK,GAAG;EAChC,OAAO,OAAO,GAAG,KAAK,IAAI,MAAM,YAAY,MAAM;CACpD,CAAC,CAAC,CACD,KAAK,IAAI;CAEd,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AACxD;AAEA,MAAM,OAAO,YAA2B;CACtC,aAAa,KACX,GAAG,WAAW,KAAK,GAAG,WAAW,QAAQ,QAAQ,WAAW,UAAU,GAAG,WAAW,cAAc,SAAS,QAAQ,QAAQ,EAC7H;CAEA,MAAM,SAAS,WAAW;CAG1B,MAAM,EAAE,WAAW,aAAa;EAAE;EAAQ,QAAQ;CAAa,CAAC;CAChE,MAAM,YAAY,IAAI,qBAAqB;CAC3C,MAAM,OAAO,QAAQ,SAAS;CAE9B,aAAa,KACX,qCAAqC,OAAO,KAAK,OAC9C,OAAO,SAAS,UACb,WAAW,OAAO,aAAa,UAAU,SAAS,OAAO,SAAS,YAAY,UAAU,MACxF,QAAQ,OAAO,WAAW,UAAU,SAAS,OAAO,YAAY,UAAU,OAC9E,UAAU,OAAO,cAAc,YAAY,WAAW,QAC/C,OAAO,SAAS,WAAW,OAAO,YAAY,aAAa,aAAa,EACnF;CAKA,KAAK,MAAM,SAAS,OAAO,QAAQ,aAAa,KAAK,KAAK,OAAO;CAEjE,IAAI,CAAC,aAAa,MAAM,GAAG;EACzB,aAAa,KAAK,iEAAiE;EACnF,KAAK,MAAM,QAAQ,kBAAkB,MAAM,GAAG,aAAa,KAAK,KAAK,MAAM;EAC3E,aAAa,KAAK,yEAAyE;CAC7F;CAEA,MAAM,YAAY,WAAyB;EACzC,aAAa,KAAK,YAAY,OAAO,gBAAgB;EACrD,QAAQ,KAAK,CAAC;CAChB;CACA,QAAQ,GAAG,gBAAgB,SAAS,QAAQ,CAAC;CAC7C,QAAQ,GAAG,iBAAiB,SAAS,SAAS,CAAC;AACjD;AAEA,KAAK,CAAC,CAAC,OAAO,QAAiB;CAC7B,QAAQ,MAAM,8BAA8B,cAAc,GAAG,GAAG;CAChE,IAAI,QAAQ,IAAI,uBAAuB,eAAe,OAAO,QAAQ,MAAM,IAAI,KAAK;CACpF,QAAQ,KAAK,CAAC;AAChB,CAAC"}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as UPDATES_WS_PATH, B as saveSession, C as buildQuery, D as staticSessionProvider, E as createSessionProvider, F as resolveConfigPath, H as ProtectApiError, I as resolveSessionPath, L as setupInstructions, M as isConfigured, N as loadConfig, O as LOGIN_PATH, P as normalizeBaseUrl, R as clearSession, S as backoffMs, T as createDeviceCache, U as ProtectAuthError, V as NotConfiguredError, W as WritesDisabledError, _ as summarizeNvr, a as registerTools, b as summarizeViewer, c as buildNameIndex, d as summarizeCamera, f as summarizeChime, g as summarizeLiveview, h as summarizeLight, i as createServer, j as expandTilde, k as PRIVATE_API_PATH, l as isoTime, m as summarizeEvent, n as SERVER_VERSION, o as assertSafePath, p as summarizeEach, r as USER_AGENT, s as toEpochMs, t as SERVER_NAME, u as summarizeBootstrap, v as summarizeSensor, w as retryAfterMs, x as ProtectClient, y as summarizeUser, z as loadSession } from "./server-
|
|
1
|
+
import { A as UPDATES_WS_PATH, B as saveSession, C as buildQuery, D as staticSessionProvider, E as createSessionProvider, F as resolveConfigPath, H as ProtectApiError, I as resolveSessionPath, L as setupInstructions, M as isConfigured, N as loadConfig, O as LOGIN_PATH, P as normalizeBaseUrl, R as clearSession, S as backoffMs, T as createDeviceCache, U as ProtectAuthError, V as NotConfiguredError, W as WritesDisabledError, _ as summarizeNvr, a as registerTools, b as summarizeViewer, c as buildNameIndex, d as summarizeCamera, f as summarizeChime, g as summarizeLiveview, h as summarizeLight, i as createServer, j as expandTilde, k as PRIVATE_API_PATH, l as isoTime, m as summarizeEvent, n as SERVER_VERSION, o as assertSafePath, p as summarizeEach, r as USER_AGENT, s as toEpochMs, t as SERVER_NAME, u as summarizeBootstrap, v as summarizeSensor, w as retryAfterMs, x as ProtectClient, y as summarizeUser, z as loadSession } from "./server-9inOsyBi.js";
|
|
2
2
|
export { LOGIN_PATH, NotConfiguredError, PRIVATE_API_PATH, ProtectApiError, ProtectAuthError, ProtectClient, SERVER_NAME, SERVER_VERSION, UPDATES_WS_PATH, USER_AGENT, WritesDisabledError, assertSafePath, backoffMs, buildNameIndex, buildQuery, clearSession, createDeviceCache, createServer, createSessionProvider, expandTilde, isConfigured, isoTime, loadConfig, loadSession, normalizeBaseUrl, registerTools, resolveConfigPath, resolveSessionPath, retryAfterMs, saveSession, setupInstructions, staticSessionProvider, summarizeBootstrap, summarizeCamera, summarizeChime, summarizeEach, summarizeEvent, summarizeLight, summarizeLiveview, summarizeNvr, summarizeSensor, summarizeUser, summarizeViewer, toEpochMs };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { McpServer } from "@modelcontextprotocol/
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import { readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
@@ -21,8 +21,8 @@ const pkg = readPackageJson();
|
|
|
21
21
|
const BUILD_INFO = {
|
|
22
22
|
name: pkg.name,
|
|
23
23
|
version: pkg.version,
|
|
24
|
-
gitCommit: "
|
|
25
|
-
gitCommitDate: "2026-08-
|
|
24
|
+
gitCommit: "b8d2fd6",
|
|
25
|
+
gitCommitDate: "2026-08-30T21:31:50+02:00"
|
|
26
26
|
};
|
|
27
27
|
//#endregion
|
|
28
28
|
//#region src/client/errors.ts
|
|
@@ -1082,34 +1082,60 @@ const registerPrompts = (server) => {
|
|
|
1082
1082
|
server.registerPrompt("who_passed", {
|
|
1083
1083
|
title: "Who passed by",
|
|
1084
1084
|
description: "Find out who or what was present at a place during a time window, falling back to motion frames on cameras whose detectors are off.",
|
|
1085
|
-
argsSchema: {
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1085
|
+
argsSchema: z.object({ query: z.string().optional().describe("What to look for, in plain language — where and when, as you would say it. QUOTE IT: arguments are split shell-style, so /who_passed \"in front of the house last night\" arrives whole while the same words unquoted arrive as just \"in\". Leave empty for everything in the last 24 hours.") })
|
|
1086
|
+
}, ({ query }) => {
|
|
1087
|
+
const asked = query?.trim();
|
|
1088
|
+
const looksTruncated = asked !== void 0 && asked.length > 0 && !/\s/.test(asked);
|
|
1089
|
+
return text([
|
|
1090
|
+
asked ? `Find out who or what passed, per this question: "${asked}"` : "Find out who or what passed any camera in the last 24 hours.",
|
|
1091
|
+
...looksTruncated ? [
|
|
1092
|
+
"",
|
|
1093
|
+
"STOP — that is a single word, which usually means the question was cut off.",
|
|
1094
|
+
"Slash-command arguments are split shell-style and only the first word reaches",
|
|
1095
|
+
"here, so `who_passed in front of the house last night?` arrives as \"in\".",
|
|
1096
|
+
"Do not answer a fragment. Ask what was meant, and mention that quoting the",
|
|
1097
|
+
"sentence — who_passed \"in front of the house last night?\" — passes it whole."
|
|
1098
|
+
] : [],
|
|
1099
|
+
"",
|
|
1100
|
+
...asked ? [
|
|
1101
|
+
"Interpret that question yourself — it is one sentence of free text, and the",
|
|
1102
|
+
"place and period are in it. Do not pass it to a tool verbatim.",
|
|
1103
|
+
"",
|
|
1104
|
+
" * The PERIOD: `unifi_protect_list_events` takes local times directly",
|
|
1105
|
+
" (\"1am\", \"22:00\", \"2h ago\"), read in the console's own time zone, so",
|
|
1106
|
+
" translate the phrasing into start/end rather than converting by hand.",
|
|
1107
|
+
" \"This night\" or \"last night\" means roughly 22:00 to 08:00.",
|
|
1108
|
+
" * The PLACE: read `unifi-protect://locations` for the configured names and",
|
|
1109
|
+
" pass `location`. If it names nowhere that is configured, look at",
|
|
1110
|
+
" `unifi_protect_list_cameras`, choose the cameras that plausibly cover it,",
|
|
1111
|
+
" pass them as `cameraIds`, and SAY which you chose and that it was your",
|
|
1112
|
+
" inference — the console does not know where anything is.",
|
|
1113
|
+
""
|
|
1114
|
+
] : [
|
|
1115
|
+
"No question was given, so this covers every camera over the last 24 hours. If",
|
|
1116
|
+
"a narrower period or place was meant, say so.",
|
|
1117
|
+
""
|
|
1118
|
+
],
|
|
1119
|
+
"1. Search with `unifi_protect_list_events`, types `[\"smartDetectZone\"]` and",
|
|
1120
|
+
" smartDetectTypes [\"person\"], over the window and cameras you settled on.",
|
|
1121
|
+
"2. READ THE `warnings` FIELD BEFORE REPORTING ANYTHING. A camera with person",
|
|
1122
|
+
" detection disabled contributes zero matches, and zero is NOT evidence that nobody",
|
|
1123
|
+
" was there. If any warning says a detector is off, you have not answered the",
|
|
1124
|
+
" question yet — continue to step 3 for those cameras.",
|
|
1125
|
+
"3. For each camera whose detector was off, search the same window again with types",
|
|
1126
|
+
" `[\"motion\"]` restricted to that camera, then call",
|
|
1127
|
+
" `unifi_protect_get_event_thumbnails` on the results and LOOK at the frames. This",
|
|
1128
|
+
" is the only way to tell a person from a branch on such a camera.",
|
|
1129
|
+
"4. Report each sighting with its LOCAL time and camera name, and say how it was",
|
|
1130
|
+
" established — classified by the camera, or seen by you in a frame. Keep those",
|
|
1131
|
+
" two apart: one is the console's judgement and one is yours.",
|
|
1132
|
+
"5. Say plainly what was NOT covered: cameras that were offline, not recording, or",
|
|
1133
|
+
" whose motion zone excludes part of the scene. A thumbnail is the triggering frame",
|
|
1134
|
+
" only, so someone entering later in a clip does not appear in it. The warnings in",
|
|
1135
|
+
" step 2 describe each camera's setting NOW — if the window reaches back before a",
|
|
1136
|
+
" recent settings change, a detector may have been off then with nothing to say so."
|
|
1137
|
+
].join("\n"));
|
|
1138
|
+
});
|
|
1113
1139
|
};
|
|
1114
1140
|
//#endregion
|
|
1115
1141
|
//#region src/client/locations.ts
|
|
@@ -1734,8 +1760,9 @@ const auditCameras = (cameras, nvr) => {
|
|
|
1734
1760
|
};
|
|
1735
1761
|
const registerAuditTools = (server, client, _ctx) => {
|
|
1736
1762
|
server.registerTool("unifi_protect_check_settings", {
|
|
1763
|
+
title: "UniFi Protect: Check Settings",
|
|
1737
1764
|
description: "Check every camera and the console for settings that are inconsistent, or that mean the system is not doing what someone believes it is. Finds detectors that look enabled but are gated off, cameras keeping no footage, offline devices, motion detection switched off, and storage about to stop recording. This is the tool for \"are my camera settings correct\" — the checks encode traps that are invisible in the Protect UI, notably a detection zone asking for an object type the device list blocks. Read-only: it reports findings and names the tool that would fix each one, and changes nothing.",
|
|
1738
|
-
inputSchema: {},
|
|
1765
|
+
inputSchema: z.object({}),
|
|
1739
1766
|
annotations: { readOnlyHint: true }
|
|
1740
1767
|
}, async () => wrap(async () => {
|
|
1741
1768
|
const [rawCameras, nvr] = await Promise.all([client.get("cameras"), client.get("nvr")]);
|
|
@@ -1770,23 +1797,26 @@ const RECORDING_MODES = [
|
|
|
1770
1797
|
const slug = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "camera";
|
|
1771
1798
|
const registerCameraTools = (server, client, ctx) => {
|
|
1772
1799
|
server.registerTool("unifi_protect_list_cameras", {
|
|
1800
|
+
title: "UniFi Protect: List Cameras",
|
|
1773
1801
|
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.",
|
|
1774
|
-
inputSchema: {},
|
|
1802
|
+
inputSchema: z.object({}),
|
|
1775
1803
|
annotations: { readOnlyHint: true }
|
|
1776
1804
|
}, async () => wrap(async () => summarizeEach(await client.get("cameras"), summarizeCamera)));
|
|
1777
1805
|
server.registerTool("unifi_protect_get_camera", {
|
|
1806
|
+
title: "UniFi Protect: Get Camera",
|
|
1778
1807
|
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.",
|
|
1779
|
-
inputSchema: { cameraId: cameraIdArg },
|
|
1808
|
+
inputSchema: z.object({ cameraId: cameraIdArg }),
|
|
1780
1809
|
annotations: { readOnlyHint: true }
|
|
1781
1810
|
}, async ({ cameraId }) => wrap(() => client.get(`cameras/${encodeURIComponent(cameraId)}`)));
|
|
1782
1811
|
server.registerTool("unifi_protect_get_camera_snapshot", {
|
|
1812
|
+
title: "UniFi Protect: Get Camera Snapshot",
|
|
1783
1813
|
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.",
|
|
1784
|
-
inputSchema: {
|
|
1814
|
+
inputSchema: z.object({
|
|
1785
1815
|
cameraId: cameraIdArg,
|
|
1786
1816
|
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."),
|
|
1787
1817
|
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."),
|
|
1788
1818
|
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.")
|
|
1789
|
-
},
|
|
1819
|
+
}),
|
|
1790
1820
|
annotations: { readOnlyHint: true }
|
|
1791
1821
|
}, async ({ cameraId, output, highQuality, savePath }) => wrapResult(async () => {
|
|
1792
1822
|
const { bytes, contentType } = await client.requestBytes(`cameras/${encodeURIComponent(cameraId)}/snapshot`, {
|
|
@@ -1817,19 +1847,22 @@ const registerCameraTools = (server, client, ctx) => {
|
|
|
1817
1847
|
});
|
|
1818
1848
|
}));
|
|
1819
1849
|
server.registerTool("unifi_protect_list_ptz_presets", {
|
|
1850
|
+
title: "UniFi Protect: List PTZ Presets",
|
|
1820
1851
|
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.",
|
|
1821
|
-
inputSchema: { cameraId: cameraIdArg },
|
|
1852
|
+
inputSchema: z.object({ cameraId: cameraIdArg }),
|
|
1822
1853
|
annotations: { readOnlyHint: true }
|
|
1823
1854
|
}, async ({ cameraId }) => wrap(() => client.get(`cameras/${encodeURIComponent(cameraId)}/ptz/preset`)));
|
|
1824
1855
|
server.registerTool("unifi_protect_list_ptz_patrols", {
|
|
1856
|
+
title: "UniFi Protect: List PTZ Patrols",
|
|
1825
1857
|
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.",
|
|
1826
|
-
inputSchema: { cameraId: cameraIdArg },
|
|
1858
|
+
inputSchema: z.object({ cameraId: cameraIdArg }),
|
|
1827
1859
|
annotations: { readOnlyHint: true }
|
|
1828
1860
|
}, async ({ cameraId }) => wrap(() => client.get(`cameras/${encodeURIComponent(cameraId)}/ptz/patrol`)));
|
|
1829
1861
|
if (!ctx.allowWrites) return;
|
|
1830
1862
|
server.registerTool("unifi_protect_update_camera", {
|
|
1863
|
+
title: "UniFi Protect: Update Camera",
|
|
1831
1864
|
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.",
|
|
1832
|
-
inputSchema: {
|
|
1865
|
+
inputSchema: z.object({
|
|
1833
1866
|
cameraId: cameraIdArg,
|
|
1834
1867
|
name: z.string().min(1).optional().describe("Display name shown throughout Protect."),
|
|
1835
1868
|
micVolume: z.number().int().min(0).max(100).optional().describe("Microphone sensitivity, 0-100. 0 mutes the microphone."),
|
|
@@ -1837,7 +1870,7 @@ const registerCameraTools = (server, client, ctx) => {
|
|
|
1837
1870
|
statusLedEnabled: z.boolean().optional().describe("Whether the camera's status LED lights up at all."),
|
|
1838
1871
|
osdName: z.boolean().optional().describe("Overlay the camera name on the video."),
|
|
1839
1872
|
osdDate: z.boolean().optional().describe("Overlay the date and time on the video.")
|
|
1840
|
-
},
|
|
1873
|
+
}),
|
|
1841
1874
|
annotations: {
|
|
1842
1875
|
readOnlyHint: false,
|
|
1843
1876
|
destructiveHint: false,
|
|
@@ -1860,13 +1893,14 @@ const registerCameraTools = (server, client, ctx) => {
|
|
|
1860
1893
|
return summarizeCamera(await client.patch(`cameras/${encodeURIComponent(cameraId)}`, body));
|
|
1861
1894
|
}));
|
|
1862
1895
|
server.registerTool("unifi_protect_set_camera_detections", {
|
|
1896
|
+
title: "UniFi Protect: Set Camera Detections",
|
|
1863
1897
|
description: "Turn a camera's smart detections on or off — which objects it looks for (person, vehicle, animal, package) and which sounds it listens for. THIS is the setting that decides whether a person search can ever match: a detection zone may list `person` while the device list omits it, and the console then reports nothing at all, with no error. Pass objectTypes to set the device list, and leave syncZones true so the zones are brought into line rather than left making a promise the device does not keep. Values the camera does not support are rejected by name rather than silently dropped.",
|
|
1864
|
-
inputSchema: {
|
|
1898
|
+
inputSchema: z.object({
|
|
1865
1899
|
cameraId: cameraIdArg,
|
|
1866
1900
|
objectTypes: z.array(z.enum(GATED_OBJECT_TYPES)).optional().describe("What the camera should detect. This REPLACES the current list, so include everything you want kept. Check the camera's supported types with unifi_protect_list_cameras first; `package` only works on a doorbell's second lens, which the console handles on its own."),
|
|
1867
1901
|
audioTypes: z.array(z.string().min(1)).optional().describe("Audio detections to enable, e.g. [\"alrmSmoke\",\"alrmSpeak\"]. Replaces the current list. `alrmSpeak` catches conversation, which is often the only signal on a camera with object detection off."),
|
|
1868
1902
|
syncZones: z.boolean().default(true).describe("Also rewrite the detection zones to match objectTypes. Leave this true: a zone asking for a type the device list blocks is the misconfiguration this tool exists to prevent.")
|
|
1869
|
-
},
|
|
1903
|
+
}),
|
|
1870
1904
|
annotations: {
|
|
1871
1905
|
readOnlyHint: false,
|
|
1872
1906
|
destructiveHint: false,
|
|
@@ -1910,11 +1944,12 @@ const registerCameraTools = (server, client, ctx) => {
|
|
|
1910
1944
|
};
|
|
1911
1945
|
}));
|
|
1912
1946
|
server.registerTool("unifi_protect_set_camera_recording_mode", {
|
|
1947
|
+
title: "UniFi Protect: Set Camera Recording Mode",
|
|
1913
1948
|
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.",
|
|
1914
|
-
inputSchema: {
|
|
1949
|
+
inputSchema: z.object({
|
|
1915
1950
|
cameraId: cameraIdArg,
|
|
1916
1951
|
mode: z.enum(RECORDING_MODES).describe("Recording mode. `never` means no footage is kept from now on — this is the one worth pausing over.")
|
|
1917
|
-
},
|
|
1952
|
+
}),
|
|
1918
1953
|
annotations: {
|
|
1919
1954
|
readOnlyHint: false,
|
|
1920
1955
|
destructiveHint: false,
|
|
@@ -1922,11 +1957,12 @@ const registerCameraTools = (server, client, ctx) => {
|
|
|
1922
1957
|
}
|
|
1923
1958
|
}, async ({ cameraId, mode }) => wrap(async () => summarizeCamera(await client.patch(`cameras/${encodeURIComponent(cameraId)}`, { recordingSettings: { mode } }))));
|
|
1924
1959
|
server.registerTool("unifi_protect_reboot_camera", {
|
|
1960
|
+
title: "UniFi Protect: Reboot Camera",
|
|
1925
1961
|
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.",
|
|
1926
|
-
inputSchema: {
|
|
1962
|
+
inputSchema: z.object({
|
|
1927
1963
|
cameraId: cameraIdArg,
|
|
1928
1964
|
confirm: confirmArg
|
|
1929
|
-
},
|
|
1965
|
+
}),
|
|
1930
1966
|
annotations: {
|
|
1931
1967
|
readOnlyHint: false,
|
|
1932
1968
|
destructiveHint: true,
|
|
@@ -1939,34 +1975,39 @@ const registerCameraTools = (server, client, ctx) => {
|
|
|
1939
1975
|
const idArg = (kind, listTool) => z.string().min(1).describe(`${kind} id — the \`id\` from ${listTool}.`);
|
|
1940
1976
|
const registerDeviceTools = (server, client, ctx) => {
|
|
1941
1977
|
server.registerTool("unifi_protect_list_lights", {
|
|
1978
|
+
title: "UniFi Protect: List Lights",
|
|
1942
1979
|
description: "List UniFi Protect floodlights with their connection state, whether the light is currently on, whether PIR motion is being detected, and brightness.",
|
|
1943
|
-
inputSchema: {},
|
|
1980
|
+
inputSchema: z.object({}),
|
|
1944
1981
|
annotations: { readOnlyHint: true }
|
|
1945
1982
|
}, async () => wrap(async () => summarizeEach(await client.get("lights"), summarizeLight)));
|
|
1946
1983
|
server.registerTool("unifi_protect_list_sensors", {
|
|
1984
|
+
title: "UniFi Protect: List Sensors",
|
|
1947
1985
|
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.",
|
|
1948
|
-
inputSchema: {},
|
|
1986
|
+
inputSchema: z.object({}),
|
|
1949
1987
|
annotations: { readOnlyHint: true }
|
|
1950
1988
|
}, async () => wrap(async () => summarizeEach(await client.get("sensors"), summarizeSensor)));
|
|
1951
1989
|
server.registerTool("unifi_protect_list_viewers", {
|
|
1990
|
+
title: "UniFi Protect: List Viewers",
|
|
1952
1991
|
description: "List UniFi Protect Viewport devices and which live view each is currently displaying.",
|
|
1953
|
-
inputSchema: {},
|
|
1992
|
+
inputSchema: z.object({}),
|
|
1954
1993
|
annotations: { readOnlyHint: true }
|
|
1955
1994
|
}, async () => wrap(async () => summarizeEach(await client.get("viewers"), summarizeViewer)));
|
|
1956
1995
|
server.registerTool("unifi_protect_list_chimes", {
|
|
1996
|
+
title: "UniFi Protect: List Chimes",
|
|
1957
1997
|
description: "List UniFi Protect chimes, their volume, and which doorbell cameras each is paired to.",
|
|
1958
|
-
inputSchema: {},
|
|
1998
|
+
inputSchema: z.object({}),
|
|
1959
1999
|
annotations: { readOnlyHint: true }
|
|
1960
2000
|
}, async () => wrap(async () => summarizeEach(await client.get("chimes"), summarizeChime)));
|
|
1961
2001
|
if (!ctx.allowWrites) return;
|
|
1962
2002
|
server.registerTool("unifi_protect_update_light", {
|
|
2003
|
+
title: "UniFi Protect: Update Light",
|
|
1963
2004
|
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.",
|
|
1964
|
-
inputSchema: {
|
|
2005
|
+
inputSchema: z.object({
|
|
1965
2006
|
lightId: idArg("Light", "unifi_protect_list_lights"),
|
|
1966
2007
|
isLightOn: z.boolean().optional().describe("Turn the light on or off right now."),
|
|
1967
2008
|
ledLevel: z.number().int().min(1).max(6).optional().describe("Brightness, 1 (dimmest) to 6 (brightest)."),
|
|
1968
2009
|
pirSensitivity: z.number().int().min(0).max(100).optional().describe("Motion sensitivity, 0-100. Higher triggers on smaller movement.")
|
|
1969
|
-
},
|
|
2010
|
+
}),
|
|
1970
2011
|
annotations: {
|
|
1971
2012
|
readOnlyHint: false,
|
|
1972
2013
|
destructiveHint: false,
|
|
@@ -1986,15 +2027,16 @@ const registerDeviceTools = (server, client, ctx) => {
|
|
|
1986
2027
|
return summarizeLight(await client.patch(`lights/${encodeURIComponent(lightId)}`, body));
|
|
1987
2028
|
}));
|
|
1988
2029
|
server.registerTool("unifi_protect_update_sensor", {
|
|
2030
|
+
title: "UniFi Protect: Update Sensor",
|
|
1989
2031
|
description: "Rename a sensor or change which of its capabilities are enabled. Only the fields you pass are sent.",
|
|
1990
|
-
inputSchema: {
|
|
2032
|
+
inputSchema: z.object({
|
|
1991
2033
|
sensorId: idArg("Sensor", "unifi_protect_list_sensors"),
|
|
1992
2034
|
name: z.string().min(1).optional().describe("Display name for the sensor."),
|
|
1993
2035
|
motionEnabled: z.boolean().optional().describe("Whether motion detection reports events."),
|
|
1994
2036
|
temperatureEnabled: z.boolean().optional().describe("Whether temperature is reported."),
|
|
1995
2037
|
humidityEnabled: z.boolean().optional().describe("Whether humidity is reported."),
|
|
1996
2038
|
lightEnabled: z.boolean().optional().describe("Whether the light level is reported.")
|
|
1997
|
-
},
|
|
2039
|
+
}),
|
|
1998
2040
|
annotations: {
|
|
1999
2041
|
readOnlyHint: false,
|
|
2000
2042
|
destructiveHint: false,
|
|
@@ -2012,12 +2054,13 @@ const registerDeviceTools = (server, client, ctx) => {
|
|
|
2012
2054
|
return summarizeSensor(await client.patch(`sensors/${encodeURIComponent(sensorId)}`, body));
|
|
2013
2055
|
}));
|
|
2014
2056
|
server.registerTool("unifi_protect_update_viewer", {
|
|
2057
|
+
title: "UniFi Protect: Update Viewer",
|
|
2015
2058
|
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.",
|
|
2016
|
-
inputSchema: {
|
|
2059
|
+
inputSchema: z.object({
|
|
2017
2060
|
viewerId: idArg("Viewer", "unifi_protect_list_viewers"),
|
|
2018
2061
|
liveview: z.string().min(1).optional().describe("Live view id from unifi_protect_list_liveviews — the layout to display."),
|
|
2019
2062
|
name: z.string().min(1).optional().describe("Display name for the viewer.")
|
|
2020
|
-
},
|
|
2063
|
+
}),
|
|
2021
2064
|
annotations: {
|
|
2022
2065
|
readOnlyHint: false,
|
|
2023
2066
|
destructiveHint: false,
|
|
@@ -2032,12 +2075,13 @@ const registerDeviceTools = (server, client, ctx) => {
|
|
|
2032
2075
|
return summarizeViewer(await client.patch(`viewers/${encodeURIComponent(viewerId)}`, body));
|
|
2033
2076
|
}));
|
|
2034
2077
|
server.registerTool("unifi_protect_update_chime", {
|
|
2078
|
+
title: "UniFi Protect: Update Chime",
|
|
2035
2079
|
description: "Change a chime's volume or rename it. Volume 0 silences it, so a doorbell press will make no sound.",
|
|
2036
|
-
inputSchema: {
|
|
2080
|
+
inputSchema: z.object({
|
|
2037
2081
|
chimeId: idArg("Chime", "unifi_protect_list_chimes"),
|
|
2038
2082
|
volume: z.number().int().min(0).max(100).optional().describe("Volume, 0-100. 0 means the chime stays silent when the doorbell is pressed."),
|
|
2039
2083
|
name: z.string().min(1).optional().describe("Display name for the chime.")
|
|
2040
|
-
},
|
|
2084
|
+
}),
|
|
2041
2085
|
annotations: {
|
|
2042
2086
|
readOnlyHint: false,
|
|
2043
2087
|
destructiveHint: false,
|
|
@@ -2103,8 +2147,9 @@ const SMART_DETECT_TYPES = [
|
|
|
2103
2147
|
];
|
|
2104
2148
|
const registerEventTools = (server, client, ctx) => {
|
|
2105
2149
|
server.registerTool("unifi_protect_list_events", {
|
|
2150
|
+
title: "UniFi Protect: List Events",
|
|
2106
2151
|
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. Times may be given in the console's own local clock (\"1am\"), which is what a question about last night means. READ ANY `warnings` IN THE RESULT BEFORE REPORTING A COUNT: a camera with the detector switched off returns zero matches, which is not the same as nothing having happened, and this tool says which case it is. Narrow with `types`, `smartDetectTypes` and `cameraIds` wherever you can: a busy system logs thousands of motion events a day.",
|
|
2107
|
-
inputSchema: {
|
|
2152
|
+
inputSchema: z.object({
|
|
2108
2153
|
start: timeArg("Beginning of the search window."),
|
|
2109
2154
|
end: timeArg("End of the search window."),
|
|
2110
2155
|
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."),
|
|
@@ -2114,7 +2159,7 @@ const registerEventTools = (server, client, ctx) => {
|
|
|
2114
2159
|
location: z.string().optional().describe("A configured place name, e.g. \"front\". Resolves to the cameras covering it — the console has no idea where anything is, so this comes from UNIFI_PROTECT_LOCATIONS. Read unifi-protect://locations to see what is defined."),
|
|
2115
2160
|
limit: limitArg,
|
|
2116
2161
|
order: z.enum(["newest", "oldest"]).default("newest").describe("Which end of the window to return first.")
|
|
2117
|
-
},
|
|
2162
|
+
}),
|
|
2118
2163
|
annotations: { readOnlyHint: true }
|
|
2119
2164
|
}, async ({ start, end, types, smartDetectTypes, cameraId, cameraIds, location, limit, order }) => wrap(async () => {
|
|
2120
2165
|
const facts = await ctx.devices.facts();
|
|
@@ -2176,17 +2221,19 @@ const registerEventTools = (server, client, ctx) => {
|
|
|
2176
2221
|
};
|
|
2177
2222
|
}));
|
|
2178
2223
|
server.registerTool("unifi_protect_get_event", {
|
|
2224
|
+
title: "UniFi Protect: Get Event",
|
|
2179
2225
|
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.",
|
|
2180
|
-
inputSchema: { eventId: z.string().min(1).describe("Event id — the `id` from unifi_protect_list_events.") },
|
|
2226
|
+
inputSchema: z.object({ eventId: z.string().min(1).describe("Event id — the `id` from unifi_protect_list_events.") }),
|
|
2181
2227
|
annotations: { readOnlyHint: true }
|
|
2182
2228
|
}, async ({ eventId }) => wrap(() => client.get(`events/${encodeURIComponent(eventId)}`)));
|
|
2183
2229
|
server.registerTool("unifi_protect_get_event_thumbnail", {
|
|
2230
|
+
title: "UniFi Protect: Get Event Thumbnail",
|
|
2184
2231
|
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.",
|
|
2185
|
-
inputSchema: {
|
|
2232
|
+
inputSchema: z.object({
|
|
2186
2233
|
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."),
|
|
2187
2234
|
output: z.enum(["file", "image"]).default("file").describe("\"file\" writes it to disk and returns the path; \"image\" returns it inline."),
|
|
2188
2235
|
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.")
|
|
2189
|
-
},
|
|
2236
|
+
}),
|
|
2190
2237
|
annotations: { readOnlyHint: true }
|
|
2191
2238
|
}, async ({ eventId, output, savePath }) => wrapResult(async () => {
|
|
2192
2239
|
const id = eventId.startsWith("e-") ? eventId.slice(2) : eventId;
|
|
@@ -2206,11 +2253,12 @@ const registerEventTools = (server, client, ctx) => {
|
|
|
2206
2253
|
});
|
|
2207
2254
|
}));
|
|
2208
2255
|
server.registerTool("unifi_protect_get_event_thumbnails", {
|
|
2256
|
+
title: "UniFi Protect: Get Event Thumbnails",
|
|
2209
2257
|
description: "Fetch the still frames for SEVERAL events at once and return them inline to look at. This is the tool for answering who or what was actually there, and it matters most when a camera has no smart detection: motion events carry no classification, so the only way to tell a person from a branch is to look. Prefer this over calling unifi_protect_get_event_thumbnail repeatedly. Costs roughly 1-2K tokens per image, so it is capped at 6 — pick the events worth seeing from unifi_protect_list_events rather than sweeping a whole night. Events that have no thumbnail are reported by id instead of failing the call.",
|
|
2210
|
-
inputSchema: {
|
|
2258
|
+
inputSchema: z.object({
|
|
2211
2259
|
eventIds: z.array(z.string().min(1)).min(1).max(6).describe("Event ids from unifi_protect_list_events, at most 6. Results showing `hasThumbnail: true` have an image."),
|
|
2212
2260
|
output: z.enum(["image", "file"]).default("image").describe("\"image\" returns the frames inline for a vision model to look at, which is the point of this tool; \"file\" writes them to disk and returns paths instead.")
|
|
2213
|
-
},
|
|
2261
|
+
}),
|
|
2214
2262
|
annotations: { readOnlyHint: true }
|
|
2215
2263
|
}, async ({ eventIds, output }) => wrapResult(async () => {
|
|
2216
2264
|
const content = [];
|
|
@@ -2259,14 +2307,15 @@ const registerEventTools = (server, client, ctx) => {
|
|
|
2259
2307
|
return { content };
|
|
2260
2308
|
}));
|
|
2261
2309
|
server.registerTool("unifi_protect_export_video", {
|
|
2310
|
+
title: "UniFi Protect: Export Video",
|
|
2262
2311
|
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.",
|
|
2263
|
-
inputSchema: {
|
|
2312
|
+
inputSchema: z.object({
|
|
2264
2313
|
cameraId: cameraIdArg,
|
|
2265
2314
|
start: requiredTimeArg("Beginning of the footage to export."),
|
|
2266
2315
|
end: requiredTimeArg("End of the footage to export."),
|
|
2267
2316
|
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."),
|
|
2268
2317
|
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.")
|
|
2269
|
-
},
|
|
2318
|
+
}),
|
|
2270
2319
|
annotations: { readOnlyHint: true }
|
|
2271
2320
|
}, async ({ cameraId, start, end, savePath, channel }) => wrap(async () => {
|
|
2272
2321
|
const startMs = toEpochMs(start);
|
|
@@ -2318,13 +2367,14 @@ const registerRequestTool = (server, client, allowWrites) => {
|
|
|
2318
2367
|
"DELETE"
|
|
2319
2368
|
] : ["GET"];
|
|
2320
2369
|
server.registerTool("unifi_protect_request", {
|
|
2370
|
+
title: "UniFi Protect: Request",
|
|
2321
2371
|
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."),
|
|
2322
|
-
inputSchema: {
|
|
2372
|
+
inputSchema: z.object({
|
|
2323
2373
|
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."),
|
|
2324
2374
|
method: z.enum(methods).default("GET").describe("HTTP method."),
|
|
2325
2375
|
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."),
|
|
2326
2376
|
body: z.record(z.string(), z.unknown()).optional().describe("JSON request body, for POST and PATCH.")
|
|
2327
|
-
},
|
|
2377
|
+
}),
|
|
2328
2378
|
annotations: {
|
|
2329
2379
|
readOnlyHint: !allowWrites,
|
|
2330
2380
|
destructiveHint: allowWrites
|
|
@@ -2349,8 +2399,9 @@ const fileMode = (path) => {
|
|
|
2349
2399
|
};
|
|
2350
2400
|
const registerStatusTools = (server, client, ctx) => {
|
|
2351
2401
|
server.registerTool("unifi_protect_auth_status", {
|
|
2402
|
+
title: "UniFi Protect: Auth Status",
|
|
2352
2403
|
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.",
|
|
2353
|
-
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.") },
|
|
2404
|
+
inputSchema: z.object({ 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.") }),
|
|
2354
2405
|
annotations: { readOnlyHint: true }
|
|
2355
2406
|
}, async ({ probe }) => wrap(async () => {
|
|
2356
2407
|
if (!isConfigured(ctx.config)) return {
|
|
@@ -2408,8 +2459,9 @@ const registerStatusTools = (server, client, ctx) => {
|
|
|
2408
2459
|
}));
|
|
2409
2460
|
if (!isConfigured(ctx.config) || ctx.config.mode === "cloud") return;
|
|
2410
2461
|
server.registerTool("unifi_protect_auth_login", {
|
|
2462
|
+
title: "UniFi Protect: Auth Login",
|
|
2411
2463
|
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.",
|
|
2412
|
-
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.") },
|
|
2464
|
+
inputSchema: z.object({ 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.") }),
|
|
2413
2465
|
annotations: {
|
|
2414
2466
|
readOnlyHint: false,
|
|
2415
2467
|
destructiveHint: false,
|
|
@@ -2426,8 +2478,9 @@ const registerStatusTools = (server, client, ctx) => {
|
|
|
2426
2478
|
};
|
|
2427
2479
|
}));
|
|
2428
2480
|
server.registerTool("unifi_protect_auth_logout", {
|
|
2481
|
+
title: "UniFi Protect: Auth Logout",
|
|
2429
2482
|
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.",
|
|
2430
|
-
inputSchema: { confirm: confirmArg },
|
|
2483
|
+
inputSchema: z.object({ confirm: confirmArg }),
|
|
2431
2484
|
annotations: {
|
|
2432
2485
|
readOnlyHint: false,
|
|
2433
2486
|
destructiveHint: true,
|
|
@@ -2445,28 +2498,32 @@ const registerStatusTools = (server, client, ctx) => {
|
|
|
2445
2498
|
//#region src/tools/system.ts
|
|
2446
2499
|
const registerSystemTools = (server, client, ctx) => {
|
|
2447
2500
|
server.registerTool("unifi_protect_get_system_info", {
|
|
2501
|
+
title: "UniFi Protect: Get System Info",
|
|
2448
2502
|
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.",
|
|
2449
|
-
inputSchema: {},
|
|
2503
|
+
inputSchema: z.object({}),
|
|
2450
2504
|
annotations: { readOnlyHint: true }
|
|
2451
2505
|
}, async () => wrap(async () => summarizeBootstrap(await client.get("bootstrap"))));
|
|
2452
2506
|
server.registerTool("unifi_protect_list_users", {
|
|
2507
|
+
title: "UniFi Protect: List Users",
|
|
2453
2508
|
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.",
|
|
2454
|
-
inputSchema: {},
|
|
2509
|
+
inputSchema: z.object({}),
|
|
2455
2510
|
annotations: { readOnlyHint: true }
|
|
2456
2511
|
}, async () => wrap(async () => summarizeEach(await client.get("users"), summarizeUser)));
|
|
2457
2512
|
server.registerTool("unifi_protect_list_liveviews", {
|
|
2513
|
+
title: "UniFi Protect: List Liveviews",
|
|
2458
2514
|
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.",
|
|
2459
|
-
inputSchema: {},
|
|
2515
|
+
inputSchema: z.object({}),
|
|
2460
2516
|
annotations: { readOnlyHint: true }
|
|
2461
2517
|
}, async () => wrap(async () => summarizeEach(await client.get("liveviews"), summarizeLiveview)));
|
|
2462
2518
|
if (!ctx.allowWrites) return;
|
|
2463
2519
|
server.registerTool("unifi_protect_update_nvr_settings", {
|
|
2520
|
+
title: "UniFi Protect: Update NVR Settings",
|
|
2464
2521
|
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.",
|
|
2465
|
-
inputSchema: {
|
|
2522
|
+
inputSchema: z.object({
|
|
2466
2523
|
name: z.string().min(1).optional().describe("Display name for the console."),
|
|
2467
2524
|
timezone: z.string().min(1).optional().describe("IANA timezone, e.g. \"Europe/Paris\". Affects event timestamps and schedules."),
|
|
2468
2525
|
isRecordingDisabled: z.boolean().optional().describe("Disable recording across every camera. True means no footage is kept, system-wide.")
|
|
2469
|
-
},
|
|
2526
|
+
}),
|
|
2470
2527
|
annotations: {
|
|
2471
2528
|
readOnlyHint: false,
|
|
2472
2529
|
destructiveHint: false,
|
|
@@ -2482,8 +2539,9 @@ const registerSystemTools = (server, client, ctx) => {
|
|
|
2482
2539
|
return client.patch("nvr", body);
|
|
2483
2540
|
}));
|
|
2484
2541
|
server.registerTool("unifi_protect_reboot_nvr", {
|
|
2542
|
+
title: "UniFi Protect: Reboot NVR",
|
|
2485
2543
|
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.",
|
|
2486
|
-
inputSchema: { confirm: confirmArg },
|
|
2544
|
+
inputSchema: z.object({ confirm: confirmArg }),
|
|
2487
2545
|
annotations: {
|
|
2488
2546
|
readOnlyHint: false,
|
|
2489
2547
|
destructiveHint: true,
|
|
@@ -2569,4 +2627,4 @@ const createServer = (opts) => {
|
|
|
2569
2627
|
//#endregion
|
|
2570
2628
|
export { UPDATES_WS_PATH as A, saveSession as B, buildQuery as C, staticSessionProvider as D, createSessionProvider as E, resolveConfigPath as F, BUILD_INFO as G, ProtectApiError as H, resolveSessionPath as I, setupInstructions as L, isConfigured as M, loadConfig as N, LOGIN_PATH as O, normalizeBaseUrl as P, clearSession as R, backoffMs as S, createDeviceCache as T, ProtectAuthError as U, NotConfiguredError as V, WritesDisabledError as W, summarizeNvr as _, registerTools as a, summarizeViewer as b, buildNameIndex as c, summarizeCamera as d, summarizeChime as f, summarizeLiveview as g, summarizeLight as h, createServer as i, expandTilde as j, PRIVATE_API_PATH as k, isoTime as l, summarizeEvent as m, SERVER_VERSION as n, assertSafePath as o, summarizeEach as p, USER_AGENT as r, toEpochMs as s, SERVER_NAME as t, summarizeBootstrap as u, summarizeSensor as v, retryAfterMs as w, ProtectClient as x, summarizeUser as y, loadSession as z };
|
|
2571
2629
|
|
|
2572
|
-
//# sourceMappingURL=server-
|
|
2630
|
+
//# sourceMappingURL=server-9inOsyBi.js.map
|