@owine/unifi-protect-mcp 2.14.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +248 -92
  2. package/dist/client.d.ts +43 -3
  3. package/dist/client.js +255 -85
  4. package/dist/config.d.ts +7 -1
  5. package/dist/config.js +114 -6
  6. package/dist/index.js +14 -6
  7. package/dist/instructions.d.ts +6 -0
  8. package/dist/instructions.js +24 -0
  9. package/dist/schemas/cameras.d.ts +32 -30
  10. package/dist/schemas/cameras.js +7 -6
  11. package/dist/schemas/common.d.ts +31 -6
  12. package/dist/schemas/common.js +45 -6
  13. package/dist/schemas/devices.d.ts +2 -10
  14. package/dist/schemas/devices.js +35 -26
  15. package/dist/schemas/misc.d.ts +50 -18
  16. package/dist/schemas/misc.js +23 -15
  17. package/dist/schemas/pos.d.ts +1 -1
  18. package/dist/schemas/pos.js +1 -1
  19. package/dist/tls.d.ts +41 -0
  20. package/dist/tls.js +75 -0
  21. package/dist/tools/alarm-manager.d.ts +3 -0
  22. package/dist/tools/alarm-manager.js +32 -0
  23. package/dist/tools/arm-profiles.d.ts +8 -2
  24. package/dist/tools/arm-profiles.js +98 -95
  25. package/dist/tools/cameras.d.ts +2 -2
  26. package/dist/tools/cameras.js +156 -181
  27. package/dist/tools/device-actions.d.ts +2 -2
  28. package/dist/tools/device-actions.js +84 -97
  29. package/dist/tools/devices.d.ts +6 -3
  30. package/dist/tools/devices.js +50 -51
  31. package/dist/tools/files.d.ts +2 -2
  32. package/dist/tools/files.js +82 -57
  33. package/dist/tools/index.d.ts +2 -2
  34. package/dist/tools/index.js +15 -10
  35. package/dist/tools/liveviews.d.ts +2 -2
  36. package/dist/tools/liveviews.js +52 -52
  37. package/dist/tools/pos.d.ts +6 -4
  38. package/dist/tools/pos.js +15 -20
  39. package/dist/tools/registrar.d.ts +34 -0
  40. package/dist/tools/registrar.js +103 -0
  41. package/dist/tools/subscriptions.d.ts +13 -2
  42. package/dist/tools/subscriptions.js +148 -27
  43. package/dist/tools/system.d.ts +2 -2
  44. package/dist/tools/system.js +5 -15
  45. package/dist/tools/users.d.ts +2 -2
  46. package/dist/tools/users.js +18 -35
  47. package/dist/utils/limits.d.ts +33 -0
  48. package/dist/utils/limits.js +74 -0
  49. package/dist/utils/paging.d.ts +15 -0
  50. package/dist/utils/paging.js +28 -0
  51. package/dist/utils/params.d.ts +13 -0
  52. package/dist/utils/params.js +40 -0
  53. package/dist/utils/responses.js +2 -3
  54. package/dist/utils/safety.d.ts +39 -3
  55. package/dist/utils/safety.js +45 -3
  56. package/dist/utils/url.d.ts +4 -1
  57. package/dist/utils/url.js +4 -1
  58. package/dist/version.d.ts +6 -0
  59. package/dist/version.js +11 -0
  60. package/package.json +14 -12
@@ -5,78 +5,103 @@ const zod_1 = require("zod");
5
5
  const responses_js_1 = require("../utils/responses.js");
6
6
  const safety_js_1 = require("../utils/safety.js");
7
7
  const url_js_1 = require("../utils/url.js");
8
+ const paging_js_1 = require("../utils/paging.js");
9
+ const limits_js_1 = require("../utils/limits.js");
8
10
  const misc_js_1 = require("../schemas/misc.js");
