@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 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-B1wSHfi7.js";
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/sdk/server/stdio.js";
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/sdk/server/stdio.js\";\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"}
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
@@ -1,4 +1,4 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
2
  import { z } from "zod";
3
3
  //#region src/config.d.ts
4
4
  /**
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-B1wSHfi7.js";
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/sdk/server/mcp.js";
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: "b29aa8b",
25
- gitCommitDate: "2026-08-30T15:19:08+02:00"
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
- location: z.string().optional().describe("Place to look at, e.g. \"front\". Omit to search every camera."),
1087
- start: z.string().describe("Start of the window in the console's local clock, e.g. \"1am\" or \"22:00\"."),
1088
- end: z.string().describe("End of the window, e.g. \"6am\".")
1089
- }
1090
- }, ({ location, start, end }) => text([
1091
- `Find out who or what passed ${location ? `the ${location}` : "any camera"} between ${start} and ${end}.`,
1092
- "",
1093
- "Times are in the console's local zone; `unifi_protect_list_events` accepts them",
1094
- "directly, so pass them through rather than converting by hand.",
1095
- "",
1096
- "1. Search with `unifi_protect_list_events`, types `[\"smartDetectZone\"]` and",
1097
- ` smartDetectTypes ["person"]${location ? `, location "${location}"` : ""}.`,
1098
- "2. READ THE `warnings` FIELD BEFORE REPORTING ANYTHING. A camera with person",
1099
- " detection disabled contributes zero matches, and zero is NOT evidence that nobody",
1100
- " was there. If any warning says a detector is off, you have not answered the",
1101
- " question yetcontinue to step 3 for those cameras.",
1102
- "3. For each camera whose detector was off, search the same window again with types",
1103
- " `[\"motion\"]` restricted to that camera, then call",
1104
- " `unifi_protect_get_event_thumbnails` on the results and LOOK at the frames. This",
1105
- " is the only way to tell a person from a branch on such a camera.",
1106
- "4. Report each sighting with its LOCAL time and camera name, and say how it was",
1107
- " established classified by the camera, or seen by you in a frame. Keep those",
1108
- " two apart: one is the console's judgement and one is yours.",
1109
- "5. Say plainly what was NOT covered: cameras that were offline, not recording, or",
1110
- " whose motion zone excludes part of the scene. A thumbnail is the triggering frame",
1111
- " only, so someone entering later in a clip does not appear in it."
1112
- ].join("\n")));
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 yourselfit 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-B1wSHfi7.js.map
2630
+ //# sourceMappingURL=server-9inOsyBi.js.map