@owine/unifi-protect-mcp 1.1.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,15 +15,7 @@ An MCP (Model Context Protocol) server that exposes UniFi Protect's Integration
15
15
  Add to Claude Code with a single command — no clone or build needed:
16
16
 
17
17
  ```bash
18
- claude mcp add-json unifi-protect '{
19
- "command": "npx",
20
- "args": ["-y", "@owine/unifi-protect-mcp@latest"],
21
- "env": {
22
- "UNIFI_PROTECT_HOST": "192.168.1.1",
23
- "UNIFI_PROTECT_API_KEY": "your-api-key",
24
- "UNIFI_PROTECT_VERIFY_SSL": "false"
25
- }
26
- }' -s user
18
+ claude mcp add-json unifi-protect '{"command":"npx","args":["-y","@owine/unifi-protect-mcp@latest"],"env":{"UNIFI_PROTECT_HOST":"192.168.1.1","UNIFI_PROTECT_API_KEY":"your-api-key","UNIFI_PROTECT_VERIFY_SSL":"false"}}' -s user
27
19
  ```
28
20
 
29
21
  Use `-s user` for global availability across all projects, or `-s project` for the current project only.
@@ -42,15 +34,7 @@ npm run build
42
34
  Then add to Claude Code:
43
35
 
44
36
  ```bash
45
- claude mcp add-json unifi-protect '{
46
- "command": "node",
47
- "args": ["/path/to/unifi-protect-mcp/dist/index.js"],
48
- "env": {
49
- "UNIFI_PROTECT_HOST": "192.168.1.1",
50
- "UNIFI_PROTECT_API_KEY": "your-api-key",
51
- "UNIFI_PROTECT_VERIFY_SSL": "false"
52
- }
53
- }' -s user
37
+ claude mcp add-json unifi-protect '{"command":"node","args":["/path/to/unifi-protect-mcp/dist/index.js"],"env":{"UNIFI_PROTECT_HOST":"192.168.1.1","UNIFI_PROTECT_API_KEY":"your-api-key","UNIFI_PROTECT_VERIFY_SSL":"false"}}' -s user
54
38
  ```
55
39
 
56
40
  ### Environment Variables
@@ -60,7 +44,7 @@ claude mcp add-json unifi-protect '{
60
44
  | `UNIFI_PROTECT_HOST` | Yes | — | IP or hostname of your UniFi Protect console |
61
45
  | `UNIFI_PROTECT_API_KEY` | Yes | — | API key from Protect integration settings |
62
46
  | `UNIFI_PROTECT_VERIFY_SSL` | No | `true` | Set to `false` to skip TLS certificate verification (needed for self-signed certs) |
63
- | `UNIFI_PROTECT_READ_ONLY` | No | `false` | Set to `true` to disable all write/mutating tools (monitoring-only mode) |
47
+ | `UNIFI_PROTECT_READ_ONLY` | No | `true` | Set to `false` to enable write/mutating tools (default is monitoring-only mode) |
64
48
 
65
49
  ### Manual Configuration
66
50
 
@@ -87,7 +71,7 @@ Alternatively, add to your `~/.claude.json` under the top-level `"mcpServers"` k
87
71
  This server provides layered safety controls for responsible operation:
88
72
 
89
73
  - **Tool annotations** — Every tool declares `readOnlyHint` and `destructiveHint` so MCP clients (like Claude Code) can make informed confirmation decisions
90
- - **Read-only mode** — Set `UNIFI_PROTECT_READ_ONLY=true` to completely hide all write/mutating tools. Only read operations (list, get, snapshot) are registered. Ideal for monitoring-only deployments
74
+ - **Read-only mode** — Enabled by default. Only read operations (list, get, snapshot) are registered. Set `UNIFI_PROTECT_READ_ONLY=false` to enable write/mutating tools
91
75
  - **Confirmation parameter** — The most dangerous tools (`protect_disable_mic`, `protect_trigger_alarm_webhook`) require an explicit `confirm: true` parameter that must be present for the call to succeed
92
76
  - **Dry-run support** — All write tools (except those with `confirm`) accept an optional `dryRun: true` parameter that returns a preview of what would happen without making any changes
93
77
 
