@owine/unifi-protect-mcp 2.3.3 → 2.4.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # UniFi Protect MCP Server
2
2
 
3
- An MCP (Model Context Protocol) server that exposes UniFi Protect's Integration REST API as tools for Claude Code and other MCP clients. Provides 33 tools for managing cameras, lights, sensors, chimes, viewers, live views, and NVR status.
3
+ An MCP (Model Context Protocol) server that exposes UniFi Protect's Integration REST API as tools for Claude Code and other MCP clients. Aligned with UniFi Protect API **7.1.47** — 73 tools covering cameras, lights, sensors, chimes, viewers, sirens, fobs, relays, speakers, bridges, link stations, alarm hubs, arm profiles, live views, files, users, NVR status, and WebSocket subscriptions.
4
4
 
5
5
  ## Prerequisites
6
6
 
@@ -75,7 +75,7 @@ This server provides layered safety controls for responsible operation:
75
75
  - **Confirmation parameter** — The most dangerous tools (`protect_disable_mic`, `protect_trigger_alarm_webhook`) require an explicit `confirm: true` parameter that must be present for the call to succeed
76
76
  - **Dry-run support** — All write tools (except those with `confirm`) accept an optional `dryRun: true` parameter that returns a preview of what would happen without making any changes
77
77
 
78
- ## Tools (33 total)
78
+ ## Tools (73 total)
79
79
 
80
80
  ### System (2)
81
81
  | Tool | Description |
@@ -83,6 +83,12 @@ This server provides layered safety controls for responsible operation:
83
83
  | `protect_get_info` | Get system information and version details |
84
84
  | `protect_list_nvrs` | List all NVR devices |
85
85
 
86
+ ### Subscriptions (2)
87
+ | Tool | Description |
88
+ |---|---|
89
+ | `protect_subscribe_devices` | Subscribe via WebSocket to device state updates |
90
+ | `protect_subscribe_events` | Subscribe via WebSocket to event notifications |
91
+
86
92
  ### Cameras (12)
87
93
  | Tool | Description |
88
94
  |---|---|
@@ -127,6 +133,72 @@ This server provides layered safety controls for responsible operation:
127
133
  | `protect_get_viewer` | Get viewer details by ID |
128
134
  | `protect_update_viewer` | Update viewer settings |
129
135
 
136
+ ### Sirens (6)
137
+ | Tool | Description |
138
+ |---|---|
139
+ | `protect_list_sirens` | List all sirens |
140
+ | `protect_get_siren` | Get siren details by ID |
141
+ | `protect_update_siren` | Update siren settings (name, volume, LED) |
142
+ | `protect_play_siren` | Activate the siren alarm for a given duration (5/10/20/30s) |
143
+ | `protect_stop_siren` | Stop an active siren |
144
+ | `protect_test_siren_sound` | Test the siren sound for 5 seconds at a given volume |
145
+
146
+ ### Fobs (3)
147
+ | Tool | Description |
148
+ |---|---|
149
+ | `protect_list_fobs` | List all key fobs |
150
+ | `protect_get_fob` | Get fob details by ID |
151
+ | `protect_update_fob` | Update fob settings |
152
+
153
+ ### Relays (4)
154
+ | Tool | Description |
155
+ |---|---|
156
+ | `protect_list_relays` | List all relays |
157
+ | `protect_get_relay` | Get relay details by ID |
158
+ | `protect_update_relay` | Update relay settings |
159
+ | `protect_activate_relay_output` | Set/toggle a relay output channel, with optional pulse duration |
160
+
161
+ ### Speakers (4)
162
+ | Tool | Description |
163
+ |---|---|
164
+ | `protect_list_speakers` | List all speakers |
165
+ | `protect_get_speaker` | Get speaker details by ID |
166
+ | `protect_update_speaker` | Update speaker settings (volume, mic) |
167
+ | `protect_test_speaker_sound` | Test the speaker sound at a given volume |
168
+
169
+ ### Bridges (3)
170
+ | Tool | Description |
171
+ |---|---|
172
+ | `protect_list_bridges` | List all bridges |
173
+ | `protect_get_bridge` | Get bridge details by ID |
174
+ | `protect_update_bridge` | Update bridge settings |
175
+
176
+ ### Link Stations (3)
177
+ | Tool | Description |
178
+ |---|---|
179
+ | `protect_list_link_stations` | List all link stations (non-alarm-hub gateways) |
180
+ | `protect_get_link_station` | Get link station details by ID |
181
+ | `protect_update_link_station` | Update link station settings |
182
+
183
+ ### Alarm Hubs (4)
184
+ | Tool | Description |
185
+ |---|---|
186
+ | `protect_list_alarm_hubs` | List all alarm hubs |
187
+ | `protect_get_alarm_hub` | Get alarm hub details by ID |
188
+ | `protect_update_alarm_hub` | Update alarm hub settings |
189
+ | `protect_trigger_alarm_hub_output` | Trigger an alarm hub output channel (sirens, lights, etc.) |
190
+
191
+ ### Arm Profiles (7) — local alarm manager
192
+ | Tool | Description |
193
+ |---|---|
194
+ | `protect_list_arm_profiles` | List all arm profiles |
195
+ | `protect_create_arm_profile` | Create a new arm profile |
196
+ | `protect_set_current_arm_profile` | Set the active profile used when arming |
197
+ | `protect_update_arm_profile` | Update an arm profile |
198
+ | `protect_delete_arm_profile` | **DESTRUCTIVE:** Delete an arm profile by ID |
199
+ | `protect_enable_arm_alarm` | Arm the alarm using the current profile |
200
+ | `protect_disable_arm_alarm` | Disarm the alarm |
201
+
130
202
  ### Live Views (4)
