@mgcrea/mcp-unifi-protect 0.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/LICENSE +21 -0
- package/README.md +300 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +51 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +385 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/server-iu_3JECB.js +1743 -0
- package/dist/server-iu_3JECB.js.map +1 -0
- package/package.json +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Olivier Louvignes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# @mgcrea/mcp-unifi-protect
|
|
2
|
+
|
|
3
|
+
Model Context Protocol server for a self-hosted **UniFi Protect** console — cameras, recorded
|
|
4
|
+
events and smart detections, snapshots, footage export, and the lights, sensors, viewers and
|
|
5
|
+
chimes attached to it. Read-only by default: the tools that change anything are not registered
|
|
6
|
+
at all unless you ask for them.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- **Search recorded events over any time range** — motion, person / vehicle / animal / package /
|
|
11
|
+
licence-plate detections, doorbell rings — with each result already carrying its camera's
|
|
12
|
+
**name**, not just an id.
|
|
13
|
+
- **Snapshots and footage** — capture a frame now, pull an event's thumbnail, export an MP4.
|
|
14
|
+
All written to disk by default, so a still frame does not silently cost you a context window.
|
|
15
|
+
- **Devices** — cameras, lights, sensors (with their temperature, humidity and light readings),
|
|
16
|
+
viewers, chimes, live views and users.
|
|
17
|
+
- **Shaped responses.** A console camera record is 8-15 KB of JSON; a list of ten is over 100 KB.
|
|
18
|
+
List tools return the fifteen fields anyone actually asks about. `get_*` returns everything.
|
|
19
|
+
- **Stays up with no credentials**, reporting what to configure through `unifi_protect_auth_status`
|
|
20
|
+
rather than exiting and showing in your client as a bare `Connection closed`.
|
|
21
|
+
|
|
22
|
+
## Security
|
|
23
|
+
|
|
24
|
+
**Supply chain.** Three runtime dependencies: the MCP SDK, zod, and `undici`. Retry and backoff
|
|
25
|
+
are hand-rolled; there is no HTTP client wrapper, no logger, no crypto library. `undici` earns its
|
|
26
|
+
place by being the only way to scope the TLS exception below to this server's own requests — see
|
|
27
|
+
the note there. Published from CI with provenance via OIDC trusted publishing; the container
|
|
28
|
+
image is multi-arch, carries an SBOM, and is signed with cosign.
|
|
29
|
+
|
|
30
|
+
**Your credentials.** The username and password come from the environment or a config file, and
|
|
31
|
+
never leave this process except in the login request to your console. The resulting session
|
|
32
|
+
cookie is cached at `~/.config/unifi-protect/session.json` with mode `600`.
|
|
33
|
+
|
|
34
|
+
**Certificate verification is ON by default**, and disabling it is scoped to this server's own
|
|
35
|
+
requests through an undici dispatcher — it is not `NODE_TLS_REJECT_UNAUTHORIZED`, so nothing else
|
|
36
|
+
in the process is affected. (It previously _was_ process-wide, on the belief that a dispatcher
|
|
37
|
+
could not be scoped without a dependency. It can, and `undici` is now that dependency.)
|
|
38
|
+
|
|
39
|
+
Verifying takes two things together, and either alone achieves nothing: the certificate is
|
|
40
|
+
self-signed, so `NODE_EXTRA_CA_CERTS` must point at it; **and** it is issued to `unifi.local` with
|
|
41
|
+
no IP SAN, so `UNIFI_PROTECT_HOST` must be a host name that resolves to the console rather than its
|
|
42
|
+
IP address. Reached by IP, verification fails on the host name however the certificate is trusted.
|
|
43
|
+
See `.env.example` for the two commands. If the console has no name on your network, set
|
|
44
|
+
`UNIFI_PROTECT_VERIFY_TLS=false`; the startup banner then prints `tls=UNVERIFIED` on every run.
|
|
45
|
+
|
|
46
|
+
**Blast radius.** With the defaults, the worst an agent can do is read your cameras and write
|
|
47
|
+
image files into the snapshot directory. With `UNIFI_PROTECT_ALLOW_WRITES=1` it can additionally
|
|
48
|
+
reconfigure devices, stop a camera recording, and reboot a camera or the whole console. Use a
|
|
49
|
+
Local-Access-Only account with View Only rights, and leave writes off unless you need them.
|
|
50
|
+
|
|
51
|
+
## Configure
|
|
52
|
+
|
|
53
|
+
| Variable | Required | Default | What it does |
|
|
54
|
+
| ---------------------------------- | -------- | -------------------------------------- | --------------------------------------------------------------- |
|
|
55
|
+
| `UNIFI_PROTECT_HOST` | yes | — | Console IP or hostname. `https://` assumed, `:port` preserved |
|
|
56
|
+
| `UNIFI_PROTECT_USERNAME` | yes | — | Console login |
|
|
57
|
+
| `UNIFI_PROTECT_PASSWORD` | yes | — | Its password |
|
|
58
|
+
| `UNIFI_PROTECT_TOTP` | no | — | 2FA code. Expires in ~30s — prefer `unifi_protect_auth_login` |
|
|
59
|
+
| `UNIFI_PROTECT_VERIFY_TLS` | no | `true` | Verify the console's certificate (needs a host name, not an IP) |
|
|
60
|
+
| `UNIFI_PROTECT_ALLOW_WRITES` | no | `false` | Register the 12 mutating tools |
|
|
61
|
+
| `UNIFI_PROTECT_SESSION_FILE` | no | `~/.config/unifi-protect/session.json` | Cached session, mode 600 |
|
|
62
|
+
| `UNIFI_PROTECT_SNAPSHOT_DIR` | no | `~/.cache/unifi-protect` | Where images and exports are written |
|
|
63
|
+
| `UNIFI_PROTECT_CONFIG` | no | `~/.config/unifi-protect/config.json` | Config file location |
|
|
64
|
+
| `UNIFI_PROTECT_MAX_RETRIES` | no | `3` | Retries on 401 / 429 / 5xx |
|
|
65
|
+
| `UNIFI_PROTECT_MAX_DOWNLOAD_BYTES` | no | `200000000` | Refuse a download larger than this |
|
|
66
|
+
| `UNIFI_PROTECT_DEVICE_CACHE_TTL` | no | `60` | Camera id→name cache lifetime, seconds |
|
|
67
|
+
| `UNIFI_PROTECT_DEBUG` | no | — | Verbose request logging to stderr |
|
|
68
|
+
|
|
69
|
+
The config file mirrors these as camelCase JSON (`host`, `username`, `verifyTls`, …). It is
|
|
70
|
+
strict: an unknown key is an error rather than a silent no-op. **Environment variables win over
|
|
71
|
+
the file, field by field**, so a one-off `UNIFI_PROTECT_ALLOW_WRITES=0` still beats a file that
|
|
72
|
+
says `true`.
|
|
73
|
+
|
|
74
|
+
### Create an account for it
|
|
75
|
+
|
|
76
|
+
UniFi OS → **Settings → Admins & Users → Add User → Local Access Only**, with Protect
|
|
77
|
+
permissions and **View Only** unless you plan to enable writes.
|
|
78
|
+
|
|
79
|
+
Use a local account rather than your Ubiquiti (SSO) one. Cloud accounts frequently cannot log in
|
|
80
|
+
locally at all, and a scoped local account keeps this server away from the rest of the console.
|
|
81
|
+
|
|
82
|
+
## Quick start
|
|
83
|
+
|
|
84
|
+
**A. npx**
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
UNIFI_PROTECT_HOST=192.168.1.1 UNIFI_PROTECT_USERNAME=mcp UNIFI_PROTECT_PASSWORD=… \
|
|
88
|
+
npx -y @mgcrea/mcp-unifi-protect
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**B. Docker (stdio)**
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
docker run --rm -i \
|
|
95
|
+
-e UNIFI_PROTECT_HOST=192.168.1.1 \
|
|
96
|
+
-e UNIFI_PROTECT_USERNAME=mcp \
|
|
97
|
+
-e UNIFI_PROTECT_PASSWORD=… \
|
|
98
|
+
ghcr.io/mgcrea/mcp-unifi-protect
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**C. From source**
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pnpm install && pnpm build
|
|
105
|
+
node dist/cli.js
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Inspect the tools
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
printf '%s\n' \
|
|
112
|
+
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"cli","version":"0"}}}' \
|
|
113
|
+
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
|
|
114
|
+
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
|
|
115
|
+
| node dist/cli.js 2>/dev/null | jq -r '.result.tools[]?.name'
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Tools
|
|
119
|
+
|
|
120
|
+
20 read tools, plus 9 more when writes are enabled.
|
|
121
|
+
|
|
122
|
+
| Tool | What it does | Writes |
|
|
123
|
+
| ----------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------- |
|
|
124
|
+
| `unifi_protect_auth_status` | Log in and make a real call, reporting whether the console is reachable and on what Protect version | — |
|
|
125
|
+
| `unifi_protect_auth_login` | Force a fresh login; the only way to supply a 2FA code | — |
|
|
126
|
+
| `unifi_protect_auth_logout` | Drop the cached session and delete the session file | confirm |
|
|
127
|
+
| `unifi_protect_get_system_info` | Console model, Protect version, storage, device counts | — |
|
|
128
|
+
| `unifi_protect_list_cameras` | Every camera, summarized | — |
|
|
129
|
+
| `unifi_protect_get_camera` | One camera's complete record (large) | — |
|
|
130
|
+
| `unifi_protect_get_camera_snapshot` | Capture a frame now, to a file or inline | — |
|
|
131
|
+
| `unifi_protect_list_ptz_presets` | A PTZ camera's saved preset slots | — |
|
|
132
|
+
| `unifi_protect_list_ptz_patrols` | A PTZ camera's saved patrol routes | — |
|
|
133
|
+
| `unifi_protect_list_events` | **Search recorded events over any time range** | — |
|
|
134
|
+
| `unifi_protect_get_event` | One event's full detection metadata | — |
|
|
135
|
+
| `unifi_protect_get_event_thumbnail` | The frame that triggered a detection | — |
|
|
136
|
+
| `unifi_protect_export_video` | Export footage as an MP4 on disk | — |
|
|
137
|
+
| `unifi_protect_list_lights` | Floodlights, with state and brightness | — |
|
|
138
|
+
| `unifi_protect_list_sensors` | Sensors, with temperature / humidity / light readings | — |
|
|
139
|
+
| `unifi_protect_list_viewers` | Viewport devices and what each displays | — |
|
|
140
|
+
| `unifi_protect_list_chimes` | Chimes, volume, paired doorbells | — |
|
|
141
|
+
| `unifi_protect_list_liveviews` | Saved camera grid layouts | — |
|
|
142
|
+
| `unifi_protect_list_users` | Who can sign in to Protect | — |
|
|
143
|
+
| `unifi_protect_request` | Escape hatch: call any private endpoint directly | GET only unless writes |
|
|
144
|
+
| `unifi_protect_update_camera` | Name, mic, status LED, OSD overlays | ✅ |
|
|
145
|
+
| `unifi_protect_set_camera_recording_mode` | `always` / `never` / `detections` / `schedule` | ✅ |
|
|
146
|
+
| `unifi_protect_reboot_camera` | Reboot one camera | ✅ confirm |
|
|
147
|
+
| `unifi_protect_update_light` | Brightness, on/off, PIR sensitivity | ✅ |
|
|
148
|
+
| `unifi_protect_update_sensor` | Name, which capabilities report | ✅ |
|
|
149
|
+
| `unifi_protect_update_viewer` | Put a live view on a screen | ✅ |
|
|
150
|
+
| `unifi_protect_update_chime` | Volume, name | ✅ |
|
|
151
|
+
| `unifi_protect_update_nvr_settings` | Console name, timezone, global recording | ✅ |
|
|
152
|
+
| `unifi_protect_reboot_nvr` | Reboot the console | ✅ confirm |
|
|
153
|
+
|
|
154
|
+
## Worked example: what happened at the front door last night
|
|
155
|
+
|
|
156
|
+
```jsonc
|
|
157
|
+
// 1. Which cameras are there?
|
|
158
|
+
{"name": "unifi_protect_list_cameras", "arguments": {}}
|
|
159
|
+
// → [{ "id": "661a…", "name": "Front Door", "hasSmartDetect": true,
|
|
160
|
+
// "smartDetectTypes": ["person","package"], "recordingMode": "detections", … }]
|
|
161
|
+
|
|
162
|
+
// 2. People seen overnight. Note the camera NAME comes back resolved.
|
|
163
|
+
{"name": "unifi_protect_list_events", "arguments": {
|
|
164
|
+
"start": "2026-08-29T22:00:00Z", "end": "2026-08-30T07:00:00Z",
|
|
165
|
+
"types": ["smartDetectZone"], "smartDetectTypes": ["person"]}}
|
|
166
|
+
// → { "count": 3, "events": [
|
|
167
|
+
// { "id": "9f3c1a02-…", "start": "2026-08-30T02:14:07.000Z", "camera": "Front Door",
|
|
168
|
+
// "smartDetectTypes": ["person"], "score": 94, "hasThumbnail": true }, … ] }
|
|
169
|
+
|
|
170
|
+
// 3. Look at the one at 02:14 — pass the event's own id.
|
|
171
|
+
{"name": "unifi_protect_get_event_thumbnail",
|
|
172
|
+
"arguments": {"eventId": "9f3c1a02-…", "output": "image"}}
|
|
173
|
+
|
|
174
|
+
// 4. Pull the footage around it.
|
|
175
|
+
{"name": "unifi_protect_export_video", "arguments": {
|
|
176
|
+
"cameraId": "661a…", "start": "2026-08-30T02:13:30Z", "end": "2026-08-30T02:15:00Z"}}
|
|
177
|
+
// → { "path": "/Users/you/.cache/unifi-protect/front-door-….mp4", "bytes": 18432000 }
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Traps worth knowing
|
|
181
|
+
|
|
182
|
+
**This wraps Protect's private API, not the official one.** Ubiquiti publishes an Integration
|
|
183
|
+
API at `/proxy/protect/integration/v1` with an OpenAPI spec and an `X-API-KEY` header. It is not
|
|
184
|
+
used here, because it has **no historical query capability at all** — the only query parameters
|
|
185
|
+
in its entire spec are `channel`, `highQuality` and `qualities`, and events exist solely as a
|
|
186
|
+
live WebSocket. "What happened last night" is unanswerable through it. The private API answers
|
|
187
|
+
that, at the cost of being undocumented and liable to change between Protect releases. This was
|
|
188
|
+
built and verified end-to-end against a live **UNVR running Protect 7.2.105**.
|
|
189
|
+
`unifi_protect_get_system_info` reports the version you are actually running, and
|
|
190
|
+
`unifi_protect_request` reaches any endpoint that moves.
|
|
191
|
+
|
|
192
|
+
Two shapes already changed between 6.x and 7.x, both found by running this against a real
|
|
193
|
+
console, and both now handled in either form:
|
|
194
|
+
|
|
195
|
+
- **Storage moved.** 6.x had `nvr.storageInfo` with `totalSize` / `totalSpaceUsed`. By 7.2 that
|
|
196
|
+
key is gone; the numbers live under `nvr.systemInfo.storage` and `nvr.storageStats`, with
|
|
197
|
+
per-disk health in `systemInfo.ustorage.disks`.
|
|
198
|
+
- **A camera has no `ledLevel`.** The 0-6 brightness that looks like it belongs there is a
|
|
199
|
+
_floodlight_ field; a camera's LED is the on/off `ledSettings.isEnabled`. Sub-objects also
|
|
200
|
+
deep-merge on PATCH, so setting one OSD overlay preserves the others — verified by writing to
|
|
201
|
+
a live camera and reading it back.
|
|
202
|
+
- **An event's `thumbnail` field is not a thumbnail id you can use here.** It reads `e-<eventId>`
|
|
203
|
+
and belongs to the `thumbnails/<id>` endpoint; `events/<eventId>/thumbnail` — the one this
|
|
204
|
+
server calls — wants the bare event id. Passing the console's own value returns 404. So list
|
|
205
|
+
results report `hasThumbnail: true` rather than an id, and `unifi_protect_get_event_thumbnail`
|
|
206
|
+
takes the event's `id` (though it tolerates an `e-…` value too).
|
|
207
|
+
|
|
208
|
+
**Times are milliseconds, and getting it wrong fails silently.** The console takes JavaScript
|
|
209
|
+
millisecond timestamps. A Unix _seconds_ value is not rejected — it is read as a moment in 1970,
|
|
210
|
+
so the query succeeds and returns an empty list, which reads as "nothing happened". Every time
|
|
211
|
+
argument here accepts ISO 8601, a relative expression (`"2h ago"`, `"30m"`, `"7d"`) or `"now"`,
|
|
212
|
+
and a ten-digit number is refused with the corrected value in the error.
|
|
213
|
+
|
|
214
|
+
**Event search is always filtered by type.** Omitting `types` entirely triggers a pagination bug
|
|
215
|
+
in Protect where the console ignores the window and returns the wrong slice. `unifi_protect_list_events`
|
|
216
|
+
always sends an explicit list, defaulting to motion, smart detections and rings.
|
|
217
|
+
|
|
218
|
+
**Footage only exists if the camera was recording.** An empty event search may mean the camera's
|
|
219
|
+
recording mode is `never`, not that nothing happened. `unifi_protect_list_cameras` shows the mode.
|
|
220
|
+
|
|
221
|
+
**Snapshots are forced.** Without that the console can return a cached frame minutes old, which
|
|
222
|
+
is indistinguishable from a current one.
|
|
223
|
+
|
|
224
|
+
**A cloud account may not work.** Ubiquiti SSO accounts frequently cannot log in locally. Create
|
|
225
|
+
a Local Access Only user.
|
|
226
|
+
|
|
227
|
+
## Troubleshooting
|
|
228
|
+
|
|
229
|
+
**The server does not appear, or shows `Connection closed`.** It should never exit on missing
|
|
230
|
+
credentials — run it by hand with the same environment and read stderr. Everything it logs goes
|
|
231
|
+
to stderr, because stdout is the protocol channel.
|
|
232
|
+
|
|
233
|
+
**Only `unifi_protect_auth_status` is listed.** No console is configured. Call that tool; it
|
|
234
|
+
returns the setup steps as data.
|
|
235
|
+
|
|
236
|
+
**A tool I expected is missing.** The write tools are not registered unless
|
|
237
|
+
`UNIFI_PROTECT_ALLOW_WRITES=1`. That is the design, not a bug — an absent tool cannot be called,
|
|
238
|
+
whereas a refused one invites an agent to keep trying.
|
|
239
|
+
|
|
240
|
+
**`self-signed certificate` errors.** Verification is on by default and cannot pass against an IP address. Either address the console by name with `NODE_EXTRA_CA_CERTS` set, or `UNIFI_PROTECT_VERIFY_TLS=false`
|
|
241
|
+
unless you have installed a trusted certificate on the console.
|
|
242
|
+
|
|
243
|
+
**Everything returns 401.** Check the account is a local one, and that it has Protect
|
|
244
|
+
permissions. `unifi_protect_auth_status` distinguishes "cannot log in" from "logged in but
|
|
245
|
+
forbidden".
|
|
246
|
+
|
|
247
|
+
**A tool that used to work now returns 404.** Compare the Protect version from
|
|
248
|
+
`unifi_protect_get_system_info` against 7.2.105 above; an upgrade may have moved the endpoint.
|
|
249
|
+
`unifi_protect_request` is the workaround while it is fixed — it reaches any path under
|
|
250
|
+
`/proxy/protect/api` directly, which is how both of the 6.x→7.x changes above were pinned down.
|
|
251
|
+
|
|
252
|
+
**Storage shows as nearly full.** That is normal on an NVR: `isRecycling: true` means the
|
|
253
|
+
console continuously overwrites the oldest footage rather than stopping. `get_system_info` says
|
|
254
|
+
so inline so it does not read as a fault.
|
|
255
|
+
|
|
256
|
+
## What has been verified against real hardware
|
|
257
|
+
|
|
258
|
+
Built and exercised end-to-end against a live **UNVR4 on Protect 7.2.105** with 12 cameras, one
|
|
259
|
+
floodlight and one chime. Every read tool was run; the write tools were exercised with _no-op_
|
|
260
|
+
writes — each value set to the value it already held — and the device state read back unchanged
|
|
261
|
+
afterwards. That run is also what caught three bugs this README's earlier drafts described
|
|
262
|
+
wrongly: the storage layout, the event-thumbnail id, and two camera fields that do not exist.
|
|
263
|
+
|
|
264
|
+
Two tools remain **unverified for want of hardware**, and are marked here rather than left to look
|
|
265
|
+
tested:
|
|
266
|
+
|
|
267
|
+
- `unifi_protect_update_sensor` — no UP Sense device on the test console (`sensors` is empty).
|
|
268
|
+
Note that a Protect **floodlight has its own built-in PIR**, reported as `isPirMotionDetected`
|
|
269
|
+
and tuned via `pirSensitivity`; that is part of the light, so it is `unifi_protect_update_light`
|
|
270
|
+
that controls it, not this tool. A garden lamp is not a sensor device.
|
|
271
|
+
- `unifi_protect_update_viewer` — no Viewport device on the test console.
|
|
272
|
+
|
|
273
|
+
Both follow the same PATCH shape as the tools that were verified, so they are likely correct, but
|
|
274
|
+
"likely" is the honest word until someone runs them.
|
|
275
|
+
|
|
276
|
+
## Not implemented
|
|
277
|
+
|
|
278
|
+
The realtime WebSocket at `/proxy/protect/ws/updates` is not wired up. It is a binary framed
|
|
279
|
+
protocol, and because this server wraps the private API, event _history_ is already available
|
|
280
|
+
over REST through `unifi_protect_list_events` — which is what the WebSocket would have been
|
|
281
|
+
needed for. Node's global `WebSocket` follows the WHATWG signature and ignores a headers option,
|
|
282
|
+
so attaching the session cookie would mean adding `ws` as a dependency.
|
|
283
|
+
|
|
284
|
+
## Develop
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
pnpm install
|
|
288
|
+
pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Publish:
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
pnpm dlx release-it # bump, commit, tag
|
|
295
|
+
git push --follow-tags # CI publishes to npm + GHCR from the tag
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## License
|
|
299
|
+
|
|
300
|
+
MIT
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { B as loadConfig, G as BUILD_INFO, W as setupInstructions, i as createServer, z as isConfigured } from "./server-iu_3JECB.js";
|
|
3
|
+
import { ZodError } from "zod";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
//#region src/cli.ts
|
|
6
|
+
const stderrLogger = {
|
|
7
|
+
debug: (...args) => {
|
|
8
|
+
if (process.env.UNIFI_PROTECT_DEBUG) console.error("[unifi-protect-mcp]", ...args);
|
|
9
|
+
},
|
|
10
|
+
warn: (...args) => console.error("[unifi-protect-mcp]", ...args),
|
|
11
|
+
error: (...args) => console.error("[unifi-protect-mcp]", ...args)
|
|
12
|
+
};
|
|
13
|
+
/** Show a config mistake as its field messages, not 40 frames of zod internals. */
|
|
14
|
+
const describeFatal = (err) => {
|
|
15
|
+
if (err instanceof ZodError) return err.issues.map((issue) => {
|
|
16
|
+
const path = issue.path.join(".");
|
|
17
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
18
|
+
}).join("\n");
|
|
19
|
+
return err instanceof Error ? err.message : String(err);
|
|
20
|
+
};
|
|
21
|
+
const main = async () => {
|
|
22
|
+
stderrLogger.warn(`${BUILD_INFO.name}@${BUILD_INFO.version} (git ${BUILD_INFO.gitCommit} ${BUILD_INFO.gitCommitDate}, node ${process.version})`);
|
|
23
|
+
const config = loadConfig();
|
|
24
|
+
const { server } = createServer({
|
|
25
|
+
config,
|
|
26
|
+
logger: stderrLogger
|
|
27
|
+
});
|
|
28
|
+
const transport = new StdioServerTransport();
|
|
29
|
+
await server.connect(transport);
|
|
30
|
+
stderrLogger.warn(`unifi-protect-mcp connected (host=${config.baseUrl ?? "MISSING"}, user=${config.username ?? "MISSING"}, writes=${config.allowWrites ? "ENABLED" : "disabled"}, tls=${config.verifyTls ? "verified" : "UNVERIFIED"})`);
|
|
31
|
+
if (!isConfigured(config)) {
|
|
32
|
+
stderrLogger.warn(" not configured — only unifi_protect_auth_status is available:");
|
|
33
|
+
for (const line of setupInstructions(config)) stderrLogger.warn(` ${line}`);
|
|
34
|
+
stderrLogger.warn(" Call unifi_protect_auth_status for this same guidance in your client.");
|
|
35
|
+
}
|
|
36
|
+
const shutdown = (signal) => {
|
|
37
|
+
stderrLogger.warn(`received ${signal}, shutting down`);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
};
|
|
40
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
41
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
42
|
+
};
|
|
43
|
+
main().catch((err) => {
|
|
44
|
+
console.error(`[unifi-protect-mcp] fatal: ${describeFatal(err)}`);
|
|
45
|
+
if (process.env.UNIFI_PROTECT_DEBUG && err instanceof Error) console.error(err.stack);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|
|
48
|
+
//#endregion
|
|
49
|
+
export {};
|
|
50
|
+
|
|
51
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { ZodError } from \"zod\";\n\nimport { BUILD_INFO } from \"./build-info.js\";\nimport { isConfigured, loadConfig, setupInstructions } from \"./config.js\";\nimport { createServer } from \"./server.js\";\n\n// Everything goes to stderr: stdout is the MCP protocol channel, and a stray\n// log line there corrupts the JSON-RPC stream — usually failing the client's\n// next parse, far from the cause.\n// oxlint-disable no-console -- this is the process entry point; stderr is the log channel.\nconst stderrLogger = {\n debug: (...args: unknown[]) => {\n if (process.env.UNIFI_PROTECT_DEBUG) console.error(\"[unifi-protect-mcp]\", ...args);\n },\n warn: (...args: unknown[]) => console.error(\"[unifi-protect-mcp]\", ...args),\n error: (...args: unknown[]) => console.error(\"[unifi-protect-mcp]\", ...args),\n};\n\n/** Show a config mistake as its field messages, not 40 frames of zod internals. */\nconst describeFatal = (err: unknown): string => {\n if (err instanceof ZodError) {\n return err.issues\n .map((issue) => {\n const path = issue.path.join(\".\");\n return path ? `${path}: ${issue.message}` : issue.message;\n })\n .join(\"\\n\");\n }\n return err instanceof Error ? err.message : String(err);\n};\n\nconst main = async (): Promise<void> => {\n stderrLogger.warn(\n `${BUILD_INFO.name}@${BUILD_INFO.version} (git ${BUILD_INFO.gitCommit} ${BUILD_INFO.gitCommitDate}, node ${process.version})`,\n );\n\n const config = loadConfig();\n // Before anything can open a socket.\n\n const { server } = createServer({ config, logger: stderrLogger });\n const transport = new StdioServerTransport();\n await server.connect(transport);\n\n stderrLogger.warn(\n `unifi-protect-mcp connected (host=${config.baseUrl ?? \"MISSING\"}, ` +\n `user=${config.username ?? \"MISSING\"}, ` +\n `writes=${config.allowWrites ? \"ENABLED\" : \"disabled\"}, ` +\n `tls=${config.verifyTls ? \"verified\" : \"UNVERIFIED\"})`,\n );\n\n // Connecting successfully but exposing one tool is confusing unless we say\n // why. The server no longer refuses to start over this, so the banner and\n // unifi_protect_auth_status are the only channels left.\n if (!isConfigured(config)) {\n stderrLogger.warn(\" not configured — only unifi_protect_auth_status is available:\");\n for (const line of setupInstructions(config)) stderrLogger.warn(` ${line}`);\n stderrLogger.warn(\" Call unifi_protect_auth_status for this same guidance in your client.\");\n }\n\n const shutdown = (signal: string): void => {\n stderrLogger.warn(`received ${signal}, shutting down`);\n process.exit(0);\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n};\n\nmain().catch((err: unknown) => {\n console.error(`[unifi-protect-mcp] fatal: ${describeFatal(err)}`);\n if (process.env.UNIFI_PROTECT_DEBUG && err instanceof Error) console.error(err.stack);\n process.exit(1);\n});\n"],"mappings":";;;;;AAYA,MAAM,eAAe;CACnB,QAAQ,GAAG,SAAoB;EAC7B,IAAI,QAAQ,IAAI,qBAAqB,QAAQ,MAAM,uBAAuB,GAAG,IAAI;CACnF;CACA,OAAO,GAAG,SAAoB,QAAQ,MAAM,uBAAuB,GAAG,IAAI;CAC1E,QAAQ,GAAG,SAAoB,QAAQ,MAAM,uBAAuB,GAAG,IAAI;AAC7E;;AAGA,MAAM,iBAAiB,QAAyB;CAC9C,IAAI,eAAe,UACjB,OAAO,IAAI,OACR,KAAK,UAAU;EACd,MAAM,OAAO,MAAM,KAAK,KAAK,GAAG;EAChC,OAAO,OAAO,GAAG,KAAK,IAAI,MAAM,YAAY,MAAM;CACpD,CAAC,CAAC,CACD,KAAK,IAAI;CAEd,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AACxD;AAEA,MAAM,OAAO,YAA2B;CACtC,aAAa,KACX,GAAG,WAAW,KAAK,GAAG,WAAW,QAAQ,QAAQ,WAAW,UAAU,GAAG,WAAW,cAAc,SAAS,QAAQ,QAAQ,EAC7H;CAEA,MAAM,SAAS,WAAW;CAG1B,MAAM,EAAE,WAAW,aAAa;EAAE;EAAQ,QAAQ;CAAa,CAAC;CAChE,MAAM,YAAY,IAAI,qBAAqB;CAC3C,MAAM,OAAO,QAAQ,SAAS;CAE9B,aAAa,KACX,qCAAqC,OAAO,WAAW,UAAU,SACvD,OAAO,YAAY,UAAU,WAC3B,OAAO,cAAc,YAAY,WAAW,QAC/C,OAAO,YAAY,aAAa,aAAa,EACxD;CAKA,IAAI,CAAC,aAAa,MAAM,GAAG;EACzB,aAAa,KAAK,iEAAiE;EACnF,KAAK,MAAM,QAAQ,kBAAkB,MAAM,GAAG,aAAa,KAAK,KAAK,MAAM;EAC3E,aAAa,KAAK,yEAAyE;CAC7F;CAEA,MAAM,YAAY,WAAyB;EACzC,aAAa,KAAK,YAAY,OAAO,gBAAgB;EACrD,QAAQ,KAAK,CAAC;CAChB;CACA,QAAQ,GAAG,gBAAgB,SAAS,QAAQ,CAAC;CAC7C,QAAQ,GAAG,iBAAiB,SAAS,SAAS,CAAC;AACjD;AAEA,KAAK,CAAC,CAAC,OAAO,QAAiB;CAC7B,QAAQ,MAAM,8BAA8B,cAAc,GAAG,GAAG;CAChE,IAAI,QAAQ,IAAI,uBAAuB,eAAe,OAAO,QAAQ,MAAM,IAAI,KAAK;CACpF,QAAQ,KAAK,CAAC;AAChB,CAAC"}
|