Skip to main content
Glama
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.