axis-camera-mcp
by cdp
README.md
# axis-camera-mcp
An MCP (Model Context Protocol) server that lets an LLM (Claude Code/Desktop) talk
to an Axis network camera over VAPIX (Axis's HTTP/JSON camera API): view the live
stream in VLC, control pan/tilt/zoom, adjust image sensor settings (brightness,
exposure, gain, white balance), capture snapshots, add text/image overlays,
get/set the clock, manage local camera user accounts, and control the light.
Built and live-verified against an **AXIS M3057-PLVE Mk II** on a local network.
See `docs/live-verification-checklist.md` for exactly what's been tested live vs.
not, and `docs/open-questions.md` for a confirmed limitation of this specific camera:
PTZ commands succeed and the reported position updates, but produce no real visual
pan/tilt/zoom effect on this fixed-camera model.
## Architecture
```
axis_camera_mcp/
config.py Loads camera host/username/password from config.json
vapix/
session.py requests.Session + HTTP Digest auth
jsonrpc.py Client for VAPIX's modern JSON-RPC-style APIs
legacy.py Client for VAPIX's legacy query-param/plain-text CGIs
client.py VapixClient facade - the only thing tools/* imports
exceptions.py
vlc_launcher.py Starts/stops the local VLC install to view the RTSP stream
rag/ Local knowledge base: VAPIX docs + this project's own
live-verified findings, hybrid BM25+embedding search
(see "Knowledge base (RAG)" below)
app.py / context.py Shared FastMCP instance + lazy VapixClient/VlcLauncher/
HybridRetriever singletons
tools/ One module per capability, each registering @mcp.tool()s
server.py Entrypoint: import tools, preload_rag(), mcp.run()
tests/
test_*.py Unit tests, mocked HTTP, no live camera needed
manual/ Plain scripts (not pytest) exercised against the real camera
docs/
live-verification-checklist.md One row per tool - what's actually been tested live
open-questions.md Confirmed limitations and things not yet tried
```
Two VAPIX request styles exist side by side on this camera, and the client layer
handles both without leaking the difference into tool code:
- **Modern JSON-RPC style** (`api-discovery`, `basicdeviceinfo`, `streamprofile`,
`lightcontrol`, `time.cgi`, `dynamicoverlay`): POST JSON body
`{"apiVersion", "method", "params"}`, response `{"data"}` or `{"error"}`.
- **Legacy query-param/plain-text CGIs** (`com/ptz.cgi`, `com/ptzconfig.cgi`,
`pwdgrp.cgi`, `usergroup.cgi`, `jpg/image.cgi`): plain GET with query params,
responses are plain text (sometimes `key=value` lines) or, for the snapshot
endpoint, raw JPEG bytes.
## Setup
Credentials live in a config file, never in code or environment variables:
```powershell
copy config.example.json config.json
notepad config.json # fill in host/username/password
```
`config.json` is gitignored - it never gets committed.
```powershell
py -m venv .venv
.venv\Scripts\python -m pip install -e ".[dev]"
```
## Running
```powershell
.venv\Scripts\python -m axis_camera_mcp.server
```
## Knowledge base (RAG)
A local, offline knowledge base lets the LLM search the real VAPIX API docs and this
project's own hard-won, live-verified findings (undocumented required fields, real
parameter values confirmed only through live trial-and-error, etc.) instead of
guessing or re-deriving them each time.
**All open-source, all local** - no paid API, no vector-DB service:
- **Crawler** (`rag/crawler.py`): plain `requests` + BeautifulSoup against
`developer.axis.com` (a server-rendered Docusaurus site - no headless browser
needed), plus this project's own README/docs/`vapix/*.py` as local sources.
- **Chunking** (`rag/chunker.py`): paragraph-aware, ~700 chars with 100 overlap.
- **Retrieval** (`rag/retriever.py`): **hybrid** - BM25 (`rank-bm25`, exact keyword
matches like `streamProfileName`) and dense embeddings (`sentence-transformers`,
`all-MiniLM-L6-v2`, CPU-only, for paraphrased questions), combined via reciprocal
rank fusion. A plain numpy `.npz` file serves as the vector store (a few hundred
chunks makes brute-force cosine search microseconds-scale - not worth a real
vector-DB dependency).
- **Tracking** (`rag/tracking.py`): a local SQLite file logs every ingestion run
(per-source chunk/char counts, ok/error status) and every query (text, results,
scores) - inspectable directly or via the `rag_stats` tool.
**Setup**: `sentence-transformers` pulls in `torch`, a genuinely heavy install. On a
CPU-only machine, install the CPU-only wheel explicitly first to keep it much
smaller than the default resolved build:
```powershell
.venv\Scripts\python -m pip install torch --index-url https://download.pytorch.org/whl/cpu
.venv\Scripts\python -m pip install -e ".[dev]"
```
**Build/refresh the index** (a separate, manually-run step - never runs automatically
at server startup, so startup stays fast and network-call-free):
```powershell
.venv\Scripts\python -m axis_camera_mcp.rag.ingest
```
Edit `axis_camera_mcp/rag/sources.json` to add/remove doc pages or local files, then
re-run the command above. The embedding model (~80MB) downloads once from Hugging
Face Hub on first run and is cached afterward; everything else is fully offline.
Generated index files (`rag/data/*.jsonl`, `*.npz`, `*.sqlite3`) are gitignored.
Query it with the `search_knowledge` and `rag_stats` tools (see Tool reference below).
### Register with Claude Code/Desktop
```json
{
"mcpServers": {
"axis-camera": {
"command": "<path-to-repo>\\.venv\\Scripts\\python.exe",
"args": ["-m", "axis_camera_mcp.server"]
}
}
}
```
Restart Claude Code/Desktop after adding this for the tools to appear.
## Testing
```powershell
.venv\Scripts\python -m pytest tests/ --ignore=tests/manual # unit tests, mocked HTTP
.venv\Scripts\python tests/manual/live_smoke_test.py # read-only checks against the real camera
```
The `tests/manual/` scripts talk to the real camera in `config.json` - review what
each one does before running it against a camera you care about. `live_smoke_test.py`
is read-only. The `mcp_harness_*.py` scripts exercise the full MCP tool-call path
(including mutations like PTZ moves, clock/timezone changes, and user add/remove) -
these were this project's own live-verification scripts; treat them as reference,
not something to re-run casually against a production camera.
## Tool reference
**Device/API info** (read-only): `get_device_info`, `get_supported_apis`, `get_camera_overview`
**Streaming** (view the live stream in the local VLC install): `start_stream`,
`stop_stream`, `get_stream_status`, `list_stream_profiles`
**Snapshot** (single still frame, separate from the video stream): `save_snapshot`
(file only, the LLM never sees the image), `get_snapshot` (returned directly to the
LLM so it can describe what's in view)
**PTZ** (pan/tilt/zoom - see `docs/open-questions.md` re: no visual effect on
this specific camera): `ptz_move_absolute`, `ptz_move_relative`, `ptz_get_position`,
`ptz_save_preset`, `ptz_goto_preset`, `ptz_list_presets`
**Image sensor** (brightness/contrast/saturation/sharpness, exposure, gain,
shutter, white balance - the actual sensor/ISP settings, distinct from PTZ's
framing/crop and light's physical illuminator): `get_image_settings`,
`set_image_appearance`, `set_exposure_mode`, `set_exposure_value`,
`set_manual_gain`, `set_manual_shutter`, `set_white_balance`
**Overlays** (text/image burned into the video stream): `add_text_overlay`,
`set_text_overlay`, `add_image_overlay`, `set_image_overlay`, `list_overlays`,
`remove_overlay`, `get_overlay_capabilities`
**Clock**: `get_clock_info`, `set_clock`, `set_timezone`
**Light** (this specific camera has no light hardware - confirmed via
`get_light_status`, which reports that clearly): `get_light_status`,
`activate_light`/`deactivate_light`, `enable_light`/`disable_light`,
`set_light_intensity`, `get_valid_intensity`, `set_light_auto_mode`
**Users** - mutations require `confirm: true`, the one area with real security
consequence: `list_users` (returns every account with its privilege groups, for a
privileged caller), `add_user`, `update_user`, `remove_user`
**Knowledge base** (see "Knowledge base (RAG)" above): `search_knowledge` (hybrid
BM25+embedding search over VAPIX docs and this project's own live-verified findings,
with source citations and scores), `rag_stats` (what's indexed, from where, when,
and recent query history - states plainly if the index needs `rag.ingest` run)
## Safety
User-account mutations (`add_user`, `update_user`, `remove_user`) require an explicit
`confirm: true` tool argument and raise a clear error without it - this is the one
area where a careless call has real security consequence (unauthorized access or an
accidental lockout). Everything else (PTZ, light, clock, streaming, overlays, snapshots)
is unconfirmed by design: it's freely reversible and this is a single camera the user
owns directly, not a shared production system.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues