@owine/unifi-protect-mcp 1.0.0 → 2.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.
- package/README.md +16 -22
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -0
- package/dist/index.js +1 -1
- package/dist/tools/cameras.d.ts +1 -1
- package/dist/tools/cameras.js +150 -34
- package/dist/tools/devices.d.ts +1 -1
- package/dist/tools/devices.js +47 -19
- package/dist/tools/files.d.ts +1 -1
- package/dist/tools/files.js +53 -18
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +5 -5
- package/dist/tools/liveviews.d.ts +1 -1
- package/dist/tools/liveviews.js +47 -14
- package/dist/tools/system.js +9 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -15,15 +15,7 @@ An MCP (Model Context Protocol) server that exposes UniFi Protect's Integration
|
|
|
15
15
|
Add to Claude Code with a single command — no clone or build needed:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
claude mcp add-json unifi-protect '{
|
|
19
|
-
"command": "npx",
|
|
20
|
-
"args": ["-y", "@owine/unifi-protect-mcp"],
|
|
21
|
-
"env": {
|
|
22
|
-
"UNIFI_PROTECT_HOST": "192.168.1.1",
|
|
23
|
-
"UNIFI_PROTECT_API_KEY": "your-api-key",
|
|
24
|
-
"UNIFI_PROTECT_VERIFY_SSL": "false"
|
|
25
|
-
}
|
|
26
|
-
}' -s user
|
|
18
|
+
claude mcp add-json unifi-protect '{"command":"npx","args":["-y","@owine/unifi-protect-mcp@latest"],"env":{"UNIFI_PROTECT_HOST":"192.168.1.1","UNIFI_PROTECT_API_KEY":"your-api-key","UNIFI_PROTECT_VERIFY_SSL":"false"}}' -s user
|
|
27
19
|
```
|
|
28
20
|
|
|
29
21
|
Use `-s user` for global availability across all projects, or `-s project` for the current project only.
|
|
@@ -42,15 +34,7 @@ npm run build
|
|
|
42
34
|
Then add to Claude Code:
|
|
43
35
|
|
|
44
36
|
```bash
|
|
45
|
-
claude mcp add-json unifi-protect '{
|
|
46
|
-
"command": "node",
|
|
47
|
-
"args": ["/path/to/unifi-protect-mcp/dist/index.js"],
|
|
48
|
-
"env": {
|
|
49
|
-
"UNIFI_PROTECT_HOST": "192.168.1.1",
|
|
50
|
-
"UNIFI_PROTECT_API_KEY": "your-api-key",
|
|
51
|
-
"UNIFI_PROTECT_VERIFY_SSL": "false"
|
|
52
|
-
}
|
|
53
|
-
}' -s user
|
|
37
|
+
claude mcp add-json unifi-protect '{"command":"node","args":["/path/to/unifi-protect-mcp/dist/index.js"],"env":{"UNIFI_PROTECT_HOST":"192.168.1.1","UNIFI_PROTECT_API_KEY":"your-api-key","UNIFI_PROTECT_VERIFY_SSL":"false"}}' -s user
|
|
54
38
|
```
|
|
55
39
|
|
|
56
40
|
### Environment Variables
|
|
@@ -60,6 +44,7 @@ claude mcp add-json unifi-protect '{
|
|
|
60
44
|
| `UNIFI_PROTECT_HOST` | Yes | — | IP or hostname of your UniFi Protect console |
|
|
61
45
|
| `UNIFI_PROTECT_API_KEY` | Yes | — | API key from Protect integration settings |
|
|
62
46
|
| `UNIFI_PROTECT_VERIFY_SSL` | No | `true` | Set to `false` to skip TLS certificate verification (needed for self-signed certs) |
|
|
47
|
+
| `UNIFI_PROTECT_READ_ONLY` | No | `true` | Set to `false` to enable write/mutating tools (default is monitoring-only mode) |
|
|
63
48
|
|
|
64
49
|
### Manual Configuration
|
|
65
50
|
|
|
@@ -70,7 +55,7 @@ Alternatively, add to your `~/.claude.json` under the top-level `"mcpServers"` k
|
|
|
70
55
|
"mcpServers": {
|
|
71
56
|
"unifi-protect": {
|
|
72
57
|
"command": "npx",
|
|
73
|
-
"args": ["-y", "@owine/unifi-protect-mcp"],
|
|
58
|
+
"args": ["-y", "@owine/unifi-protect-mcp@latest"],
|
|
74
59
|
"env": {
|
|
75
60
|
"UNIFI_PROTECT_HOST": "192.168.1.1",
|
|
76
61
|
"UNIFI_PROTECT_API_KEY": "your-api-key",
|
|
@@ -81,6 +66,15 @@ Alternatively, add to your `~/.claude.json` under the top-level `"mcpServers"` k
|
|
|
81
66
|
}
|
|
82
67
|
```
|
|
83
68
|
|
|
69
|
+
## Safety Features
|
|
70
|
+
|
|
71
|
+
This server provides layered safety controls for responsible operation:
|
|
72
|
+
|
|
73
|
+
- **Tool annotations** — Every tool declares `readOnlyHint` and `destructiveHint` so MCP clients (like Claude Code) can make informed confirmation decisions
|
|
74
|
+
- **Read-only mode** — Enabled by default. Only read operations (list, get, snapshot) are registered. Set `UNIFI_PROTECT_READ_ONLY=false` to enable write/mutating tools
|
|
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
|
+
- **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
|
+
|
|
84
78
|
## Tools (33 total)
|
|
85
79
|
|
|
86
80
|
### System (2)
|
|
@@ -98,9 +92,9 @@ Alternatively, add to your `~/.claude.json` under the top-level `"mcpServers"` k
|
|
|
98
92
|
| `protect_get_snapshot` | Get a JPEG snapshot (returns image) |
|
|
99
93
|
| `protect_create_rtsp_stream` | Create an RTSPS stream session |
|
|
100
94
|
| `protect_get_rtsp_streams` | Get active RTSPS stream sessions |
|
|
101
|
-
| `protect_delete_rtsp_stream` |
|
|
95
|
+
| `protect_delete_rtsp_stream` | Stop and delete an active RTSPS stream |
|
|
102
96
|
| `protect_create_talkback` | Create a talkback (two-way audio) session |
|
|
103
|
-
| `protect_disable_mic` | Permanently disable camera microphone |
|
|
97
|
+
| `protect_disable_mic` | **IRREVERSIBLE:** Permanently disable camera microphone |
|
|
104
98
|
| `protect_start_ptz_patrol` | Start PTZ patrol at a given slot |
|
|
105
99
|
| `protect_stop_ptz_patrol` | Stop PTZ patrol |
|
|
106
100
|
| `protect_goto_ptz_preset` | Move PTZ to a preset position |
|
|
@@ -144,7 +138,7 @@ Alternatively, add to your `~/.claude.json` under the top-level `"mcpServers"` k
|
|
|
144
138
|
### Alarm & Files (3)
|
|
145
139
|
| Tool | Description |
|
|
146
140
|
|---|---|
|
|
147
|
-
| `protect_trigger_alarm_webhook` | Trigger an alarm webhook
|
|
141
|
+
| `protect_trigger_alarm_webhook` | Trigger an alarm webhook (fires external alarm action) |
|
|
148
142
|
| `protect_list_files` | List files by type |
|
|
149
143
|
| `protect_upload_file` | Upload a file (base64-encoded) |
|
|
150
144
|
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -6,12 +6,14 @@ const ConfigSchema = zod_1.z.object({
|
|
|
6
6
|
host: zod_1.z.string().min(1, "UNIFI_PROTECT_HOST is required"),
|
|
7
7
|
apiKey: zod_1.z.string().min(1, "UNIFI_PROTECT_API_KEY is required"),
|
|
8
8
|
verifySsl: zod_1.z.boolean(),
|
|
9
|
+
readOnly: zod_1.z.boolean(),
|
|
9
10
|
});
|
|
10
11
|
function loadConfig() {
|
|
11
12
|
const result = ConfigSchema.safeParse({
|
|
12
13
|
host: process.env.UNIFI_PROTECT_HOST,
|
|
13
14
|
apiKey: process.env.UNIFI_PROTECT_API_KEY,
|
|
14
15
|
verifySsl: process.env.UNIFI_PROTECT_VERIFY_SSL !== "false",
|
|
16
|
+
readOnly: process.env.UNIFI_PROTECT_READ_ONLY !== "false",
|
|
15
17
|
});
|
|
16
18
|
if (!result.success) {
|
|
17
19
|
const errors = result.error.issues.map((i) => i.message).join(", ");
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ async function main() {
|
|
|
13
13
|
name: "unifi-protect",
|
|
14
14
|
version: "1.0.0",
|
|
15
15
|
});
|
|
16
|
-
(0, index_js_1.registerAllTools)(server, client);
|
|
16
|
+
(0, index_js_1.registerAllTools)(server, client, config.readOnly);
|
|
17
17
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
18
18
|
await server.connect(transport);
|
|
19
19
|
console.error("UniFi Protect MCP server running on stdio");
|
package/dist/tools/cameras.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { ProtectClient } from "../client.js";
|
|
3
|
-
export declare function registerCameraTools(server: McpServer, client: ProtectClient): void;
|
|
3
|
+
export declare function registerCameraTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
|
package/dist/tools/cameras.js
CHANGED
|
@@ -3,8 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.registerCameraTools = registerCameraTools;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
const responses_js_1 = require("../utils/responses.js");
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false };
|
|
7
|
+
const WRITE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false };
|
|
8
|
+
const DESTRUCTIVE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: true };
|
|
9
|
+
function registerCameraTools(server, client, readOnly) {
|
|
10
|
+
// --- Read-only tools (always registered) ---
|
|
11
|
+
server.registerTool("protect_list_cameras", {
|
|
12
|
+
description: "List all cameras managed by UniFi Protect",
|
|
13
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
14
|
+
}, async () => {
|
|
8
15
|
try {
|
|
9
16
|
const data = await client.get("/cameras");
|
|
10
17
|
return (0, responses_js_1.formatSuccess)(data);
|
|
@@ -13,7 +20,11 @@ function registerCameraTools(server, client) {
|
|
|
13
20
|
return (0, responses_js_1.formatError)(err);
|
|
14
21
|
}
|
|
15
22
|
});
|
|
16
|
-
server.
|
|
23
|
+
server.registerTool("protect_get_camera", {
|
|
24
|
+
description: "Get details for a specific camera by ID",
|
|
25
|
+
inputSchema: { id: zod_1.z.string().describe("Camera ID") },
|
|
26
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
27
|
+
}, async ({ id }) => {
|
|
17
28
|
try {
|
|
18
29
|
const data = await client.get(`/cameras/${id}`);
|
|
19
30
|
return (0, responses_js_1.formatSuccess)(data);
|
|
@@ -22,21 +33,11 @@ function registerCameraTools(server, client) {
|
|
|
22
33
|
return (0, responses_js_1.formatError)(err);
|
|
23
34
|
}
|
|
24
35
|
});
|
|
25
|
-
server.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}, async ({ id, settings }) => {
|
|
31
|
-
try {
|
|
32
|
-
const data = await client.patch(`/cameras/${id}`, settings);
|
|
33
|
-
return (0, responses_js_1.formatSuccess)(data);
|
|
34
|
-
}
|
|
35
|
-
catch (err) {
|
|
36
|
-
return (0, responses_js_1.formatError)(err);
|
|
37
|
-
}
|
|
38
|
-
});
|
|
39
|
-
server.tool("protect_get_snapshot", "Get a JPEG snapshot from a camera (returns image)", { id: zod_1.z.string().describe("Camera ID") }, async ({ id }) => {
|
|
36
|
+
server.registerTool("protect_get_snapshot", {
|
|
37
|
+
description: "Get a JPEG snapshot from a camera (returns image)",
|
|
38
|
+
inputSchema: { id: zod_1.z.string().describe("Camera ID") },
|
|
39
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
40
|
+
}, async ({ id }) => {
|
|
40
41
|
try {
|
|
41
42
|
const { data, mimeType } = await client.getBinary(`/cameras/${id}/snapshot`);
|
|
42
43
|
return {
|
|
@@ -53,26 +54,84 @@ function registerCameraTools(server, client) {
|
|
|
53
54
|
return (0, responses_js_1.formatError)(err);
|
|
54
55
|
}
|
|
55
56
|
});
|
|
56
|
-
server.
|
|
57
|
+
server.registerTool("protect_get_rtsp_streams", {
|
|
58
|
+
description: "Get active RTSPS stream sessions for a camera",
|
|
59
|
+
inputSchema: { id: zod_1.z.string().describe("Camera ID") },
|
|
60
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
61
|
+
}, async ({ id }) => {
|
|
57
62
|
try {
|
|
58
|
-
const data = await client.
|
|
63
|
+
const data = await client.get(`/cameras/${id}/rtsps-stream`);
|
|
59
64
|
return (0, responses_js_1.formatSuccess)(data);
|
|
60
65
|
}
|
|
61
66
|
catch (err) {
|
|
62
67
|
return (0, responses_js_1.formatError)(err);
|
|
63
68
|
}
|
|
64
69
|
});
|
|
65
|
-
|
|
70
|
+
// --- Write tools (skipped in read-only mode) ---
|
|
71
|
+
if (readOnly)
|
|
72
|
+
return;
|
|
73
|
+
server.registerTool("protect_update_camera", {
|
|
74
|
+
description: "Update camera settings (partial update via PATCH)",
|
|
75
|
+
inputSchema: {
|
|
76
|
+
id: zod_1.z.string().describe("Camera ID"),
|
|
77
|
+
settings: zod_1.z
|
|
78
|
+
.record(zod_1.z.string(), zod_1.z.unknown())
|
|
79
|
+
.describe("Partial camera settings to update. Known fields: name (string), osdSettings (object), ledSettings (object), lcdMessage (object), videoMode (string), hdrType (string), smartDetectSettings (object)"),
|
|
80
|
+
dryRun: zod_1.z
|
|
81
|
+
.boolean()
|
|
82
|
+
.optional()
|
|
83
|
+
.describe("If true, return what would happen without making changes"),
|
|
84
|
+
},
|
|
85
|
+
annotations: WRITE_ANNOTATIONS,
|
|
86
|
+
}, async ({ id, settings, dryRun }) => {
|
|
66
87
|
try {
|
|
67
|
-
|
|
88
|
+
if (dryRun) {
|
|
89
|
+
return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "PATCH", path: `/cameras/${id}`, body: settings });
|
|
90
|
+
}
|
|
91
|
+
const data = await client.patch(`/cameras/${id}`, settings);
|
|
92
|
+
return (0, responses_js_1.formatSuccess)(data);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
return (0, responses_js_1.formatError)(err);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
server.registerTool("protect_create_rtsp_stream", {
|
|
99
|
+
description: "Create an RTSPS stream session for a camera",
|
|
100
|
+
inputSchema: {
|
|
101
|
+
id: zod_1.z.string().describe("Camera ID"),
|
|
102
|
+
dryRun: zod_1.z
|
|
103
|
+
.boolean()
|
|
104
|
+
.optional()
|
|
105
|
+
.describe("If true, return what would happen without making changes"),
|
|
106
|
+
},
|
|
107
|
+
annotations: WRITE_ANNOTATIONS,
|
|
108
|
+
}, async ({ id, dryRun }) => {
|
|
109
|
+
try {
|
|
110
|
+
if (dryRun) {
|
|
111
|
+
return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "POST", path: `/cameras/${id}/rtsps-stream` });
|
|
112
|
+
}
|
|
113
|
+
const data = await client.post(`/cameras/${id}/rtsps-stream`);
|
|
68
114
|
return (0, responses_js_1.formatSuccess)(data);
|
|
69
115
|
}
|
|
70
116
|
catch (err) {
|
|
71
117
|
return (0, responses_js_1.formatError)(err);
|
|
72
118
|
}
|
|
73
119
|
});
|
|
74
|
-
server.
|
|
120
|
+
server.registerTool("protect_delete_rtsp_stream", {
|
|
121
|
+
description: "Stop and delete an active RTSPS stream session for a camera",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
id: zod_1.z.string().describe("Camera ID"),
|
|
124
|
+
dryRun: zod_1.z
|
|
125
|
+
.boolean()
|
|
126
|
+
.optional()
|
|
127
|
+
.describe("If true, return what would happen without making changes"),
|
|
128
|
+
},
|
|
129
|
+
annotations: DESTRUCTIVE_ANNOTATIONS,
|
|
130
|
+
}, async ({ id, dryRun }) => {
|
|
75
131
|
try {
|
|
132
|
+
if (dryRun) {
|
|
133
|
+
return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "DELETE", path: `/cameras/${id}/rtsps-stream` });
|
|
134
|
+
}
|
|
76
135
|
const data = await client.delete(`/cameras/${id}/rtsps-stream`);
|
|
77
136
|
return (0, responses_js_1.formatSuccess)(data);
|
|
78
137
|
}
|
|
@@ -80,8 +139,21 @@ function registerCameraTools(server, client) {
|
|
|
80
139
|
return (0, responses_js_1.formatError)(err);
|
|
81
140
|
}
|
|
82
141
|
});
|
|
83
|
-
server.
|
|
142
|
+
server.registerTool("protect_create_talkback", {
|
|
143
|
+
description: "Create a talkback (two-way audio) session for a camera",
|
|
144
|
+
inputSchema: {
|
|
145
|
+
id: zod_1.z.string().describe("Camera ID"),
|
|
146
|
+
dryRun: zod_1.z
|
|
147
|
+
.boolean()
|
|
148
|
+
.optional()
|
|
149
|
+
.describe("If true, return what would happen without making changes"),
|
|
150
|
+
},
|
|
151
|
+
annotations: WRITE_ANNOTATIONS,
|
|
152
|
+
}, async ({ id, dryRun }) => {
|
|
84
153
|
try {
|
|
154
|
+
if (dryRun) {
|
|
155
|
+
return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "POST", path: `/cameras/${id}/talkback-session` });
|
|
156
|
+
}
|
|
85
157
|
const data = await client.post(`/cameras/${id}/talkback-session`);
|
|
86
158
|
return (0, responses_js_1.formatSuccess)(data);
|
|
87
159
|
}
|
|
@@ -89,7 +161,16 @@ function registerCameraTools(server, client) {
|
|
|
89
161
|
return (0, responses_js_1.formatError)(err);
|
|
90
162
|
}
|
|
91
163
|
});
|
|
92
|
-
server.
|
|
164
|
+
server.registerTool("protect_disable_mic", {
|
|
165
|
+
description: "IRREVERSIBLE: Permanently disable the microphone on a camera. Can only be re-enabled by factory resetting the camera.",
|
|
166
|
+
inputSchema: {
|
|
167
|
+
id: zod_1.z.string().describe("Camera ID"),
|
|
168
|
+
confirm: zod_1.z
|
|
169
|
+
.literal(true)
|
|
170
|
+
.describe("Must be true to confirm this irreversible action"),
|
|
171
|
+
},
|
|
172
|
+
annotations: DESTRUCTIVE_ANNOTATIONS,
|
|
173
|
+
}, async ({ id }) => {
|
|
93
174
|
try {
|
|
94
175
|
const data = await client.post(`/cameras/${id}/disable-mic-permanently`);
|
|
95
176
|
return (0, responses_js_1.formatSuccess)(data);
|
|
@@ -98,11 +179,22 @@ function registerCameraTools(server, client) {
|
|
|
98
179
|
return (0, responses_js_1.formatError)(err);
|
|
99
180
|
}
|
|
100
181
|
});
|
|
101
|
-
server.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
182
|
+
server.registerTool("protect_start_ptz_patrol", {
|
|
183
|
+
description: "Start PTZ patrol on a camera at a given slot",
|
|
184
|
+
inputSchema: {
|
|
185
|
+
id: zod_1.z.string().describe("Camera ID"),
|
|
186
|
+
slot: zod_1.z.number().int().describe("Patrol slot number"),
|
|
187
|
+
dryRun: zod_1.z
|
|
188
|
+
.boolean()
|
|
189
|
+
.optional()
|
|
190
|
+
.describe("If true, return what would happen without making changes"),
|
|
191
|
+
},
|
|
192
|
+
annotations: WRITE_ANNOTATIONS,
|
|
193
|
+
}, async ({ id, slot, dryRun }) => {
|
|
105
194
|
try {
|
|
195
|
+
if (dryRun) {
|
|
196
|
+
return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "POST", path: `/cameras/${id}/ptz/patrol/start/${slot}` });
|
|
197
|
+
}
|
|
106
198
|
const data = await client.post(`/cameras/${id}/ptz/patrol/start/${slot}`);
|
|
107
199
|
return (0, responses_js_1.formatSuccess)(data);
|
|
108
200
|
}
|
|
@@ -110,8 +202,21 @@ function registerCameraTools(server, client) {
|
|
|
110
202
|
return (0, responses_js_1.formatError)(err);
|
|
111
203
|
}
|
|
112
204
|
});
|
|
113
|
-
server.
|
|
205
|
+
server.registerTool("protect_stop_ptz_patrol", {
|
|
206
|
+
description: "Stop PTZ patrol on a camera",
|
|
207
|
+
inputSchema: {
|
|
208
|
+
id: zod_1.z.string().describe("Camera ID"),
|
|
209
|
+
dryRun: zod_1.z
|
|
210
|
+
.boolean()
|
|
211
|
+
.optional()
|
|
212
|
+
.describe("If true, return what would happen without making changes"),
|
|
213
|
+
},
|
|
214
|
+
annotations: WRITE_ANNOTATIONS,
|
|
215
|
+
}, async ({ id, dryRun }) => {
|
|
114
216
|
try {
|
|
217
|
+
if (dryRun) {
|
|
218
|
+
return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "POST", path: `/cameras/${id}/ptz/patrol/stop` });
|
|
219
|
+
}
|
|
115
220
|
const data = await client.post(`/cameras/${id}/ptz/patrol/stop`);
|
|
116
221
|
return (0, responses_js_1.formatSuccess)(data);
|
|
117
222
|
}
|
|
@@ -119,11 +224,22 @@ function registerCameraTools(server, client) {
|
|
|
119
224
|
return (0, responses_js_1.formatError)(err);
|
|
120
225
|
}
|
|
121
226
|
});
|
|
122
|
-
server.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
227
|
+
server.registerTool("protect_goto_ptz_preset", {
|
|
228
|
+
description: "Move camera PTZ to a preset position",
|
|
229
|
+
inputSchema: {
|
|
230
|
+
id: zod_1.z.string().describe("Camera ID"),
|
|
231
|
+
slot: zod_1.z.number().int().describe("PTZ preset slot number"),
|
|
232
|
+
dryRun: zod_1.z
|
|
233
|
+
.boolean()
|
|
234
|
+
.optional()
|
|
235
|
+
.describe("If true, return what would happen without making changes"),
|
|
236
|
+
},
|
|
237
|
+
annotations: WRITE_ANNOTATIONS,
|
|
238
|
+
}, async ({ id, slot, dryRun }) => {
|
|
126
239
|
try {
|
|
240
|
+
if (dryRun) {
|
|
241
|
+
return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "POST", path: `/cameras/${id}/ptz/goto/${slot}` });
|
|
242
|
+
}
|
|
127
243
|
const data = await client.post(`/cameras/${id}/ptz/goto/${slot}`);
|
|
128
244
|
return (0, responses_js_1.formatSuccess)(data);
|
|
129
245
|
}
|
package/dist/tools/devices.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { ProtectClient } from "../client.js";
|
|
3
|
-
export declare function registerDeviceTools(server: McpServer, client: ProtectClient): void;
|
|
3
|
+
export declare function registerDeviceTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
|
package/dist/tools/devices.js
CHANGED
|
@@ -3,10 +3,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.registerDeviceTools = registerDeviceTools;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
const responses_js_1 = require("../utils/responses.js");
|
|
6
|
-
|
|
6
|
+
const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false };
|
|
7
|
+
const WRITE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false };
|
|
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), mountType (string), motionSettings (object), humiditySettings (object), temperatureSettings (object), lightSettings (object), alarmSettings (object)",
|
|
11
|
+
chime: "Known fields: name (string), volume (number), 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) {
|
|
7
15
|
const plural = `${deviceType}s`;
|
|
8
16
|
const label = deviceType.charAt(0).toUpperCase() + deviceType.slice(1);
|
|
9
|
-
server.
|
|
17
|
+
server.registerTool(`protect_list_${plural}`, {
|
|
18
|
+
description: `List all ${plural} managed by UniFi Protect`,
|
|
19
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
20
|
+
}, async () => {
|
|
10
21
|
try {
|
|
11
22
|
const data = await client.get(`/${plural}`);
|
|
12
23
|
return (0, responses_js_1.formatSuccess)(data);
|
|
@@ -15,7 +26,11 @@ function registerDeviceCrud(server, client, deviceType) {
|
|
|
15
26
|
return (0, responses_js_1.formatError)(err);
|
|
16
27
|
}
|
|
17
28
|
});
|
|
18
|
-
server.
|
|
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`) },
|
|
32
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
33
|
+
}, async ({ id }) => {
|
|
19
34
|
try {
|
|
20
35
|
const data = await client.get(`/${plural}/${id}`);
|
|
21
36
|
return (0, responses_js_1.formatSuccess)(data);
|
|
@@ -24,24 +39,37 @@ function registerDeviceCrud(server, client, deviceType) {
|
|
|
24
39
|
return (0, responses_js_1.formatError)(err);
|
|
25
40
|
}
|
|
26
41
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
if (!readOnly) {
|
|
43
|
+
server.registerTool(`protect_update_${deviceType}`, {
|
|
44
|
+
description: `Update ${deviceType} settings (partial update via PATCH)`,
|
|
45
|
+
inputSchema: {
|
|
46
|
+
id: zod_1.z.string().describe(`${label} ID`),
|
|
47
|
+
settings: zod_1.z
|
|
48
|
+
.record(zod_1.z.string(), zod_1.z.unknown())
|
|
49
|
+
.describe(`Partial ${deviceType} settings to update. ${settingsHints[deviceType]}`),
|
|
50
|
+
dryRun: zod_1.z
|
|
51
|
+
.boolean()
|
|
52
|
+
.optional()
|
|
53
|
+
.describe("If true, return what would happen without making changes"),
|
|
54
|
+
},
|
|
55
|
+
annotations: WRITE_ANNOTATIONS,
|
|
56
|
+
}, async ({ id, settings, dryRun }) => {
|
|
57
|
+
try {
|
|
58
|
+
if (dryRun) {
|
|
59
|
+
return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "PATCH", path: `/${plural}/${id}`, body: settings });
|
|
60
|
+
}
|
|
61
|
+
const data = await client.patch(`/${plural}/${id}`, settings);
|
|
62
|
+
return (0, responses_js_1.formatSuccess)(data);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return (0, responses_js_1.formatError)(err);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
41
69
|
}
|
|
42
|
-
function registerDeviceTools(server, client) {
|
|
70
|
+
function registerDeviceTools(server, client, readOnly) {
|
|
43
71
|
const deviceTypes = ["light", "sensor", "chime", "viewer"];
|
|
44
72
|
for (const dt of deviceTypes) {
|
|
45
|
-
registerDeviceCrud(server, client, dt);
|
|
73
|
+
registerDeviceCrud(server, client, dt, readOnly);
|
|
46
74
|
}
|
|
47
75
|
}
|
package/dist/tools/files.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { ProtectClient } from "../client.js";
|
|
3
|
-
export declare function registerFileTools(server: McpServer, client: ProtectClient): void;
|
|
3
|
+
export declare function registerFileTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
|
package/dist/tools/files.js
CHANGED
|
@@ -3,38 +3,73 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.registerFileTools = registerFileTools;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
const responses_js_1 = require("../utils/responses.js");
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false };
|
|
7
|
+
const WRITE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false };
|
|
8
|
+
const DESTRUCTIVE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: true };
|
|
9
|
+
function registerFileTools(server, client, readOnly) {
|
|
10
|
+
server.registerTool("protect_list_files", {
|
|
11
|
+
description: "List files of a given type (e.g. 'video', 'timelapse')",
|
|
12
|
+
inputSchema: {
|
|
13
|
+
fileType: zod_1.z
|
|
14
|
+
.string()
|
|
15
|
+
.describe("File type to list (e.g. 'video', 'timelapse')"),
|
|
16
|
+
},
|
|
17
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
18
|
+
}, async ({ fileType }) => {
|
|
8
19
|
try {
|
|
9
|
-
const data = await client.
|
|
20
|
+
const data = await client.get(`/files/${fileType}`);
|
|
10
21
|
return (0, responses_js_1.formatSuccess)(data);
|
|
11
22
|
}
|
|
12
23
|
catch (err) {
|
|
13
24
|
return (0, responses_js_1.formatError)(err);
|
|
14
25
|
}
|
|
15
26
|
});
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
if (readOnly)
|
|
28
|
+
return;
|
|
29
|
+
server.registerTool("protect_trigger_alarm_webhook", {
|
|
30
|
+
description: "Trigger an alarm manager webhook by ID. This fires an external alarm action.",
|
|
31
|
+
inputSchema: {
|
|
32
|
+
id: zod_1.z.string().describe("Webhook ID"),
|
|
33
|
+
confirm: zod_1.z
|
|
34
|
+
.literal(true)
|
|
35
|
+
.describe("Must be true to confirm triggering the alarm webhook"),
|
|
36
|
+
},
|
|
37
|
+
annotations: DESTRUCTIVE_ANNOTATIONS,
|
|
38
|
+
}, async ({ id }) => {
|
|
21
39
|
try {
|
|
22
|
-
|
|
23
|
-
return (0, responses_js_1.formatSuccess)(
|
|
40
|
+
await client.post(`/alarm-manager/webhook/${id}`);
|
|
41
|
+
return (0, responses_js_1.formatSuccess)({ triggered: true, webhookId: id });
|
|
24
42
|
}
|
|
25
43
|
catch (err) {
|
|
26
44
|
return (0, responses_js_1.formatError)(err);
|
|
27
45
|
}
|
|
28
46
|
});
|
|
29
|
-
server.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
.string()
|
|
34
|
-
.
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
server.registerTool("protect_upload_file", {
|
|
48
|
+
description: "Upload a base64-encoded file to UniFi Protect",
|
|
49
|
+
inputSchema: {
|
|
50
|
+
fileType: zod_1.z.string().describe("File type category for upload"),
|
|
51
|
+
base64Data: zod_1.z.string().describe("Base64-encoded file content"),
|
|
52
|
+
contentType: zod_1.z
|
|
53
|
+
.string()
|
|
54
|
+
.default("application/octet-stream")
|
|
55
|
+
.describe("MIME type of the file"),
|
|
56
|
+
dryRun: zod_1.z
|
|
57
|
+
.boolean()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("If true, return what would happen without making changes"),
|
|
60
|
+
},
|
|
61
|
+
annotations: WRITE_ANNOTATIONS,
|
|
62
|
+
}, async ({ fileType, base64Data, contentType, dryRun }) => {
|
|
37
63
|
try {
|
|
64
|
+
if (dryRun) {
|
|
65
|
+
return (0, responses_js_1.formatSuccess)({
|
|
66
|
+
dryRun: true,
|
|
67
|
+
action: "POST",
|
|
68
|
+
path: `/files/${fileType}`,
|
|
69
|
+
contentType,
|
|
70
|
+
dataLength: base64Data.length,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
38
73
|
const buffer = Buffer.from(base64Data, "base64");
|
|
39
74
|
const data = await client.postBinary(`/files/${fileType}`, buffer, contentType);
|
|
40
75
|
return (0, responses_js_1.formatSuccess)(data);
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { ProtectClient } from "../client.js";
|
|
3
|
-
export declare function registerAllTools(server: McpServer, client: ProtectClient): void;
|
|
3
|
+
export declare function registerAllTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
|
package/dist/tools/index.js
CHANGED
|
@@ -6,10 +6,10 @@ const cameras_js_1 = require("./cameras.js");
|
|
|
6
6
|
const devices_js_1 = require("./devices.js");
|
|
7
7
|
const liveviews_js_1 = require("./liveviews.js");
|
|
8
8
|
const files_js_1 = require("./files.js");
|
|
9
|
-
function registerAllTools(server, client) {
|
|
9
|
+
function registerAllTools(server, client, readOnly) {
|
|
10
10
|
(0, system_js_1.registerSystemTools)(server, client);
|
|
11
|
-
(0, cameras_js_1.registerCameraTools)(server, client);
|
|
12
|
-
(0, devices_js_1.registerDeviceTools)(server, client);
|
|
13
|
-
(0, liveviews_js_1.registerLiveviewTools)(server, client);
|
|
14
|
-
(0, files_js_1.registerFileTools)(server, client);
|
|
11
|
+
(0, cameras_js_1.registerCameraTools)(server, client, readOnly);
|
|
12
|
+
(0, devices_js_1.registerDeviceTools)(server, client, readOnly);
|
|
13
|
+
(0, liveviews_js_1.registerLiveviewTools)(server, client, readOnly);
|
|
14
|
+
(0, files_js_1.registerFileTools)(server, client, readOnly);
|
|
15
15
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { ProtectClient } from "../client.js";
|
|
3
|
-
export declare function registerLiveviewTools(server: McpServer, client: ProtectClient): void;
|
|
3
|
+
export declare function registerLiveviewTools(server: McpServer, client: ProtectClient, readOnly: boolean): void;
|
package/dist/tools/liveviews.js
CHANGED
|
@@ -3,8 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.registerLiveviewTools = registerLiveviewTools;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
const responses_js_1 = require("../utils/responses.js");
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false };
|
|
7
|
+
const WRITE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false };
|
|
8
|
+
function registerLiveviewTools(server, client, readOnly) {
|
|
9
|
+
server.registerTool("protect_list_liveviews", {
|
|
10
|
+
description: "List all live views configured in UniFi Protect",
|
|
11
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
12
|
+
}, async () => {
|
|
8
13
|
try {
|
|
9
14
|
const data = await client.get("/liveviews");
|
|
10
15
|
return (0, responses_js_1.formatSuccess)(data);
|
|
@@ -13,7 +18,11 @@ function registerLiveviewTools(server, client) {
|
|
|
13
18
|
return (0, responses_js_1.formatError)(err);
|
|
14
19
|
}
|
|
15
20
|
});
|
|
16
|
-
server.
|
|
21
|
+
server.registerTool("protect_get_liveview", {
|
|
22
|
+
description: "Get details for a specific live view by ID",
|
|
23
|
+
inputSchema: { id: zod_1.z.string().describe("Liveview ID") },
|
|
24
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
25
|
+
}, async ({ id }) => {
|
|
17
26
|
try {
|
|
18
27
|
const data = await client.get(`/liveviews/${id}`);
|
|
19
28
|
return (0, responses_js_1.formatSuccess)(data);
|
|
@@ -22,12 +31,25 @@ function registerLiveviewTools(server, client) {
|
|
|
22
31
|
return (0, responses_js_1.formatError)(err);
|
|
23
32
|
}
|
|
24
33
|
});
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
if (readOnly)
|
|
35
|
+
return;
|
|
36
|
+
server.registerTool("protect_create_liveview", {
|
|
37
|
+
description: "Create a new live view",
|
|
38
|
+
inputSchema: {
|
|
39
|
+
settings: zod_1.z
|
|
40
|
+
.record(zod_1.z.string(), zod_1.z.unknown())
|
|
41
|
+
.describe("Liveview configuration. Required fields: name (string), layout (number of slots), slots (array of {cameras: string[], cycleMode: string, cycleInterval: number}), isDefault (boolean), isGlobal (boolean)"),
|
|
42
|
+
dryRun: zod_1.z
|
|
43
|
+
.boolean()
|
|
44
|
+
.optional()
|
|
45
|
+
.describe("If true, return what would happen without making changes"),
|
|
46
|
+
},
|
|
47
|
+
annotations: WRITE_ANNOTATIONS,
|
|
48
|
+
}, async ({ settings, dryRun }) => {
|
|
30
49
|
try {
|
|
50
|
+
if (dryRun) {
|
|
51
|
+
return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "POST", path: "/liveviews", body: settings });
|
|
52
|
+
}
|
|
31
53
|
const data = await client.post("/liveviews", settings);
|
|
32
54
|
return (0, responses_js_1.formatSuccess)(data);
|
|
33
55
|
}
|
|
@@ -35,13 +57,24 @@ function registerLiveviewTools(server, client) {
|
|
|
35
57
|
return (0, responses_js_1.formatError)(err);
|
|
36
58
|
}
|
|
37
59
|
});
|
|
38
|
-
server.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
60
|
+
server.registerTool("protect_update_liveview", {
|
|
61
|
+
description: "Update an existing live view (partial update via PATCH)",
|
|
62
|
+
inputSchema: {
|
|
63
|
+
id: zod_1.z.string().describe("Liveview ID"),
|
|
64
|
+
settings: zod_1.z
|
|
65
|
+
.record(zod_1.z.string(), zod_1.z.unknown())
|
|
66
|
+
.describe("Partial liveview settings to update. Known fields: name (string), layout (number), slots (array of {cameras: string[], cycleMode: string, cycleInterval: number}), isDefault (boolean), isGlobal (boolean)"),
|
|
67
|
+
dryRun: zod_1.z
|
|
68
|
+
.boolean()
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("If true, return what would happen without making changes"),
|
|
71
|
+
},
|
|
72
|
+
annotations: WRITE_ANNOTATIONS,
|
|
73
|
+
}, async ({ id, settings, dryRun }) => {
|
|
44
74
|
try {
|
|
75
|
+
if (dryRun) {
|
|
76
|
+
return (0, responses_js_1.formatSuccess)({ dryRun: true, action: "PATCH", path: `/liveviews/${id}`, body: settings });
|
|
77
|
+
}
|
|
45
78
|
const data = await client.patch(`/liveviews/${id}`, settings);
|
|
46
79
|
return (0, responses_js_1.formatSuccess)(data);
|
|
47
80
|
}
|
package/dist/tools/system.js
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.registerSystemTools = registerSystemTools;
|
|
4
4
|
const responses_js_1 = require("../utils/responses.js");
|
|
5
|
+
const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false };
|
|
5
6
|
function registerSystemTools(server, client) {
|
|
6
|
-
server.
|
|
7
|
+
server.registerTool("protect_get_info", {
|
|
8
|
+
description: "Get UniFi Protect system information and version details",
|
|
9
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
10
|
+
}, async () => {
|
|
7
11
|
try {
|
|
8
12
|
const data = await client.get("/meta/info");
|
|
9
13
|
return (0, responses_js_1.formatSuccess)(data);
|
|
@@ -12,7 +16,10 @@ function registerSystemTools(server, client) {
|
|
|
12
16
|
return (0, responses_js_1.formatError)(err);
|
|
13
17
|
}
|
|
14
18
|
});
|
|
15
|
-
server.
|
|
19
|
+
server.registerTool("protect_list_nvrs", {
|
|
20
|
+
description: "List all NVR (Network Video Recorder) devices",
|
|
21
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
22
|
+
}, async () => {
|
|
16
23
|
try {
|
|
17
24
|
const data = await client.get("/nvrs");
|
|
18
25
|
return (0, responses_js_1.formatSuccess)(data);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owine/unifi-protect-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "MCP server for UniFi Protect Integration API",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@eslint/js": "9.39.2",
|
|
44
44
|
"@types/node": "24.10.13",
|
|
45
|
+
"@vitest/coverage-v8": "4.0.18",
|
|
45
46
|
"eslint": "9.39.2",
|
|
46
47
|
"typescript": "5.9.3",
|
|
47
48
|
"typescript-eslint": "8.55.0",
|