package/dist/config.js CHANGED
@@ -13,7 +13,7 @@ function loadConfig() {
13
13
  host: process.env.UNIFI_PROTECT_HOST,
14
14
  apiKey: process.env.UNIFI_PROTECT_API_KEY,
15
15
  verifySsl: process.env.UNIFI_PROTECT_VERIFY_SSL !== "false",
16
- readOnly: process.env.UNIFI_PROTECT_READ_ONLY === "true",
16
+ readOnly: process.env.UNIFI_PROTECT_READ_ONLY !== "false",
17
17
  });
18
18
  if (!result.success) {
19
19
  const errors = result.error.issues.map((i) => i.message).join(", ");
@@ -3,14 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerCameraTools = registerCameraTools;
4
4
  const zod_1 = require("zod");
5
5
  const responses_js_1 = require("../utils/responses.js");
6
- const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false };
7
- const WRITE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false };
8
- const DESTRUCTIVE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: true };
6
+ const safety_js_1 = require("../utils/safety.js");
9
7
  function registerCameraTools(server, client, readOnly) {
10
8
  // --- Read-only tools (always registered) ---
11
9
  server.registerTool("protect_list_cameras", {
12
10
  description: "List all cameras managed by UniFi Protect",
13
- annotations: READ_ONLY_ANNOTATIONS,
11
+ annotations: safety_js_1.READ_ONLY,
14
12
  }, async () => {
15
13
  try {
16
14
  const data = await client.get("/cameras");
@@ -23,7 +21,7 @@ function registerCameraTools(server, client, readOnly) {
23
21
  server.registerTool("protect_get_camera", {
24
22
  description: "Get details for a specific camera by ID",
25
23
  inputSchema: { id: zod_1.z.string().describe("Camera ID") },
26
- annotations: READ_ONLY_ANNOTATIONS,
24
+ annotations: safety_js_1.READ_ONLY,
27
25
  }, async ({ id }) => {
28
26
  try {
29
27
  const data = await client.get(`/cameras/${id}`);
@@ -36,7 +34,7 @@ function registerCameraTools(server, client, readOnly) {
36
34
  server.registerTool("protect_get_snapshot", {
37
35
  description: "Get a JPEG snapshot from a camera (returns image)",
38
36
  inputSchema: { id: zod_1.z.string().describe("Camera ID") },
39
- annotations: READ_ONLY_ANNOTATIONS,
37
+ annotations: safety_js_1.READ_ONLY,
40
38
  }, async ({ id }) => {
41
39
  try {
42
40
  const { data, mimeType } = await client.getBinary(`/cameras/${id}/snapshot`);
@@ -57,7 +55,7 @@ function registerCameraTools(server, client, readOnly) {
57
55
  server.registerTool("protect_get_rtsp_streams", {
58
56
  description: "Get active RTSPS stream sessions for a camera",
59
57
  inputSchema: { id: zod_1.z.string().describe("Camera ID") },
60
- annotations: READ_ONLY_ANNOTATIONS,
58
+ annotations: safety_js_1.READ_ONLY,
61
59
  }, async ({ id }) => {
62
60
  try {
63
61
  const data = await client.get(`/cameras/${id}/rtsps-stream`);
@@ -76,17 +74,17 @@ function registerCameraTools(server, client, readOnly) {
76
74
  id: zod_1.z.string().describe("Camera ID"),
77
75
  settings: zod_1.z
78
76
  .record(zod_1.z.string(), zod_1.z.unknown())
79
- .describe("Partial camera settings to update (JSON object)"),
77
+ .describe("Partial camera settings to update. Known fields: name (string), osdSettings (object), ledSettings (object), lcdMessage (object), videoMode (string), hdrType (string), smartDetectSettings (object)"),
80
78
  dryRun: zod_1.z
81
79
  .boolean()
82
80
  .optional()
83
81
  .describe("If true, return what would happen without making changes"),
84
82
  },
85
- annotations: WRITE_ANNOTATIONS,
83
+ annotations: safety_js_1.WRITE,
86
84
  }, async ({ id, settings, dryRun }) => {
87
85
  try {
88
86
  if (dryRun) {
89
- return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "PATCH", path: `/cameras/${id}`, body: settings });
87
+ return (0, safety_js_1.formatDryRun)("PATCH", `/cameras/${id}`, settings);
90
88
  }
91
89
  const data = await client.patch(`/cameras/${id}`, settings);
92
90
  return (0, responses_js_1.formatSuccess)(data);
@@ -104,11 +102,11 @@ function registerCameraTools(server, client, readOnly) {
104
102
  .optional()
105
103
  .describe("If true, return what would happen without making changes"),
106
104
  },
107
- annotations: WRITE_ANNOTATIONS,
105
+ annotations: safety_js_1.WRITE,
108
106
  }, async ({ id, dryRun }) => {
109
107
  try {
110
108
  if (dryRun) {
111
- return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "POST", path: `/cameras/${id}/rtsps-stream` });
109
+ return (0, safety_js_1.formatDryRun)("POST", `/cameras/${id}/rtsps-stream`);
112
110
  }
113
111
  const data = await client.post(`/cameras/${id}/rtsps-stream`);
114
112
  return (0, responses_js_1.formatSuccess)(data);
@@ -126,11 +124,11 @@ function registerCameraTools(server, client, readOnly) {
126
124
  .optional()
127
125
  .describe("If true, return what would happen without making changes"),
128
126
  },
129
- annotations: DESTRUCTIVE_ANNOTATIONS,
127
+ annotations: safety_js_1.DESTRUCTIVE,
130
128
  }, async ({ id, dryRun }) => {
131
129
  try {
132
130
  if (dryRun) {
133
- return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "DELETE", path: `/cameras/${id}/rtsps-stream` });
131
+ return (0, safety_js_1.formatDryRun)("DELETE", `/cameras/${id}/rtsps-stream`);
134
132
  }
135
133
  const data = await client.delete(`/cameras/${id}/rtsps-stream`);
136
134
  return (0, responses_js_1.formatSuccess)(data);
@@ -148,11 +146,11 @@ function registerCameraTools(server, client, readOnly) {
148
146
  .optional()
149
147
  .describe("If true, return what would happen without making changes"),
150
148
  },
151
- annotations: WRITE_ANNOTATIONS,
149
+ annotations: safety_js_1.WRITE,
152
150
  }, async ({ id, dryRun }) => {
153
151
  try {
154
152
  if (dryRun) {
155
- return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "POST", path: `/cameras/${id}/talkback-session` });
153
+ return (0, safety_js_1.formatDryRun)("POST", `/cameras/${id}/talkback-session`);
156
154
  }
157
155
  const data = await client.post(`/cameras/${id}/talkback-session`);
158
156
  return (0, responses_js_1.formatSuccess)(data);
@@ -162,15 +160,18 @@ function registerCameraTools(server, client, readOnly) {
162
160
  }
163
161
  });
164
162
  server.registerTool("protect_disable_mic", {
165
- description: "IRREVERSIBLE: Permanently disable the microphone on a camera. Cannot be re-enabled.",
163
+ description: "IRREVERSIBLE: Permanently disable the microphone on a camera. Can only be re-enabled by factory resetting the camera.",
166
164
  inputSchema: {
167
165
  id: zod_1.z.string().describe("Camera ID"),
168
166
  confirm: zod_1.z
169
167
  .literal(true)
170
168
  .describe("Must be true to confirm this irreversible action"),
171
169
  },
172
- annotations: DESTRUCTIVE_ANNOTATIONS,
173
- }, async ({ id }) => {
170
+ annotations: safety_js_1.DESTRUCTIVE,
171
+ }, async ({ id, confirm }) => {
172
+ const denied = (0, safety_js_1.requireConfirmation)(confirm, "permanently disable the microphone");
173
+ if (denied)
174
+ return denied;
174
175
  try {
175
176
  const data = await client.post(`/cameras/${id}/disable-mic-permanently`);
176
177
  return (0, responses_js_1.formatSuccess)(data);
@@ -189,11 +190,11 @@ function registerCameraTools(server, client, readOnly) {
189
190
  .optional()
190
191
  .describe("If true, return what would happen without making changes"),
191
192
  },
192
- annotations: WRITE_ANNOTATIONS,
193
+ annotations: safety_js_1.WRITE,
193
194
  }, async ({ id, slot, dryRun }) => {
194
195
  try {
195
196
  if (dryRun) {
196
- return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "POST", path: `/cameras/${id}/ptz/patrol/start/${slot}` });
197
+ return (0, safety_js_1.formatDryRun)("POST", `/cameras/${id}/ptz/patrol/start/${slot}`);
197
198
  }
198
199
  const data = await client.post(`/cameras/${id}/ptz/patrol/start/${slot}`);
199
200
  return (0, responses_js_1.formatSuccess)(data);
@@ -211,11 +212,11 @@ function registerCameraTools(server, client, readOnly) {
211
212
  .optional()
212
213
  .describe("If true, return what would happen without making changes"),
213
214
  },
214
- annotations: WRITE_ANNOTATIONS,
215
+ annotations: safety_js_1.WRITE,
215
216
  }, async ({ id, dryRun }) => {
216
217
  try {
217
218
  if (dryRun) {
218
- return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "POST", path: `/cameras/${id}/ptz/patrol/stop` });
219
+ return (0, safety_js_1.formatDryRun)("POST", `/cameras/${id}/ptz/patrol/stop`);
219
220
  }
220
221
  const data = await client.post(`/cameras/${id}/ptz/patrol/stop`);
221
222
  return (0, responses_js_1.formatSuccess)(data);
@@ -234,11 +235,11 @@ function registerCameraTools(server, client, readOnly) {
234
235
  .optional()
235
236
  .describe("If true, return what would happen without making changes"),
236
237
  },
237
- annotations: WRITE_ANNOTATIONS,
238
+ annotations: safety_js_1.WRITE,
238
239
  }, async ({ id, slot, dryRun }) => {
239
240
  try {
240
241
  if (dryRun) {
241
- return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "POST", path: `/cameras/${id}/ptz/goto/${slot}` });
242
+ return (0, safety_js_1.formatDryRun)("POST", `/cameras/${id}/ptz/goto/${slot}`);
242
243
  }
243
244
  const data = await client.post(`/cameras/${id}/ptz/goto/${slot}`);
244
245
  return (0, responses_js_1.formatSuccess)(data);
@@ -3,14 +3,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerDeviceTools = registerDeviceTools;
4
4
  const zod_1 = require("zod");
5
5
  const responses_js_1 = require("../utils/responses.js");
6
- const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false };
7
- const WRITE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false };
6
+ const safety_js_1 = require("../utils/safety.js");
7
+ const settingsHints = {
8
+ light: "Known fields: name (string), isLightForceEnabled (boolean), lightModeSettings (object with mode, enableAt), lightDeviceSettings (object with isIndicatorEnabled, pirDuration, pirSensitivity, ledLevel)",
9
+ sensor: "Known fields: name (string), mountType (string), motionSettings (object), humiditySettings (object), temperatureSettings (object), lightSettings (object), alarmSettings (object)",
10
+ chime: "Known fields: name (string), volume (number), ringSettings (array of ring tone configurations)",
11
+ viewer: "Known fields: name (string), liveview (string, liveview ID to display)",
12
+ };
8
13
  function registerDeviceCrud(server, client, deviceType, readOnly) {
9
14
  const plural = `${deviceType}s`;
10
15
  const label = deviceType.charAt(0).toUpperCase() + deviceType.slice(1);
11
16
  server.registerTool(`protect_list_${plural}`, {
12
17
  description: `List all ${plural} managed by UniFi Protect`,
13
- annotations: READ_ONLY_ANNOTATIONS,
18
+ annotations: safety_js_1.READ_ONLY,
14
19
  }, async () => {
15
20
  try {
16
21
  const data = await client.get(`/${plural}`);
@@ -23,7 +28,7 @@ function registerDeviceCrud(server, client, deviceType, readOnly) {
23
28
  server.registerTool(`protect_get_${deviceType}`, {
24
29
  description: `Get details for a specific ${deviceType} by ID`,
25
30
  inputSchema: { id: zod_1.z.string().describe(`${label} ID`) },
26
- annotations: READ_ONLY_ANNOTATIONS,
31
+ annotations: safety_js_1.READ_ONLY,
27
32
  }, async ({ id }) => {
28
33
  try {
29
34
  const data = await client.get(`/${plural}/${id}`);
@@ -40,17 +45,17 @@ function registerDeviceCrud(server, client, deviceType, readOnly) {
40
45
  id: zod_1.z.string().describe(`${label} ID`),
41
46
  settings: zod_1.z
42
47
  .record(zod_1.z.string(), zod_1.z.unknown())
43
- .describe(`Partial ${deviceType} settings to update (JSON object)`),
48
+ .describe(`Partial ${deviceType} settings to update. ${settingsHints[deviceType]}`),
44
49
  dryRun: zod_1.z
45
50
  .boolean()
46
51
  .optional()
47
52
  .describe("If true, return what would happen without making changes"),
48
53
  },
49
- annotations: WRITE_ANNOTATIONS,
54
+ annotations: safety_js_1.WRITE,
50
55
  }, async ({ id, settings, dryRun }) => {
51
56
  try {
52
57
  if (dryRun) {
53
- return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "PATCH", path: `/${plural}/${id}`, body: settings });
58
+ return (0, safety_js_1.formatDryRun)("PATCH", `/${plural}/${id}`, settings);
54
59
  }
55
60
  const data = await client.patch(`/${plural}/${id}`, settings);
56
61
  return (0, responses_js_1.formatSuccess)(data);
@@ -3,9 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerFileTools = registerFileTools;
4
4
  const zod_1 = require("zod");
5
5
  const responses_js_1 = require("../utils/responses.js");
6
- const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false };
7
- const WRITE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false };
8
- const DESTRUCTIVE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: true };
6
+ const safety_js_1 = require("../utils/safety.js");
9
7
  function registerFileTools(server, client, readOnly) {
10
8
  server.registerTool("protect_list_files", {
11
9
  description: "List files of a given type (e.g. 'video', 'timelapse')",
@@ -14,7 +12,7 @@ function registerFileTools(server, client, readOnly) {
14
12
  .string()
15
13
  .describe("File type to list (e.g. 'video', 'timelapse')"),
16
14
  },
17
- annotations: READ_ONLY_ANNOTATIONS,
15
+ annotations: safety_js_1.READ_ONLY,
18
16
  }, async ({ fileType }) => {
19
17
  try {
20
18
  const data = await client.get(`/files/${fileType}`);
@@ -34,11 +32,14 @@ function registerFileTools(server, client, readOnly) {
34
32
  .literal(true)
35
33
  .describe("Must be true to confirm triggering the alarm webhook"),
36
34
  },
37
- annotations: DESTRUCTIVE_ANNOTATIONS,
38
- }, async ({ id }) => {
35
+ annotations: safety_js_1.DESTRUCTIVE,
36
+ }, async ({ id, confirm }) => {
37
+ const denied = (0, safety_js_1.requireConfirmation)(confirm, "trigger the alarm webhook");
38
+ if (denied)
39
+ return denied;
39
40
  try {
40
- const data = await client.post(`/alarm-manager/webhook/${id}`);
41
- return (0, responses_js_1.formatSuccess)(data);
41
+ await client.post(`/alarm-manager/webhook/${id}`);
42
+ return (0, responses_js_1.formatSuccess)({ triggered: true, webhookId: id });
42
43
  }
43
44
  catch (err) {
44
45
  return (0, responses_js_1.formatError)(err);
@@ -58,14 +59,11 @@ function registerFileTools(server, client, readOnly) {
58
59
  .optional()
59
60
  .describe("If true, return what would happen without making changes"),
60
61
  },
61
- annotations: WRITE_ANNOTATIONS,
62
+ annotations: safety_js_1.WRITE,
62
63
  }, async ({ fileType, base64Data, contentType, dryRun }) => {
63
64
  try {
64
65
  if (dryRun) {
65
- return (0, responses_js_1.formatSuccess)({
66
- dryRun: true,
67
- action: "POST",
68
- path: `/files/${fileType}`,
66
+ return (0, safety_js_1.formatDryRun)("POST", `/files/${fileType}`, {
69
67
  contentType,
70
68
  dataLength: base64Data.length,
71
69
  });
@@ -3,12 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerLiveviewTools = registerLiveviewTools;
4
4
  const zod_1 = require("zod");
5
5
  const responses_js_1 = require("../utils/responses.js");
6
- const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false };
7
- const WRITE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false };
6
+ const safety_js_1 = require("../utils/safety.js");
8
7
  function registerLiveviewTools(server, client, readOnly) {
9
8
  server.registerTool("protect_list_liveviews", {
10
9
  description: "List all live views configured in UniFi Protect",
11
- annotations: READ_ONLY_ANNOTATIONS,
10
+ annotations: safety_js_1.READ_ONLY,
12
11
  }, async () => {
13
12
  try {
14
13
  const data = await client.get("/liveviews");
@@ -21,7 +20,7 @@ function registerLiveviewTools(server, client, readOnly) {
21
20
  server.registerTool("protect_get_liveview", {
22
21
  description: "Get details for a specific live view by ID",
23
22
  inputSchema: { id: zod_1.z.string().describe("Liveview ID") },
24
- annotations: READ_ONLY_ANNOTATIONS,
23
+ annotations: safety_js_1.READ_ONLY,
25
24
  }, async ({ id }) => {
26
25
  try {
27
26
  const data = await client.get(`/liveviews/${id}`);
@@ -38,17 +37,17 @@ function registerLiveviewTools(server, client, readOnly) {
38
37
  inputSchema: {
39
38
  settings: zod_1.z
40
39
  .record(zod_1.z.string(), zod_1.z.unknown())
41
- .describe("Liveview configuration (JSON object with name, slots, etc.)"),
40
+ .describe("Liveview configuration. Required fields: name (string), layout (number of slots), slots (array of {cameras: string[], cycleMode: string, cycleInterval: number}), isDefault (boolean), isGlobal (boolean)"),
42
41
  dryRun: zod_1.z
43
42
  .boolean()
44
43
  .optional()
45
44
  .describe("If true, return what would happen without making changes"),
46
45
  },
47
- annotations: WRITE_ANNOTATIONS,
46
+ annotations: safety_js_1.WRITE,
48
47
  }, async ({ settings, dryRun }) => {
49
48
  try {
50
49
  if (dryRun) {
51
- return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "POST", path: "/liveviews", body: settings });
50
+ return (0, safety_js_1.formatDryRun)("POST", "/liveviews", settings);
52
51
  }
53
52
  const data = await client.post("/liveviews", settings);
54
53
  return (0, responses_js_1.formatSuccess)(data);
@@ -63,17 +62,17 @@ function registerLiveviewTools(server, client, readOnly) {
63
62
  id: zod_1.z.string().describe("Liveview ID"),
64
63
  settings: zod_1.z
65
64
  .record(zod_1.z.string(), zod_1.z.unknown())
66
- .describe("Partial liveview settings to update (JSON object)"),
65
+ .describe("Partial liveview settings to update. Known fields: name (string), layout (number), slots (array of {cameras: string[], cycleMode: string, cycleInterval: number}), isDefault (boolean), isGlobal (boolean)"),
67
66
  dryRun: zod_1.z
68
67
  .boolean()
69
68
  .optional()
70
69
  .describe("If true, return what would happen without making changes"),
71
70
  },
72
- annotations: WRITE_ANNOTATIONS,
71
+ annotations: safety_js_1.WRITE,
73
72
  }, async ({ id, settings, dryRun }) => {
74
73
  try {
75
74
  if (dryRun) {
76
- return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "PATCH", path: `/liveviews/${id}`, body: settings });
75
+ return (0, safety_js_1.formatDryRun)("PATCH", `/liveviews/${id}`, settings);
77
76
  }
78
77
  const data = await client.patch(`/liveviews/${id}`, settings);
79
78
  return (0, responses_js_1.formatSuccess)(data);
@@ -2,11 +2,11 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerSystemTools = registerSystemTools;
4
4
  const responses_js_1 = require("../utils/responses.js");
5
- const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false };
5
+ const safety_js_1 = require("../utils/safety.js");
6
6
  function registerSystemTools(server, client) {
7
7
  server.registerTool("protect_get_info", {
8
8
  description: "Get UniFi Protect system information and version details",
9
- annotations: READ_ONLY_ANNOTATIONS,
9
+ annotations: safety_js_1.READ_ONLY,
10
10
  }, async () => {
11
11
  try {
12
12
  const data = await client.get("/meta/info");
@@ -18,7 +18,7 @@ function registerSystemTools(server, client) {
18
18
  });
19
19
  server.registerTool("protect_list_nvrs", {
20
20
  description: "List all NVR (Network Video Recorder) devices",
21
- annotations: READ_ONLY_ANNOTATIONS,
21
+ annotations: safety_js_1.READ_ONLY,
22
22
  }, async () => {
23
23
  try {
24
24
  const data = await client.get("/nvrs");
@@ -0,0 +1,34 @@
1
+ /** Annotation constants for tool safety classification */
2
+ export declare const READ_ONLY: {
3
+ readonly readOnlyHint: true;
4
+ readonly destructiveHint: false;
5
+ };
6
+ export declare const WRITE: {
7
+ readonly readOnlyHint: false;
8
+ readonly destructiveHint: false;
9
+ };
10
+ export declare const DESTRUCTIVE: {
11
+ readonly readOnlyHint: false;
12
+ readonly destructiveHint: true;
13
+ };
14
+ /**
15
+ * Build a standard dry-run preview response.
16
+ * Returns the MCP tool result that describes what *would* happen.
17
+ */
18
+ export declare function formatDryRun(method: string, path: string, body?: unknown): {
19
+ content: {
20
+ type: "text";
21
+ text: string;
22
+ }[];
23
+ };
24
+ /**
25
+ * Guard for destructive actions that require explicit confirmation.
26
+ * Returns an error result if `confirm` is not `true`, otherwise `null`.
27
+ */
28
+ export declare function requireConfirmation(confirm: boolean, action: string): {
29
+ content: {
30
+ type: "text";
31
+ text: string;
32
+ }[];
33
+ isError: boolean;
34
+ } | null;
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DESTRUCTIVE = exports.WRITE = exports.READ_ONLY = void 0;
4
+ exports.formatDryRun = formatDryRun;
5
+ exports.requireConfirmation = requireConfirmation;
6
+ const responses_js_1 = require("./responses.js");
7
+ /** Annotation constants for tool safety classification */
8
+ exports.READ_ONLY = { readOnlyHint: true, destructiveHint: false };
9
+ exports.WRITE = { readOnlyHint: false, destructiveHint: false };
10
+ exports.DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true };
11
+ /**
12
+ * Build a standard dry-run preview response.
13
+ * Returns the MCP tool result that describes what *would* happen.
14
+ */
15
+ function formatDryRun(method, path, body) {
16
+ const preview = { dryRun: true, action: method, path };
17
+ if (body !== undefined) {
18
+ preview.body = body;
19
+ }
20
+ return (0, responses_js_1.formatSuccess)(preview);
21
+ }
22
+ /**
23
+ * Guard for destructive actions that require explicit confirmation.
24
+ * Returns an error result if `confirm` is not `true`, otherwise `null`.
25
+ */
26
+ function requireConfirmation(confirm, action) {
27
+ if (confirm !== true) {
28
+ return {
29
+ content: [
30
+ {
31
+ type: "text",
32
+ text: `Error: You must set confirm to true to ${action}`,
33
+ },
34
+ ],
35
+ isError: true,
36
+ };
37
+ }
38
+ return null;
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owine/unifi-protect-mcp",
3
- "version": "1.1.0",
3
+ "version": "2.0.1",
4
4
  "description": "MCP server for UniFi Protect Integration API",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "lint:fix": "eslint . --fix"
21
21
  },
22
22
  "engines": {
23
- "node": ">=20.0.0"
23
+ "node": "^20.19.0 || ^22.13.0 || >=24"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",
@@ -40,11 +40,12 @@
40
40
  "zod": "^4.0.0"
41
41
  },
42
42
  "devDependencies": {
43
- "@eslint/js": "9.39.2",
43
+ "@eslint/js": "10.0.1",
44
44
  "@types/node": "24.10.13",
45
- "eslint": "9.39.2",
45
+ "@vitest/coverage-v8": "4.0.18",
46
+ "eslint": "10.0.0",
46
47
  "typescript": "5.9.3",
47
- "typescript-eslint": "8.55.0",
48
+ "typescript-eslint": "8.56.0",
48
49
  "vitest": "4.0.18"
49
50
  }
50
51
  }