@owine/unifi-protect-mcp 2.14.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +248 -92
  2. package/dist/client.d.ts +43 -3
  3. package/dist/client.js +255 -85
  4. package/dist/config.d.ts +7 -1
  5. package/dist/config.js +114 -6
  6. package/dist/index.js +14 -6
  7. package/dist/instructions.d.ts +6 -0
  8. package/dist/instructions.js +24 -0
  9. package/dist/schemas/cameras.d.ts +32 -30
  10. package/dist/schemas/cameras.js +7 -6
  11. package/dist/schemas/common.d.ts +31 -6
  12. package/dist/schemas/common.js +45 -6
  13. package/dist/schemas/devices.d.ts +2 -10
  14. package/dist/schemas/devices.js +35 -26
  15. package/dist/schemas/misc.d.ts +50 -18
  16. package/dist/schemas/misc.js +23 -15
  17. package/dist/schemas/pos.d.ts +1 -1
  18. package/dist/schemas/pos.js +1 -1
  19. package/dist/tls.d.ts +41 -0
  20. package/dist/tls.js +75 -0
  21. package/dist/tools/alarm-manager.d.ts +3 -0
  22. package/dist/tools/alarm-manager.js +32 -0
  23. package/dist/tools/arm-profiles.d.ts +8 -2
  24. package/dist/tools/arm-profiles.js +98 -95
  25. package/dist/tools/cameras.d.ts +2 -2
  26. package/dist/tools/cameras.js +156 -181
  27. package/dist/tools/device-actions.d.ts +2 -2
  28. package/dist/tools/device-actions.js +84 -97
  29. package/dist/tools/devices.d.ts +6 -3
  30. package/dist/tools/devices.js +50 -51
  31. package/dist/tools/files.d.ts +2 -2
  32. package/dist/tools/files.js +82 -57
  33. package/dist/tools/index.d.ts +2 -2
  34. package/dist/tools/index.js +15 -10
  35. package/dist/tools/liveviews.d.ts +2 -2
  36. package/dist/tools/liveviews.js +52 -52
  37. package/dist/tools/pos.d.ts +6 -4
  38. package/dist/tools/pos.js +15 -20
  39. package/dist/tools/registrar.d.ts +34 -0
  40. package/dist/tools/registrar.js +103 -0
  41. package/dist/tools/subscriptions.d.ts +13 -2
  42. package/dist/tools/subscriptions.js +148 -27
  43. package/dist/tools/system.d.ts +2 -2
  44. package/dist/tools/system.js +5 -15
  45. package/dist/tools/users.d.ts +2 -2
  46. package/dist/tools/users.js +18 -35
  47. package/dist/utils/limits.d.ts +33 -0
  48. package/dist/utils/limits.js +74 -0
  49. package/dist/utils/paging.d.ts +15 -0
  50. package/dist/utils/paging.js +28 -0
  51. package/dist/utils/params.d.ts +13 -0
  52. package/dist/utils/params.js +40 -0
  53. package/dist/utils/responses.js +2 -3
  54. package/dist/utils/safety.d.ts +39 -3
  55. package/dist/utils/safety.js +45 -3
  56. package/dist/utils/url.d.ts +4 -1
  57. package/dist/utils/url.js +4 -1
  58. package/dist/version.d.ts +6 -0
  59. package/dist/version.js +11 -0
  60. package/package.json +14 -12
@@ -5,155 +5,142 @@ const zod_1 = require("zod");
5
5
  const responses_js_1 = require("../utils/responses.js");
6
6
  const safety_js_1 = require("../utils/safety.js");
7
7
  const url_js_1 = require("../utils/url.js");
