
ebenezer-isaac/vigi-nvr-mcp
Developer toolsLocal, browser-free MCP server for a TP-Link VIGI NVR: read-first, write-gated, lockout-aware; manage channels and export footage over RTSP.
Overview
An MCP server for a single TP-Link VIGI NVR on your own network. It speaks the NVR's local JSON API directly — no cloud, no browser, no vendor app — and exposes it to an AI agent or any MCP client as a set of safe, typed tools: read the device and its cameras, manage channels, and export recorded footage over RTSP. Reads are free; every write needs two separate opt-ins; and login is engineered around the NVR's account-lockout so an agent cannot lock you out of your own recordings. VIGI NVRs lock the admin account after ~10 failed logins, and while locked you cannot reach the NVR at all, including its recordings. This server never auto-retries a login and, by default, stops all logins after a failure until a human clears the breaker. Confirm your password with vigi-nvr-mcp --check-auth --login before pointing an agent at it. See Safety model. Other VIGI models and firmware very likely work, but — this unit uses MD5+RSA, and newer firmware is known to use SHA-256-based schemes.
README
vigi-nvr-mcp
An MCP server for a single TP-Link VIGI NVR on your own network. It speaks the NVR’s local JSON API directly — no cloud, no browser, no vendor app — and exposes it to an AI agent or any MCP client as a set of safe, typed tools: read the device and its cameras, manage channels, and export recorded footage over RTSP. Reads are free; every write needs two separate opt-ins; and login is engineered around the NVR’s account-lockout so an agent cannot lock you out of your own recordings.
[!WARNING] Account lockout is the main risk. VIGI NVRs lock the
adminaccount after ~10 failed logins, and while locked you cannot reach the NVR at all, including its recordings. This server never auto-retries a login and, by default, stops all logins after a single failure until a human clears the breaker. Confirm your password withvigi-nvr-mcp --check-auth --loginbefore pointing an agent at it. See Safety model.
Supported hardware
| Tested model | VIGI NVR1016H(UN) |
| Tested firmware | 1.1.3 Build 260727 |
| Auth scheme (this firmware) | MD5 + RSA-PKCS1v15 (encrypt_type 2), falling back to bare MD5 (encrypt_type 1) |
Other VIGI models and firmware very likely work, but the login scheme varies
by firmware — this unit uses MD5+RSA, and newer firmware is known to use
SHA-256-based schemes. The server auto-detects encrypt_type 1 vs 2, but if
your firmware uses something else, login will fail. Verify first, without
risking the lockout counter:
vigi-nvr-mcp --check-auth # fetch the challenge only: offered schemes, counters; NO login
vigi-nvr-mcp --check-auth --login # plus exactly ONE login attempt
The full wire protocol is documented in docs/protocol/vigi-nvr.md.
Features
- 45 tools over one NVR, all returning a uniform
{success, data, error}envelope with credentials redacted. - Catalog gateway —
nvr_callreaches every one of the 586 documented calls across 61 API modules through one validated, write-gated entry point, so coverage is the whole inventory rather than 586 hand-written tools. - Typed reads for the high-value calls: device/system/network info, users, storage and disks, recording status, video/image/detection config, firewall, cloud status, time.
- Channel management — list bound cameras, group duplicates by device UUID, flag stale “ghost” channels, and plan/execute cleanup (remove, move) with per-write UUID checks.
- Footage over RTSP — enable ONVIF/RTSP, build redacted stream URLs, export
replay windows to mp4 (lossless
-c copy, or re-encode to a size target), grab snapshots, build time-stamped contact sheets, sample frames, and run a full “find the footage and send it” investigation recipe. - MCP resources — the export directory is also exposed as
exports://blobs for clients that support resources.
Install
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install . # or: pip install -e '.[dev]'
- Python 3.11+. Runtime deps:
mcp(1.x, FastMCP),httpx,cryptography,pydantic,python-dotenv,tzdata. ffmpeg/ffprobeare optional and only needed for the media/export tools. If they are not found, those tools return aMEDIA_UNAVAILABLEenvelope instead of failing; everything else works without them. PointVIGI_NVR_FFMPEGat the binary if it is not onPATH.
Configuration
Configuration comes only from environment variables (a .env in the working
directory is loaded if present). Copy .env.example and never
commit your .env. Everything is validated once at startup; a bad value stops
the server with a message that names the variable but never prints its value.
NVR connection
| Variable | Default | Meaning |
|---|---|---|
VIGI_NVR_HOST |
(required) | NVR hostname or IP (no scheme/port/path) |
VIGI_NVR_PORT |
443 |
HTTPS API port |
VIGI_NVR_USERNAME |
admin |
Login username |
VIGI_NVR_PASSWORD |
(required) | Login password (1–128 chars) |
VIGI_NVR_VERIFY_TLS |
false |
NVRs ship self-signed certs, so off by default |
VIGI_NVR_TLS_FINGERPRINT_SHA256 |
(unset) | Pin the cert by SHA-256 (64 hex, colons optional); fails closed on mismatch. Observe the value via nvr_status / --check-auth (tls_fingerprint_observed) |
VIGI_NVR_TIMEOUT_SECONDS |
10 |
Per-request timeout, 1–120 |
Safety switches
| Variable | Default | Meaning |
|---|---|---|
VIGI_NVR_ALLOW_WRITES |
false |
First write gate (env). Writes also need confirm_write: true per call |
VIGI_NVR_DRY_RUN |
false |
Writes return the exact request without sending it |
VIGI_NVR_LOGIN_DISABLED |
false |
Freeze authentication before any network I/O |
VIGI_NVR_MAX_LOGIN_FAILURES |
1 |
Per-host failure budget before the breaker trips, 1–5 |
VIGI_NVR_BACKUP_DIR |
backups |
Where nvr_backup_config writes (dir/files 0600) |
VIGI_NVR_STATE_DIR |
~/.local/state/vigi-nvr-mcp/ |
Persistent login-breaker state (per host, 0600) |
RTSP / media export
| Variable | Default | Meaning |
|---|---|---|
VIGI_NVR_RTSP_PORT |
554 |
RTSP/ONVIF port probed by nvr_get_rtsp_status |
VIGI_NVR_RTSP_USERNAME |
(NVR username) | RTSP account; defaults to VIGI_NVR_USERNAME |
VIGI_NVR_RTSP_PASSWORD |
(NVR password) | RTSP password; defaults to VIGI_NVR_PASSWORD |
VIGI_NVR_EXPORT_DIR |
~/.local/share/vigi-nvr-mcp/exports |
Clip/snapshot output (dir 0700) |
VIGI_NVR_EXPORT_MAX_MINUTES |
60 |
Longest export window, 1–1440 |
VIGI_NVR_EXPORT_RETENTION_DAYS |
7 |
Age after which nvr_purge_exports deletes a clip, 1–3650 |
VIGI_NVR_FFMPEG |
(PATH) | Path to ffmpeg if not on PATH (ffprobe is found beside it) |
MCP server
| Variable | Default | Meaning |
|---|---|---|
VIGI_MCP_TRANSPORT |
stdio |
stdio or streamable-http |
VIGI_MCP_HOST |
127.0.0.1 |
IP literal to bind for HTTP (keep it loopback) |
VIGI_MCP_PORT |
8765 |
HTTP port |
VIGI_MCP_LOG_LEVEL |
INFO |
Log level; logs go to stderr |
Any unknown VIGI_NVR_* / VIGI_MCP_* variable is rejected at startup (a typo
must not silently leave a write gate or TLS verification in an unintended state).
Running
stdio (Claude Desktop / Claude Code)
{
"mcpServers": {
"vigi-nvr": {
"command": "/path/to/.venv/bin/vigi-nvr-mcp",
"env": { "VIGI_NVR_HOST": "192.0.2.1", "VIGI_NVR_PASSWORD": "" }
}
}
}
streamable-HTTP (hosting)
VIGI_MCP_TRANSPORT=streamable-http vigi-nvr-mcp # http://127.0.0.1:8765/mcp
[!IMPORTANT] The server has no authentication of its own. Keep it bound to loopback and reach it over Tailscale or an SSH tunnel — never bind it to the LAN. A hardened systemd example and install guide are in
deploy/.
vigi-nvr-mcp --list-tools prints the tool names without touching any device.
Safety model
The whole point of this server is to be safe to hand to an autonomous agent.
- Reads are free. Any
getcall runs without extra gates. - Writes are double-gated. Every mutating call needs both
VIGI_NVR_ALLOW_WRITES=trueon the server andconfirm_write: true(the literal JSON boolean) on the call. Theconfirm_writeparameter is typed, so a truthy string or number ("true","1",1,"yes") is collapsed tofalseat the MCP boundary and refused with aWRITE_REFUSEDenvelope and no network call — it is never coerced through the gate. Channel writes add three more checks:expected_uuidmust match the live row (re-read just before writing); remove refuses a live (online=="1") row unlessforce=true; move refuses an occupied target. Guarded writes are serialised so two cannot interleave. - Dry-run.
VIGI_NVR_DRY_RUN=truemakes every write return{"dry_run": true, "request": }and send nothing. Dry-run lives in the one guarded-write executor every mutating tool shares, so no write path can forget it. - Lockout breaker. A failed login is never retried. After
VIGI_NVR_MAX_LOGIN_FAILURESfailures (default 1) the breaker trips: every failure/success is reserved-then-recorded under a cross-process lock in a per-host JSON file underVIGI_NVR_STATE_DIR(mode 0600), so even a crash-loop with bad credentials cannot keep spending the device’s login budget. “Tripped” means a sticky flag is set; once tripped, no login is sent until a human clears it — raising the budget afterwards does not silently un-trip it. Inspect or clear it:
A corrupt or unwritable state file fails closed (refuses logins).vigi-nvr-mcp breaker --show # state, failures, whether it is tripped vigi-nvr-mcp breaker --clear # reset after you have fixed the credentialsVIGI_NVR_LOGIN_DISABLED=truefreezes authentication before any network I/O. The credential-free challenge/reachability probe stays allowed either way. - TLS pinning (TOFU). Set
VIGI_NVR_TLS_FINGERPRINT_SHA256to pin the NVR’s self-signed certificate; the pin is enforced on this server’s own connection and fails closed on mismatch. On an unpinned connection,nvr_status/--check-authreport the observed fingerprint (tls_fingerprint_observed) so you can trust-on-first-use and paste it back into your config. - Redaction. Every tool result is recursively stripped of credential-bearing
fields by a rule-based matcher (keys equal to
ciphertext, stok, token, nonce, cookie, secret, authorization, pubkey, key, or containingpass/pwd/secret/token/cipher, or ending_key). No tool can return camera or session credentials. Request bodies are never logged; session tokens are masked in every log line, including httpx’s own. - Exit codes (so a shell script can branch): 0 success · 1 auth failed · 2 config error · 3 lockout / breaker open / login disabled · 4 transport error.
Tools
Every tool returns {"success": bool, "data": ..., "error": {code, message, details} | null}. “Kind” marks whether a tool mutates; a write needs both
write gates.
Status & auth
| Tool | Kind | Description |
|---|---|---|
nvr_status |
read | Healthcheck: reachability, offered auth scheme, lockout counters, session state, safety policy, observed TLS fingerprint. Never logs in |
nvr_auth_status |
local | Session state and last-failure counters. No I/O |
nvr_login |
auth | Explicit single login attempt (never retried) |
Catalog gateway
| Tool | Kind | Description |
|---|---|---|
nvr_list_modules |
read | Every API module with its call/mutating counts |
nvr_list_calls |
read | Catalogued calls for one module |
nvr_describe_call |
read | One call’s spec: example params, whether it mutates, response-shape hint |
nvr_call |
read/write | Catalogued gateway (module, method, key, params); login/user_management always refused |
nvr_raw_call |
read/write | Off-catalog escape hatch (method, module, params); write-gated unless get |
Device & network
| Tool | Kind | Description |
|---|---|---|
nvr_get_device_info |
read | Model, firmware, identity |
nvr_get_module_spec |
read | Capability spec: max channels, codecs, feature flags |
nvr_get_system_info |
read | Device name, time zone, session timeout |
nvr_get_network_info |
read | IP/mask/gateway/DNS, service ports |
nvr_get_video_resolutions |
read | Main/minor stream resolution tables |
nvr_get_video_config |
read | Per-channel resolution, codec, bitrate |
nvr_get_image_config |
read | Image, OSD, privacy-mask, ROI config for one channel |
nvr_get_detection_config |
read | Detection config for a channel and detection kind |
nvr_get_users |
read | User account names and groups only (never credentials) |
nvr_get_firewall |
read | Allow/deny lists and service exposure |
nvr_get_cloud_status |
read | TP-Link ID binding/connection status |
nvr_get_time |
read | Device time, time zone, NTP, DST |
Channels
| Tool | Kind | Description |
|---|---|---|
nvr_list_channels |
read | All bound cameras (chm added_dev); credentials redacted |
nvr_get_channel |
read | One channel by id |
nvr_find_duplicate_channels |
read | Group by device uuid; flag offline/disconnected rows as stale ghosts |
nvr_plan_channel_cleanup |
read | Ordered, resumable cleanup plan (back up, remove ghosts, re-home real cameras) |
nvr_backup_config |
read | Download the config backup (download_conf); not write-gated, so run it before any cleanup |
nvr_remove_channel |
write | Unbind a channel (chm_del_dev); refuses a live row unless force=true |
nvr_move_channel |
write | Move a binding to an empty slot (chm_mod_dev_chn), keeping its settings |
nvr_set_channel_credentials |
write | Re-authenticate a bound camera by pushing a username + RSA-encrypted password (chm_edit_dev); polls until it reconnects |
nvr_renumber_channel |
write | Re-point a bound channel to a new camera IP (delete + re-add + rename, since chm_edit_dev ignores ip); DESTRUCTIVE and the channel id changes. Camera must already be reachable at the new IP |
Storage & recording
| Tool | Kind | Description |
|---|---|---|
nvr_list_disks |
read | Installed hard disks and state (raw reply) |
nvr_get_storage |
read | Disks (status/capacity/free/health) plus overwrite policy |
nvr_get_recording_status |
read | Recording/storage policy status |
nvr_list_recording_segments |
read | Typed recording timeline for a channel/day (nvr_search_recordings is an alias) |
nvr_search_recordings |
read | Alias of nvr_list_recording_segments |
nvr_list_events |
read | Best-effort recent events/alerts (disk, network, video-loss, smart) |
Media / export
| Tool | Kind | Description |
|---|---|---|
nvr_get_rtsp_status |
read | Whether ONVIF/RTSP is enabled, plus a TCP probe of the RTSP port |
nvr_enable_rtsp |
write | One-time ONVIF/RTSP enable (onvif_server set) |
nvr_get_stream_url |
read | Redacted live RTSP URL + which env vars hold the credentials |
nvr_export_clip |
write (local) | Export a replay window to mp4 (-c copy; target_max_mb/max_width re-encode to fit) |
nvr_snapshot |
read | One JPEG frame from a live stream into the export dir |
nvr_list_exports |
read | List exports with age and next-purge time |
nvr_delete_export |
write (local) | Delete one export by name (path-confined) |
nvr_get_export |
read | Return a file’s content as base64 (≤ max_mb, else TOO_LARGE) |
nvr_purge_exports |
write (local) | Delete exports older than the retention window (double-gated, dry-run) |
Investigation
| Tool | Kind | Description |
|---|---|---|
nvr_list_motion_windows |
read | Merged, de-duplicated activity windows across the timeline and event logs |
nvr_contact_sheet |
read | One JPEG grid of time-stamped frames across a window, with a tile→time map |
nvr_sample_frames |
read | Individual JPEG frames at an interval across a window |
The export directory is also exposed as MCP resources (exports://,
blob content, path-confined) for clients that support them.
Recipes
Placeholder names (Front Door, dates, channel numbers) stand in for yours.
Channel cleanup (remove ghosts, re-home cameras)
Over time a VIGI NVR accumulates ghost channels — stale, offline duplicates of a camera that was re-added or moved. This cleans them up safely.
- Back up first (not write-gated, so always safe):
nvr_backup_config - See the duplicates. Groups channels by device UUID and flags the ghosts:
nvr_find_duplicate_channels - Get an ordered plan. Read-only; produces a resumable step list (ghosts
first, then moves, one at a time, re-read after each):
nvr_plan_channel_cleanup - Dry-run the plan. With
VIGI_NVR_DRY_RUN=true, run each step’s tool to see the exact request body without sending it. - Execute one step at a time, re-listing after each and stopping on any
surprise (needs
VIGI_NVR_ALLOW_WRITES=true):nvr_remove_channel(channel_id=13, expected_uuid="", confirm_write=true) nvr_move_channel(from_id=9, to_id=1, expected_uuid="", confirm_write=true) - Verify:
nvr_list_channelsandnvr_get_recording_status.
Footage investigation (“find when a car came to the front door yesterday”)
The MCP is the data plane; your agent (with vision and a mail tool) does the looking, deciding and sending.
- Find activity windows from the NVR’s own timeline, not raw video:
Returns mergednvr_list_motion_windows(channel="Front Door", date="2026-10-02", kinds=["motion","smart","alarm"])[{channel, name, start, end, duration_s, kinds, sources}]. - Look at each window cheaply. One contact sheet per window; each tile has
its exact time burned in, so a hit can be cited to the second:
nvr_contact_sheet(channel=5, start="2026-10-02T14:03:00Z", end="2026-10-02T14:05:00Z", cols=4, rows=3) - Pin the moment. On a hit, pull individual frames:
nvr_sample_frames(channel=5, start="2026-10-02T14:03:40Z", end="2026-10-02T14:04:10Z", every_s=1) - Export a mail-sized clip (re-encode to fit, e.g. 20 MB under Gmail’s
~25 MB cap; needs
VIGI_NVR_ALLOW_WRITES=true):nvr_export_clip(channel=5, start="2026-10-02T14:03:50Z", end="2026-10-02T14:04:05Z", target_max_mb=20, confirm_write=true) - Retrieve and send. Fetch the bytes (or read the
exports://resource) and hand them to the agent’s mail tool:nvr_get_export(name="ch5_...mp4", max_mb=20)
Exports live on the server host (VIGI_NVR_EXPORT_DIR), not on the agent’s
machine; nvr_get_export and the exports:// resources are how a remote agent
pulls them. Housekeeping runs through nvr_purge_exports.
Limitations
- Local-monitor layout is not exposed. The NVR’s own HDMI/VGA display grid and view layout are not reachable through this server.
- Video is delivered over RTSP, not JSON. Clips and frames come from the
RTSP endpoints via
ffmpeg; there is no JSON “give me the video” call. The media tools therefore needffmpeg/ffprobe. - RTSP credentials are visible to local
ps.ffmpegreceives the RTSP URL with credentials in its userinfo, so they appear in that process’s command line on the host running this server. Every URL the server returns or logs is redacted, but the plaintext is unavoidable in theffmpeginvocation — use a dedicated viewer account and a single-admin box. - Some response shapes are
INFERREDuntil a first live run. These were derived statically from the 1.1.3 web client and have not yet been confirmed against a live device; the parsers return aPROTOCOL_ERRORenvelope (never a crash) on an unexpected shape:nvr_list_recording_segments/nvr_search_recordings(theplaybacksearch timeline)nvr_list_events(event/alarm lists)- the merge inputs for
nvr_list_motion_windowsThe channel and backup primitives, auth, and the catalog counts are verified.
Troubleshooting
-40401everywhere. This one code means three things depending on the path: the credential-free challenge reply (normal), a failed login (with a rising attempt counter), or an expired session token on/stok=.../ds(the client re-logs in once automatically). If--check-auth --loginreturns-40401withattempts_leftdropping, the password is wrong — stop before you hit the lockout.- Account locked (
-40404/-40408). Too many failed logins. Wait out the lock (or power-cycle per your firmware), fix the password, thenvigi-nvr-mcp breaker --clear.-40408means locked until manual intervention. - Nonce invalid (
-40410). The challenge nonce went stale between fetch and login; fetch a fresh challenge and try once more. - Logins refused with no network call. The breaker is tripped or login is
disabled. Run
vigi-nvr-mcp breaker --show; clear it withbreaker --clearafter fixing credentials, and checkVIGI_NVR_LOGIN_DISABLED. - RTSP tools fail or return nothing. Check
nvr_get_rtsp_status: if RTSP is off or port 554 is closed, runnvr_enable_rtsp(confirm_write=true)once. If tools returnMEDIA_UNAVAILABLE,ffmpeg/ffprobeare not found — install them or setVIGI_NVR_FFMPEG. An empty replay window surfaces asNO_FOOTAGE_IN_WINDOW. - Login scheme mismatch. If
--check-authshows anencrypt_typethis server does not implement, your firmware uses a different login scheme (e.g. SHA-256). See docs/protocol/vigi-nvr.md.
Prior art
tools/capture-login/ is an optional, isolated, one-time dev utility (Node +
puppeteer-core) that records the login envelopes the vendor web UI itself sends,
on a device you own. It is not part of the server — the server imports nothing
from it and runs no browser. See its README.
Development
pip install -e '.[dev]'
python scripts/gate.py # ruff, pytest + coverage (core >= 90%, package >= 80%),
# secret scan, stub scan, --list-tools; prints PASS/FAIL
- All tests run against mocks; nothing touches a device. The gate must print PASS before every commit.
vigi_nvr_mcp/core/is a self-contained, device-agnostic template that sibling projects copy verbatim; acore/VERSIONmanifest plustests/test_core_identity.pyfail the gate if a copy drifts. Editcore/only in this repo (see CONTRIBUTING.md).- Design docs: the specs (master plan, NVR spec, breaker protocol, breaker root-cause) and the wire protocol.
- Security reporting and the threat model: SECURITY.md. Release history: CHANGELOG.md.
License
MIT — see LICENSE. Not affiliated with or endorsed by TP-Link. “TP-Link” and “VIGI” are trademarks of their owner.
Install
This server does not publish a one-line install command.
Open the repository installation guideConfiguration
{
"mcpServers": {
"vigi-nvr": {
"command": "/path/to/.venv/bin/vigi-nvr-mcp",
"env": { "VIGI_NVR_HOST": "192.0.2.1", "VIGI_NVR_PASSWORD": "<password>" }
}
}
}