@mgcrea/mcp-unifi-protect
This server is an MCP gateway to a self-hosted UniFi Protect console, letting an agent inspect cameras, search recorded events and detections, grab snapshots/footage, manage devices, and audit or reconfigure the system (writes optional).
Check connectivity & auth:
unifi_protect_auth_status, login (incl. 2FA), logout, and clear cached sessions.System overview: console model, Protect version, firmware, timezone, storage, device counts.
Cameras: list all cameras (summarized), get full camera records, capture live snapshots to disk or inline, and list PTZ presets/patrols.
Event search: search motion/smart detections/rings over any time range, fetch full event metadata, fetch event thumbnails singly or up to 6 inline for visual review.
Footage export: export recorded video as MP4 files on disk with configurable channel/quality.
Devices: list lights, sensors (temp/humidity/light), viewers, chimes, liveviews, and user accounts.
Audit:
unifi_protect_check_settingsdetects inconsistent camera settings (e.g., detectors gated off, no recording, offline devices).Write tools (only registered with
UNIFI_PROTECT_ALLOW_WRITES=1): update cameras (name, detection types, recording mode), reboot cameras/NVR, control lights, update sensors/viewers/chimes, and change NVR settings.Escape hatch:
unifi_protect_requestcalls any private Protect API path directly (GET-only unless writes enabled).
Provides tools for interacting with a self-hosted UniFi Protect console, including cameras, recorded events and smart detections, snapshots, video export, lights, sensors, viewers, chimes, and system information.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@@mgcrea/mcp-unifi-protectShow me motion events from the front door camera in the last hour"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
@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.
Related MCP server: UniFi MCP Server
Two ways to connect
|
| |
Reaches the console | directly on your LAN | via |
Credentials | host + username + password | API key + console id |
Auth mechanism | UniFi OS login → session cookie + CSRF token |
|
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 | 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=falseUNIFI_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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
| session cookie + CSRF | ✅ |
|
| ❌ |
|
| ✅ |
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 |
| yes | — | Console IP or hostname. |
| yes | — | Console login |
| yes | — | Its password |
| no | — | 2FA code. Expires in ~30s — prefer |
| no |
| Verify the console's certificate (needs a host name, not an IP) |
| no |
| Register the 12 mutating tools |
| no |
| Cached session, mode 600 |
| no |
| Where images and exports are written |
| no |
| Config file location |
| no |
| Retries on 401 / 429 / 5xx |
| no |
| Refuse a download larger than this |
| no |
| Camera id→name cache lifetime, seconds |
| 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 |
| Log in and make a real call, reporting whether the console is reachable and on what Protect version | — |
| Force a fresh login; the only way to supply a 2FA code | — |
| Drop the cached session and delete the session file | confirm |
| Console model, Protect version, storage, device counts | — |
| Every camera, summarized | — |
| One camera's complete record (large) | — |
| Capture a frame now, to a file or inline | — |
| A PTZ camera's saved preset slots | — |
| A PTZ camera's saved patrol routes | — |
| Audit every camera for inconsistent or self-defeating settings | — |
| Search recorded events over any time range | — |
| One event's full detection metadata | — |
| The frame that triggered a detection | — |
| Up to 6 frames at once, inline — how you tell a person from a branch | — |
| Export footage as an MP4 on disk | — |
| Floodlights, with state and brightness | — |
| Sensors, with temperature / humidity / light readings | — |
| Viewport devices and what each displays | — |
| Chimes, volume, paired doorbells | — |
| Saved camera grid layouts | — |
| Who can sign in to Protect | — |
| Escape hatch: call any private endpoint directly | GET only unless writes |
| Name, mic, status LED, OSD overlays | ✅ |
| Which objects and sounds a camera detects — the gate below | ✅ |
|
| ✅ |
| Reboot one camera | ✅ confirm |
| Brightness, on/off, PIR sensitivity | ✅ |
| Name, which capabilities report | ✅ |
| Put a live view on a screen | ✅ |
| Volume, name | ✅ |
| Console name, timezone, global recording | ✅ |
| 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 |
| The console's time zone, so "1am" is read as the local clock rather than UTC |
| What each camera will actually detect, what its zones ask for, and where they differ |
| 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. 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 (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. 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 buildPublish:
pnpm release minor # bump, commit, tag (patch|minor|major)
git push --follow-tags # CI publishes to npm + GHCR from the tagLicense
MIT
Maintenance
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
- AlicenseDqualityDmaintenanceEnables 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.3952Apache 2.0
- AlicenseBqualityDmaintenanceEnables 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.41MIT
- AlicenseBqualityDmaintenanceEnables 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.52585MIT
- FlicenseNot gradedqualityDmaintenanceProvides 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
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
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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