11
+ // The only asset file type the API accepts (path param `assetFileType`).
12
+ const FILE_TYPE = zod_1.z
13
+ .literal("animations")
14
+ .describe("Asset file type (only 'animations' is supported)");
15
+ const UPLOAD_CONTENT_TYPES = [
16
+ "image/gif",
17
+ "image/jpeg",
18
+ "image/png",
19
+ "audio/mpeg",
20
+ "audio/mp4",
21
+ "audio/wave",
22
+ "audio/x-caf",
23
+ ];
9
24
  function registerFileTools(server, client, readOnly) {
10
25
  server.registerTool("protect_list_files", {
11
- description: "List files of a given type (only 'animations' is currently supported — clips uploadable to G4 Doorbell screens). Returns array; each record (7.1.83 docs): name (server-generated), type, originalName, path.",
12
- inputSchema: {
13
- fileType: zod_1.z
14
- .string()
15
- .describe("File type to list (only 'animations' is currently supported)"),
16
- },
26
+ description: "List device asset files (only 'animations' is supported — clips uploadable to G4 Doorbell screens). Returns array; each record (verified live 7.3.70): id, name (server-generated), type, originalName, path, size, createdAt, updatedAt, metadata. An image upload (verified for GIF) later gains a derived <name>.gif preview record, created asynchronously, so a listing taken right after the upload may show only the stored image." + paging_js_1.PAGING_NOTE,
27
+ inputSchema: { fileType: FILE_TYPE, ...paging_js_1.pagingInput },
17
28
  outputSchema: misc_js_1.fileListOutputSchema,
18
29
  annotations: safety_js_1.READ_ONLY,
19
- }, async ({ fileType }) => {
20
- try {
21
- const data = await client.get((0, url_js_1.safePath) `/files/${fileType}`);
22
- return (0, responses_js_1.formatSuccess)(data);
23
- }
24
- catch (err) {
25
- return (0, responses_js_1.formatError)(err);
26
- }
30
+ }, async ({ fileType, limit, offset }) => {
31
+ const data = await client.get((0, url_js_1.safePath) `/files/${fileType}`);
32
+ return (0, responses_js_1.formatSuccess)((0, paging_js_1.pageOf)(data, limit, offset));
27
33
  });
28
34
  if (readOnly)
29
35
  return;
30
- server.registerTool("protect_trigger_alarm_webhook", {
31
- description: "Trigger an alarm manager webhook by ID. This fires an external alarm action.",
32
- inputSchema: {
33
- id: zod_1.z.string().describe("User-defined webhook ID matching an alarm manager configuration"),
34
- confirm: zod_1.z
35
- .literal(true)
36
- .describe("Must be true to confirm triggering the alarm webhook"),
37
- },
38
- annotations: safety_js_1.DESTRUCTIVE,
39
- }, async ({ id, confirm }) => {
40
- const denied = (0, safety_js_1.requireConfirmation)(confirm, "trigger the alarm webhook");
41
- if (denied)
42
- return denied;
43
- try {
44
- await client.post((0, url_js_1.safePath) `/alarm-manager/webhook/${id}`);
45
- return (0, responses_js_1.formatSuccess)({ triggered: true, webhookId: id });
46
- }
47
- catch (err) {
48
- return (0, responses_js_1.formatError)(err);
49
- }
50
- });
51
36
  server.registerTool("protect_upload_file", {
52
- description: "Upload a base64-encoded file to UniFi Protect",
37
+ description: "Upload a base64-encoded device asset file (sent as multipart/form-data). Observed for GIF image uploads: Protect stores the image as PNG (appending the stored extension to originalName) and a few seconds later creates a separate <name>.gif preview asset, which protect_list_files shows once it exists. Audio uploads have not been observed. Returns: id, name (server-generated), type, originalName, path, size, createdAt, updatedAt, metadata.",
53
38
  inputSchema: {
54
- fileType: zod_1.z.string().describe("File type category for upload"),
55
- base64Data: zod_1.z.string().describe("Base64-encoded file content"),
56
- contentType: zod_1.z
39
+ fileType: FILE_TYPE,
40
+ base64Data: zod_1.z
57
41
  .string()
42
+ .min(1)
43
+ .max(limits_js_1.MAX_UPLOAD_BASE64_CHARS, "File too large to send in one MCP message")
44
+ .describe("Base64-encoded file content (a data: URL prefix is accepted and stripped)"),
45
+ contentType: zod_1.z
46
+ .enum(UPLOAD_CONTENT_TYPES)
58
47
  .default("image/gif")
59
- .describe("MIME type of the file (accepted: image/gif, image/jpeg, image/png, audio/mpeg, audio/mp4, audio/wave, audio/x-caf)"),
60
- dryRun: zod_1.z
61
- .boolean()
48
+ .describe("MIME type of the file"),
49
+ fileName: zod_1.z
50
+ .string()
51
+ .min(1)
62
52
  .optional()
63
- .describe("If true, return what would happen without making changes"),
53
+ .describe("Original file name to record. Reported back as originalName, with the stored extension appended when Protect converts the file (e.g. welcome.gif → welcome.gif.png)"),
54
+ dryRun: (0, safety_js_1.dryRunInput)(),
64
55
  },
56
+ outputSchema: misc_js_1.fileOutputSchema,
65
57
  annotations: safety_js_1.WRITE,
66
- }, async ({ fileType, base64Data, contentType, dryRun }) => {
67
- try {
68
- if (dryRun) {
69
- return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/files/${fileType}`, {
70
- contentType,
71
- dataLength: base64Data.length,
72
- });
73
- }
74
- const buffer = Buffer.from(base64Data, "base64");
75
- const data = await client.postBinary((0, url_js_1.safePath) `/files/${fileType}`, buffer, contentType);
76
- return (0, responses_js_1.formatSuccess)(data);
77
- }
78
- catch (err) {
79
- return (0, responses_js_1.formatError)(err);
58
+ }, async ({ fileType, base64Data, contentType, fileName, dryRun }) => {
59
+ const name = fileName ?? defaultFileName(contentType);
60
+ const base64 = normalizeBase64(base64Data);
61
+ if (dryRun) {
62
+ return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/files/${fileType}`, {
63
+ contentType,
64
+ fileName: name,
65
+ byteLength: Buffer.byteLength(base64, "base64"),
66
+ });
80
67
  }
68
+ const data = await client.postFile((0, url_js_1.safePath) `/files/${fileType}`, Buffer.from(base64, "base64"), contentType, name);
69
+ return (0, responses_js_1.formatSuccess)(data);
81
70
  });
82
71
  }