8
+ const params_js_1 = require("../utils/params.js");
9
+ // Each gate's confirm input and handler check share one action text.
10
+ const STOP_SIREN = (0, safety_js_1.confirmGate)("silence the siren");
11
+ const SWITCH_RELAY = (0, safety_js_1.confirmGate)("switch the relay output");
12
+ const TRIGGER_HUB_OUTPUT = (0, safety_js_1.confirmGate)("trigger the alarm hub output");
8
13
  function registerDeviceActionTools(server, client, readOnly) {
9
14
  if (readOnly)
10
15
  return;
11
16
  // --- Sirens ---
12
17
  server.registerTool("protect_play_siren", {
13
- description: "Activate the siren alarm for a duration. Tracks status and can be stopped early.",
18
+ description: "Activate the siren alarm for a duration. Stop it early with protect_stop_siren, which requires confirm: true.",
14
19
  inputSchema: {
15
- id: zod_1.z.string().describe("Siren ID"),
20
+ id: (0, params_js_1.resourceId)().describe("Siren ID"),
16
21
  duration: zod_1.z
17
22
  .union([zod_1.z.literal(5), zod_1.z.literal(10), zod_1.z.literal(20), zod_1.z.literal(30)])
18
23
  .optional()
19
24
  .describe("Duration of siren activation in seconds (5, 10, 20, or 30). Defaults to 5."),
20
- dryRun: zod_1.z.boolean().optional().describe("If true, return what would happen without making changes"),
25
+ dryRun: (0, safety_js_1.dryRunInput)(),
21
26
  },
22
27
  annotations: safety_js_1.WRITE,
23
28
  }, async ({ id, duration, dryRun }) => {
24
- try {
25
- const body = duration !== undefined ? { duration } : undefined;
26
- if (dryRun) {
27
- return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/sirens/${id}/play`, body);
28
- }
29
- const data = await client.post((0, url_js_1.safePath) `/sirens/${id}/play`, body);
30
- return (0, responses_js_1.formatSuccess)(data);
31
- }
32
- catch (err) {
33
- return (0, responses_js_1.formatError)(err);
29
+ const body = duration !== undefined ? { duration } : undefined;
30
+ if (dryRun) {
31
+ return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/sirens/${id}/play`, body);
34
32
  }
33
+ const data = await client.post((0, url_js_1.safePath) `/sirens/${id}/play`, body);
34
+ return (0, responses_js_1.formatSuccess)(data);
35
35
  });
36
36
  server.registerTool("protect_stop_siren", {
37
- description: "Stop an active siren",
37
+ description: "Stop an active siren. Requires confirm: true: it silences an alarm siren.",
38
38
  inputSchema: {
39
- id: zod_1.z.string().describe("Siren ID"),
40
- dryRun: zod_1.z.boolean().optional().describe("If true, return what would happen without making changes"),
39
+ id: (0, params_js_1.resourceId)().describe("Siren ID"),
40
+ confirm: STOP_SIREN.input,
41
+ dryRun: (0, safety_js_1.dryRunInput)(),
41
42
  },
42
- annotations: safety_js_1.WRITE,
43
- }, async ({ id, dryRun }) => {
44
- try {
45
- if (dryRun) {
46
- return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/sirens/${id}/stop`);
47
- }
48
- const data = await client.post((0, url_js_1.safePath) `/sirens/${id}/stop`);
49
- return (0, responses_js_1.formatSuccess)(data);
50
- }
51
- catch (err) {
52
- return (0, responses_js_1.formatError)(err);
43
+ annotations: safety_js_1.DESTRUCTIVE,
44
+ }, async ({ id, confirm, dryRun }) => {
45
+ if (dryRun) {
46
+ return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/sirens/${id}/stop`);
53
47
  }
48
+ const denied = STOP_SIREN.check(confirm);
49
+ if (denied)
50
+ return denied;
51
+ const data = await client.post((0, url_js_1.safePath) `/sirens/${id}/stop`);
52
+ return (0, responses_js_1.formatSuccess)(data);
54
53
  });
55
54
  server.registerTool("protect_test_siren_sound", {
56
55
  description: "Test the siren sound for 5 seconds at the specified volume",
57
56
  inputSchema: {
58
- id: zod_1.z.string().describe("Siren ID"),
57
+ id: (0, params_js_1.resourceId)().describe("Siren ID"),
59
58
  volume: zod_1.z.number().int().min(1).max(100).optional().describe("Test volume 1-100. Defaults to configured device volume."),
60
- dryRun: zod_1.z.boolean().optional().describe("If true, return what would happen without making changes"),
59
+ dryRun: (0, safety_js_1.dryRunInput)(),
61
60
  },
62
61
  annotations: safety_js_1.WRITE,
63
62
  }, async ({ id, volume, dryRun }) => {
64
- try {
65
- const body = volume !== undefined ? { volume } : undefined;
66
- if (dryRun) {
67
- return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/sirens/${id}/test-sound`, body);
68
- }
69
- const data = await client.post((0, url_js_1.safePath) `/sirens/${id}/test-sound`, body);
70
- return (0, responses_js_1.formatSuccess)(data);
71
- }
72
- catch (err) {
73
- return (0, responses_js_1.formatError)(err);
63
+ const body = volume !== undefined ? { volume } : undefined;
64
+ if (dryRun) {
65
+ return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/sirens/${id}/test-sound`, body);
74
66
  }
67
+ const data = await client.post((0, url_js_1.safePath) `/sirens/${id}/test-sound`, body);
68
+ return (0, responses_js_1.formatSuccess)(data);
75
69
  });
76
70
  // --- Speakers ---
77
71
  server.registerTool("protect_test_speaker_sound", {
78
72
  description: "Test the speaker sound at the specified volume",
79
73
  inputSchema: {
80
- id: zod_1.z.string().describe("Speaker ID"),
74
+ id: (0, params_js_1.resourceId)().describe("Speaker ID"),
81
75
  volume: zod_1.z.number().int().min(0).max(100).optional().describe("Test volume 0-100. Defaults to configured device volume."),
82
- dryRun: zod_1.z.boolean().optional().describe("If true, return what would happen without making changes"),
76
+ dryRun: (0, safety_js_1.dryRunInput)(),
83
77
  },
84
78
  annotations: safety_js_1.WRITE,
85
79
  }, async ({ id, volume, dryRun }) => {
86
- try {
87
- const body = volume !== undefined ? { volume } : undefined;
88
- if (dryRun) {
89
- return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/speakers/${id}/test-sound`, body);
90
- }
91
- const data = await client.post((0, url_js_1.safePath) `/speakers/${id}/test-sound`, body);
92
- return (0, responses_js_1.formatSuccess)(data);
93
- }
94
- catch (err) {
95
- return (0, responses_js_1.formatError)(err);
80
+ const body = volume !== undefined ? { volume } : undefined;
81
+ if (dryRun) {
82
+ return (0, safety_js_1.formatDryRun)("POST", (0, url_js_1.safePath) `/speakers/${id}/test-sound`, body);
96
83
  }
84
+ const data = await client.post((0, url_js_1.safePath) `/speakers/${id}/test-sound`, body);
85
+ return (0, responses_js_1.formatSuccess)(data);
97
86
  });
98
87
  // --- Relays ---
99
88
  server.registerTool("protect_activate_relay_output", {
100
- description: "Control a relay output. Set state to \"on\"/\"off\" to set explicitly, or omit to toggle. When state is \"on\", pulseDuration auto-turns off after the given milliseconds.",
89
+ description: "Control a relay output. Relays commonly drive gates, door strikes and garage doors, so this requires confirm: true. state (\"on\"/\"off\") is required: the API's toggle mode is not exposed because a retried toggle would flip the output back. Read the current output state with protect_get_relay. When state is \"on\", pulseDuration auto-turns off after the given milliseconds.",
101
90
  inputSchema: {
102
- id: zod_1.z.string().describe("Relay ID"),
103
- outputId: zod_1.z.number().int().min(0).describe("Output channel ID (0 or 1)"),
104
- state: zod_1.z.enum(["on", "off"]).optional().describe("Desired output state. If omitted, toggles current state."),
91
+ id: (0, params_js_1.resourceId)().describe("Relay ID"),
92
+ outputId: zod_1.z.number().int().min(0).describe("Output channel ID: an outputs[].id from protect_get_relay"),
93
+ state: zod_1.z.enum(["on", "off"]).describe("Desired output state"),
105
94
  pulseDuration: zod_1.z.number().int().min(0).optional().describe("Auto-off duration in milliseconds (only when state is \"on\"). 0 means stay on."),
106
- dryRun: zod_1.z.boolean().optional().describe("If true, return what would happen without making changes"),
95
+ confirm: SWITCH_RELAY.input,
96
+ dryRun: (0, safety_js_1.dryRunInput)(),
107
97
  },
108
- annotations: safety_js_1.WRITE,
109
- }, async ({ id, outputId, state, pulseDuration, dryRun }) => {
110
- try {
111
- const body = {};
112
- if (state !== undefined)
113
- body.state = state;
114
- if (pulseDuration !== undefined)
115
- body.pulseDuration = pulseDuration;
116
- const path = (0, url_js_1.safePath) `/relays/${id}/outputs/${outputId}/activate`;
117
- if (dryRun) {
118
- return (0, safety_js_1.formatDryRun)("POST", path, Object.keys(body).length > 0 ? body : undefined);
119
- }
120
- const data = await client.post(path, Object.keys(body).length > 0 ? body : undefined);
121
- return (0, responses_js_1.formatSuccess)(data);
122
- }
123
- catch (err) {
124
- return (0, responses_js_1.formatError)(err);
98
+ // Not idempotent: an explicit state is safe to repeat, but a timed one
99
+ // is not, since a repeated call starts the timer, and the output, again.
100
+ annotations: safety_js_1.DESTRUCTIVE,
101
+ }, async ({ id, outputId, state, pulseDuration, confirm, dryRun }) => {
102
+ const body = { state };
103
+ if (pulseDuration !== undefined)
104
+ body.pulseDuration = pulseDuration;
105
+ const path = (0, url_js_1.safePath) `/relays/${id}/outputs/${outputId}/activate`;
106
+ if (dryRun) {
107
+ return (0, safety_js_1.formatDryRun)("POST", path, body);
125
108
  }
109
+ const denied = SWITCH_RELAY.check(confirm);
110
+ if (denied)
111
+ return denied;
112
+ const data = await client.post(path, body);
113
+ return (0, responses_js_1.formatSuccess)(data);
126
114
  });
127
115
  // --- Alarm hubs ---
128
116
  server.registerTool("protect_trigger_alarm_hub_output", {
129
- description: "Trigger an alarm hub output channel. Used to turn on/off connected sirens, lights, or other actuators.",
117
+ description: "Trigger an alarm hub output channel. Used to turn on/off connected sirens, lights, locks, or other actuators, so this requires confirm: true. enable is required: the API's toggle mode is not exposed because a retried toggle would flip the output back.",
130
118
  inputSchema: {
131
- id: zod_1.z.string().describe("Alarm hub (link station) ID"),
132
- outputId: zod_1.z.number().int().min(0).describe("Output channel ID (0 or 1)"),
133
- enable: zod_1.z.boolean().optional().describe("True to turn on, false to turn off. If omitted, toggles current state."),
119
+ id: (0, params_js_1.resourceId)().describe("Alarm hub (link station) ID"),
120
+ outputId: zod_1.z.number().int().min(0).describe("Output channel ID: a key of alarmHub.output from protect_get_alarm_hub"),
121
+ enable: zod_1.z.boolean().describe("True to turn on, false to turn off"),
134
122
  delay: zod_1.z.number().int().min(0).optional().describe("Delay in milliseconds before output activates"),
135
123
  duration: zod_1.z.number().int().min(0).optional().describe("Duration in milliseconds to keep output active. 0 = indefinite."),
136
- dryRun: zod_1.z.boolean().optional().describe("If true, return what would happen without making changes"),
124
+ confirm: TRIGGER_HUB_OUTPUT.input,
125
+ dryRun: (0, safety_js_1.dryRunInput)(),
137
126
  },
138
- annotations: safety_js_1.WRITE,
139
- }, async ({ id, outputId, enable, delay, duration, dryRun }) => {
140
- try {
141
- const body = {};
142
- if (enable !== undefined)
143
- body.enable = enable;
144
- if (delay !== undefined)
145
- body.delay = delay;
146
- if (duration !== undefined)
147
- body.duration = duration;
148
- const path = (0, url_js_1.safePath) `/alarm-hubs/${id}/outputs/${outputId}/trigger`;
149
- if (dryRun) {
150
- return (0, safety_js_1.formatDryRun)("POST", path, Object.keys(body).length > 0 ? body : undefined);
151
- }
152
- const data = await client.post(path, Object.keys(body).length > 0 ? body : undefined);
153
- return (0, responses_js_1.formatSuccess)(data);
154
- }
155
- catch (err) {
156
- return (0, responses_js_1.formatError)(err);
127
+ // Not idempotent: an explicit state is safe to repeat, but a timed one
128
+ // is not, since a repeated call starts the timer, and the output, again.
129
+ annotations: safety_js_1.DESTRUCTIVE,
130
+ }, async ({ id, outputId, enable, delay, duration, confirm, dryRun }) => {
131
+ const body = { enable };
132
+ if (delay !== undefined)
133
+ body.delay = delay;
134
+ if (duration !== undefined)
135
+ body.duration = duration;
136
+ const path = (0, url_js_1.safePath) `/alarm-hubs/${id}/outputs/${outputId}/trigger`;
137
+ if (dryRun) {
138
+ return (0, safety_js_1.formatDryRun)("POST", path, body);
157
139
  }
140
+ const denied = TRIGGER_HUB_OUTPUT.check(confirm);
141
+ if (denied)
142
+ return denied;
143
+ const data = await client.post(path, body);
144
+ return (0, responses_js_1.formatSuccess)(data);
158
145
  });
159
146
  }
@@ -1,14 +1,17 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1
+ import type { ToolRegistrar } from "./registrar.js";
2
2
  import { ProtectClient } from "../client.js";
3
+ import { DEVICE_SCHEMAS } from "../schemas/devices.js";
3
4
  interface DeviceConfig {
4
5
  urlPath: string;
5
- singular: string;
6
+ singular: keyof typeof DEVICE_SCHEMAS;
6
7
  plural: string;
7
8
  label: string;
8
9
  hint: string;
9
10
  returns: string;
11
+ note?: string;
12
+ confirmFields?: readonly string[];
10
13
  }
11
- export declare function registerDeviceTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
14
+ export declare function registerDeviceTools(server: ToolRegistrar, client: ProtectClient, readOnly: boolean): void;
12
15
  /** Exported for tests */
13
16
  export declare const _DEVICE_CONFIGS: DeviceConfig[];
14
17
  export {};
@@ -6,11 +6,14 @@ const zod_1 = require("zod");
6
6
  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
+ const paging_js_1 = require("../utils/paging.js");
10
+ const params_js_1 = require("../utils/params.js");
9
11
  const devices_js_1 = require("../schemas/devices.js");
10
- // Field lists below are from the UniFi Protect Integration API 7.3.47 docs.
12
+ // Field lists below are from the UniFi Protect Integration API 7.3.70 docs.
11
13
  // 7.3.x added `type` (hardware model name) and `guid` to every device. NOTE:
12
14
  // `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.
15
+ // model returns the same guid (verified live 7.3.47 and 7.3.70). `id` is the
16
+ // device identity.
14
17
  // Chimes are additionally verified against live console responses; the rest
15
18
  // have no instances on the available console (top-level fields documented,
16
19
  // nested shapes intentionally left opaque in the output schema).
@@ -21,23 +24,24 @@ const DEVICES = [
21
24
  plural: "lights",
22
25
  label: "Light",
23
26
  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)",
27
+ 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
28
  },
26
29
  {
27
30
  urlPath: "sensors",
28
31
  singular: "sensor",
32
+ confirmFields: ["alarmSettings", "motionSettings", "glassBreakSettings", "armProfileIds", "scheduleMode"],
29
33
  plural: "sensors",
30
34
  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)",
35
+ 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",
36
+ 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
37
  },
34
38
  {
35
39
  urlPath: "chimes",
36
40
  singular: "chime",
37
41
  plural: "chimes",
38
42
  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",
43
+ hint: "Known fields: name (string), cameraIds (array of doorbell camera IDs linked to the chime), ringSettings (array of objects: cameraId, volume, ringtoneId, repeatTimes)",
44
+ 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
45
  },
42
46
  {
43
47
  urlPath: "viewers",
@@ -45,23 +49,26 @@ const DEVICES = [
45
49
  plural: "viewers",
46
50
  label: "Viewer",
47
51
  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)",
52
+ returns: "id, modelKey, name, mac, state, type, guid, liveview, streamLimit (7.3.70 docs)",
49
53
  },
50
54
  {
51
55
  urlPath: "sirens",
52
56
  singular: "siren",
57
+ // A lower volume weakens the alarm as surely as stopping the siren does.
58
+ confirmFields: ["volume"],
53
59
  plural: "sirens",
54
60
  label: "Siren",
55
61
  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)",
62
+ 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
63
  },
58
64
  {
59
65
  urlPath: "fobs",
60
66
  singular: "fob",
67
+ confirmFields: ["armControlSettings"],
61
68
  plural: "fobs",
62
69
  label: "Fob",
63
- 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)",
70
+ hint: "Known fields: name (string). armControlSettings is not documented as patchable; sending it requires confirm: true",
71
+ 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
72
  },
66
73
  {
67
74
  urlPath: "relays",
@@ -69,7 +76,7 @@ const DEVICES = [
69
76
  plural: "relays",
70
77
  label: "Relay",
71
78
  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)",
79
+ 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
80
  },
74
81
  {
75
82
  urlPath: "speakers",
@@ -77,7 +84,7 @@ const DEVICES = [
77
84
  plural: "speakers",
78
85
  label: "Speaker",
79
86
  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)",
87
+ returns: "id, modelKey, name, mac, state, type, guid, volume, micVolume, isMicEnabled, speakerState (status, mode), featureFlags (hasMic) (7.3.70 docs)",
81
88
  },
82
89
  {
83
90
  urlPath: "bridges",
@@ -85,15 +92,16 @@ const DEVICES = [
85
92
  plural: "bridges",
86
93
  label: "Bridge",
87
94
  hint: "Known fields: name (string)",
88
- returns: "id, modelKey, name, mac, state, type, guid, platform, clients (array of MACs), maxClients (7.3.47 docs)",
95
+ returns: "id, modelKey, name, mac, state, type, guid, platform, clients (array of MACs), maxClients (7.3.70 docs)",
89
96
  },
90
97
  {
91
98
  urlPath: "link-stations",
92
99
  singular: "link_station",
93
100
  plural: "link_stations",
94
101
  label: "Link station",
102
+ note: "Link stations are non-alarm-hub gateways (use protect_list_alarm_hubs for alarm hubs).",
95
103
  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)",
104
+ 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
105
  },
98
106
  {
99
107
  urlPath: "alarm-hubs",
@@ -101,64 +109,55 @@ const DEVICES = [
101
109
  plural: "alarm_hubs",
102
110
  label: "Alarm hub",
103
111
  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)",
112
+ 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
113
  },
106
114
  ];
