@mgcrea/mcp-unifi-protect
v1.0.1
Published
Model Context Protocol server for UniFi Protect
Maintainers
Readme
@mgcrea/mcp-unifi-protect
Model Context Protocol server for a self-hosted UniFi Protect console — cameras, recorded events and smart detections, snapshots, footage export, and the lights, sensors, viewers and chimes attached to it. Read-only by default: the tools that change anything are not registered at all unless you ask for them.
Features
- Search recorded events over any time range — motion, person / vehicle / animal / package / licence-plate detections, doorbell rings — with each result already carrying its camera's name, not just an id.
- Snapshots and footage — capture a frame now, pull an event's thumbnail, export an MP4. All written to disk by default, so a still frame does not silently cost you a context window.
- Devices — cameras, lights, sensors (with their temperature, humidity and light readings), viewers, chimes, live views and users.
- Shaped responses. A console camera record is 8-15 KB of JSON; a list of ten is over 100 KB.
List tools return the fifteen fields anyone actually asks about.
get_*returns everything. - Stays up with no credentials, reporting what to configure through
unifi_protect_auth_statusrather than exiting and showing in your client as a bareConnection closed.
Two ways to connect
| | local (default) | cloud |
| --------------------- | ------------------------------------------------ | --------------------------------------- |
| Reaches the console | directly on your LAN | via api.ui.com Site Manager connector |
| Credentials | host + username + password | API key + console id |
| Auth mechanism | UniFi OS login → session cookie + CSRF token | X-API-KEY header |
| TLS | console's self-signed cert — pinned on first use | a real certificate, nothing to do |
| Works off-LAN | no | yes |
| Session state on disk | yes, mode 600 | none |
Both modes expose exactly the same tools, because both speak the same private
Protect API — the connector forwards the whole /proxy/protect/... tree, the private
API included. That is not obvious and is worth stating plainly: Ubiquiti's official
Integration API has no historical query capability whatsoever, so if the connector only
carried that, cloud mode could not answer a single question about the past. It carries
the private API too, verified against a live console — bootstrap, events, cameras
and binary snapshots all answer 200.
So cloud mode is a full alternative, not a reduced one, and it removes the local account, the password, the session file, the CSRF handshake and the self-signed certificate problem in one go.
# cloud — no local account at all
UNIFI_PROTECT_API_KEY=… # unifi.ui.com → Settings → API Keys
UNIFI_PROTECT_CONSOLE_ID=… # curl -H "X-API-KEY: $KEY" https://api.ui.com/v1/hosts
# local — on the LAN
UNIFI_PROTECT_HOST=192.168.1.1
UNIFI_PROTECT_USERNAME=mcp
UNIFI_PROTECT_PASSWORD=…
# TLS needs nothing: the console's certificate is pinned on the first request.UNIFI_PROTECT_MODE is inferred as cloud when an API key and a console id are both
set, so it usually needs no setting. console/unifios/lan and
remote/site-manager/connector are accepted as synonyms, and an unrecognised value
is reported through unifi_protect_auth_status rather than killing the server.
Two traps in cloud mode. A key that works for /v1/hosts can still return
403 user cannot access host in the organization for a console outside the
organization it was issued in — valid key, wrong org, and the message reads nothing like
a credentials problem. And API keys are per-console: a key created on your Network
gateway or a UNAS does not authenticate against the NVR running Protect, and the NVR
rejects it exactly as it rejects a made-up key.
Why local mode needs a username and password
An API key looks like it ought to work here, and it is the obvious thing to reach for. It does not, and the reason is worth writing down so nobody spends an afternoon on it.
A key created on the console itself (UniFi OS → Control Plane → Integrations) is recognised — but only by Ubiquiti's official Integration API. It is refused by the private API this server depends on. Tested against a UNVR on Protect 7.2.105 with a key issued on that console:
| Endpoint | With a console API key |
| ----------------------------------------- | ---------------------- |
| /proxy/protect/integration/v1/meta/info | 200 |
| /proxy/protect/integration/v1/cameras | 200 |
| /proxy/protect/integration/v1/nvrs | 200 |
| /proxy/protect/api/nvr | 401 |
| /proxy/protect/api/cameras | 401 |
| /proxy/protect/api/events | 401 |
| /proxy/protect/api/bootstrap | 500 |
A fabricated key returns 401 on the official API too, so the 200s above confirm the
key really was valid — the private API simply does not accept key auth.
Putting all three paths together:
| Path | Authenticates with | Private API (event history, snapshots) |
| ----------------------------- | --------------------- | -------------------------------------- |
| local + username / password | session cookie + CSRF | ✅ |
| local + API key | X-API-KEY | ❌ 401 |
| cloud + API key | X-API-KEY | ✅ |
The asymmetry is not arbitrary. Over the connector, api.ui.com authenticates you by
key and then reaches the console over its own trusted channel, so the console is never
asked to accept a key on a private path. On the LAN there is no such intermediary, and
the private API only knows the session the web app itself uses.
So a local-only deployment needs a username and password. That is a property of Protect, not a shortcut taken here. Use a dedicated Local-Access-Only account with View Only rights, as described below, and the credential's blast radius stays small.
The one thing a console API key would unlock is the PTZ move commands
(ptz/goto, ptz/patrol/start, ptz/patrol/stop), which exist only on the official
Integration API — see Not implemented.
Security
Supply chain. Three runtime dependencies: the MCP SDK, zod, and
@mgcrea/unifi-protect — the console client,
which is ours and whose own dependencies are undici, ws and zod. There is no HTTP client
wrapper, no logger, no crypto library. That client used to be a copy inside this repo; the copy
and the original had already begun to drift, which is a bad way to hold knowledge about an
undocumented API. Published from CI with provenance via OIDC trusted publishing; the container
image is multi-arch, carries an SBOM, and is signed with cosign.
Your credentials. The username and password come from the environment or a config file, and
never leave this process except in the login request to your console. The resulting session
cookie is cached at ~/.config/unifi-protect/session.json with mode 600.
Certificate verification is ON by default, and now needs no setup. The console's certificate
is pinned: on the first request the server reads it, records its SHA-256 fingerprint and PEM in
~/.config/unifi-protect/trust.json (mode 600), and every connection afterwards verifies against
that one certificate as its own anchor. The host name check is replaced — not skipped — by a
fingerprint comparison, so addressing the console by IP is fine and a swapped certificate fails
hard.
This is what changed. The certificate is issued to unifi.local with no IP SAN, so reached by
an IP address, host name verification could never pass however the certificate was trusted; the
old advice was to set NODE_EXTRA_CA_CERTS and address the console by a resolvable name, or
give up and disable verification. Pinning removes both requirements.
Pinning is trust-on-first-use, and it is only as good as that first moment. The fingerprint is
logged when it is learned and reported by unifi_protect_auth_status; set
UNIFI_PROTECT_FINGERPRINT to make the trust explicit instead, and a console that presents
anything else is then refused rather than adopted. If the console legitimately reissues its
certificate, delete the trust file.
UNIFI_PROTECT_VERIFY_TLS=false still turns checking off entirely, scoped to this server's own
requests through an undici dispatcher — it is not NODE_TLS_REJECT_UNAUTHORIZED, so nothing else
in the process is affected. There should no longer be a reason to use it; the startup banner
prints tls=UNVERIFIED on every run when you do.
Blast radius. With the defaults, the worst an agent can do is read your cameras and write
image files into the snapshot directory. With UNIFI_PROTECT_ALLOW_WRITES=1 it can additionally
reconfigure devices, stop a camera recording, and reboot a camera or the whole console. Use a
Local-Access-Only account with View Only rights, and leave writes off unless you need them.
Configure
| Variable | Required | Default | What it does |
| ---------------------------------- | -------- | -------------------------------------- | --------------------------------------------------------------- |
| UNIFI_PROTECT_HOST | yes | — | Console IP or hostname. https:// assumed, :port preserved |
| UNIFI_PROTECT_USERNAME | yes | — | Console login |
| UNIFI_PROTECT_PASSWORD | yes | — | Its password |
| UNIFI_PROTECT_TOTP | no | — | 2FA code. Expires in ~30s — prefer unifi_protect_auth_login |
| UNIFI_PROTECT_VERIFY_TLS | no | true | Verify the console's certificate. Off disables pinning entirely |
| UNIFI_PROTECT_FINGERPRINT | no | — | Pin this SHA-256 up front instead of learning it on first use |
| UNIFI_PROTECT_TRUST_FILE | no | ~/.config/unifi-protect/trust.json | Where the pinned certificate is remembered, mode 600 |
| UNIFI_PROTECT_ALLOW_WRITES | no | false | Register the 12 mutating tools |
| UNIFI_PROTECT_SESSION_FILE | no | ~/.config/unifi-protect/session.json | Cached session, mode 600 |
| UNIFI_PROTECT_SNAPSHOT_DIR | no | ~/.cache/unifi-protect | Where images and exports are written |
| UNIFI_PROTECT_CONFIG | no | ~/.config/unifi-protect/config.json | Config file location |
| UNIFI_PROTECT_MAX_RETRIES | no | 3 | Retries on 401 / 429 / 5xx |
| UNIFI_PROTECT_MAX_DOWNLOAD_BYTES | no | 200000000 | Refuse a download larger than this |
| UNIFI_PROTECT_DEVICE_CACHE_TTL | no | 60 | Camera id→name cache lifetime, seconds |
| UNIFI_PROTECT_DEBUG | no | — | Verbose request logging to stderr |
The config file mirrors these as camelCase JSON (host, username, verifyTls, …). It is
strict: an unknown key is an error rather than a silent no-op. Environment variables win over
the file, field by field, so a one-off UNIFI_PROTECT_ALLOW_WRITES=0 still beats a file that
says true.
Create an account for it
UniFi OS → Settings → Admins & Users → Add User → Local Access Only, with Protect permissions and View Only unless you plan to enable writes.
Use a local account rather than your Ubiquiti (SSO) one. Cloud accounts frequently cannot log in locally at all, and a scoped local account keeps this server away from the rest of the console.
Quick start
A. npx
UNIFI_PROTECT_HOST=192.168.1.1 UNIFI_PROTECT_USERNAME=mcp UNIFI_PROTECT_PASSWORD=… \
npx -y @mgcrea/mcp-unifi-protectB. Docker (stdio)
docker run --rm -i \
-e UNIFI_PROTECT_HOST=192.168.1.1 \
-e UNIFI_PROTECT_USERNAME=mcp \
-e UNIFI_PROTECT_PASSWORD=… \
ghcr.io/mgcrea/mcp-unifi-protectC. From source
pnpm install && pnpm build
node dist/cli.jsInspect the tools
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"cli","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| node dist/cli.js 2>/dev/null | jq -r '.result.tools[]?.name'Tools
22 read tools, plus 10 more when writes are enabled.
| Tool | What it does | Writes |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------- |
| unifi_protect_auth_status | Log in and make a real call, reporting whether the console is reachable and on what Protect version | — |
| unifi_protect_auth_login | Force a fresh login; the only way to supply a 2FA code | — |
| unifi_protect_auth_logout | Drop the cached session and delete the session file | confirm |
| unifi_protect_get_system_info | Console model, Protect version, storage, device counts | — |
| unifi_protect_list_cameras | Every camera, summarized | — |
| unifi_protect_get_camera | One camera's complete record (large) | — |
| unifi_protect_get_camera_snapshot | Capture a frame now, to a file or inline | — |
| unifi_protect_list_ptz_presets | A PTZ camera's saved preset slots | — |
| unifi_protect_list_ptz_patrols | A PTZ camera's saved patrol routes | — |
| unifi_protect_check_settings | Audit every camera for inconsistent or self-defeating settings | — |
| unifi_protect_list_events | Search recorded events over any time range | — |
| unifi_protect_get_event | One event's full detection metadata | — |
| unifi_protect_get_event_thumbnail | The frame that triggered a detection | — |
| unifi_protect_get_event_thumbnails | Up to 6 frames at once, inline — how you tell a person from a branch | — |
| unifi_protect_export_video | Export footage as an MP4 on disk | — |
| unifi_protect_list_lights | Floodlights, with state and brightness | — |
| unifi_protect_list_sensors | Sensors, with temperature / humidity / light readings | — |
| unifi_protect_list_viewers | Viewport devices and what each displays | — |
| unifi_protect_list_chimes | Chimes, volume, paired doorbells | — |
| unifi_protect_list_liveviews | Saved camera grid layouts | — |
| unifi_protect_list_users | Who can sign in to Protect | — |
| unifi_protect_request | Escape hatch: call any private endpoint directly | GET only unless writes |
| unifi_protect_update_camera | Name, mic, status LED, OSD overlays | ✅ |
| unifi_protect_set_camera_detections | Which objects and sounds a camera detects — the gate below | ✅ |
| unifi_protect_set_camera_recording_mode | always / never / detections / schedule | ✅ |
| unifi_protect_reboot_camera | Reboot one camera | ✅ confirm |
| unifi_protect_update_light | Brightness, on/off, PIR sensitivity | ✅ |
| unifi_protect_update_sensor | Name, which capabilities report | ✅ |
| unifi_protect_update_viewer | Put a live view on a screen | ✅ |
| unifi_protect_update_chime | Volume, name | ✅ |
| unifi_protect_update_nvr_settings | Console name, timezone, global recording | ✅ |
| unifi_protect_reboot_nvr | Reboot the console | ✅ confirm |
Resources and prompts
Three resources carry the standing facts a question needs before a tool is chosen, so a client can attach them once instead of spending a call per question:
| Resource | Why it exists |
| --------------------------- | ---------------------------------------------------------------------------------------- |
| unifi-protect://console | The console's time zone, so "1am" is read as the local clock rather than UTC |
| unifi-protect://cameras | What each camera will actually detect, what its zones ask for, and where they differ |
| unifi-protect://locations | Named groups of cameras, so a question about a place resolves to ids |
Two prompts carry the procedure, which is the part a tool list cannot express:
check_camera_settings— run the audit and interpret it, changing nothing. Several findings have two valid opposite fixes, and which is right depends on what the camera is for.who_passed— find who was present in a window, and fall back to motion frames on any camera whose detector is off rather than reporting a zero count as an absence. It takes the question as one free-text argument, so quote it: slash-command arguments are split shell-style and mapped positionally, sowho_passed in front of the house last night?arrives as just"in", whilewho_passed "in front of the house last night?"arrives whole. A single-word question is treated as that truncation and refused rather than answered.
Worked example: what happened at the front door last night
// 1. Which cameras are there?
{"name": "unifi_protect_list_cameras", "arguments": {}}
// → [{ "id": "661a…", "name": "Front Door", "hasSmartDetect": true,
// "smartDetectTypes": ["person","package"], "recordingMode": "detections", … }]
// 2. People seen overnight. Note the camera NAME comes back resolved.
{"name": "unifi_protect_list_events", "arguments": {
"start": "2026-08-29T22:00:00Z", "end": "2026-08-30T07:00:00Z",
"types": ["smartDetectZone"], "smartDetectTypes": ["person"]}}
// → { "count": 3, "events": [
// { "id": "9f3c1a02-…", "start": "2026-08-30T02:14:07.000Z", "camera": "Front Door",
// "smartDetectTypes": ["person"], "score": 94, "hasThumbnail": true }, … ] }
// 3. Look at the one at 02:14 — pass the event's own id.
{"name": "unifi_protect_get_event_thumbnail",
"arguments": {"eventId": "9f3c1a02-…", "output": "image"}}
// 4. Pull the footage around it.
{"name": "unifi_protect_export_video", "arguments": {
"cameraId": "661a…", "start": "2026-08-30T02:13:30Z", "end": "2026-08-30T02:15:00Z"}}
// → { "path": "/Users/you/.cache/unifi-protect/front-door-….mp4", "bytes": 18432000 }Traps worth knowing
This wraps Protect's private API, not the official one. Ubiquiti publishes an Integration
API at /proxy/protect/integration/v1 with an OpenAPI spec and an X-API-KEY header. It is not
used here, because it has no historical query capability at all — the only query parameters
in its entire spec are channel, highQuality and qualities, and events exist solely as a
live WebSocket. "What happened last night" is unanswerable through it. The private API answers
that, at the cost of being undocumented and liable to change between Protect releases. This was
built and verified end-to-end against a live UNVR running Protect 7.2.105.
unifi_protect_get_system_info reports the version you are actually running, and
unifi_protect_request reaches any endpoint that moves.
Two shapes already changed between 6.x and 7.x, both found by running this against a real console, and both now handled in either form:
- Storage moved. 6.x had
nvr.storageInfowithtotalSize/totalSpaceUsed. By 7.2 that key is gone; the numbers live undernvr.systemInfo.storageandnvr.storageStats, with per-disk health insystemInfo.ustorage.disks. - A camera has no
ledLevel. The 0-6 brightness that looks like it belongs there is a floodlight field; a camera's LED is the on/offledSettings.isEnabled. Sub-objects also deep-merge on PATCH, so setting one OSD overlay preserves the others — verified by writing to a live camera and reading it back. - An event's
thumbnailfield is not a thumbnail id you can use here. It readse-<eventId>and belongs to thethumbnails/<id>endpoint;events/<eventId>/thumbnail— the one this server calls — wants the bare event id. Passing the console's own value returns 404. So list results reporthasThumbnail: truerather than an id, andunifi_protect_get_event_thumbnailtakes the event'sid(though it tolerates ane-…value too).
Smart detection is gated in two places, and only one of them is obvious.
smartDetectSettings.objectTypes on the device is the master switch;
smartDetectZones[].objectTypes says what each zone asks for. A zone can ask for person while
the device list omits it, and the console then reports nothing at all — no error, no warning,
just an empty result forever. On the console this was built against, a doorbell had
zone: [person, vehicle, animal] against device: [animal], so a person search returned zero
across seven days while people walked past nightly.
Zero results are therefore never reported bare. unifi_protect_list_events cross-checks the
requested detection types against each camera's device list and returns a warnings array
saying the detector was off — the difference between "nobody was there" and "nothing was
looking". unifi_protect_check_settings finds the same misconfiguration across the whole
system, and unifi_protect_set_camera_detections fixes it, keeping the zones in step.
One limit worth knowing: the check reflects the camera's setting now, so a historical search over a period when the detector was off but has since been enabled gets no warning.
Some settings are reported on read but refused on write. smartDetectSettings.audioTypes
comes back containing smoke_cmonx, and a PATCH containing it fails with
400 The smart detection feature is not enabled for: smoke_cmonx. Any read-modify-write that
echoes the list back therefore breaks. unifi_protect_set_camera_detections filters against
featureFlags.smartDetectAudioTypes and reports what it dropped.
Camera filtering happens on the console, and the parameter must be repeated. /events
accepts cameras=<id>, repeated once per camera. A comma-separated list is accepted and
silently matches nothing. This mattered more than it looks: filtering client-side instead
fetches the newest limit events across all cameras and discards the rest, so a quiet camera
over a long window came back empty while reporting a successful search.
Times are milliseconds, and getting it wrong fails silently. The console takes JavaScript
millisecond timestamps. A Unix seconds value is not rejected — it is read as a moment in 1970,
so the query succeeds and returns an empty list, which reads as "nothing happened". Every time
argument here accepts ISO 8601, a relative expression ("2h ago", "30m", "7d") or "now",
and a ten-digit number is refused with the corrected value in the error.
Local forms are also accepted — "1am", "01:30", "2026-08-30 01:00" — and read in the
console's own time zone, because a question about last night is a question about the clock
where the cameras are. A bare time of day resolves to its most recent occurrence, and start
anchors to the window's end, so "1am to 6am" stays one coherent night however late it is asked.
Event search is always filtered by type. Omitting types entirely triggers a pagination bug
in Protect where the console ignores the window and returns the wrong slice. unifi_protect_list_events
always sends an explicit list, defaulting to motion, smart detections and rings.
Footage only exists if the camera was recording. An empty event search may mean the camera's
recording mode is never, not that nothing happened. unifi_protect_list_cameras shows the mode.
Snapshots are forced. Without that the console can return a cached frame minutes old, which is indistinguishable from a current one.
A cloud account may not work. Ubiquiti SSO accounts frequently cannot log in locally. Create a Local Access Only user.
Troubleshooting
The server does not appear, or shows Connection closed. It should never exit on missing
credentials — run it by hand with the same environment and read stderr. Everything it logs goes
to stderr, because stdout is the protocol channel.
Only unifi_protect_auth_status is listed. No console is configured. Call that tool; it
returns the setup steps as data.
A tool I expected is missing. The write tools are not registered unless
UNIFI_PROTECT_ALLOW_WRITES=1. That is the design, not a bug — an absent tool cannot be called,
whereas a refused one invites an agent to keep trying.
self-signed certificate errors. These should no longer happen: the certificate is pinned on the first request, which works against an IP address. If one appears, the console is presenting a different certificate than the one pinned — delete ~/.config/unifi-protect/trust.json if it was legitimately reissued, and investigate if it was not
unless you have installed a trusted certificate on the console.
Cloud mode returns 403 user cannot access host in the organization. The key is
valid but was issued in an organization that does not contain that console. Check the
console appears in curl -H "X-API-KEY: $KEY" https://api.ui.com/v1/hosts; if the web
dashboard shows it but that call does not, they are different organizations.
I set an API key for local mode and everything returns 401. Local mode cannot use an
API key — see Why local mode needs a username and password.
Set UNIFI_PROTECT_USERNAME and UNIFI_PROTECT_PASSWORD, or switch to cloud mode,
where a key is all you need.
A local API key returns 401 on everything. API keys are per-console. A key created
on your Network gateway is not valid on the NVR running Protect — and the NVR rejects an
unknown key with exactly the same 401 it gives a fabricated one, so the message cannot
distinguish "wrong console" from "wrong key". Create the key on the console you are
addressing, or use cloud mode.
Everything returns 401. Check the account is a local one, and that it has Protect
permissions. unifi_protect_auth_status distinguishes "cannot log in" from "logged in but
forbidden".
A tool that used to work now returns 404. Compare the Protect version from
unifi_protect_get_system_info against 7.2.105 above; an upgrade may have moved the endpoint.
unifi_protect_request is the workaround while it is fixed — it reaches any path under
/proxy/protect/api directly, which is how both of the 6.x→7.x changes above were pinned down.
Storage shows as nearly full. That is normal on an NVR: isRecycling: true means the
console continuously overwrites the oldest footage rather than stopping. get_system_info says
so inline so it does not read as a fault.
What has been verified against real hardware
Cloud mode was verified end-to-end against a live console over the Site Manager
connector: auth_status reachable, camera list, and event search returning real
detections with their camera names resolved — all authenticated by an API key alone,
with no local account anywhere in the picture.
Local mode was built and exercised end-to-end against a live UNVR4 on Protect 7.2.105 with 12 cameras, one floodlight and one chime. Every read tool was run; the write tools were exercised with no-op writes — each value set to the value it already held — and the device state read back unchanged afterwards. That run is also what caught three bugs this README's earlier drafts described wrongly: the storage layout, the event-thumbnail id, and two camera fields that do not exist.
Certificate pinning was verified against that same console on 2026-08-31, addressed by IP
(192.168.6.3) with verification ON — the case that was impossible before. Confirmed in one run:
the certificate is captured and its fingerprint logged on first contact; the trust file is written
mode 600; a restart reuses the pin without re-capturing; a correct UNIFI_PROTECT_FINGERPRINT
(colons and all) is accepted; and a wrong one is refused with a message naming both fingerprints,
surfacing as a failed tool call rather than a server that never starts. auth_status reported
reachable: true on Protect 7.2.105 throughout, and a session cached by the previous release was
restored unchanged — the on-disk format did not move.
Two tools remain unverified for want of hardware, and are marked here rather than left to look tested:
unifi_protect_update_sensor— no UP Sense device on the test console (sensorsis empty). Note that a Protect floodlight has its own built-in PIR, reported asisPirMotionDetectedand tuned viapirSensitivity; that is part of the light, so it isunifi_protect_update_lightthat controls it, not this tool. A garden lamp is not a sensor device.unifi_protect_update_viewer— no Viewport device on the test console.
Both follow the same PATCH shape as the tools that were verified, so they are likely correct, but "likely" is the honest word until someone runs them.
Not implemented
The realtime WebSocket at /proxy/protect/ws/updates is not wired up here, though
@mgcrea/unifi-protect now implements it — connectEventStream and ProtectStore are a
decoded frame stream and a live device cache, and this server deliberately uses neither.
The reason is that they are the wrong shape for an MCP server. Both want a long-lived process
that bootstraps at start-up and stays subscribed; this server must start with no credentials and
no connectivity at all, answering unifi_protect_auth_status until it is configured. It would
also gain little: because this wraps the private API, event history is already available over
REST through unifi_protect_list_events, which is what a subscription would have been for.
Adopting it would mean deferring the bootstrap until credentials appear — worth doing if a tool
ever needs push rather than poll, and not before.
Develop
pnpm install
pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm buildPublish:
pnpm release minor # bump, commit, tag (patch|minor|major)
git push --follow-tags # CI publishes to npm + GHCR from the tagLicense
MIT
