@owine/unifi-protect-mcp 2.0.2 → 2.2.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/dist/client.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import WebSocket from "ws";
1
2
  import { Config } from "./config.js";
2
3
  export declare class ProtectClient {
3
4
  private baseUrl;
@@ -13,4 +14,5 @@ export declare class ProtectClient {
13
14
  mimeType: string;
14
15
  }>;
15
16
  postBinary(path: string, data: Buffer, contentType: string): Promise<unknown>;
17
+ connectWebSocket(path: string): WebSocket;
16
18
  }
package/dist/client.js CHANGED
@@ -1,6 +1,10 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.ProtectClient = void 0;
7
+ const ws_1 = __importDefault(require("ws"));
4
8
  class ProtectClient {
5
9
  baseUrl;
6
10
  headers;
@@ -80,5 +84,12 @@ class ProtectClient {
80
84
  }
81
85
  return response.text();
82
86
  }
87
+ connectWebSocket(path) {
88
+ const url = `wss://${this.baseUrl.replace(/^https?:\/\//, "")}${path}`;
89
+ return new ws_1.default(url, {
90
+ headers: { "X-API-KEY": this.headers["X-API-KEY"] },
91
+ rejectUnauthorized: process.env.NODE_TLS_REJECT_UNAUTHORIZED !== "0",
92
+ });
93
+ }
83
94
  }
84
95
  exports.ProtectClient = ProtectClient;
@@ -4,6 +4,7 @@ exports.registerCameraTools = registerCameraTools;
4
4
  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