131
203
  | Tool | Description |
132
204
  |---|---|
@@ -142,6 +214,14 @@ This server provides layered safety controls for responsible operation:
142
214
  | `protect_list_files` | List files by type |
143
215
  | `protect_upload_file` | Upload a file (base64-encoded) |
144
216
 
217
+ ### Users (4)
218
+ | Tool | Description |
219
+ |---|---|
220
+ | `protect_list_users` | List Protect users (filtered by access permissions) |
221
+ | `protect_get_user` | Get a Protect user by ID |
222
+ | `protect_list_ulp_users` | List UniFi Identity (ULP) users with enrolled credentials |
223
+ | `protect_get_ulp_user` | Get a UniFi Identity user by ID |
224
+
145
225
  ## Development
146
226
 
147
227
  ```bash
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ProtectClient } from "../client.js";
3
+ export declare function registerArmProfileTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerArmProfileTools = registerArmProfileTools;
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 url_js_1 = require("../utils/url.js");
8
+ const ACTIVATION_DELAY_VALUES = [0, 60000, 300000, 600000];
9
+ function registerArmProfileTools(server, client, readOnly) {
10
+ server.registerTool("protect_list_arm_profiles", {
11
+ description: "List all arm profiles. Only available when using local alarm manager.",
12
+ annotations: safety_js_1.READ_ONLY,
13
+ }, async () => {
14
+ try {
15
+ const data = await client.get("/arm-profiles");
16
+ return (0, responses_js_1.formatSuccess)(data);
17
+ }
18
+ catch (err) {
19
+ return (0, responses_js_1.formatError)(err);
20
+ }
21
+ });
22
+ if (readOnly)
23
+ return;
24
+ server.registerTool("protect_create_arm_profile", {
25
+ description: "Create a new arm profile. Only available when using local alarm manager.",
26
+ inputSchema: {
27
+ name: zod_1.z.string().min(1).max(255).describe("Name of the arm profile"),
28
+ automations: zod_1.z.array(zod_1.z.string()).describe("List of automation IDs associated with this arm profile"),
29
+ schedules: zod_1.z.array(zod_1.z.record(zod_1.z.string(), zod_1.z.unknown())).describe("List of arm schedules"),
30
+ recordEverything: zod_1.z.boolean().describe("Whether to record everything when this arm profile is active"),
31
+ activationDelay: zod_1.z
32
+ .union([zod_1.z.literal(0), zod_1.z.literal(60000), zod_1.z.literal(300000), zod_1.z.literal(600000)])
33
+ .describe("Activation delay in milliseconds. Allowed: 0 (none), 60000 (1m), 300000 (5m), 600000 (10m)."),
34
+ dryRun: zod_1.z.boolean().optional().describe("If true, return what would happen without making changes"),
35
+ },
36
+ annotations: safety_js_1.WRITE,
37
+ }, async ({ name, automations, schedules, recordEverything, activationDelay, dryRun }) => {
38
+ try {
39
+ const body = { name, automations, schedules, recordEverything, activationDelay };
40
+ if (dryRun) {
41
+ return (0, safety_js_1.formatDryRun)("POST", "/arm-profiles", body);
42
+ }
43
+ const data = await client.post("/arm-profiles", body);
44
+ return (0, responses_js_1.formatSuccess)(data);
45
+ }
46
+ catch (err) {
47
+ return (0, responses_js_1.formatError)(err);
48
+ }
49
+ });
50
+ server.registerTool("protect_set_current_arm_profile", {
51
+ description: "Set the current arm profile to be used when enabling the arm alarm. Only available when using local alarm manager.",
52
+ inputSchema: {
53
+ armProfileId: zod_1.z.string().describe("The arm profile ID to set as current"),
54
+ dryRun: zod_1.z.boolean().optional().describe("If true, return what would happen without making changes"),
55
+ },
56
+ annotations: safety_js_1.WRITE,
57
+ }, async ({ armProfileId, dryRun }) => {
58
+ try {
59
+ const body = { armProfileId };
60
+ if (dryRun) {
61
+ return (0, safety_js_1.formatDryRun)("PATCH", "/arm-profiles/settings", body);
62
+ }
63
+ const data = await client.patch("/arm-profiles/settings", body);
64
+ return (0, responses_js_1.formatSuccess)(data);
65
+ }
66
+ catch (err) {
67
+ return (0, responses_js_1.formatError)(err);
68
+ }
69
+ });
70
+ server.registerTool("protect_update_arm_profile", {
71
+ description: "Update an existing arm profile (partial update via PATCH). Only available when using local alarm manager.",
72
+ inputSchema: {
73
+ id: zod_1.z.string().describe("Arm profile ID"),
74
+ settings: zod_1.z
75
+ .record(zod_1.z.string(), zod_1.z.unknown())
76
+ .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(", ")})`),
77
+ dryRun: zod_1.z.boolean().optional().describe("If true, return what would happen without making changes"),
78
+ },
79
+ annotations: safety_js_1.WRITE,
80
+ }, async ({ id, settings, dryRun }) => {
81
+ try {
82
+ if (dryRun) {
83
+ return (0, safety_js_1.formatDryRun)("PATCH", (0, url_js_1.safePath) `/arm-profiles/${id}`, settings);
84
+ }
85
+ const data = await client.patch((0, url_js_1.safePath) `/arm-profiles/${id}`, settings);
86
+ return (0, responses_js_1.formatSuccess)(data);
87
+ }
88
+ catch (err) {
89
+ return (0, responses_js_1.formatError)(err);
90
+ }
91
+ });
92
+ server.registerTool("protect_delete_arm_profile", {
93
+ description: "Delete an arm profile by ID. Only available when using local alarm manager.",
94
+ inputSchema: {
95
+ id: zod_1.z.string().describe("Arm profile ID"),
96
+ dryRun: zod_1.z.boolean().optional().describe("If true, return what would happen without making changes"),
97
+ },
98
+ annotations: safety_js_1.DESTRUCTIVE,
99
+ }, async ({ id, dryRun }) => {
100
+ try {
101
+ if (dryRun) {
102
+ return (0, safety_js_1.formatDryRun)("DELETE", (0, url_js_1.safePath) `/arm-profiles/${id}`);
103
+ }
104
+ const data = await client.delete((0, url_js_1.safePath) `/arm-profiles/${id}`);
105
+ return (0, responses_js_1.formatSuccess)(data);
106
+ }
107
+ catch (err) {
108
+ return (0, responses_js_1.formatError)(err);
109
+ }
110
+ });
111
+ server.registerTool("protect_enable_arm_alarm", {
112
+ description: "Enable the arm alarm using the currently selected arm profile. Only available when using local alarm manager.",
113
+ inputSchema: {
114
+ dryRun: zod_1.z.boolean().optional().describe("If true, return what would happen without making changes"),
115
+ },
116
+ annotations: safety_js_1.WRITE,
117
+ }, async ({ dryRun }) => {
118
+ try {
119
+ if (dryRun) {
120
+ return (0, safety_js_1.formatDryRun)("POST", "/arm-profiles/enable");
121
+ }
122
+ const data = await client.post("/arm-profiles/enable");
123
+ return (0, responses_js_1.formatSuccess)(data);
124
+ }
125
+ catch (err) {
126
+ return (0, responses_js_1.formatError)(err);
127
+ }
128
+ });
129
+ server.registerTool("protect_disable_arm_alarm", {
130
+ description: "Disable the arm alarm. Only available when using local alarm manager.",
131
+ inputSchema: {
132
+ dryRun: zod_1.z.boolean().optional().describe("If true, return what would happen without making changes"),
133
+ },
134
+ annotations: safety_js_1.WRITE,
135
+ }, async ({ dryRun }) => {
136
+ try {
137
+ if (dryRun) {
138
+ return (0, safety_js_1.formatDryRun)("POST", "/arm-profiles/disable");
139
+ }
140
+ const data = await client.post("/arm-profiles/disable");
141
+ return (0, responses_js_1.formatSuccess)(data);
142
+ }
143
+ catch (err) {
144
+ return (0, responses_js_1.formatError)(err);
145
+ }
146
+ });
147
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ProtectClient } from "../client.js";
3
+ export declare function registerDeviceActionTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerDeviceActionTools = registerDeviceActionTools;
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 url_js_1 = require("../utils/url.js");
8
+ function registerDeviceActionTools(server, client, readOnly) {
9
+ if (readOnly)
10
+ return;
11
+ // --- Sirens ---
12
+ server.registerTool("protect_play_siren", {
13
+ description: "Activate the siren alarm for a duration. Tracks status and can be stopped early.",
14
+ inputSchema: {
15
+ id: zod_1.z.string().describe("Siren ID"),
16
+ duration: zod_1.z
17
+ .union([zod_1.z.literal(5), zod_1.z.literal(10), zod_1.z.literal(20), zod_1.z.literal(30)])
18
+ .optional()
19
+ .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"),
21
+ },
22
+ annotations: safety_js_1.WRITE,
23
+ }, 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);
34
+ }
35
+ });
36
+ server.registerTool("protect_stop_siren", {
37
+ description: "Stop an active siren",
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"),
41
+ },
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);
53
+ }
54
+ });
55
+ server.registerTool("protect_test_siren_sound", {
56
+ description: "Test the siren sound for 5 seconds at the specified volume",
57
+ inputSchema: {
58
+ id: zod_1.z.string().describe("Siren ID"),
59
+ 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"),
61
+ },
62
+ annotations: safety_js_1.WRITE,
63
+ }, 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);
74
+ }
75
+ });
76
+ // --- Speakers ---
77
+ server.registerTool("protect_test_speaker_sound", {
78
+ description: "Test the speaker sound at the specified volume",
79
+ inputSchema: {
80
+ id: zod_1.z.string().describe("Speaker ID"),
81
+ 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"),
83
+ },
84
+ annotations: safety_js_1.WRITE,
85
+ }, 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);
96
+ }
97
+ });
98
+ // --- Relays ---
99
+ 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.",
101
+ 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."),
105
+ 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"),
107
+ },
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);
125
+ }
126
+ });
127
+ // --- Alarm hubs ---
128
+ 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.",
130
+ 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."),
134
+ delay: zod_1.z.number().int().min(0).optional().describe("Delay in milliseconds before output activates"),
135
+ 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"),
137
+ },
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);
157
+ }
158
+ });
159
+ }
@@ -1,3 +1,13 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { ProtectClient } from "../client.js";
3
+ interface DeviceConfig {
4
+ urlPath: string;
5
+ singular: string;
6
+ plural: string;
7
+ label: string;
8
+ hint: string;
9
+ }
3
10
  export declare function registerDeviceTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
