Skip to main content
Glama

@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.

Related MCP server: UniFi MCP Server

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 — needs setup

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=…
UNIFI_PROTECT_VERIFY_TLS=false

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 undici. Retry and backoff are hand-rolled; there is no HTTP client wrapper, no logger, no crypto library. undici earns its place by being the only way to scope the TLS exception below to this server's own requests — see the note there. 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 disabling it is 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. (It previously was process-wide, on the belief that a dispatcher could not be scoped without a dependency. It can, and undici is now that dependency.)

Verifying takes two things together, and either alone achieves nothing: the certificate is self-signed, so NODE_EXTRA_CA_CERTS must point at it; and it is issued to unifi.local with no IP SAN, so UNIFI_PROTECT_HOST must be a host name that resolves to the console rather than its IP address. Reached by IP, verification fails on the host name however the certificate is trusted. See .env.example for the two commands. If the console has no name on your network, set UNIFI_PROTECT_VERIFY_TLS=false; the startup banner then prints tls=UNVERIFIED on every run.

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 (needs a host name, not an IP)

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

B. 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-protect

C. From source

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

Inspect 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, 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

// 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. 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 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.

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. It is a binary framed protocol, and because this server wraps the private API, event history is already available over REST through unifi_protect_list_events — which is what the WebSocket would have been needed for. Node's global WebSocket follows the WHATWG signature and ignores a headers option, so attaching the session cookie would mean adding ws as a dependency.

Develop

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

Publish:

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

License

MIT

A
license - permissive license
A
quality
A
maintenance

Maintenance

Maintainers
Response time
0dRelease cycle
3Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    D
    quality
    D
    maintenance
    Enables comprehensive management of UniFi network infrastructure through the UniFi Cloud API, including device control, client management, camera settings, and access door control through natural language.
    39
    52
    Apache 2.0
  • A
    license
    B
    quality
    D
    maintenance
    Enables AI assistants to manage and monitor UniFi Network Controllers through natural language. Provides 25 read-only tools for discovering devices and clients, viewing security configurations, analyzing network statistics, and exporting configuration data.
    41
    MIT
  • A
    license
    B
    quality
    D
    maintenance
    Enables AI assistants to manage UniFi network infrastructure through 50+ tools covering devices, clients, networks, WiFi, firewall rules, and guest access using the official UniFi Network API.
    52
    58
    5
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    Provides AI assistants with access to UniFi Network and Protect infrastructure for managing devices, monitoring clients, analyzing network health, viewing camera snapshots, and getting optimization recommendations across multiple UniFi controllers.
    2

View all related MCP servers

Related MCP Connectors

  • Operate Linux, macOS and Windows from your LLM. Every action runs through an auditable allowlist.

  • Generate, edit and upscale AI video and images from any agent via VicSee.

  • Your Plaud recordings in natural language: list recordings, read speaker-attributed transcripts and

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mgcrea/mcp-unifi-protect'

If you have feedback or need assistance with the MCP directory API, please join our Discord server