72
+ const EXTENSIONS = {
73
+ "image/gif": "gif",
74
+ "image/jpeg": "jpg",
75
+ "image/png": "png",
76
+ "audio/mpeg": "mp3",
77
+ "audio/mp4": "m4a",
78
+ "audio/wave": "wav",
79
+ "audio/x-caf": "caf",
80
+ };
81
+ function defaultFileName(contentType) {
82
+ return `upload.${EXTENSIONS[contentType]}`;
83
+ }
84
+ const DATA_URL_PREFIX = /^data:[^,]*;base64,/i;
85
+ /** Base64 encodes every 3 bytes as a group of 4 characters. */
86
+ const BASE64_GROUP_LENGTH = 4;
87
+ /** Standard or URL-safe base64 (one alphabet, not mixed), padded or not. */
88
+ const BASE64 = /^[A-Za-z0-9+/]+={0,2}$|^[A-Za-z0-9_-]+={0,2}$/;
89
+ /**
90
+ * Strip whitespace and a data: URL prefix, then reject anything that is not
91
+ * well-formed base64. Buffer.from(x, "base64") skips characters it cannot
92
+ * decode and ignores a dangling final character, so malformed input would
93
+ * otherwise upload as corrupt bytes with no error. Whitespace goes first, so
94
+ * a prefix after a leading line break is still found.
95
+ */
96
+ function normalizeBase64(input) {
97
+ const cleaned = input.replace(/\s+/g, "").replace(DATA_URL_PREFIX, "");
98
+ // Padded input comes in whole groups; unpadded input can end in a partial
99
+ // group of 2 or 3 characters, but never a lone character (remainder 1).
100
+ const remainder = cleaned.length % BASE64_GROUP_LENGTH;
101
+ const wellFormed = BASE64.test(cleaned) &&
102
+ (cleaned.endsWith("=") ? remainder === 0 : remainder !== 1);
103
+ if (!wellFormed) {
104
+ throw new Error("base64Data is not valid base64 (expected A-Z, a-z, 0-9 with + and / or - and _, correctly = padded, or a data: URL)");
105
+ }
106
+ return cleaned;
107
+ }
@@ -1,3 +1,3 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { ProtectClient } from "../client.js";
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ProtectClient } from "../client.js";
3
3
  export declare function registerAllTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
@@ -1,25 +1,30 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerAllTools = registerAllTools;
4
+ const registrar_js_1 = require("./registrar.js");
4
5
  const system_js_1 = require("./system.js");
5
6
  const cameras_js_1 = require("./cameras.js");
6
7
  const devices_js_1 = require("./devices.js");
7
8
  const device_actions_js_1 = require("./device-actions.js");
8
9
  const liveviews_js_1 = require("./liveviews.js");
9
10
  const files_js_1 = require("./files.js");
11
+ const alarm_manager_js_1 = require("./alarm-manager.js");
10
12
  const subscriptions_js_1 = require("./subscriptions.js");
11
13
  const arm_profiles_js_1 = require("./arm-profiles.js");
12
14
  const users_js_1 = require("./users.js");
13
15
  const pos_js_1 = require("./pos.js");