11
+ /** Exported for tests */
12
+ export declare const _DEVICE_CONFIGS: DeviceConfig[];
13
+ export {};
@@ -1,38 +1,110 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports._DEVICE_CONFIGS = void 0;
3
4
  exports.registerDeviceTools = registerDeviceTools;
4
5
  const zod_1 = require("zod");
5
6
  const responses_js_1 = require("../utils/responses.js");
6
7
  const safety_js_1 = require("../utils/safety.js");
7
8
  const url_js_1 = require("../utils/url.js");
8
- const settingsHints = {
9
- light: "Known fields: name (string), isLightForceEnabled (boolean), lightModeSettings (object with mode, enableAt), lightDeviceSettings (object with isIndicatorEnabled, pirDuration, pirSensitivity, ledLevel)",
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)",
12
- viewer: "Known fields: name (string), liveview (string, liveview ID to display)",
13
- };
14
- function registerDeviceCrud(server, client, deviceType, readOnly) {
15
- const plural = `${deviceType}s`;
16
- const label = deviceType.charAt(0).toUpperCase() + deviceType.slice(1);
17
- server.registerTool(`protect_list_${plural}`, {
18
- description: `List all ${plural} managed by UniFi Protect`,
9
+ const DEVICES = [
10
+ {
11
+ urlPath: "lights",
12
+ singular: "light",
13
+ plural: "lights",
14
+ label: "Light",
15
+ hint: "Known fields: name (string), isLightForceEnabled (boolean), lightModeSettings (object with mode, enableAt), lightDeviceSettings (object with isIndicatorEnabled, pirDuration, pirSensitivity, ledLevel)",
16
+ },
17
+ {
18
+ urlPath: "sensors",
19
+ singular: "sensor",
20
+ plural: "sensors",
21
+ label: "Sensor",
22
+ hint: "Known fields: name (string), motionSettings (object), humiditySettings (object), temperatureSettings (object), lightSettings (object), alarmSettings (object)",
23
+ },
24
+ {
25
+ urlPath: "chimes",
26
+ singular: "chime",
27
+ plural: "chimes",
28
+ label: "Chime",
29
+ hint: "Known fields: name (string), cameraIds (array of camera IDs linked to the chime), ringSettings (array of ring tone configurations)",
30
+ },
31
+ {
32
+ urlPath: "viewers",
33
+ singular: "viewer",
34
+ plural: "viewers",
35
+ label: "Viewer",
36
+ hint: "Known fields: name (string), liveview (string, liveview ID to display)",
37
+ },
38
+ {
39
+ urlPath: "sirens",
40
+ singular: "siren",
41
+ plural: "sirens",
42
+ label: "Siren",
43
+ hint: "Known fields: name (string), volume (integer 1-100), ledSettings (object with isEnabled boolean)",
44
+ },
45
+ {
46
+ urlPath: "fobs",
47
+ singular: "fob",
48
+ plural: "fobs",
49
+ label: "Fob",
50
+ hint: "Known fields: name (string)",
51
+ },
52
+ {
53
+ urlPath: "relays",
54
+ singular: "relay",
55
+ plural: "relays",
56
+ label: "Relay",
57
+ hint: "Known fields: name (string), ledSettings (object with isEnabled boolean)",
58
+ },
59
+ {
60
+ urlPath: "speakers",
61
+ singular: "speaker",
62
+ plural: "speakers",
63
+ label: "Speaker",
64
+ hint: "Known fields: name (string), volume (integer 0-100), micVolume (integer 0-100), isMicEnabled (boolean)",
65
+ },
66
+ {
67
+ urlPath: "bridges",
68
+ singular: "bridge",
69
+ plural: "bridges",
70
+ label: "Bridge",
71
+ hint: "Known fields: name (string)",
72
+ },
73
+ {
74
+ urlPath: "link-stations",
75
+ singular: "link_station",
76
+ plural: "link_stations",
77
+ label: "Link station",
78
+ hint: "Known fields: name (string)",
79
+ },
80
+ {
81
+ urlPath: "alarm-hubs",
82
+ singular: "alarm_hub",
83
+ plural: "alarm_hubs",
84
+ label: "Alarm hub",
85
+ hint: "Known fields: name (string)",
86
+ },
87
+ ];
88
+ function registerDeviceCrud(server, client, cfg, readOnly) {
89
+ server.registerTool(`protect_list_${cfg.plural}`, {
90
+ description: `List all ${cfg.label.toLowerCase()}s managed by UniFi Protect`,
19
91
  annotations: safety_js_1.READ_ONLY,
20
92
  }, async () => {
21
93
  try {
22
- const data = await client.get(`/${plural}`);
94
+ const data = await client.get(`/${cfg.urlPath}`);
23
95
  return (0, responses_js_1.formatSuccess)(data);
24
96
  }
25
97
  catch (err) {
26
98
  return (0, responses_js_1.formatError)(err);
27
99
  }
28
100
  });
29
- server.registerTool(`protect_get_${deviceType}`, {
30
- description: `Get details for a specific ${deviceType} by ID`,
31
- inputSchema: { id: zod_1.z.string().describe(`${label} ID`) },
101
+ server.registerTool(`protect_get_${cfg.singular}`, {
102
+ description: `Get details for a specific ${cfg.label.toLowerCase()} by ID`,
103
+ inputSchema: { id: zod_1.z.string().describe(`${cfg.label} ID`) },
32
104
  annotations: safety_js_1.READ_ONLY,
33
105
  }, async ({ id }) => {
34
106
  try {
35
- const data = await client.get((0, url_js_1.safePath) `/${plural}/${id}`);
107
+ const data = await client.get((0, url_js_1.safePath) `/${cfg.urlPath}/${id}`);
36
108
  return (0, responses_js_1.formatSuccess)(data);
37
109
  }
38
110
  catch (err) {
@@ -40,13 +112,13 @@ function registerDeviceCrud(server, client, deviceType, readOnly) {
40
112
  }
41
113
  });
42
114
  if (!readOnly) {
43
- server.registerTool(`protect_update_${deviceType}`, {
44
- description: `Update ${deviceType} settings (partial update via PATCH)`,
115
+ server.registerTool(`protect_update_${cfg.singular}`, {
116
+ description: `Update ${cfg.label.toLowerCase()} settings (partial update via PATCH)`,
45
117
  inputSchema: {
46
- id: zod_1.z.string().describe(`${label} ID`),
118
+ id: zod_1.z.string().describe(`${cfg.label} ID`),
47
119
  settings: zod_1.z
48
120
  .record(zod_1.z.string(), zod_1.z.unknown())
49
- .describe(`Partial ${deviceType} settings to update. ${settingsHints[deviceType]}`),
121
+ .describe(`Partial ${cfg.label.toLowerCase()} settings to update. ${cfg.hint}`),
50
122
  dryRun: zod_1.z
51
123
  .boolean()
52
124
  .optional()
@@ -56,9 +128,9 @@ function registerDeviceCrud(server, client, deviceType, readOnly) {
56
128
  }, async ({ id, settings, dryRun }) => {
57
129
  try {
58
130
  if (dryRun) {
59
- return (0, safety_js_1.formatDryRun)("PATCH", (0, url_js_1.safePath) `/${plural}/${id}`, settings);
131
+ return (0, safety_js_1.formatDryRun)("PATCH", (0, url_js_1.safePath) `/${cfg.urlPath}/${id}`, settings);
60
132
  }
61
- const data = await client.patch((0, url_js_1.safePath) `/${plural}/${id}`, settings);
133
+ const data = await client.patch((0, url_js_1.safePath) `/${cfg.urlPath}/${id}`, settings);
62
134
  return (0, responses_js_1.formatSuccess)(data);
63
135
  }
64
136
  catch (err) {
@@ -68,8 +140,9 @@ function registerDeviceCrud(server, client, deviceType, readOnly) {
68
140
  }
69
141
  }
70
142
  function registerDeviceTools(server, client, readOnly) {
71
- const deviceTypes = ["light", "sensor", "chime", "viewer"];
72
- for (const dt of deviceTypes) {
73
- registerDeviceCrud(server, client, dt, readOnly);
143
+ for (const cfg of DEVICES) {
144
+ registerDeviceCrud(server, client, cfg, readOnly);
74
145
  }
75
146
  }
147
+ /** Exported for tests */
148
+ exports._DEVICE_CONFIGS = DEVICES;
@@ -4,14 +4,20 @@ exports.registerAllTools = registerAllTools;
4
4
  const system_js_1 = require("./system.js");
5
5
  const cameras_js_1 = require("./cameras.js");
6
6
  const devices_js_1 = require("./devices.js");
7
+ const device_actions_js_1 = require("./device-actions.js");
7
8
  const liveviews_js_1 = require("./liveviews.js");
8
9
  const files_js_1 = require("./files.js");
9
10
  const subscriptions_js_1 = require("./subscriptions.js");
11
+ const arm_profiles_js_1 = require("./arm-profiles.js");
12
+ const users_js_1 = require("./users.js");
10
13
  function registerAllTools(server, client, readOnly) {
11
14
  (0, system_js_1.registerSystemTools)(server, client);
12
15
  (0, subscriptions_js_1.registerSubscriptionTools)(server, client);
13
16
  (0, cameras_js_1.registerCameraTools)(server, client, readOnly);
14
17
  (0, devices_js_1.registerDeviceTools)(server, client, readOnly);
18
+ (0, device_actions_js_1.registerDeviceActionTools)(server, client, readOnly);
15
19
  (0, liveviews_js_1.registerLiveviewTools)(server, client, readOnly);
16
20
  (0, files_js_1.registerFileTools)(server, client, readOnly);
21
+ (0, arm_profiles_js_1.registerArmProfileTools)(server, client, readOnly);
22
+ (0, users_js_1.registerUserTools)(server, client);
17
23
  }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ProtectClient } from "../client.js";
3
+ export declare function registerUserTools(server: McpServer, client: ProtectClient): void;
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerUserTools = registerUserTools;
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 url_js_1 = require("../utils/url.js");
8
+ function registerUserTools(server, client) {
9
+ server.registerTool("protect_list_users", {
10
+ description: "List all Protect users. Filtered by access permissions.",
11
+ annotations: safety_js_1.READ_ONLY,
12
+ }, async () => {
13
+ try {
14
+ const data = await client.get("/users");
15
+ return (0, responses_js_1.formatSuccess)(data);
16
+ }
17
+ catch (err) {
18
+ return (0, responses_js_1.formatError)(err);
19
+ }
20
+ });
21
+ server.registerTool("protect_get_user", {
22
+ description: "Get details for a specific Protect user by ID",
23
+ inputSchema: { id: zod_1.z.string().describe("User ID") },
24
+ annotations: safety_js_1.READ_ONLY,
25
+ }, async ({ id }) => {
26
+ try {
27
+ const data = await client.get((0, url_js_1.safePath) `/users/${id}`);
28
+ return (0, responses_js_1.formatSuccess)(data);
29
+ }
30
+ catch (err) {
31
+ return (0, responses_js_1.formatError)(err);
32
+ }
33
+ });
34
+ server.registerTool("protect_list_ulp_users", {
35
+ description: "List all UniFi Identity (ULP) users with enrolled credentials (NFC cards, fingerprints).",
36
+ annotations: safety_js_1.READ_ONLY,
37
+ }, async () => {
38
+ try {
39
+ const data = await client.get("/ulp-users");
40
+ return (0, responses_js_1.formatSuccess)(data);
41
+ }
42
+ catch (err) {
43
+ return (0, responses_js_1.formatError)(err);
44
+ }
45
+ });
46
+ server.registerTool("protect_get_ulp_user", {
47
+ description: "Get details for a specific UniFi Identity (ULP) user by ID",
48
+ inputSchema: { id: zod_1.z.string().describe("ULP user ID") },
49
+ annotations: safety_js_1.READ_ONLY,
50
+ }, async ({ id }) => {
51
+ try {
52
+ const data = await client.get((0, url_js_1.safePath) `/ulp-users/${id}`);
53
+ return (0, responses_js_1.formatSuccess)(data);
54
+ }
55
+ catch (err) {
56
+ return (0, responses_js_1.formatError)(err);
57
+ }
58
+ });
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owine/unifi-protect-mcp",
3
- "version": "2.3.3",
3
+ "version": "2.4.0",
4
4
  "description": "MCP server for UniFi Protect Integration API",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "@eslint/js": "10.0.1",
45
- "@types/node": "24.12.2",
45
+ "@types/node": "24.12.3",
46
46
  "@types/ws": "8.18.1",
47
47
  "@vitest/coverage-v8": "4.1.5",
48
48
  "eslint": "10.3.0",