@owine/unifi-protect-mcp 2.3.2 → 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 +82 -2
- package/dist/tools/arm-profiles.d.ts +3 -0
- package/dist/tools/arm-profiles.js +147 -0
- package/dist/tools/device-actions.d.ts +3 -0
- package/dist/tools/device-actions.js +159 -0
- package/dist/tools/devices.d.ts +10 -0
- package/dist/tools/devices.js +98 -25
- package/dist/tools/index.js +6 -0
- package/dist/tools/users.d.ts +3 -0
- package/dist/tools/users.js +59 -0
- package/package.json +4 -4
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.
|
|
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 (
|
|
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,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,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
|
+
}
|
package/dist/tools/devices.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/tools/devices.js
CHANGED
|
@@ -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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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(`/${
|
|
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_${
|
|
30
|
-
description: `Get details for a specific ${
|
|
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) `/${
|
|
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_${
|
|
44
|
-
description: `Update ${
|
|
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 ${
|
|
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) `/${
|
|
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) `/${
|
|
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
|
|
72
|
-
|
|
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;
|
package/dist/tools/index.js
CHANGED
|
@@ -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,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
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "MCP server for UniFi Protect Integration API",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -42,12 +42,12 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@eslint/js": "10.0.1",
|
|
45
|
-
"@types/node": "24.12.
|
|
45
|
+
"@types/node": "24.12.3",
|
|
46
46
|
"@types/ws": "8.18.1",
|
|
47
47
|
"@vitest/coverage-v8": "4.1.5",
|
|
48
|
-
"eslint": "10.
|
|
48
|
+
"eslint": "10.3.0",
|
|
49
49
|
"typescript": "6.0.3",
|
|
50
|
-
"typescript-eslint": "8.59.
|
|
50
|
+
"typescript-eslint": "8.59.2",
|
|
51
51
|
"vitest": "4.1.5"
|
|
52
52
|
}
|
|
53
53
|
}
|