+ const url_js_1 = require("../utils/url.js");
7
8
  function registerCameraTools(server, client, readOnly) {
8
9
  // --- Read-only tools (always registered) ---
9
10
  server.registerTool("protect_list_cameras", {
@@ -24,7 +25,7 @@ function registerCameraTools(server, client, readOnly) {
24
25
  annotations: safety_js_1.READ_ONLY,
25
26
  }, async ({ id }) => {
26
27
  try {
27
- const data = await client.get(`/cameras/${id}`);
28
+ const data = await client.get((0, url_js_1.safePath) `/cameras/${id}`);
28
29
  return (0, responses_js_1.formatSuccess)(data);
29
30
  }
30
31
  catch (err) {
@@ -33,11 +34,20 @@ function registerCameraTools(server, client, readOnly) {
33
34
  });
34
35
  server.registerTool("protect_get_snapshot", {
35
36
  description: "Get a JPEG snapshot from a camera (returns image)",
36
- inputSchema: { id: zod_1.z.string().describe("Camera ID") },
37
+ inputSchema: {
38
+ id: zod_1.z.string().describe("Camera ID"),
39
+ highQuality: zod_1.z
40
+ .boolean()
41
+ .optional()
42
+ .describe("If true, request a high-quality snapshot"),
43
+ },
37
44
  annotations: safety_js_1.READ_ONLY,
38
- }, async ({ id }) => {
45
+ }, async ({ id, highQuality }) => {
39
46
  try {
40
- const { data, mimeType } = await client.getBinary(`/cameras/${id}/snapshot`);
47
+ const url = highQuality
48
+ ? (0, url_js_1.safePath) `/cameras/${id}/snapshot?highQuality=true`
49
+ : (0, url_js_1.safePath) `/cameras/${id}/snapshot`;
50
+ const { data, mimeType } = await client.getBinary(url);
41
51
  return {
42
52
  content: [
43
53
  {
@@ -58,7 +68,7 @@ function registerCameraTools(server, client, readOnly) {
58
68
  annotations: safety_js_1.READ_ONLY,
59
69
  }, async ({ id }) => {
60
70
  try {
61
- const data = await client.get(`/cameras/${id}/rtsps-stream`);
71
+ const data = await client.get((0, url_js_1.safePath) `/cameras/${id}/rtsps-stream`);
62
72
  return (0, responses_js_1.formatSuccess)(data);
63
73
  }
64
74
  catch (err) {
@@ -74,7 +84,7 @@ function registerCameraTools(server, client, readOnly) {
74
84
  id: zod_1.z.string().describe("Camera ID"),
75
85
  settings: zod_1.z
76
86
  .record(zod_1.z.string(), zod_1.z.unknown())
77
- .describe("Partial camera settings to update. Known fields: name (string), osdSettings (object), ledSettings (object), lcdMessage (object), videoMode (string), hdrType (string), smartDetectSettings (object)"),
87
+ .describe("Partial camera settings to update. Known fields: name (string), osdSettings (object), ledSettings (object), lcdMessage (object), videoMode (string: \"default\" | \"highFps\" | \"slo-mo\"), hdrType (string: \"off\" | \"normal\" | \"always\"), micVolume (number 0-100), smartDetectSettings (object)"),
78
88
  dryRun: zod_1.z
79
89
  .boolean()
80
90
  .optional()
@@ -84,9 +94,9 @@ function registerCameraTools(server, client, readOnly) {
84
94
  }, async ({ id, settings, dryRun }) => {
85
95
  try {
86
96
  if (dryRun) {
87
- return (0, safety_js_1.formatDryRun)("PATCH", `/cameras/${id}`, settings);
97
+ return (0, safety_js_1.formatDryRun)("PATCH", (0, url_js_1.safePath) `/cameras/${id}`, settings);
88
98
  }
89
- const data = await client.patch(`/cameras/${id}`, settings);
99
+ const data = await client.patch((0, url_js_1.safePath) `/cameras/${id}`, settings);
90
100
  return (0, responses_js_1.formatSuccess)(data);
91
101
  }
92
102
  catch (err) {
@@ -97,18 +107,23 @@ function registerCameraTools(server, client, readOnly) {
97
107
  description: "Create an RTSPS stream session for a camera",
98
108
  inputSchema: {
99
109
  id: zod_1.z.string().describe("Camera ID"),
110
+ qualities: zod_1.z
111
+ .array(zod_1.z.enum(["high", "medium", "low", "package"]))
112
+ .describe("Quality levels to include in the RTSPS stream"),
100
113
  dryRun: zod_1.z
101
114
  .boolean()
102
115
  .optional()
103
116
  .describe("If true, return what would happen without making changes"),
104
117
  },
105
118
  annotations: safety_js_1.WRITE,
106
- }, async ({ id, dryRun }) => {
119
+ }, async ({ id, qualities, dryRun }) => {
107
120
  try {
108
121
  if (dryRun) {
109
- return (0, safety_js_1.formatDryRun)("POST", `/cameras/${id}/rtsps-stream`);
122
+ return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/cameras/${id}/rtsps-stream`, { qualities });
110
123
  }
111
- const data = await client.post(`/cameras/${id}/rtsps-stream`);
124
+ const data = await client.post((0, url_js_1.safePath) `/cameras/${id}/rtsps-stream`, {
125
+ qualities,
126
+ });
112
127
  return (0, responses_js_1.formatSuccess)(data);
113
128
  }
114
129
  catch (err) {
@@ -119,18 +134,21 @@ function registerCameraTools(server, client, readOnly) {
119
134
  description: "Stop and delete an active RTSPS stream session for a camera",
120
135
  inputSchema: {
121
136
  id: zod_1.z.string().describe("Camera ID"),
137
+ qualities: zod_1.z
138
+ .array(zod_1.z.enum(["high", "medium", "low", "package"]))
139
+ .describe("Quality levels to remove from the RTSPS stream"),
122
140
  dryRun: zod_1.z
123
141
  .boolean()
124
142
  .optional()
125
143
  .describe("If true, return what would happen without making changes"),
126
144
  },
127
145
  annotations: safety_js_1.DESTRUCTIVE,
128
- }, async ({ id, dryRun }) => {
146
+ }, async ({ id, qualities, dryRun }) => {
129
147
  try {
130
148
  if (dryRun) {
131
- return (0, safety_js_1.formatDryRun)("DELETE", `/cameras/${id}/rtsps-stream`);
149
+ return (0, safety_js_1.formatDryRun)("DELETE", (0, url_js_1.safePath) `/cameras/${id}/rtsps-stream?qualities=${qualities.join(",")}`);
132
150
  }
133
- const data = await client.delete(`/cameras/${id}/rtsps-stream`);
151
+ const data = await client.delete((0, url_js_1.safePath) `/cameras/${id}/rtsps-stream?qualities=${qualities.join(",")}`);
134
152
  return (0, responses_js_1.formatSuccess)(data);
135
153
  }
136
154
  catch (err) {
@@ -150,9 +168,9 @@ function registerCameraTools(server, client, readOnly) {
150
168
  }, async ({ id, dryRun }) => {
151
169
  try {
152
170
  if (dryRun) {
153
- return (0, safety_js_1.formatDryRun)("POST", `/cameras/${id}/talkback-session`);
171
+ return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/cameras/${id}/talkback-session`);
154
172
  }
155
- const data = await client.post(`/cameras/${id}/talkback-session`);
173
+ const data = await client.post((0, url_js_1.safePath) `/cameras/${id}/talkback-session`);
156
174
  return (0, responses_js_1.formatSuccess)(data);
157
175
  }
158
176
  catch (err) {
@@ -173,7 +191,7 @@ function registerCameraTools(server, client, readOnly) {
173
191
  if (denied)
174
192
  return denied;
175
193
  try {
176
- const data = await client.post(`/cameras/${id}/disable-mic-permanently`);
194
+ const data = await client.post((0, url_js_1.safePath) `/cameras/${id}/disable-mic-permanently`);
177
195
  return (0, responses_js_1.formatSuccess)(data);
178
196
  }
179
197
  catch (err) {
@@ -184,7 +202,7 @@ function registerCameraTools(server, client, readOnly) {
184
202
  description: "Start PTZ patrol on a camera at a given slot",
185
203
  inputSchema: {
186
204
  id: zod_1.z.string().describe("Camera ID"),
187
- slot: zod_1.z.number().int().describe("Patrol slot number"),
205
+ slot: zod_1.z.number().int().min(0).max(4).describe("Patrol slot number (0-4)"),
188
206
  dryRun: zod_1.z
189
207
  .boolean()
190
208
  .optional()
@@ -194,9 +212,9 @@ function registerCameraTools(server, client, readOnly) {
194
212
  }, async ({ id, slot, dryRun }) => {
195
213
  try {
196
214
  if (dryRun) {
197
- return (0, safety_js_1.formatDryRun)("POST", `/cameras/${id}/ptz/patrol/start/${slot}`);
215
+ return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/cameras/${id}/ptz/patrol/start/${slot}`);
198
216
  }
199
- const data = await client.post(`/cameras/${id}/ptz/patrol/start/${slot}`);
217
+ const data = await client.post((0, url_js_1.safePath) `/cameras/${id}/ptz/patrol/start/${slot}`);
200
218
  return (0, responses_js_1.formatSuccess)(data);
201
219
  }
202
220
  catch (err) {
@@ -216,9 +234,9 @@ function registerCameraTools(server, client, readOnly) {
216
234
  }, async ({ id, dryRun }) => {
217
235
  try {
218
236
  if (dryRun) {
219
- return (0, safety_js_1.formatDryRun)("POST", `/cameras/${id}/ptz/patrol/stop`);
237
+ return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/cameras/${id}/ptz/patrol/stop`);
220
238
  }
221
- const data = await client.post(`/cameras/${id}/ptz/patrol/stop`);
239
+ const data = await client.post((0, url_js_1.safePath) `/cameras/${id}/ptz/patrol/stop`);
222
240
  return (0, responses_js_1.formatSuccess)(data);
223
241
  }
224
242
  catch (err) {
@@ -229,7 +247,7 @@ function registerCameraTools(server, client, readOnly) {
229
247
  description: "Move camera PTZ to a preset position",
230
248
  inputSchema: {
231
249
  id: zod_1.z.string().describe("Camera ID"),
232
- slot: zod_1.z.number().int().describe("PTZ preset slot number"),
250
+ slot: zod_1.z.number().int().min(-1).max(4).describe("PTZ preset slot number (0-4, or -1 for home position)"),
233
251
  dryRun: zod_1.z
234
252
  .boolean()
235
253
  .optional()
@@ -239,9 +257,9 @@ function registerCameraTools(server, client, readOnly) {
239
257
  }, async ({ id, slot, dryRun }) => {
240
258
  try {
241
259
  if (dryRun) {
242
- return (0, safety_js_1.formatDryRun)("POST", `/cameras/${id}/ptz/goto/${slot}`);
260
+ return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/cameras/${id}/ptz/goto/${slot}`);
243
261
  }
244
- const data = await client.post(`/cameras/${id}/ptz/goto/${slot}`);
262
+ const data = await client.post((0, url_js_1.safePath) `/cameras/${id}/ptz/goto/${slot}`);
245
263
  return (0, responses_js_1.formatSuccess)(data);
246
264
  }
247
265
  catch (err) {
@@ -4,10 +4,11 @@ exports.registerDeviceTools = registerDeviceTools;
4
4
  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
+ const url_js_1 = require("../utils/url.js");
7
8
  const settingsHints = {
8
9
  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)",
10
+ sensor: "Known fields: name (string), motionSettings (object), humiditySettings (object), temperatureSettings (object), lightSettings (object), alarmSettings (object)",
11
+ chime: "Known fields: name (string), cameraIds (array of camera IDs linked to the chime), ringSettings (array of ring tone configurations)",
11
12
  viewer: "Known fields: name (string), liveview (string, liveview ID to display)",
12
13
  };
13
14
  function registerDeviceCrud(server, client, deviceType, readOnly) {
@@ -31,7 +32,7 @@ function registerDeviceCrud(server, client, deviceType, readOnly) {
31
32
  annotations: safety_js_1.READ_ONLY,
32
33
  }, async ({ id }) => {
33
34
  try {
34
- const data = await client.get(`/${plural}/${id}`);
35
+ const data = await client.get((0, url_js_1.safePath) `/${plural}/${id}`);
35
36
  return (0, responses_js_1.formatSuccess)(data);
36
37
  }
37
38
  catch (err) {
@@ -55,9 +56,9 @@ function registerDeviceCrud(server, client, deviceType, readOnly) {
55
56
  }, async ({ id, settings, dryRun }) => {
56
57
  try {
57
58
  if (dryRun) {
58
- return (0, safety_js_1.formatDryRun)("PATCH", `/${plural}/${id}`, settings);
59
+ return (0, safety_js_1.formatDryRun)("PATCH", (0, url_js_1.safePath) `/${plural}/${id}`, settings);
59
60
  }
60
- const data = await client.patch(`/${plural}/${id}`, settings);
61
+ const data = await client.patch((0, url_js_1.safePath) `/${plural}/${id}`, settings);
61
62
  return (0, responses_js_1.formatSuccess)(data);
62
63
  }
63
64
  catch (err) {
@@ -4,18 +4,19 @@ exports.registerFileTools = registerFileTools;
4
4
  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
+ const url_js_1 = require("../utils/url.js");
7
8
  function registerFileTools(server, client, readOnly) {
8
9
  server.registerTool("protect_list_files", {
9
- description: "List files of a given type (e.g. 'video', 'timelapse')",
10
+ description: "List files of a given type (only 'animations' is supported)",
10
11
  inputSchema: {
11
12
  fileType: zod_1.z
12
13
  .string()
13
- .describe("File type to list (e.g. 'video', 'timelapse')"),
14
+ .describe("File type to list (only 'animations' is currently supported)"),
14
15
  },
15
16
  annotations: safety_js_1.READ_ONLY,
16
17
  }, async ({ fileType }) => {
17
18
  try {
18
- const data = await client.get(`/files/${fileType}`);
19
+ const data = await client.get((0, url_js_1.safePath) `/files/${fileType}`);
19
20
  return (0, responses_js_1.formatSuccess)(data);
20
21
  }
21
22
  catch (err) {
@@ -27,7 +28,7 @@ function registerFileTools(server, client, readOnly) {
27
28
  server.registerTool("protect_trigger_alarm_webhook", {
28
29
  description: "Trigger an alarm manager webhook by ID. This fires an external alarm action.",
29
30
  inputSchema: {
30
- id: zod_1.z.string().describe("Webhook ID"),
31
+ id: zod_1.z.string().describe("User-defined webhook ID matching an alarm manager configuration"),
31
32
  confirm: zod_1.z
32
33
  .literal(true)
33
34
  .describe("Must be true to confirm triggering the alarm webhook"),
@@ -38,7 +39,7 @@ function registerFileTools(server, client, readOnly) {
38
39
  if (denied)
39
40
  return denied;
40
41
  try {
41
- await client.post(`/alarm-manager/webhook/${id}`);
42
+ await client.post((0, url_js_1.safePath) `/alarm-manager/webhook/${id}`);
42
43
  return (0, responses_js_1.formatSuccess)({ triggered: true, webhookId: id });
43
44
  }
44
45
  catch (err) {
@@ -52,8 +53,8 @@ function registerFileTools(server, client, readOnly) {
52
53
  base64Data: zod_1.z.string().describe("Base64-encoded file content"),
53
54
  contentType: zod_1.z
54
55
  .string()
55
- .default("application/octet-stream")
56
- .describe("MIME type of the file"),
56
+ .default("image/gif")
57
+ .describe("MIME type of the file (accepted: image/gif, image/png, image/jpeg)"),
57
58
  dryRun: zod_1.z
58
59
  .boolean()
59
60
  .optional()
@@ -63,13 +64,13 @@ function registerFileTools(server, client, readOnly) {
63
64
  }, async ({ fileType, base64Data, contentType, dryRun }) => {
64
65
  try {
65
66
  if (dryRun) {
66
- return (0, safety_js_1.formatDryRun)("POST", `/files/${fileType}`, {
67
+ return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/files/${fileType}`, {
67
68
  contentType,
68
69
  dataLength: base64Data.length,
69
70
  });
70
71
  }
71
72
  const buffer = Buffer.from(base64Data, "base64");
72
- const data = await client.postBinary(`/files/${fileType}`, buffer, contentType);
73
+ const data = await client.postBinary((0, url_js_1.safePath) `/files/${fileType}`, buffer, contentType);
73
74
  return (0, responses_js_1.formatSuccess)(data);
74
75
  }
75
76
  catch (err) {
@@ -6,8 +6,10 @@ const cameras_js_1 = require("./cameras.js");
6
6
  const devices_js_1 = require("./devices.js");
7
7
  const liveviews_js_1 = require("./liveviews.js");
8
8
  const files_js_1 = require("./files.js");
9
+ const subscriptions_js_1 = require("./subscriptions.js");
9
10
  function registerAllTools(server, client, readOnly) {
10
11
  (0, system_js_1.registerSystemTools)(server, client);
12
+ (0, subscriptions_js_1.registerSubscriptionTools)(server, client);
11
13
  (0, cameras_js_1.registerCameraTools)(server, client, readOnly);
12
14
  (0, devices_js_1.registerDeviceTools)(server, client, readOnly);
13
15
  (0, liveviews_js_1.registerLiveviewTools)(server, client, readOnly);
@@ -4,6 +4,7 @@ exports.registerLiveviewTools = registerLiveviewTools;
4
4
  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
+ const url_js_1 = require("../utils/url.js");
7
8
  function registerLiveviewTools(server, client, readOnly) {
8
9
  server.registerTool("protect_list_liveviews", {
9
10
  description: "List all live views configured in UniFi Protect",
@@ -23,7 +24,7 @@ function registerLiveviewTools(server, client, readOnly) {
23
24
  annotations: safety_js_1.READ_ONLY,
24
25
  }, async ({ id }) => {
25
26
  try {
26
- const data = await client.get(`/liveviews/${id}`);
27
+ const data = await client.get((0, url_js_1.safePath) `/liveviews/${id}`);
27
28
  return (0, responses_js_1.formatSuccess)(data);
28
29
  }
29
30
  catch (err) {
@@ -37,7 +38,7 @@ function registerLiveviewTools(server, client, readOnly) {
37
38
  inputSchema: {
38
39
  settings: zod_1.z
39
40
  .record(zod_1.z.string(), zod_1.z.unknown())
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)"),
41
+ .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)"),
41
42
  dryRun: zod_1.z
42
43
  .boolean()
43
44
  .optional()
@@ -57,12 +58,12 @@ function registerLiveviewTools(server, client, readOnly) {
57
58
  }
58
59
  });
59
60
  server.registerTool("protect_update_liveview", {
60
- description: "Update an existing live view (partial update via PATCH)",
61
+ description: "Update an existing live view (full replacement via PUT-style PATCH — include all fields)",
61
62
  inputSchema: {
62
63
  id: zod_1.z.string().describe("Liveview ID"),
63
64
  settings: zod_1.z
64
65
  .record(zod_1.z.string(), zod_1.z.unknown())
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)"),
66
+ .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)"),
66
67
  dryRun: zod_1.z
67
68
  .boolean()
68
69
  .optional()
@@ -72,9 +73,9 @@ function registerLiveviewTools(server, client, readOnly) {
72
73
  }, async ({ id, settings, dryRun }) => {
73
74
  try {
74
75
  if (dryRun) {
75
- return (0, safety_js_1.formatDryRun)("PATCH", `/liveviews/${id}`, settings);
76
+ return (0, safety_js_1.formatDryRun)("PATCH", (0, url_js_1.safePath) `/liveviews/${id}`, settings);
76
77
  }
77
- const data = await client.patch(`/liveviews/${id}`, settings);
78
+ const data = await client.patch((0, url_js_1.safePath) `/liveviews/${id}`, settings);
78
79
  return (0, responses_js_1.formatSuccess)(data);
79
80
  }
80
81
  catch (err) {
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ProtectClient } from "../client.js";
3
+ export declare function registerSubscriptionTools(server: McpServer, client: ProtectClient): void;
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerSubscriptionTools = registerSubscriptionTools;
4
+ const zod_1 = require("zod");
5
+ const responses_js_1 = require("../utils/responses.js");
6
+ const safety_js_1 = require("../utils/safety.js");
7
+ const durationSchema = zod_1.z
8
+ .number()
9
+ .min(1)
10
+ .max(30)
11
+ .default(5)
12
+ .describe("Seconds to listen (1-30, default 5)");
13
+ function collectMessages(client, path, duration) {
14
+ return new Promise((resolve) => {
15
+ const messages = [];
16
+ let error;
17
+ const ws = client.connectWebSocket(path);
18
+ const timeout = setTimeout(() => {
19
+ ws.close();
20
+ }, duration * 1000);
21
+ ws.on("message", (data) => {
22
+ const text = String(data);
23
+ try {
24
+ messages.push(JSON.parse(text));
25
+ }
26
+ catch {
27
+ messages.push(text);
28
+ }
29
+ });
30
+ ws.on("error", (err) => {
31
+ error = err.message;
32
+ clearTimeout(timeout);
33
+ ws.close();
34
+ });
35
+ ws.on("close", () => {
36
+ clearTimeout(timeout);
37
+ const result = { messages, duration };
38
+ if (error)
39
+ result.error = error;
40
+ resolve(result);
41
+ });
42
+ });
43
+ }
44
+ function registerSubscriptionTools(server, client) {
45
+ server.registerTool("protect_subscribe_devices", {
46
+ description: "Connect to the device update WebSocket and collect messages for a specified duration. Returns real-time add/update/remove events for Protect-managed hardware devices.",
47
+ inputSchema: { duration: durationSchema },
48
+ annotations: safety_js_1.READ_ONLY,
49
+ }, async ({ duration }) => {
50
+ try {
51
+ const data = await collectMessages(client, "/subscribe/devices", duration);
52
+ return (0, responses_js_1.formatSuccess)(data);
53
+ }
54
+ catch (err) {
55
+ return (0, responses_js_1.formatError)(err);
56
+ }
57
+ });
58
+ server.registerTool("protect_subscribe_events", {
59
+ description: "Connect to the Protect event WebSocket and collect messages for a specified duration. Returns events including motion, ring (doorbell), and smartDetect (person/vehicle/animal/package).",
60
+ inputSchema: { duration: durationSchema },
61
+ annotations: safety_js_1.READ_ONLY,
62
+ }, async ({ duration }) => {
63
+ try {
64
+ const data = await collectMessages(client, "/subscribe/events", duration);
65
+ return (0, responses_js_1.formatSuccess)(data);
66
+ }
67
+ catch (err) {
68
+ return (0, responses_js_1.formatError)(err);
69
+ }
70
+ });
71
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Tagged template that encodes interpolated values for safe URL path construction.
3
+ *
4
+ * This function URL-encodes only the interpolated values, which prevents them from
5
+ * introducing path traversal (`/`), query (`?`), or fragment (`#`) delimiters.
6
+ * The literal template segments are not modified, so they must be trusted and should
7
+ * not contain user-controlled data.
8
+ *
9
+ * Values must be raw (unencoded). Pre-encoded values will be double-encoded
10
+ * (e.g. `%2F` becomes `%252F`), which is the correct defensive behavior.
11
+ */
12
+ export declare function safePath(strings: TemplateStringsArray, ...values: (string | number)[]): string;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.safePath = safePath;
4
+ /**
5
+ * Tagged template that encodes interpolated values for safe URL path construction.
6
+ *
7
+ * This function URL-encodes only the interpolated values, which prevents them from
8
+ * introducing path traversal (`/`), query (`?`), or fragment (`#`) delimiters.
9
+ * The literal template segments are not modified, so they must be trusted and should
10
+ * not contain user-controlled data.
11
+ *
12
+ * Values must be raw (unencoded). Pre-encoded values will be double-encoded
13
+ * (e.g. `%2F` becomes `%252F`), which is the correct defensive behavior.
14
+ */
15
+ function safePath(strings, ...values) {
16
+ return strings.reduce((result, str, i) => i < values.length
17
+ ? result + str + encodeURIComponent(String(values[i]))
18
+ : result + str, "");
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owine/unifi-protect-mcp",
3
- "version": "2.0.2",
3
+ "version": "2.2.0",
4
4
  "description": "MCP server for UniFi Protect Integration API",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -37,11 +37,13 @@
37
37
  "license": "MIT",
38
38
  "dependencies": {
39
39
  "@modelcontextprotocol/sdk": "^1.12.1",
40
+ "ws": "^8.19.0",
40
41
  "zod": "^4.0.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@eslint/js": "10.0.1",
44
45
  "@types/node": "24.12.0",
46
+ "@types/ws": "^8.18.0",
45
47
  "@vitest/coverage-v8": "4.0.18",
46
48
  "eslint": "10.0.3",
47
49
  "typescript": "5.9.3",