107
115
  function registerDeviceCrud(server, client, cfg, readOnly) {
108
116
  const schemaKey = cfg.singular;
117
+ const note = cfg.note ? `${cfg.note} ` : "";
109
118
  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}.`,
119
+ description: `List all ${cfg.label.toLowerCase()}s managed by UniFi Protect. ${note}Returns array; each ${cfg.label.toLowerCase()} includes: ${cfg.returns}.${paging_js_1.PAGING_NOTE}`,
120
+ inputSchema: paging_js_1.pagingInput,
111
121
  outputSchema: (0, devices_js_1.listOutputShape)(schemaKey),
112
122
  annotations: safety_js_1.READ_ONLY,
113
- }, async () => {
114
- try {
115
- const data = await client.get(`/${cfg.urlPath}`);
116
- return (0, responses_js_1.formatSuccess)(data);
117
- }
118
- catch (err) {
119
- return (0, responses_js_1.formatError)(err);
120
- }
123
+ }, async ({ limit, offset }) => {
124
+ const data = await client.get(`/${cfg.urlPath}`);
125
+ return (0, responses_js_1.formatSuccess)((0, paging_js_1.pageOf)(data, limit, offset));
121
126
  });
122
127
  server.registerTool(`protect_get_${cfg.singular}`, {
123
- description: `Get full details for a specific ${cfg.label.toLowerCase()} by ID. Returns: ${cfg.returns}.`,
124
- inputSchema: { id: zod_1.z.string().describe(`${cfg.label} ID`) },
128
+ description: `Get full details for a specific ${cfg.label.toLowerCase()} by ID. ${note}Returns: ${cfg.returns}.`,
129
+ inputSchema: { id: (0, params_js_1.resourceId)().describe(`${cfg.label} ID`) },
125
130
  outputSchema: (0, devices_js_1.itemOutputShape)(schemaKey),
126
131
  annotations: safety_js_1.READ_ONLY,
127
132
  }, async ({ id }) => {
128
- try {
129
- const data = await client.get((0, url_js_1.safePath) `/${cfg.urlPath}/${id}`);
130
- return (0, responses_js_1.formatSuccess)(data);
131
- }
132
- catch (err) {
133
- return (0, responses_js_1.formatError)(err);
134
- }
133
+ const data = await client.get((0, url_js_1.safePath) `/${cfg.urlPath}/${id}`);
134
+ return (0, responses_js_1.formatSuccess)(data);
135
135
  });
136
136
  if (!readOnly) {
137
137
  server.registerTool(`protect_update_${cfg.singular}`, {
138
- description: `Update ${cfg.label.toLowerCase()} settings (partial update via PATCH)`,
138
+ description: `Update ${cfg.label.toLowerCase()} settings (partial update via PATCH).${cfg.confirmFields ? ` Changing ${cfg.confirmFields.join(", ")} affects alarm coverage and requires confirm: true.` : ""}`,
139
139
  inputSchema: {
140
- id: zod_1.z.string().describe(`${cfg.label} ID`),
140
+ id: (0, params_js_1.resourceId)().describe(`${cfg.label} ID`),
141
141
  settings: zod_1.z
142
142
  .record(zod_1.z.string(), zod_1.z.unknown())
143
+ .refine((o) => Object.keys(o).length > 0, "settings must include at least one field")
143
144
  .describe(`Partial ${cfg.label.toLowerCase()} settings to update. ${cfg.hint}`),
144
- dryRun: zod_1.z
145
- .boolean()
146
- .optional()
147
- .describe("If true, return what would happen without making changes"),
145
+ ...(cfg.confirmFields
146
+ ? { confirm: (0, safety_js_1.confirmInput)(`change ${cfg.confirmFields.join(", ")}`) }
147
+ : {}),
148
+ dryRun: (0, safety_js_1.dryRunInput)(),
148
149
  },
149
150
  outputSchema: (0, devices_js_1.itemOutputShape)(schemaKey),
150
- annotations: safety_js_1.WRITE,
151
- }, async ({ id, settings, dryRun }) => {
152
- try {
153
- if (dryRun) {
154
- return (0, safety_js_1.formatDryRun)("PATCH", (0, url_js_1.safePath) `/${cfg.urlPath}/${id}`, settings);
155
- }
156
- const data = await client.patch((0, url_js_1.safePath) `/${cfg.urlPath}/${id}`, settings);
157
- return (0, responses_js_1.formatSuccess)(data);
158
- }
159
- catch (err) {
160
- return (0, responses_js_1.formatError)(err);
151
+ annotations: safety_js_1.DESTRUCTIVE,
152
+ }, async ({ id, settings, dryRun, confirm }) => {
153
+ if (dryRun) {
154
+ return (0, safety_js_1.formatDryRun)("PATCH", (0, url_js_1.safePath) `/${cfg.urlPath}/${id}`, settings);
161
155
  }
156
+ const denied = (0, safety_js_1.requireConfirmationForFields)(settings, cfg.confirmFields ?? [], confirm);
157
+ if (denied)
158
+ return denied;
159
+ const data = await client.patch((0, url_js_1.safePath) `/${cfg.urlPath}/${id}`, settings);
160
+ return (0, responses_js_1.formatSuccess)(data);
162
161
  });
163
162
  }
164
163
  }
@@ -1,3 +1,3 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1
+ import type { ToolRegistrar } from "./registrar.js";
2
2
  import { ProtectClient } from "../client.js";
3
- export declare function registerFileTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
3
+ export declare function registerFileTools(server: ToolRegistrar, client: ProtectClient, readOnly: boolean): void;