14
16
  function registerAllTools(server, client, readOnly) {
15
- (0, system_js_1.registerSystemTools)(server, client);
16
- (0, subscriptions_js_1.registerSubscriptionTools)(server, client);
17
- (0, cameras_js_1.registerCameraTools)(server, client, readOnly);
18
- (0, devices_js_1.registerDeviceTools)(server, client, readOnly);
19
- (0, device_actions_js_1.registerDeviceActionTools)(server, client, readOnly);
20
- (0, liveviews_js_1.registerLiveviewTools)(server, client, readOnly);
21
- (0, files_js_1.registerFileTools)(server, client, readOnly);
22
- (0, arm_profiles_js_1.registerArmProfileTools)(server, client, readOnly);
23
- (0, users_js_1.registerUserTools)(server, client);
24
- (0, pos_js_1.registerPosTools)(server, client, readOnly);
17
+ // Modules register through instrumentTools only; see registrar.ts.
18
+ const tools = (0, registrar_js_1.instrumentTools)(server, readOnly);
19
+ (0, system_js_1.registerSystemTools)(tools, client);
20
+ (0, subscriptions_js_1.registerSubscriptionTools)(tools, client);
21
+ (0, cameras_js_1.registerCameraTools)(tools, client, readOnly);
22
+ (0, devices_js_1.registerDeviceTools)(tools, client, readOnly);
23
+ (0, device_actions_js_1.registerDeviceActionTools)(tools, client, readOnly);
24
+ (0, liveviews_js_1.registerLiveviewTools)(tools, client, readOnly);
25
+ (0, files_js_1.registerFileTools)(tools, client, readOnly);
26
+ (0, alarm_manager_js_1.registerAlarmManagerTools)(tools, client, readOnly);
27
+ (0, arm_profiles_js_1.registerArmProfileTools)(tools, client, readOnly);
28
+ (0, users_js_1.registerUserTools)(tools, client);
29
+ (0, pos_js_1.registerPosTools)(tools, client, readOnly);
25
30
  }
@@ -1,3 +1,3 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1
+ import type { ToolRegistrar } from "./registrar.js";
2
2
  import { ProtectClient } from "../client.js";
3
- export declare function registerLiveviewTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
3
+ export declare function registerLiveviewTools(server: ToolRegistrar, client: ProtectClient, readOnly: boolean): void;
@@ -5,86 +5,86 @@ const zod_1 = require("zod");
5
5
  const responses_js_1 = require("../utils/responses.js");
6
6
  const safety_js_1 = require("../utils/safety.js");
7
7
  const url_js_1 = require("../utils/url.js");
8
+ const paging_js_1 = require("../utils/paging.js");
9
+ const params_js_1 = require("../utils/params.js");
8
10
  const misc_js_1 = require("../schemas/misc.js");
