Skip to main content
Glama
README.md
# @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_status`
  rather than exiting and showing in your client as a bare `Connection 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.

```bash
# 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 `200`s 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](#not-implemented).

## Security

**Supply chain.** Three runtime dependencies: the MCP SDK, zod, and
[`@mgcrea/unifi-protect`](https://github.com/mgcrea/unifi-protect-client) — 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**

```bash
UNIFI_PROTECT_HOST=192.168.1.1 UNIFI_PROTECT_USERNAME=mcp UNIFI_PROTECT_PASSWORD=… \
  npx -y @mgcrea/mcp-unifi-protect
```

**B. Docker (stdio)**

```bash
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-protect
```

**C. From source**

```bash
pnpm install && pnpm build
node dist/cli.js
```

### Inspect the tools

```bash
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, so `who_passed in front of the house last night?` arrives
  as just `"in"`, while `who_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

```jsonc
// 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.storageInfo` with `totalSize` / `totalSpaceUsed`. By 7.2 that
  key is gone; the numbers live under `nvr.systemInfo.storage` and `nvr.storageStats`, with
  per-disk health in `systemInfo.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/off `ledSettings.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 `thumbnail` field is not a thumbnail id you can use here.** It reads `e-<eventId>`
  and belongs to the `thumbnails/<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 report `hasThumbnail: true` rather than an id, and `unifi_protect_get_event_thumbnail`
  takes the event's `id` (though it tolerates an `e-…` 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](#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 (`sensors` is empty).
  Note that a Protect **floodlight has its own built-in PIR**, reported as `isPirMotionDetected`
  and tuned via `pirSensitivity`; that is part of the light, so it is `unifi_protect_update_light`
  that 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

```bash
pnpm install
pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build
```

Publish:

```bash
pnpm release minor             # bump, commit, tag (patch|minor|major)
git push --follow-tags         # CI publishes to npm + GHCR from the tag
```

## License

MIT

TDQS

A4.1/5.0

Scored across 22 tools

Disambiguation5/5

Each tool targets a distinct resource and action: cameras, events, PTZ, auth, lights, sensors, viewers, and chimes are cleanly separated. Pairs like get_camera/list_cameras and get_event_thumbnail(s)/get_camera_snapshot are clearly differentiated by scope in the descriptions.

Naming Consistency4/5

The vast majority follow unifi_protect_<verb>_<noun> (list_cameras, get_event, export_video), and the prefix is consistent. The auth_* group reverses the order (auth_login, auth_status) and the generic request tool lacks a resource, which are minor but visible deviations.

Tool Count3/5

At 22 tools the server is on the heavy side of the 16-25 borderline range, though the breadth of UniFi Protect resource types explains much of the count. A few could arguably be consolidated, but the scope is broad enough that this is defensible rather than chaotic.

Completeness3/5

Core read/retrieval workflows are well covered: cameras, snapshots, events, thumbnails, video export, and peripheral devices. However, there are notable gaps in direct write/control operations—no camera settings update or liveview update despite list_liveviews explicitly referencing unifi_protect_update_viewer—and the raw request escape hatch only helps if writes are explicitly enabled.

Maintenance

ActivityMaintained
ResponsivenessNo issues