@owine/unifi-protect-mcp 2.14.2 → 2.15.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.
@@ -6,10 +6,21 @@ 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
8
  const misc_js_1 = require("../schemas/misc.js");
9
- const ACTIVATION_DELAY_VALUES = [0, 60000, 300000, 600000];
9
+ const activationDelay = zod_1.z
10
+ .union([zod_1.z.literal(0), zod_1.z.literal(60000), zod_1.z.literal(300000), zod_1.z.literal(600000)])
11
+ .describe("Activation delay in milliseconds. Allowed: 0 (none), 60000 (1m), 300000 (5m), 600000 (10m).");
12
+ const armProfileFields = {
13
+ name: zod_1.z.string().min(1).max(255).describe("Name of the arm profile"),
14
+ automations: zod_1.z.array(zod_1.z.string()).describe("List of automation IDs associated with this arm profile"),
15
+ schedules: zod_1.z
16
+ .array(zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()))
17
+ .describe('List of arm schedules, e.g. [{ start: "0 22 * * *", end: "0 6 * * *" }] (cron strings)'),
18
+ recordEverything: zod_1.z.boolean().describe("Whether to record everything when this arm profile is active"),
19
+ activationDelay,
20
+ };
10
21
  function registerArmProfileTools(server, client, readOnly) {
11
22
  server.registerTool("protect_list_arm_profiles", {
12
- description: "List all arm profiles (only available when using the local alarm manager — the standalone NVR alarm system, not Protect cloud alerts). Returns array; each profile (7.1.83 docs): id, name, automations[], creator, schedules[], recordEverything, activationDelay (0 | 60000 | 300000 | 600000), createdAt, updatedAt.",
23
+ description: "List all arm profiles (only available when using the local alarm manager — the standalone NVR alarm system, not Protect cloud alerts). Returns array; each profile (7.3.70 docs): id, name, automations[], creator, schedules[], recordEverything, activationDelay (0 | 60000 | 300000 | 600000), createdAt, updatedAt.",
13
24
  outputSchema: misc_js_1.armProfileListOutputSchema,
14
25
  annotations: safety_js_1.READ_ONLY,
15
26
  }, async () => {
@@ -26,13 +37,7 @@ function registerArmProfileTools(server, client, readOnly) {
26
37
  server.registerTool("protect_create_arm_profile", {
27
38
  description: "Create a new arm profile. Only available when using local alarm manager.",
28
39
  inputSchema: {
29
- name: zod_1.z.string().min(1).max(255).describe("Name of the arm profile"),
30
- automations: zod_1.z.array(zod_1.z.string()).describe("List of automation IDs associated with this arm profile"),
31
- schedules: zod_1.z.array(zod_1.z.record(zod_1.z.string(), zod_1.z.unknown())).describe("List of arm schedules"),
32
- recordEverything: zod_1.z.boolean().describe("Whether to record everything when this arm profile is active"),
33
- activationDelay: zod_1.z
34
- .union([zod_1.z.literal(0), zod_1.z.literal(60000), zod_1.z.literal(300000), zod_1.z.literal(600000)])
35
- .describe("Activation delay in milliseconds. Allowed: 0 (none), 60000 (1m), 300000 (5m), 600000 (10m)."),
40
+ ...armProfileFields,
36
41
  dryRun: zod_1.z.boolean().optional().describe("If true, return what would happen without making changes"),
37
42
  },
38
43
  outputSchema: misc_js_1.armProfileOutputSchema,
@@ -75,8 +80,11 @@ function registerArmProfileTools(server, client, readOnly) {
75
80
  inputSchema: {
76
81
  id: zod_1.z.string().describe("Arm profile ID"),
77
82
  settings: zod_1.z
78
- .record(zod_1.z.string(), zod_1.z.unknown())
79
- .describe(`Partial arm profile settings. Known fields: name (string 1-255 chars), automations (array of strings), schedules (array of objects), recordEverything (boolean), activationDelay (number, allowed: ${ACTIVATION_DELAY_VALUES.join(", ")})`),
83
+ .object(armProfileFields)
84
+ .partial()
85
+ .passthrough()
86
+ .refine((o) => Object.keys(o).length > 0, "settings must include at least one field")
87
+ .describe("Partial arm profile settings to update"),
80
88
  dryRun: zod_1.z.boolean().optional().describe("If true, return what would happen without making changes"),
81
89
  },
82
90
  outputSchema: misc_js_1.armProfileOutputSchema,
@@ -9,7 +9,7 @@ const cameras_js_1 = require("../schemas/cameras.js");
9
9
  function registerCameraTools(server, client, readOnly) {
10
10
  // --- Read-only tools (always registered) ---
11
11
  server.registerTool("protect_list_cameras", {
12
- description: "List all cameras managed by UniFi Protect. Returns array; each camera includes (Integration API 7.3.47-verified fields): id, mac, name, modelKey, state (CONNECTED/DISCONNECTED), type (hardware model name, e.g. UVC G6 Turret), guid (hardware MODEL GUID — every camera of the same model returns the same value, so it is NOT a per-camera identifier; use id), activePatrolSlot, hasPackageCamera, hdrType, isMicEnabled, micVolume, videoMode, featureFlags (hasHdr, hasMic, hasSpeaker, hasLedStatus, smartDetectTypes[], smartDetectAudioTypes[], videoModes[], supportFullHdSnapshot), lcdMessage (type, resetAt, text), ledSettings (isEnabled, floodLed, welcomeLed), osdSettings (isNameEnabled, isDateEnabled, isLogoEnabled, isDebugEnabled, overlayLocation), smartDetectSettings (objectTypes[], audioTypes[]). The Integration API does NOT expose recording state, motion timestamps, connection/last-seen, firmware, host, or per-channel stream config.",
12
+ description: "List all cameras managed by UniFi Protect. Returns array; each camera includes (Integration API 7.3.70-verified fields): id, mac, name, modelKey, state (CONNECTED/DISCONNECTED), type (hardware model name, e.g. UVC G6 Turret), guid (hardware MODEL GUID — every camera of the same model returns the same value, so it is NOT a per-camera identifier; use id), activePatrolSlot, hasPackageCamera, hdrType, isMicEnabled, micVolume, videoMode, featureFlags (hasHdr, hasMic, hasSpeaker, hasLedStatus, smartDetectTypes[], smartDetectAudioTypes[], videoModes[], supportFullHdSnapshot), lcdMessage (type, resetAt, text), ledSettings (isEnabled, floodLed, welcomeLed), osdSettings (isNameEnabled, isDateEnabled, isLogoEnabled, isDebugEnabled, overlayLocation), smartDetectSettings (objectTypes[], audioTypes[]). The Integration API does NOT expose recording state, motion timestamps, connection/last-seen, firmware, host, or per-channel stream config.",
13
13
  outputSchema: cameras_js_1.cameraListOutputSchema,
14
14
  annotations: safety_js_1.READ_ONLY,
15
15
  }, async () => {
@@ -22,7 +22,7 @@ function registerCameraTools(server, client, readOnly) {
22
22
  }
23
23
  });
24
24
  server.registerTool("protect_get_camera", {
25
- description: "Get details for a specific camera by ID. The Protect Integration API returns the SAME field set as protect_list_cameras entries (id, mac, name, modelKey, state, type, guid, activePatrolSlot, hasPackageCamera, hdrType, isMicEnabled, micVolume, videoMode, featureFlags, lcdMessage, ledSettings, osdSettings, smartDetectSettings) — there is no extended/by-id-only payload (confirmed live on 7.3.47). Recording state, motion events, zones, and channel/RTSP config are NOT exposed by this API surface.",
25
+ description: "Get details for a specific camera by ID. The Protect Integration API returns the SAME field set as protect_list_cameras entries (id, mac, name, modelKey, state, type, guid, activePatrolSlot, hasPackageCamera, hdrType, isMicEnabled, micVolume, videoMode, featureFlags, lcdMessage, ledSettings, osdSettings, smartDetectSettings) — there is no extended/by-id-only payload (confirmed live on 7.3.70). Recording state, motion events, zones, and channel/RTSP config are NOT exposed by this API surface.",
26
26
  inputSchema: { id: zod_1.z.string().describe("Camera ID") },
27
27
  outputSchema: cameras_js_1.cameraOutputSchema,
28
28
  annotations: safety_js_1.READ_ONLY,
@@ -36,13 +36,13 @@ function registerCameraTools(server, client, readOnly) {
36
36
  }
37
37
  });
38
38
  server.registerTool("protect_get_snapshot", {
39
- description: "Get a JPEG snapshot from a camera. Returns a base64-encoded image/jpeg (rendered directly by MCP clients). Use highQuality=true for full-resolution capture; set channel=package to capture from the secondary package camera on doorbells with hasPackageCamera=true.",
39
+ description: "Get a JPEG snapshot from a camera. Returns a base64-encoded image/jpeg (rendered directly by MCP clients). Use highQuality=true to force a 1080p-or-higher capture; set channel=package to capture from the secondary package camera on doorbells with hasPackageCamera=true. Fails with HTTP 503 when the camera is offline or unreachable.",
40
40
  inputSchema: {
41
41
  id: zod_1.z.string().describe("Camera ID"),
42
42
  highQuality: zod_1.z
43
43
  .boolean()
44
44
  .optional()
45
- .describe("If true, request a high-quality snapshot"),
45
+ .describe("If true, force a 1080p-or-higher resolution snapshot"),
46
46
  channel: zod_1.z
47
47
  .enum(["main", "package"])
48
48
  .optional()
@@ -76,7 +76,7 @@ function registerCameraTools(server, client, readOnly) {
76
76
  }
77
77
  });
78
78
  server.registerTool("protect_get_rtsp_streams", {
79
- description: "Get active RTSPS stream sessions for a camera. Returns the per-quality stream URLs currently published (keys typically: high, medium, low, package). Empty/missing keys mean no session is currently active at that quality — use protect_create_rtsp_stream to start one.",
79
+ description: "Get active RTSPS stream sessions for a camera. Returns: high, medium, low, package — each an rtsps:// URL, or null (or absent) when no session is active at that quality. Use protect_create_rtsp_stream to start one.",
80
80
  inputSchema: { id: zod_1.z.string().describe("Camera ID") },
81
81
  outputSchema: cameras_js_1.rtspStreamOutputSchema,
82
82
  annotations: safety_js_1.READ_ONLY,
@@ -93,12 +93,38 @@ function registerCameraTools(server, client, readOnly) {
93
93
  if (readOnly)
94
94
  return;
95
95
  server.registerTool("protect_update_camera", {
96
- description: "Update camera settings (partial update via PATCH)",
96
+ description: "Update camera settings (partial update via PATCH). Returns: the full updated camera (same fields as protect_get_camera).",
97
97
  inputSchema: {
98
98
  id: zod_1.z.string().describe("Camera ID"),
99
99
  settings: zod_1.z
100
- .record(zod_1.z.string(), zod_1.z.unknown())
101
- .describe("Partial camera settings to update. Known fields: name (string), osdSettings (object), ledSettings (object), lcdMessage (object: type, resetAt, text), videoMode (string: \"default\" | \"highFps\" | \"sport\" | \"slowShutter\" | \"lprReflex\" | \"lprNoneReflex\"), hdrType (string: \"auto\" | \"on\" | \"off\"), micVolume (number 0-100), smartDetectSettings (object)"),
100
+ .object({
101
+ name: zod_1.z.string().optional().describe("Camera name"),
102
+ osdSettings: zod_1.z
103
+ .record(zod_1.z.string(), zod_1.z.unknown())
104
+ .optional()
105
+ .describe("On-screen display (isNameEnabled, isDateEnabled, isLogoEnabled, isDebugEnabled, overlayLocation)"),
106
+ ledSettings: zod_1.z
107
+ .record(zod_1.z.string(), zod_1.z.unknown())
108
+ .optional()
109
+ .describe("LED settings (isEnabled, welcomeLed, floodLed)"),
110
+ lcdMessage: zod_1.z
111
+ .record(zod_1.z.string(), zod_1.z.unknown())
112
+ .optional()
113
+ .describe('Doorbell LCD message (type, resetAt, text), e.g. { type: "DO_NOT_DISTURB", resetAt: 0 }'),
114
+ micVolume: zod_1.z.number().min(0).max(100).optional().describe("Mic volume (0-100)"),
115
+ videoMode: zod_1.z
116
+ .enum(["default", "highFps", "sport", "slowShutter", "lprReflex", "lprNoneReflex"])
117
+ .optional()
118
+ .describe("Video mode"),
119
+ hdrType: zod_1.z.enum(["auto", "on", "off"]).optional().describe("HDR mode"),
120
+ smartDetectSettings: zod_1.z
121
+ .record(zod_1.z.string(), zod_1.z.unknown())
122
+ .optional()
123
+ .describe("Smart detection (objectTypes[], audioTypes[])"),
124
+ })
125
+ .passthrough()
126
+ .refine((o) => Object.keys(o).length > 0, "settings must include at least one field")
127
+ .describe("Partial camera settings to update"),
102
128
  dryRun: zod_1.z
103
129
  .boolean()
104
130
  .optional()
@@ -119,12 +145,13 @@ function registerCameraTools(server, client, readOnly) {
119
145
  }
120
146
  });
121
147
  server.registerTool("protect_create_rtsp_stream", {
122
- description: "Create an RTSPS stream session for a camera",
148
+ description: "Create an RTSPS stream session for a camera. Returns: one key per requested quality (high, medium, low, package), each an rtsps:// URL. The package quality is only available on cameras with hasPackageCamera=true.",
123
149
  inputSchema: {
124
150
  id: zod_1.z.string().describe("Camera ID"),
125
151
  qualities: zod_1.z
126
152
  .array(zod_1.z.enum(["high", "medium", "low", "package"]))
127
- .describe("Quality levels to include in the RTSPS stream"),
153
+ .min(1)
154
+ .describe("Quality levels to include in the RTSPS stream (at least one)"),
128
155
  dryRun: zod_1.z
129
156
  .boolean()
130
157
  .optional()
@@ -152,7 +179,8 @@ function registerCameraTools(server, client, readOnly) {
152
179
  id: zod_1.z.string().describe("Camera ID"),
153
180
  qualities: zod_1.z
154
181
  .array(zod_1.z.enum(["high", "medium", "low", "package"]))
155
- .describe("Quality levels to remove from the RTSPS stream"),
182
+ .min(1)
183
+ .describe("Quality levels to remove from the RTSPS stream (at least one)"),
156
184
  dryRun: zod_1.z
157
185
  .boolean()
158
186
  .optional()
@@ -161,10 +189,16 @@ function registerCameraTools(server, client, readOnly) {
161
189
  annotations: safety_js_1.DESTRUCTIVE,
162
190
  }, async ({ id, qualities, dryRun }) => {
163
191
  try {
192
+ // Protect expects repeated keys (qualities=high&qualities=medium), not
193
+ // a comma-joined value — safePath would encode the comma as %2C.
194
+ const params = new URLSearchParams();
195
+ for (const quality of qualities)
196
+ params.append("qualities", quality);
197
+ const url = (0, url_js_1.safePath) `/cameras/${id}/rtsps-stream` + `?${params.toString()}`;
164
198
  if (dryRun) {
165
- return (0, safety_js_1.formatDryRun)("DELETE", (0, url_js_1.safePath) `/cameras/${id}/rtsps-stream?qualities=${qualities.join(",")}`);
199
+ return (0, safety_js_1.formatDryRun)("DELETE", url);
166
200
  }
167
- const data = await client.delete((0, url_js_1.safePath) `/cameras/${id}/rtsps-stream?qualities=${qualities.join(",")}`);
201
+ const data = await client.delete(url);
168
202
  return (0, responses_js_1.formatSuccess)(data);
169
203
  }
170
204
  catch (err) {
@@ -195,19 +229,27 @@ function registerCameraTools(server, client, readOnly) {
195
229
  }
196
230
  });
197
231
  server.registerTool("protect_disable_mic", {
198
- description: "IRREVERSIBLE: Permanently disable the microphone on a camera. Can only be re-enabled by factory resetting the camera.",
232
+ description: "IRREVERSIBLE: Permanently disable the microphone on a camera. Can only be re-enabled by factory resetting the camera. Returns: the full updated camera (same fields as protect_get_camera).",
199
233
  inputSchema: {
200
234
  id: zod_1.z.string().describe("Camera ID"),
201
235
  confirm: zod_1.z
202
236
  .literal(true)
203
237
  .describe("Must be true to confirm this irreversible action"),
238
+ dryRun: zod_1.z
239
+ .boolean()
240
+ .optional()
241
+ .describe("If true, return what would happen without making changes"),
204
242
  },
243
+ outputSchema: cameras_js_1.cameraOutputSchema,
205
244
  annotations: safety_js_1.DESTRUCTIVE,
206
- }, async ({ id, confirm }) => {
245
+ }, async ({ id, confirm, dryRun }) => {
207
246
  const denied = (0, safety_js_1.requireConfirmation)(confirm, "permanently disable the microphone");
208
247
  if (denied)
209
248
  return denied;
210
249
  try {
250
+ if (dryRun) {
251
+ return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/cameras/${id}/disable-mic-permanently`);
252
+ }
211
253
  const data = await client.post((0, url_js_1.safePath) `/cameras/${id}/disable-mic-permanently`);
212
254
  return (0, responses_js_1.formatSuccess)(data);
213
255
  }
@@ -264,7 +306,11 @@ function registerCameraTools(server, client, readOnly) {
264
306
  description: "Move camera PTZ to a preset position",
265
307
  inputSchema: {
266
308
  id: zod_1.z.string().describe("Camera ID"),
267
- slot: zod_1.z.number().int().min(-1).max(4).describe("PTZ preset slot number (0-4, or -1 for home position)"),
309
+ slot: zod_1.z
310
+ .number()
311
+ .int()
312
+ .min(-1)
313
+ .describe("PTZ preset slot: -1 for the home position, 0 or higher for saved presets"),
268
314
  dryRun: zod_1.z
269
315
  .boolean()
270
316
  .optional()
@@ -7,6 +7,7 @@ interface DeviceConfig {
7
7
  label: string;
8
8
  hint: string;
9
9
  returns: string;
10
+ note?: string;
10
11
  }
11
12
  export declare function registerDeviceTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
12
13
  /** Exported for tests */
@@ -7,10 +7,11 @@ const responses_js_1 = require("../utils/responses.js");
7
7
  const safety_js_1 = require("../utils/safety.js");
8
8
  const url_js_1 = require("../utils/url.js");
9
9
  const devices_js_1 = require("../schemas/devices.js");
10
- // Field lists below are from the UniFi Protect Integration API 7.3.47 docs.
10
+ // Field lists below are from the UniFi Protect Integration API 7.3.70 docs.
11
11
  // 7.3.x added `type` (hardware model name) and `guid` to every device. NOTE:
12
12
  // `guid` identifies the hardware MODEL, not the unit — every camera of the same
13
- // model returns the same guid (verified live 7.3.47). `id` is the device identity.
13
+ // model returns the same guid (verified live 7.3.47 and 7.3.70). `id` is the
14
+ // device identity.
14
15
  // Chimes are additionally verified against live console responses; the rest
15
16
  // have no instances on the available console (top-level fields documented,
16
17
  // nested shapes intentionally left opaque in the output schema).
@@ -21,23 +22,23 @@ const DEVICES = [
21
22
  plural: "lights",
22
23
  label: "Light",
23
24
  hint: "Known fields: name (string), isLightForceEnabled (boolean), lightModeSettings (object with mode, enableAt), lightDeviceSettings (object with isIndicatorEnabled, pirDuration, pirSensitivity, ledLevel)",
24
- returns: "id, modelKey, name, mac, state, type, guid, lightModeSettings (mode, enableAt), lightDeviceSettings (isIndicatorEnabled, pirDuration, pirSensitivity, ledLevel), isDark, isLightOn, isLightForceEnabled, lastMotion, isPirMotionDetected, camera (7.3.47 docs)",
25
+ returns: "id, modelKey, name, mac, state, type, guid, lightModeSettings (mode, enableAt), lightDeviceSettings (isIndicatorEnabled, pirDuration, pirSensitivity, ledLevel), isDark, isLightOn, isLightForceEnabled, lastMotion, isPirMotionDetected, camera (7.3.70 docs)",
25
26
  },
26
27
  {
27
28
  urlPath: "sensors",
28
29
  singular: "sensor",
29
30
  plural: "sensors",
30
31
  label: "Sensor",
31
- hint: "Known fields: name (string), motionSettings (object), humiditySettings (object), temperatureSettings (object), lightSettings (object), alarmSettings (object), glassBreakSettings (object), leakSettings (object), scheduleMode (\"always\" | \"when_armed\"), armProfileIds (array of up to 32 strings), hasCustomSensitivityWhenArmed (boolean)",
32
- returns: "id, modelKey, name, mac, state, type, guid, mountType, featureFlags (temperature, humidity, light, motion, waterLeak, open, tamper, smoke — each {channelCount}), batteryStatus (percentage, isLow), stats (light, humidity, temperature), lightSettings, humiditySettings, temperatureSettings, isOpened, openStatusChangedAt, isMotionDetected, motionDetectedAt, motionSettings, glassBreakSettings, scheduleMode, armProfileIds, hasCustomSensitivityWhenArmed, alarmTriggeredAt, alarmSettings, leakDetectedAt, externalLeakDetectedAt, leakSettings, tamperingDetectedAt, wirelessConnectionState (7.3.47 docs)",
32
+ hint: "Known fields: name (string), lightSettings / humiditySettings / temperatureSettings (objects: isEnabled, margin, lowThreshold, highThreshold), motionSettings / glassBreakSettings (objects: isEnabled, sensitivity, sensitivityWhenArmed), alarmSettings (object: isEnabled), scheduleMode (\"always\" | \"when_armed\"), armProfileIds (array of up to 32 strings, or null), hasCustomSensitivityWhenArmed (boolean). leakSettings is read-only. Returns HTTP 409 if a setting is not supported by this sensor",
33
+ returns: "id, modelKey, name, mac, state, type, guid, mountType, featureFlags (temperature, humidity, light, motion, waterLeak, open, tamper, smoke — each {channelCount}), batteryStatus (percentage, isLow), stats (light, humidity, temperature — each {value, status}), lightSettings / humiditySettings / temperatureSettings (isEnabled, margin, lowThreshold, highThreshold), isOpened, openStatusChangedAt, isMotionDetected, motionDetectedAt, motionSettings / glassBreakSettings (isEnabled, sensitivity, sensitivityWhenArmed), scheduleMode, armProfileIds, hasCustomSensitivityWhenArmed, alarmTriggeredAt, alarmSettings, leakDetectedAt, externalLeakDetectedAt, leakSettings (isInternalEnabled, isExternalEnabled), tamperingDetectedAt, wirelessConnectionState (signalState: signalQuality, signalStrength; batteryStatus: percentage, isLow; bridge) (7.3.70 docs)",
33
34
  },
34
35
  {
35
36
  urlPath: "chimes",
36
37
  singular: "chime",
37
38
  plural: "chimes",
38
39
  label: "Chime",
39
- hint: "Known fields: name (string), cameraIds (array of camera IDs linked to the chime), ringSettings (array of objects: cameraId, volume, ringtoneId, repeatTimes)",
40
- returns: "id, modelKey, name, mac, state, type, guid, cameraIds (array of camera IDs), ringSettings (array of objects: cameraId, volume, ringtoneId, repeatTimes) — verified live 7.3.47",
40
+ hint: "Known fields: name (string), cameraIds (array of doorbell camera IDs linked to the chime), ringSettings (array of objects: cameraId, volume, ringtoneId, repeatTimes)",
41
+ returns: "id, modelKey, name, mac, state, type, guid, cameraIds (array of camera IDs), ringSettings (array of objects: cameraId, volume, ringtoneId, repeatTimes) — verified live 7.3.70",
41
42
  },
42
43
  {
43
44
  urlPath: "viewers",
@@ -45,7 +46,7 @@ const DEVICES = [
45
46
  plural: "viewers",
46
47
  label: "Viewer",
47
48
  hint: "Known fields: name (string), liveview (string, liveview ID to display, or null)",
48
- returns: "id, modelKey, name, mac, state, type, guid, liveview, streamLimit (7.3.47 docs)",
49
+ returns: "id, modelKey, name, mac, state, type, guid, liveview, streamLimit (7.3.70 docs)",
49
50
  },
50
51
  {
51
52
  urlPath: "sirens",
@@ -53,7 +54,7 @@ const DEVICES = [
53
54
  plural: "sirens",
54
55
  label: "Siren",
55
56
  hint: "Known fields: name (string), volume (integer 1-100), ledSettings (object with isEnabled boolean)",
56
- returns: "id, modelKey, name, mac, state, type, guid, volume, ledSettings (isEnabled), sirenStatus (isActive, activatedAt, duration), connectionType, wirelessConnectionState (7.3.47 docs)",
57
+ returns: "id, modelKey, name, mac, state, type, guid, volume, ledSettings (isEnabled), sirenStatus (isActive, activatedAt, duration), connectionType, wirelessConnectionState (signalState: signalQuality, signalStrength; batteryStatus: percentage, isLow; bridge) (7.3.70 docs)",
57
58
  },
58
59
  {
59
60
  urlPath: "fobs",
@@ -61,7 +62,7 @@ const DEVICES = [
61
62
  plural: "fobs",
62
63
  label: "Fob",
63
64
  hint: "Known fields: name (string)",
64
- returns: "id, modelKey, name, mac, state, type, guid, awayState, buttonLabels, featureFlags (buttons[]), hasKeypad, armControlSettings (enabled, armProfileId, nightProfileId), keypadSettings (beepEnabled, beepVolume, backlightEnabled, backlightBrightness), wirelessConnectionState (7.3.47 docs)",
65
+ returns: "id, modelKey, name, mac, state, type, guid, awayState, buttonLabels, featureFlags (buttons[], hasKeypad), armControlSettings (enabled, armProfileId, nightProfileId), keypadSettings (beepEnabled, beepVolume), wirelessConnectionState (signalState: signalQuality, signalStrength; batteryStatus: percentage, isLow; bridge) (7.3.70 docs)",
65
66
  },
66
67
  {
67
68
  urlPath: "relays",
@@ -69,7 +70,7 @@ const DEVICES = [
69
70
  plural: "relays",
70
71
  label: "Relay",
71
72
  hint: "Known fields: name (string), ledSettings (object with isEnabled boolean)",
72
- returns: "id, modelKey, name, mac, state, type, guid, ledSettings (isEnabled), outputs (array), inputs (array), wirelessConnectionState (7.3.47 docs)",
73
+ returns: "id, modelKey, name, mac, state, type, guid, ledSettings (isEnabled), outputs (array of objects: id, name, type, delay, pulseDuration, state, rebootState — id is the outputId for protect_activate_relay_output), inputs (array of objects: id, name, state, actionTrigger, actionType, actionOutputId), wirelessConnectionState (signalState: signalQuality, signalStrength; batteryStatus: percentage, isLow; bridge) (7.3.70 docs)",
73
74
  },
74
75
  {
75
76
  urlPath: "speakers",
@@ -77,7 +78,7 @@ const DEVICES = [
77
78
  plural: "speakers",
78
79
  label: "Speaker",
79
80
  hint: "Known fields: name (string), volume (integer 0-100), micVolume (integer 0-100), isMicEnabled (boolean)",
80
- returns: "id, modelKey, name, mac, state, type, guid, volume, micVolume, isMicEnabled, speakerState (status, mode), featureFlags (hasMic) (7.3.47 docs)",
81
+ returns: "id, modelKey, name, mac, state, type, guid, volume, micVolume, isMicEnabled, speakerState (status, mode), featureFlags (hasMic) (7.3.70 docs)",
81
82
  },
82
83
  {
83
84
  urlPath: "bridges",
@@ -85,15 +86,16 @@ const DEVICES = [
85
86
  plural: "bridges",
86
87
  label: "Bridge",
87
88
  hint: "Known fields: name (string)",
88
- returns: "id, modelKey, name, mac, state, type, guid, platform, clients (array of MACs), maxClients (7.3.47 docs)",
89
+ returns: "id, modelKey, name, mac, state, type, guid, platform, clients (array of MACs), maxClients (7.3.70 docs)",
89
90
  },
90
91
  {
91
92
  urlPath: "link-stations",
92
93
  singular: "link_station",
93
94
  plural: "link_stations",
94
95
  label: "Link station",
96
+ note: "Link stations are non-alarm-hub gateways (use protect_list_alarm_hubs for alarm hubs).",
95
97
  hint: "Known fields: name (string)",
96
- returns: "id, modelKey (\"linkstation\"), name, mac, state, type, guid, isAlarmHub, ledSettings (isEnabled), lastEvent, alarmHub (object), deviceTamperStatus, threadState (network: status, role, networkName, channel, panId, extendedPanId, joinedDeviceCount, errorReason, lastUpdatedAt) (7.3.47 docs)",
98
+ returns: "id, modelKey (\"linkstation\"), name, mac, state, type, guid, isAlarmHub, ledSettings (isEnabled), lastEvent, alarmHub (armed, battery, buckboost, connector, cover, deviceTamperStatus (e.g. \"tampered\"), currentMeterChannelStatus, currentMeterStatus, inputPower, poeout, powerMeter, output and input (maps keyed by channel), inputTerminalStatus, outputTerminalStatus, emergencyTerminalStatus, auxiliaryPowerTerminalStatus), threadState (network: status, role, networkName, channel, panId, extendedPanId, joinedDeviceCount, errorReason, lastUpdatedAt) (7.3.70 docs)",
97
99
  },
98
100
  {
99
101
  urlPath: "alarm-hubs",
@@ -101,13 +103,14 @@ const DEVICES = [
101
103
  plural: "alarm_hubs",
102
104
  label: "Alarm hub",
103
105
  hint: "Known fields: name (string)",
104
- returns: "id, modelKey (\"linkstation\"), name, mac, state, type, guid, isAlarmHub, ledSettings (isEnabled), lastEvent, alarmHub (object), deviceTamperStatus, threadState (network: status, role, networkName, channel, panId, extendedPanId, joinedDeviceCount, errorReason, lastUpdatedAt) (7.3.47 docs)",
106
+ returns: "id, modelKey (\"linkstation\"), name, mac, state, type, guid, isAlarmHub, ledSettings (isEnabled), lastEvent, alarmHub (armed, battery, buckboost, connector, cover, deviceTamperStatus (e.g. \"tampered\"), currentMeterChannelStatus, currentMeterStatus, inputPower, poeout, powerMeter, output and input (maps keyed by channel), inputTerminalStatus, outputTerminalStatus, emergencyTerminalStatus, auxiliaryPowerTerminalStatus), threadState (network: status, role, networkName, channel, panId, extendedPanId, joinedDeviceCount, errorReason, lastUpdatedAt) (7.3.70 docs)",
105
107
  },
106
108
  ];
107
109
  function registerDeviceCrud(server, client, cfg, readOnly) {
108
110
  const schemaKey = cfg.singular;
111
+ const note = cfg.note ? `${cfg.note} ` : "";
109
112
  server.registerTool(`protect_list_${cfg.plural}`, {
110
- description: `List all ${cfg.label.toLowerCase()}s managed by UniFi Protect. Returns array; each ${cfg.label.toLowerCase()} includes: ${cfg.returns}.`,
113
+ description: `List all ${cfg.label.toLowerCase()}s managed by UniFi Protect. ${note}Returns array; each ${cfg.label.toLowerCase()} includes: ${cfg.returns}.`,
111
114
  outputSchema: (0, devices_js_1.listOutputShape)(schemaKey),
112
115
  annotations: safety_js_1.READ_ONLY,
113
116
  }, async () => {
@@ -120,7 +123,7 @@ function registerDeviceCrud(server, client, cfg, readOnly) {
120
123
  }
121
124
  });
122
125
  server.registerTool(`protect_get_${cfg.singular}`, {
123
- description: `Get full details for a specific ${cfg.label.toLowerCase()} by ID. Returns: ${cfg.returns}.`,
126
+ description: `Get full details for a specific ${cfg.label.toLowerCase()} by ID. ${note}Returns: ${cfg.returns}.`,
124
127
  inputSchema: { id: zod_1.z.string().describe(`${cfg.label} ID`) },
125
128
  outputSchema: (0, devices_js_1.itemOutputShape)(schemaKey),
126
129
  annotations: safety_js_1.READ_ONLY,
@@ -140,6 +143,7 @@ function registerDeviceCrud(server, client, cfg, readOnly) {
140
143
  id: zod_1.z.string().describe(`${cfg.label} ID`),
141
144
  settings: zod_1.z
142
145
  .record(zod_1.z.string(), zod_1.z.unknown())
146
+ .refine((o) => Object.keys(o).length > 0, "settings must include at least one field")
143
147
  .describe(`Partial ${cfg.label.toLowerCase()} settings to update. ${cfg.hint}`),
144
148
  dryRun: zod_1.z
145
149
  .boolean()
@@ -6,14 +6,23 @@ 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
8
  const misc_js_1 = require("../schemas/misc.js");
9
+ // The only asset file type the API accepts (path param `assetFileType`).
10
+ const FILE_TYPE = zod_1.z
11
+ .literal("animations")
12
+ .describe("Asset file type (only 'animations' is supported)");
13
+ const UPLOAD_CONTENT_TYPES = [
14
+ "image/gif",
15
+ "image/jpeg",
16
+ "image/png",
17
+ "audio/mpeg",
18
+ "audio/mp4",
19
+ "audio/wave",
20
+ "audio/x-caf",
21
+ ];
9
22
  function registerFileTools(server, client, readOnly) {
10
23
  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
- },
24
+ 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.",
25
+ inputSchema: { fileType: FILE_TYPE },
17
26
  outputSchema: misc_js_1.fileListOutputSchema,
18
27
  annotations: safety_js_1.READ_ONLY,
19
28
  }, async ({ fileType }) => {
@@ -27,52 +36,39 @@ function registerFileTools(server, client, readOnly) {
27
36
  });
28
37
  if (readOnly)
29
38
  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
39
  server.registerTool("protect_upload_file", {
52
- description: "Upload a base64-encoded file to UniFi Protect",
40
+ 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
41
  inputSchema: {
54
- fileType: zod_1.z.string().describe("File type category for upload"),
42
+ fileType: FILE_TYPE,
55
43
  base64Data: zod_1.z.string().describe("Base64-encoded file content"),
56
44
  contentType: zod_1.z
57
- .string()
45
+ .enum(UPLOAD_CONTENT_TYPES)
58
46
  .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)"),
47
+ .describe("MIME type of the file"),
48
+ fileName: zod_1.z
49
+ .string()
50
+ .min(1)
51
+ .optional()
52
+ .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)"),
60
53
  dryRun: zod_1.z
61
54
  .boolean()
62
55
  .optional()
63
56
  .describe("If true, return what would happen without making changes"),
64
57
  },
58
+ outputSchema: misc_js_1.fileOutputSchema,
65
59
  annotations: safety_js_1.WRITE,
66
- }, async ({ fileType, base64Data, contentType, dryRun }) => {
60
+ }, async ({ fileType, base64Data, contentType, fileName, dryRun }) => {
67
61
  try {
62
+ const name = fileName ?? defaultFileName(contentType);
68
63
  if (dryRun) {
69
64
  return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/files/${fileType}`, {
70
65
  contentType,
66
+ fileName: name,
71
67
  dataLength: base64Data.length,
72
68
  });
73
69
  }
74
70
  const buffer = Buffer.from(base64Data, "base64");
75
- const data = await client.postBinary((0, url_js_1.safePath) `/files/${fileType}`, buffer, contentType);
71
+ const data = await client.postFile((0, url_js_1.safePath) `/files/${fileType}`, buffer, contentType, name);
76
72
  return (0, responses_js_1.formatSuccess)(data);
77
73
  }
78
74
  catch (err) {
@@ -80,3 +76,15 @@ function registerFileTools(server, client, readOnly) {
80
76
  }
81
77
  });
82
78
  }
79
+ const EXTENSIONS = {
80
+ "image/gif": "gif",
81
+ "image/jpeg": "jpg",
82
+ "image/png": "png",
83
+ "audio/mpeg": "mp3",
84
+ "audio/mp4": "m4a",
85
+ "audio/wave": "wav",
86
+ "audio/x-caf": "caf",
87
+ };
88
+ function defaultFileName(contentType) {
89
+ return `upload.${EXTENSIONS[contentType]}`;
90
+ }
@@ -7,6 +7,7 @@ const devices_js_1 = require("./devices.js");
7
7
  const device_actions_js_1 = require("./device-actions.js");
8
8
  const liveviews_js_1 = require("./liveviews.js");
9
9
  const files_js_1 = require("./files.js");
10
+ const alarm_manager_js_1 = require("./alarm-manager.js");
10
11
  const subscriptions_js_1 = require("./subscriptions.js");
11
12
  const arm_profiles_js_1 = require("./arm-profiles.js");
12
13
  const users_js_1 = require("./users.js");
@@ -19,6 +20,7 @@ function registerAllTools(server, client, readOnly) {
19
20
  (0, device_actions_js_1.registerDeviceActionTools)(server, client, readOnly);
20
21
  (0, liveviews_js_1.registerLiveviewTools)(server, client, readOnly);
21
22
  (0, files_js_1.registerFileTools)(server, client, readOnly);
23
+ (0, alarm_manager_js_1.registerAlarmManagerTools)(server, client, readOnly);
22
24
  (0, arm_profiles_js_1.registerArmProfileTools)(server, client, readOnly);
23
25
  (0, users_js_1.registerUserTools)(server, client);
24
26
  (0, pos_js_1.registerPosTools)(server, client, readOnly);
@@ -6,9 +6,31 @@ 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
8
  const misc_js_1 = require("../schemas/misc.js");
9
+ // POST and PATCH both take the full liveview object; every field is required
10
+ // (7.3.70 docs). Slot item schema is not rendered in the docs, so slots pass
11
+ // through beyond the observed { cameras, cycleMode, cycleInterval }.
12
+ const liveviewSlotInput = zod_1.z
13
+ .object({
14
+ cameras: zod_1.z.array(zod_1.z.string()).describe("Camera IDs shown in this slot"),
15
+ cycleMode: zod_1.z.string().describe('Cycle mode, e.g. "time" or "motion"'),
16
+ cycleInterval: zod_1.z.number().describe("Cycle interval in seconds"),
17
+ })
18
+ .passthrough();
19
+ const liveviewBody = zod_1.z
20
+ .object({
21
+ id: zod_1.z.string().describe("Liveview ID"),
22
+ modelKey: zod_1.z.literal("liveview").describe('Must be "liveview"'),
23
+ name: zod_1.z.string().describe("Liveview name"),
24
+ isDefault: zod_1.z.boolean().describe("Whether this is the default liveview for all viewers"),
25
+ isGlobal: zod_1.z.boolean().describe("Whether this liveview is available to all users"),
26
+ owner: zod_1.z.string().describe("Owning user ID"),
27
+ layout: zod_1.z.number().min(1).max(26).describe("Number of slots (1-26)"),
28
+ slots: zod_1.z.array(liveviewSlotInput).describe("Per-slot cameras and cycling settings"),
29
+ })
30
+ .passthrough();
9
31
  function registerLiveviewTools(server, client, readOnly) {
10
32
  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`.",
33
+ 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`.",
12
34
  outputSchema: misc_js_1.liveviewListOutputSchema,
13
35
  annotations: safety_js_1.READ_ONLY,
14
36
  }, async () => {
@@ -37,11 +59,9 @@ function registerLiveviewTools(server, client, readOnly) {
37
59
  if (readOnly)
38
60
  return;
39
61
  server.registerTool("protect_create_liveview", {
40
- description: "Create a new live view",
62
+ description: "Create a new live view. Returns: the created liveview (same fields as protect_get_liveview).",
41
63
  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)"),
64
+ settings: liveviewBody.describe("Full liveview object (all fields required)"),
45
65
  dryRun: zod_1.z
46
66
  .boolean()
47
67
  .optional()
@@ -62,12 +82,10 @@ function registerLiveviewTools(server, client, readOnly) {
62
82
  }
63
83
  });
64
84
  server.registerTool("protect_update_liveview", {
65
- description: "Update an existing live view (full replacement via PUT-style PATCH — include all fields)",
85
+ description: "Update an existing live view (full replacement via PUT-style PATCH — include all fields). Returns: the updated liveview.",
66
86
  inputSchema: {
67
87
  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)"),
88
+ settings: liveviewBody.describe("Full liveview object (all fields required — fetch it with protect_get_liveview first)"),
71
89
  dryRun: zod_1.z
72
90
  .boolean()
73
91
  .optional()
@@ -3,8 +3,10 @@ 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 `.passthrough()` 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
12
  export declare function registerPosTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
package/dist/tools/pos.js CHANGED
@@ -9,9 +9,11 @@ const pos_js_1 = require("../schemas/pos.js");
9
9
  /**
10
10
  * Point-of-sale event ingestion (added in Protect 7.3.x).
11
11
  *
12
- * `lineItems` entries use `.passthrough()` because the 7.3.47 docs render the
13
- * item schema collapsed — the request sample shows `{ title, quantity }`, but
12
+ * `lineItems` entries use `.passthrough()` because the 7.3.70 docs render the
13
+ * item schema collapsed — the request sample shows `{ title, quantity: 1 }`, but
14
14
  * an undocumented extra field should reach the API rather than be stripped.
15
+ * Since the item constraints are not published, `quantity` is only required
16
+ * to be positive (fractional quantities such as weights are allowed).
15
17
  */
16
18
  function registerPosTools(server, client, readOnly) {
17
19
  if (readOnly)
@@ -19,7 +21,7 @@ function registerPosTools(server, client, readOnly) {
19
21
  const lineItemSchema = zod_1.z
20
22
  .object({
21
23
  title: zod_1.z.string().describe("Line item name"),
22
- quantity: zod_1.z.number().min(1).describe("Quantity purchased (>= 1)"),
24
+ quantity: zod_1.z.number().positive().describe("Quantity purchased (> 0)"),
23
25
  })
24
26
  .passthrough();
25
27
  server.registerTool("protect_ingest_pos_transaction", {