11
+ // POST and PATCH both take the full liveview object; every field is required
12
+ // (7.3.70 docs). Slot item schema is not rendered in the docs, so slots pass
13
+ // through beyond the observed { cameras, cycleMode, cycleInterval }.
14
+ const liveviewSlotInput = zod_1.z
15
+ .object({
16
+ cameras: zod_1.z.array(zod_1.z.string()).describe("Camera IDs shown in this slot"),
17
+ cycleMode: zod_1.z.string().describe('Cycle mode, e.g. "time" or "motion"'),
18
+ cycleInterval: zod_1.z.number().describe("Cycle interval in seconds"),
19
+ })
20
+ .loose();
21
+ const liveviewBody = zod_1.z
22
+ .object({
23
+ id: (0, params_js_1.resourceId)().describe("Liveview ID"),
24
+ modelKey: zod_1.z.literal("liveview").describe('Must be "liveview"'),
25
+ name: zod_1.z.string().describe("Liveview name"),
26
+ isDefault: zod_1.z.boolean().describe("Whether this is the default liveview for all viewers"),
27
+ isGlobal: zod_1.z.boolean().describe("Whether this liveview is available to all users"),
28
+ owner: zod_1.z.string().describe("Owning user ID"),
29
+ layout: zod_1.z.number().min(1).max(26).describe("Number of slots (1-26)"),
30
+ slots: zod_1.z.array(liveviewSlotInput).describe("Per-slot cameras and cycling settings"),
31
+ })
32
+ .loose();
9
33
  function registerLiveviewTools(server, client, readOnly) {
10
34
  server.registerTool("protect_list_liveviews", {
11
- description: "List all live views (camera grid layouts shown on viewers / in the Protect UI). Returns array; each liveview includes (Integration API 7.1.83-verified): id, modelKey, name, isDefault, isGlobal, layout (number, slot count), owner (user ID), slots (array of {cameras: string[], cycleMode, cycleInterval}). NOTE: slots use a `cameras` string-array, not a single `cameraId`.",
35
+ description: "List all live views (camera grid layouts shown on viewers / in the Protect UI). Returns array; each liveview includes (Integration API 7.3.70-verified): id, modelKey, name, isDefault, isGlobal, layout (number, slot count), owner (user ID), slots (array of {cameras: string[], cycleMode, cycleInterval}). NOTE: slots use a `cameras` string-array, not a single `cameraId`." + paging_js_1.PAGING_NOTE,
36
+ inputSchema: paging_js_1.pagingInput,
12
37
  outputSchema: misc_js_1.liveviewListOutputSchema,
13
38
  annotations: safety_js_1.READ_ONLY,
14
- }, async () => {
15
- try {
16
- const data = await client.get("/liveviews");
17
- return (0, responses_js_1.formatSuccess)(data);
18
- }
19
- catch (err) {
20
- return (0, responses_js_1.formatError)(err);
21
- }
39
+ }, async ({ limit, offset }) => {
40
+ const data = await client.get("/liveviews");
41
+ return (0, responses_js_1.formatSuccess)((0, paging_js_1.pageOf)(data, limit, offset));
22
42
  });
23
43
  server.registerTool("protect_get_liveview", {
24
44
  description: "Get details for a specific live view by ID. Returns: id, modelKey, name, isDefault, isGlobal, layout, owner, slots (each slot: cameras string[], cycleMode, cycleInterval). The full slot list is needed when updating because PATCH replaces the slots array.",
25
- inputSchema: { id: zod_1.z.string().describe("Liveview ID") },
45
+ inputSchema: { id: (0, params_js_1.resourceId)().describe("Liveview ID") },
26
46
  outputSchema: misc_js_1.liveviewOutputSchema,
27
47
  annotations: safety_js_1.READ_ONLY,
28
48
  }, async ({ id }) => {
29
- try {
30
- const data = await client.get((0, url_js_1.safePath) `/liveviews/${id}`);
31
- return (0, responses_js_1.formatSuccess)(data);
32
- }
33
- catch (err) {
34
- return (0, responses_js_1.formatError)(err);
35
- }
49
+ const data = await client.get((0, url_js_1.safePath) `/liveviews/${id}`);
50
+ return (0, responses_js_1.formatSuccess)(data);
36
51
  });
37
52
  if (readOnly)
38
53
  return;
39
54
  server.registerTool("protect_create_liveview", {
40
- description: "Create a new live view",
55
+ description: "Create a new live view. Returns: the created liveview (same fields as protect_get_liveview).",
41
56
  inputSchema: {
42
- settings: zod_1.z
43
- .record(zod_1.z.string(), zod_1.z.unknown())
44
- .describe("Liveview configuration. Required fields: id (string), modelKey (string, must be 'liveview'), owner (string, user ID), name (string), layout (number of slots), slots (array of {cameras: string[], cycleMode: string, cycleInterval: number}), isDefault (boolean), isGlobal (boolean)"),
45
- dryRun: zod_1.z
46
- .boolean()
47
- .optional()
48
- .describe("If true, return what would happen without making changes"),
57
+ settings: liveviewBody.describe("Full liveview object (all fields required)"),
58
+ dryRun: (0, safety_js_1.dryRunInput)(),
49
59
  },
50
60
  outputSchema: misc_js_1.liveviewOutputSchema,
51
61
  annotations: safety_js_1.WRITE,
52
62
  }, async ({ settings, dryRun }) => {
53
- try {
54
- if (dryRun) {
55
- return (0, safety_js_1.formatDryRun)("POST", "/liveviews", settings);
56
- }
57
- const data = await client.post("/liveviews", settings);
58
- return (0, responses_js_1.formatSuccess)(data);
59
- }
60
- catch (err) {
61
- return (0, responses_js_1.formatError)(err);
63
+ if (dryRun) {
64
+ return (0, safety_js_1.formatDryRun)("POST", "/liveviews", settings);
62
65
  }
66
+ const data = await client.post("/liveviews", settings);
67
+ return (0, responses_js_1.formatSuccess)(data);
63
68
  });
64
69
  server.registerTool("protect_update_liveview", {
65
- description: "Update an existing live view (full replacement via PUT-style PATCH — include all fields)",
70
+ description: "Update an existing live view (full replacement via PUT-style PATCH — include all fields). Returns: the updated liveview.",
66
71
  inputSchema: {
67
- id: zod_1.z.string().describe("Liveview ID"),
68
- settings: zod_1.z
69
- .record(zod_1.z.string(), zod_1.z.unknown())
70
- .describe("Full liveview object for update (all fields required). Known fields: id (string), modelKey (string, must be 'liveview'), owner (string, user ID), name (string), layout (number), slots (array of {cameras: string[], cycleMode: string, cycleInterval: number}), isDefault (boolean), isGlobal (boolean)"),
71
- dryRun: zod_1.z
72
- .boolean()
73
- .optional()
74
- .describe("If true, return what would happen without making changes"),
72
+ id: (0, params_js_1.resourceId)().describe("Liveview ID"),
73
+ settings: liveviewBody.describe("Full liveview object (all fields required — fetch it with protect_get_liveview first)"),
74
+ dryRun: (0, safety_js_1.dryRunInput)(),
75
75
  },
76
76
  outputSchema: misc_js_1.liveviewOutputSchema,
77
- annotations: safety_js_1.WRITE,
77
+ annotations: safety_js_1.DESTRUCTIVE,
78
78
  }, async ({ id, settings, dryRun }) => {
79
- try {
80
- if (dryRun) {
81
- return (0, safety_js_1.formatDryRun)("PATCH", (0, url_js_1.safePath) `/liveviews/${id}`, settings);
82
- }
83
- const data = await client.patch((0, url_js_1.safePath) `/liveviews/${id}`, settings);
84
- return (0, responses_js_1.formatSuccess)(data);
79
+ // The body is a full liveview object, so it carries its own id. A
80
+ // mismatch means the caller copied the wrong liveview's body.
81
+ if (settings.id !== id) {
82
+ throw new Error(`settings.id (${settings.id}) does not match the liveview being updated (${id})`);
85
83
  }
86
- catch (err) {
87
- return (0, responses_js_1.formatError)(err);
84
+ if (dryRun) {
85
+ return (0, safety_js_1.formatDryRun)("PATCH", (0, url_js_1.safePath) `/liveviews/${id}`, settings);
88
86
  }
87
+ const data = await client.patch((0, url_js_1.safePath) `/liveviews/${id}`, settings);
88
+ return (0, responses_js_1.formatSuccess)(data);
89
89
  });
90
90
  }
@@ -1,10 +1,12 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1
+ import type { ToolRegistrar } from "./registrar.js";
2
2
  import { ProtectClient } from "../client.js";
3
3
  /**
4
4
  * Point-of-sale event ingestion (added in Protect 7.3.x).
5
5
  *
6
- * `lineItems` entries use `.passthrough()` because the 7.3.47 docs render the
7
- * item schema collapsed — the request sample shows `{ title, quantity }`, but
6
+ * `lineItems` entries use `.loose()` because the 7.3.70 docs render the
7
+ * item schema collapsed — the request sample shows `{ title, quantity: 1 }`, but
8
8
  * an undocumented extra field should reach the API rather than be stripped.
9
+ * Since the item constraints are not published, `quantity` is only required
10
+ * to be positive (fractional quantities such as weights are allowed).
9
11
  */
10
- export declare function registerPosTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
12
+ export declare function registerPosTools(server: ToolRegistrar, client: ProtectClient, readOnly: boolean): void;
package/dist/tools/pos.js CHANGED
@@ -5,13 +5,16 @@ const zod_1 = require("zod");
5
5
  const responses_js_1 = require("../utils/responses.js");
6
6
  const safety_js_1 = require("../utils/safety.js");
7
7
  const url_js_1 = require("../utils/url.js");
8
+ const params_js_1 = require("../utils/params.js");
8
9
  const pos_js_1 = require("../schemas/pos.js");
9
10
  /**
10
11
  * Point-of-sale event ingestion (added in Protect 7.3.x).
11
12
  *
12
- * `lineItems` entries use `.passthrough()` because the 7.3.47 docs render the
13
- * item schema collapsed — the request sample shows `{ title, quantity }`, but
13
+ * `lineItems` entries use `.loose()` because the 7.3.70 docs render the
14
+ * item schema collapsed — the request sample shows `{ title, quantity: 1 }`, but
14
15
  * an undocumented extra field should reach the API rather than be stripped.
16
+ * Since the item constraints are not published, `quantity` is only required
17
+ * to be positive (fractional quantities such as weights are allowed).
15
18
  */
16
19
  function registerPosTools(server, client, readOnly) {
17
20
  if (readOnly)
@@ -19,9 +22,9 @@ function registerPosTools(server, client, readOnly) {
19
22
  const lineItemSchema = zod_1.z
20
23
  .object({
21
24
  title: zod_1.z.string().describe("Line item name"),
22
- quantity: zod_1.z.number().min(1).describe("Quantity purchased (>= 1)"),
25
+ quantity: zod_1.z.number().positive().describe("Quantity purchased (> 0)"),
23
26
  })
24
- .passthrough();
27
+ .loose();
25
28
  server.registerTool("protect_ingest_pos_transaction", {
26
29
  description: "Record a point-of-sale transaction as a camera event so the transaction " +
27
30
  "details can be overlaid on recorded footage. Requires write access to the " +
@@ -33,7 +36,7 @@ function registerPosTools(server, client, readOnly) {
33
36
  "processed for this camera; retry shortly. " +
34
37
  "Returns: created (boolean), eventId (string).",
35
38
  inputSchema: {
36
- id: zod_1.z.string().describe("Camera ID to attach the transaction event to"),
39
+ id: (0, params_js_1.resourceId)().describe("Camera ID to attach the transaction event to"),
37
40
  type: zod_1.z.enum(["sale", "refund"]).describe("Transaction type"),
38
41
  externalId: zod_1.z
39
42
  .string()
@@ -59,7 +62,7 @@ function registerPosTools(server, client, readOnly) {
59
62
  id: zod_1.z.string().optional().describe("Store/location ID"),
60
63
  name: zod_1.z.string().optional().describe("Store/location name"),
61
64
  })
62
- .passthrough()
65
+ .loose()
63
66
  .optional()
64
67
  .describe("Point-of-sale location"),
65
68
  paymentTypes: zod_1.z
@@ -78,24 +81,16 @@ function registerPosTools(server, client, readOnly) {
78
81
  "out-of-range value with a 400 and clamps an in-skew value to now. " +
79
82
  "This tool forwards the value unchanged rather than pre-checking it, " +
80
83
  "because the window is relative to NVR time, not this process clock."),
81
- dryRun: zod_1.z
82
- .boolean()
83
- .optional()
84
- .describe("If true, return what would happen without making changes"),
84
+ dryRun: (0, safety_js_1.dryRunInput)(),
85
85
  },
86
86
  outputSchema: pos_js_1.posTransactionOutputSchema,
87
87
  annotations: safety_js_1.WRITE,
88
88
  }, async ({ id, dryRun, ...transaction }) => {
89
- try {
90
- const path = (0, url_js_1.safePath) `/pos/cameras/${id}/transactions`;
91
- if (dryRun) {
92
- return (0, safety_js_1.formatDryRun)("POST", path, transaction);
93
- }
94
- const data = await client.post(path, transaction);
95
- return (0, responses_js_1.formatSuccess)(data);
96
- }
97
- catch (err) {
98
- return (0, responses_js_1.formatError)(err);
89
+ const path = (0, url_js_1.safePath) `/pos/cameras/${id}/transactions`;
90
+ if (dryRun) {
91
+ return (0, safety_js_1.formatDryRun)("POST", path, transaction);
99
92
  }
93
+ const data = await client.post(path, transaction);
94
+ return (0, responses_js_1.formatSuccess)(data);
100
95
  });
101
96
  }
@@ -0,0 +1,34 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /**
3
+ * What a tool module may do with the server: register tools, nothing else.
4
+ * Modules get this rather than the McpServer, so they cannot reach the
5
+ * registration paths that would bypass instrumentTools (the experimental
6
+ * task API, RegisteredTool.update(), the deprecated tool()).
7
+ */
8
+ export type ToolRegistrar = Pick<McpServer, "registerTool">;
9
+ /**
10
+ * A registrar that applies these rules to every tool it registers, so no tool
11
+ * module can forget them:
12
+ *
13
+ * - The outputSchema is published as a loose object (see looseOutput): a raw
14
+ * shape would publish `additionalProperties: false`, and SDK clients would
15
+ * reject any field Protect adds.
16
+ * - A write tool that takes `dryRun` also declares the preview fields in its
17
+ * outputSchema, so its dry-run result validates and is documented.
18
+ * - Handlers run with their call's abort signal in scope, so ProtectClient
19
+ * aborts the Protect request (and subscriptions close their WebSocket) when
20
+ * the MCP client cancels the call.
21
+ * - In read-only mode, registering anything but a read-only tool fails at
22
+ * startup. Modules also return before their write tools; this catches a
23
+ * write tool placed above that return.
24
+ * - A handler that throws returns an error result (formatError), so tool
25
+ * modules need no try/catch of their own.
26
+ * - A result too large for one stdio message becomes an error, instead of a
27
+ * message the client cannot read (which ends its session).
28
+ * - Each tool gets a human-readable title derived from its name, and by
29
+ * default `openWorldHint: false`: every tool talks to one known console,
30
+ * not to an open set of external systems.
31
+ */
32
+ export declare function instrumentTools(server: McpServer, readOnly?: boolean): ToolRegistrar;
33
+ /** "protect_get_rtsp_streams" → "Get RTSP streams". */
34
+ export declare function toolTitle(name: string): string;
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.instrumentTools = instrumentTools;
4
+ exports.toolTitle = toolTitle;
5
+ const zod_1 = require("zod");
6
+ const client_js_1 = require("../client.js");
7
+ const common_js_1 = require("../schemas/common.js");
8
+ const responses_js_1 = require("../utils/responses.js");
9
+ const limits_js_1 = require("../utils/limits.js");
10
+ /**
11
+ * A registrar that applies these rules to every tool it registers, so no tool
12
+ * module can forget them:
13
+ *
14
+ * - The outputSchema is published as a loose object (see looseOutput): a raw
15
+ * shape would publish `additionalProperties: false`, and SDK clients would
16
+ * reject any field Protect adds.
17
+ * - A write tool that takes `dryRun` also declares the preview fields in its
18
+ * outputSchema, so its dry-run result validates and is documented.
19
+ * - Handlers run with their call's abort signal in scope, so ProtectClient
20
+ * aborts the Protect request (and subscriptions close their WebSocket) when
21
+ * the MCP client cancels the call.
22
+ * - In read-only mode, registering anything but a read-only tool fails at
23
+ * startup. Modules also return before their write tools; this catches a
24
+ * write tool placed above that return.
25
+ * - A handler that throws returns an error result (formatError), so tool
26
+ * modules need no try/catch of their own.
27
+ * - A result too large for one stdio message becomes an error, instead of a
28
+ * message the client cannot read (which ends its session).
29
+ * - Each tool gets a human-readable title derived from its name, and by
30
+ * default `openWorldHint: false`: every tool talks to one known console,
31
+ * not to an open set of external systems.
32
+ */
33
+ function instrumentTools(server, readOnly = false) {
34
+ const registerTool = (name, config, handler) => {
35
+ if (readOnly && config.annotations?.readOnlyHint !== true) {
36
+ throw new Error(`${name} is a write tool registered in read-only mode`);
37
+ }
38
+ // outputSchema is destructured out so the inner call infers its own
39
+ // output type rather than the caller's generic one. The SDK also accepts
40
+ // Zod 3 schemas; this server uses Zod 4 throughout, so narrow to that.
41
+ const { outputSchema, ...rest } = config;
42
+ const output = outputSchema;
43
+ const input = config.inputSchema;
44
+ const bound = async (...args) => {
45
+ // The SDK passes the call's `extra` (with its abort signal) last.
46
+ const extra = args.at(-1);
47
+ let result;
48
+ try {
49
+ result = await (0, client_js_1.withCallSignal)(extra?.signal, () => handler(...args));
50
+ }
51
+ catch (err) {
52
+ return (0, responses_js_1.formatError)(err);
53
+ }
54
+ return fitsOneMessage(result);
55
+ };
56
+ return server.registerTool(name, {
57
+ ...rest,
58
+ ...(output === undefined ? {} : { outputSchema: (0, common_js_1.looseOutput)(withPreview(output, input)) }),
59
+ title: config.title ?? toolTitle(name),
60
+ annotations: { openWorldHint: false, ...config.annotations },
61
+ }, bound); // the one cast: bound has handler's call shape
62
+ };
63
+ return { registerTool };
64
+ }
65
+ function withPreview(outputSchema, inputSchema) {
66
+ const takesDryRun = inputSchema !== undefined && !(inputSchema instanceof zod_1.z.ZodType) && "dryRun" in inputSchema;
67
+ if (!takesDryRun)
68
+ return outputSchema;
69
+ if (outputSchema instanceof zod_1.z.ZodObject)
70
+ return { ...common_js_1.dryRunPreviewFields, ...outputSchema.shape };
71
+ if (outputSchema instanceof zod_1.z.ZodType)
72
+ return outputSchema; // looseOutput rejects it
73
+ return { ...common_js_1.dryRunPreviewFields, ...outputSchema };
74
+ }
75
+ /**
76
+ * The result itself, or an error when its serialised form would exceed what
77
+ * one stdio message carries. Measured on the final result: escaping makes a
78
+ * payload under the per-kind ceilings in limits.ts larger than it looks.
79
+ */
80
+ function fitsOneMessage(result) {
81
+ const bytes = Buffer.byteLength(JSON.stringify(result), "utf8");
82
+ return bytes <= limits_js_1.MAX_RESULT_BYTES
83
+ ? result
84
+ : (0, responses_js_1.formatError)(new Error(`Result too large to send in one MCP message (${bytes.toString()} bytes); narrow the request, or page a list tool with limit and offset`));
85
+ }
86
+ // Words in tool names that read wrongly in plain sentence case.
87
+ const TITLE_WORDS = {
88
+ rtsp: "RTSP",
89
+ ptz: "PTZ",
90
+ nvrs: "NVRs",
91
+ ulp: "ULP",
92
+ pos: "POS",
93
+ goto: "go to",
94
+ };
95
+ /** "protect_get_rtsp_streams" → "Get RTSP streams". */
96
+ function toolTitle(name) {
97
+ const title = name
98
+ .replace(/^protect_/, "")
99
+ .split("_")
100
+ .map((word) => TITLE_WORDS[word] ?? word)
101
+ .join(" ");
102
+ return title.charAt(0).toUpperCase() + title.slice(1);
103
+ }
@@ -1,3 +1,14 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1
+ import type { ToolRegistrar } from "./registrar.js";
2
2
  import { ProtectClient } from "../client.js";
3
- export declare function registerSubscriptionTools(server: McpServer, client: ProtectClient): void;
3
+ /**
4
+ * Each device message carries a full device object, so a busy site can emit
5
+ * enough in 30s to flood the MCP client's context. Stop listening at the cap.
6
+ */
7
+ export declare const MAX_SUBSCRIPTION_MESSAGES = 200;
8
+ /**
9
+ * The most JSON text one listen returns, about 25k tokens: the scale at which
10
+ * MCP hosts commonly cap or warn about a tool result. The count cap alone
11
+ * allowed results several times that size.
12
+ */
13
+ export declare const MAX_SUBSCRIPTION_TEXT_CHARS = 100000;
14
+ export declare function registerSubscriptionTools(server: ToolRegistrar, client: ProtectClient): void;