AXIS MCP Server
by kotyzap
README.md
# EdgeMCP for Axis (on-camera ACAP)
A standalone ACAP (`.eap`) that runs a **Model Context Protocol server directly on an Axis camera**
(ARTPEC-8, AXIS OS 12.10–13 — e.g. Q1656). It exposes VAPIX-backed tools over **Streamable HTTP** so
Claude Desktop / Claude Code can connect to the camera and inspect/control it.
It speaks **both MCP protocol revisions from one endpoint**: 2025-era clients over the stateless legacy
path, and **2026-07-28** clients — which dropped the `initialize` handshake and protocol sessions
entirely — natively, including `server/discover` and cacheable `tools/list` results.
Built with the ACAP **Native SDK** (Node.js 20 bundled into the package). No Docker runs on the camera —
Docker is only used to *build* the `.eap` on your Mac.
> **2.0.0 removed the on-camera agent.** 1.x bundled a 45M language model, speech-to-text and a neural
> voice alongside the MCP server — 89 % of the download and nine of the thirteen endpoints, inside a
> product called EdgeMCP. This is an MCP server and nothing else. The agent continues as its own
> project; its source was moved to `Needle LLM on AXIS/src-extracted-from-edgemcp/`.
>
> The package id changed with it, `needle_mcp` → `edgemcp`, so existing installs are not upgraded in
> place and client URLs move to `/local/edgemcp/mcp`.
**Links:** [Project page](https://kotyzap.github.io/EdgeMCP-for-Axis/) ·
[Releases (prebuilt .eap)](https://github.com/kotyzap/EdgeMCP-for-Axis/releases)
## Layout
```
EdgeMCP-for-Axis/
├── Dockerfile # ACAP Native SDK build (aarch64)
├── build.sh # docker build + docker cp -> .eap
├── dev-loop.sh # fast iteration: build, deploy, ask, tail
├── release.sh # build + verify + notes -> releases/
└── app/
├── manifest.json # schemaVersion 2.2.0; appName edgemcp
├── edgemcp # launcher (filename == appName)
├── Makefile # no-op (acap-build runs make)
├── LICENSE
├── package.json # main: dist/bootstrap.js; `npm test` runs the suite
├── tsconfig.json
├── test/ # node --test: guardrail/annotation invariants,
│ # manifest schema-v2 checks, and both protocol
│ # eras exercised over real HTTP
├── src/
│ ├── bootstrap.ts # HTTP servers: /mcp, /status.cgi, /settings.cgi;
│ │ # createMcpHandler per listener, origin guard
│ ├── mcpServer.ts # tool catalog (built once) + per-request server
│ ├── guardrails.ts # access levels, per-caller rate limits,
│ │ # allow-lists, and the tool annotations
│ ├── version.ts # app version, read from package.json
│ ├── vapix.ts # Digest auth client (MD5 + SHA-256)
│ ├── settings.ts # persisted config (PERSISTENT_DATA_PATH)
│ └── tools/ # device, imaging, events, apps, system, ptz,
│ # io, storage, streaming, camstreamer,
│ # camswitcher, camoverlay, aoa, lpv, vmd4,
│ # queuemonitor, guardApps, legacyAnalytics,
│ # demographics, peoplecounter, overlay,
│ # zipstream, viewareas, imagetuning, ptzAdvanced,
│ # diagnostics, netops, streamingApis, audio,
│ # deviceConfig, recordingPipeline, pairing,
│ # dataHub, eventPull (shared ONVIF helper),
│ # controlCgiAcapFactory + legacyAcapFactory
│ # (shared ACAP factories) + deviceConfigApi
│ # (shared REST helper)
└── html/
└── index.html # settings UI (light default + theme switcher)
```
## Requirements
| | |
|---|---|
| Camera | ARTPEC-8 (or ARTPEC-7 for the armv7hf build) |
| AXIS OS | **12.10 – 13** — the floor comes from ACAP manifest schema v2, which the package uses because AXIS OS 13 requires it |
| Build SDK | ACAP Native SDK **12.11.0** (schema 2.2.0 needs 12.11; older SDKs reject the manifest) |
| Runtime | Node.js 20, bundled into the package |
Cameras below AXIS OS 12.10 are not supported. That is a consequence of schema v2, not a choice —
schema v2 is mandatory for AXIS OS 13, and an ACAP that does not declare `compatibleOsVersions` is
rolled back when the device is upgraded.
## Protocol support
| | 2025-era client | 2026-07-28 client |
|---|---|---|
| Handshake | `initialize` | none — `server/discover` |
| Sessions | stateless | removed from the spec |
| `tools/list` | plain result | `resultType`, and a **1-hour cache hint** so the list is not re-sent every turn |
| Served by | the SDK's stateless legacy path | natively |
Both arrive at the same `/mcp` endpoint; nothing needs configuring per client.
## Tools (269 across 34 modules)
| Tool | VAPIX |
|---|---|
| `get_device_info` / `get_device_properties` | basicdeviceinfo.cgi (getAllProperties / getProperties) |
| `get_system_status` | param.cgi (Network/Brand/Firmware) + temperaturecontrol.cgi |
| `get_time` | time.cgi |
| `get_view_areas` | viewarea/info.cgi (param.cgi fallback) |
| `take_snapshot` | jpg/image.cgi → MCP image content |
| `get_image_settings` / `set_image_settings` | param.cgi ImageSource/Image |
| `get_optics` | opticscontrol.cgi getOptics (ids + capabilities) |
| `autofocus` | cascade: opticscontrol.cgi performAutofocus → startFocusSearch → opticssetup.cgi → ptz.cgi?autofocus=on (caches the working method) |
| `set_zoom_focus` | opticscontrol.cgi setMagnification / autofocus cascade |
| `list_event_declarations` | SOAP GetEventInstances |
| `get_analytics_status` | applications/list.cgi (filtered) |
| `aoa_get_scenarios` | /local/objectanalytics/control.cgi getConfiguration |
| `aoa_get_object_counts` | /local/objectanalytics/control.cgi getOccupancy / getAccumulatedCounts |
| `aoa_get_triggered_alerts` | SOAP pull-point subscription (CreatePullPointSubscription / PullMessages) filtered to ObjectAnalytics topics |
| `list_apps` / `control_app` | applications/list.cgi, control.cgi |
| `get_params` / `set_param` | param.cgi (writes allowlisted) |
| `get_api_list` / `get_capabilities` | apidiscovery.cgi, param.cgi Properties.API/PTZ |
| `get_firmware_properties` | firmwaremanagement.cgi getProperties |
| `reboot_camera` / `factory_default` | firmwaremanagement.cgi (legacy CGI fallback); both require `confirm: true` |
| `ptz_move` / `ptz_relative_move` / `ptz_absolute_move` / `ptz_continuous_move` | com/ptz.cgi |
| `ptz_query` / `ptz_capabilities` | com/ptz.cgi?query= / ?info=1 |
| `ptz_preset_list` | com/ptz.cgi?query=presetposcamdata (falls back to presetposcam) — presets grouped per view area, with pan/tilt/zoom when reported |
| `ptz_preset_goto` / `ptz_preset_save` / `ptz_preset_remove` | com/ptz.cgi presets |
| `io_set_output` / `io_get_inputs` | io/output.cgi (word + symbol action fallback), io/port.cgi |
| `storage_list_recordings` / `storage_start_recording` / `storage_stop_recording` | record/list.cgi, record/record.cgi |
| `get_rtsp_url` / `get_mjpeg_url` / `get_audio_urls` | URL builders (no request) |
| `get_audio_config` | param.cgi AudioSource/AudioDevice/AudioOutput |
### CamStreamer suite (require the respective ACAP installed)
| Tool | Endpoint |
|---|---|
| `camstreamer_list_streams` | /local/camstreamer/stream_list.cgi (param-tree fallback) |
| `camstreamer_stream_status` | /local/camstreamer/get_streamstat.cgi |
| `camstreamer_control_stream` | /local/camstreamer/set_stream_enabled.cgi (start/stop) |
| `camswitcher_list_playlists` | /local/camswitcher/playlists.cgi?action=get |
| `camswitcher_switch_playlist` | /local/camswitcher/playlist_switch.cgi |
| `camswitcher_queue_playlist` | /local/camswitcher/playlist_queue_push.cgi |
| `camswitcher_get_queue` / `camswitcher_play_next` / `camswitcher_clear_queue` | playlist_queue_*.cgi |
| `camswitcher_output_info` / `camswitcher_list_clips` | output_info.cgi, clips.cgi |
| `camoverlay_list_services` | /local/camoverlay/api/services.cgi |
| `camoverlay_set_service_enabled` | services.cgi?action=set |
| `camoverlay_update_graphic_text` | customGraphics.cgi?action=update_text |
| `camoverlay_infoticker` | infoticker.cgi |
These call the CamStreamer/CamSwitcher/CamOverlay CGIs on the same camera using the same digest auth.
If the corresponding ACAP isn't installed, the tool returns a clear "not installed" error.
### Analytics ACAPs (require the respective ACAP installed)
| Tool | Endpoint |
|---|---|
| `lpv_get_push_config` | /local/fflprapp/cloud[N].cgi (flat XML, base64 url/password decoded) |
| `lpv_get_heartbeat_config` | /local/fflprapp/config_hb.cgi |
| `lpv_get_recent_plates` | SOAP pull-point subscription filtered to ALPV.* topics (listens a few seconds — most ALPV events are one-shot, not stateful) |
| `lpv_get_plate_image` | /local/fflprapp/tools.cgi?action=getImage → MCP image content |
| `vmd4_get_profiles` | /local/vmd/control.cgi getConfiguration |
| `vmd4_get_triggered_profiles` | SOAP pull-point subscription filtered to CameraApplicationPlatform/VMD/ topics |
| `queue_get_live_count` | /local/queue/.api?live-sum-people.json |
| `queue_get_params` | /local/queue/.api?params.json |
| `queue_get_history` | /local/queue/.api?export-csv-minutes / export-csv-people (CSV parsed to rows) |
`aoa_get_triggered_alerts`, `vmd4_get_triggered_profiles` and `lpv_get_recent_plates` all share one ONVIF
pull-point helper (`tools/eventPull.ts`) — a stateful event topic (AOA/VMD4's "active" flag) reports its
current value immediately on subscribe, so a short-lived subscription doubles as a one-shot state read.
ALPV plate events are mostly stateless pulses, so that tool is a genuine "listen for N seconds" call instead.
### Analytics ACAP siblings (Fence/Loitering/Motion Guard, legacy VMD/tracking apps, people counting)
| Tool | Endpoint |
|---|---|
| `fenceguard_get_profiles` / `fenceguard_get_triggered_profiles` | /local/fenceguard/control.cgi (control.cgi factory) |
| `loiteringguard_get_profiles` / `loiteringguard_get_triggered_profiles` | /local/loiteringguard/control.cgi |
| `motionguard_get_profiles` / `motionguard_get_triggered_profiles` | /local/motionguard/control.cgi |
| `crosslinedetection_get_config` / `crosslinedetection_get_triggered` | /axis-cgi/vaconfig.cgi (legacy XML; stateless event, listens a few seconds) |
| `digitalautotracking_get_config` / `digitalautotracking_get_triggered` | /axis-cgi/vaconfig.cgi (legacy XML; stateful) |
| `vmd21_get_config` / `vmd21_get_triggered` | /axis-cgi/vaconfig.cgi (legacy XML; stateful) — deprecated, superseded by VMD4 |
| `vmd3_get_config` / `vmd3_get_triggered` | /axis-cgi/vaconfig.cgi (legacy XML; stateful) — deprecated, superseded by VMD4 |
| `demographics_get_live_tracks` / `demographics_get_ended_tracks` / `demographics_get_live_and_ended_tracks` / `demographics_get_stats` | /local/demographics/.api |
| `peoplecounter_get_live_count` / `peoplecounter_get_line_position` / `peoplecounter_get_available_days` / `peoplecounter_get_history` / `peoplecounter_get_occupancy_history` | /local/tvpc/.api |
| `p8815_get_live_occupancy` / `p8815_get_foot_traffic_history` / `p8815_get_occupancy_history` | /a3dpc/api/... (AXIS P8815-2 3D People Counter) |
The Fence/Loitering/Motion Guard trio shares `tools/controlCgiAcapFactory.ts` (same control.cgi JSON-RPC
shape as VMD4/AOA). The four legacy apps (Cross line detection 1.1, Digital autotracking, VMD 2.1, VMD 3)
share `tools/legacyAcapFactory.ts` — their config is arbitrarily-nested XML, so the config tools return raw
XML rather than guessing at a generic nested mapping.
### Overlay / Zipstream / View Area (native VAPIX, no extra ACAP required)
| Tool | Endpoint |
|---|---|
| `overlay_list` / `overlay_get_capabilities` | /axis-cgi/dynamicoverlay/dynamicoverlay.cgi (list, getOverlayCapabilities) |
| `overlay_add_text` / `overlay_add_image` / `overlay_set_text` / `overlay_set_image` / `overlay_remove` | dynamicoverlay.cgi (addText/addImage/setText/setImage/remove) |
| `zipstream_get_status` | /axis-cgi/zipstream/getstatus.cgi |
| `zipstream_list_strengths` / `zipstream_set_strength` | liststrengths.cgi / setstrength.cgi |
| `zipstream_list_gop_modes` / `zipstream_set_gop` | listgopmodes.cgi / setgop.cgi |
| `zipstream_list_fps_modes` / `zipstream_set_fps_mode` / `zipstream_set_min_fps` | listfpsmodes.cgi / setfpsmode.cgi / setminfps.cgi |
| `zipstream_list_profiles` / `zipstream_set_profile` | listprofiles.cgi / setprofile.cgi |
| `viewarea_list` / `viewarea_set_geometry` / `viewarea_reset_geometry` | /axis-cgi/viewarea/info.cgi, configure.cgi |
### Camera / image tuning
| Tool | Endpoint |
|---|---|
| `daynight_get_capabilities` / `daynight_get_configuration` / `daynight_set_configuration` | /axis-cgi/daynight.cgi |
| `light_get_information` / `light_get_status` / `light_set_active` / `light_set_intensity` / `light_get_current_intensity` | /axis-cgi/lightcontrol.cgi |
| `imagestab_get_capabilities` / `imagestab_get_configuration` / `imagestab_set_configuration` | /axis-cgi/imagestabilization.cgi (EIS/OIS) |
| `imagerotation_get` / `imagerotation_set` | param.cgi ImageSource.I#.{Rotation,AutoRotationEnabled} |
| `orientation_get` | /axis-cgi/orientation/getlongitudinalvalue.cgi + getlateralvalue.cgi (gyro/accelerometer) |
| `ratecontrol_get` / `ratecontrol_set` | param.cgi Image.I#.RateControl (VBR/MBR/ABR) |
| `capturemode_get_modes` / `capturemode_set_mode` | /axis-cgi/capturemode.cgi |
| `dewarp_get_view_modes` / `dewarp_set_camera_orientation` | param.cgi Image.\*/ImageSource.I0.CameraTiltOrientation (360°/180° cameras) |
### PTZ / patrol
| Tool | Endpoint |
|---|---|
| `guardtour_list` / `guardtour_create` / `guardtour_remove` | param.cgi GuardTour.G# (preset tours) |
| `guardtour_add_preset` / `guardtour_remove_preset` / `guardtour_set_running` | param.cgi GuardTour.G#.Tour.T# |
| `recordedtour_list` / `recordedtour_record` / `recordedtour_stop_recording` / `recordedtour_play` / `recordedtour_stop_playback` | /axis-cgi/recordedtour/*.cgi (manually-steered tour recordings) |
| `autotracker_get_settings` / `autotracker_get_target` / `autotracker_set_target` / `autotracker_set_state` | /axis-cgi/ptz-autotracking/admin.cgi |
| `orientationaid_set_north` / `orientationaid_get_compass_state` / `orientationaid_set_compass_state` / `orientationaid_list_tags` / `orientationaid_set_tag_state` | /axis-cgi/ptz-orientationaid.cgi (compass overlay) |
| `ptzws_get_supported_versions` / `ptzws_get_capabilities` / `ptzws_continuous_move` | PTZ Control WS API (`ws://.../vapix/ws/v1`, new in AXIS OS 12.11) — websocket continuous move, complements `ptz_continuous_move`. **ARTPEC-7 (excl. V5925/V5938) / ARTPEC-9 mechanical PTZ only — not supported on ARTPEC-8/Q1656**; use `ptz_*` (ptz.cgi) elsewhere. |
### Ops / diagnostics
| Tool | Endpoint |
|---|---|
| `auditlog_get` / `auditlog_get_version` | /axis-cgi/auditlog.cgi |
| `log_get_persistent_enabled` / `log_set_persistent_enabled` / `log_clear_persistent` / `log_write_message` | /config/rest/log/v1/... (Device Configuration API) |
| `network_get_tcp_retransmissions` / `network_get_tcp_retransmission_spikes` | /config/rest/network-diagnostics/v1/netstats/... |
| `get_systemready` | /axis-cgi/systemready.cgi (no auth required) |
| `get_server_report` / `get_system_log` / `get_access_log` | /axis-cgi/serverreport.cgi, systemlog.cgi, accesslog.cgi |
| `find_my_device` / `stop_find_my_device` | /axis-cgi/findmydevice.cgi |
| `featureflag_list_all` / `featureflag_get` / `featureflag_set` | /axis-cgi/featureflag.cgi |
| `regionalsettings_get` / `regionalsettings_set` | /axis-cgi/regionalsettings.cgi |
| `mdnssd_get_info` / `mdnssd_set_configuration` / `mdnssd_discover` | /axis-cgi/mdnssd.cgi |
| `geolocation_get_position` / `geolocation_set_position` | /axis-cgi/geolocation/get.cgi, set.cgi |
| `ntp_get_info` / `ntp_set_client_configuration` | /axis-cgi/ntp.cgi |
| `network_get_info` | /axis-cgi/network_settings.cgi (getNetworkInfo — read-only by design) |
The Log API and Network diagnostics API use a newer REST-ish "Device Configuration API" framework
(`/config/rest/<api>/v1/...`, `PATCH`/`POST` bodies wrapped as `{"data": ...}`) instead of the
`{apiVersion, context, method, params}` JSON-RPC shape used everywhere else — `tools/deviceConfigApi.ts`
is the shared helper for it. User account management (`pwdgrp.cgi`) and network write methods
(static IP / 802.1X / WLAN config) were deliberately left out — see ROADMAP.md's Tier 2 notes and the
`network_get_info` description for why.
### Streaming
| Tool | Endpoint |
|---|---|
| `streamprofile_list` / `streamprofile_create` / `streamprofile_update` / `streamprofile_remove` | /axis-cgi/streamprofile.cgi |
| `streamstatus_list` | /axis-cgi/streamstatus.cgi (running RTSP streams) |
| `media_get_stream_url` | /axis-cgi/media.cgi (URL builder — Matroska/MP4 container stream) |
| `mqtt_configure_client` / `mqtt_activate_client` / `mqtt_deactivate_client` / `mqtt_get_client_status` | /axis-cgi/mqtt/client.cgi |
| `mqtt_configure_event_publication` / `mqtt_get_event_publication_config` / `mqtt_configure_subscription` / `mqtt_get_subscription_config` | /axis-cgi/mqtt/event.cgi (Event Service ⇄ MQTT bridge) |
| `signedvideo_get` / `signedvideo_set` | param.cgi Image.I#.MPEG.SignedVideo.Enabled |
| `metadataproducer_list` / `metadataproducer_set_enabled` / `metadataproducer_get_sample` | /axis-cgi/analyticsmetadataconfig.cgi |
### Audio
| Tool | Endpoint |
|---|---|
| `audio_get_settings` / `audio_set_enabled` / `audio_configure_source` / `audio_configure_stream` / `audio_set_global_settings` | param.cgi Audio.\*, AudioSource.\* |
| `audiodevice_get_capabilities` / `audiodevice_get_settings` / `audiodevice_set_settings` / `audiodevice_get_hazardous_settings` | /axis-cgi/audiodevicecontrol.cgi |
| `audioanalytics_get_plugin_schemas` / `audioanalytics_get_plugins_settings` / `audioanalytics_set_plugins_settings` | /axis-cgi/audioanalytics.cgi |
| `audiomixer_get_plugin_schema` / `audiomixer_get_plugins_settings` / `audiomixer_set_plugins_settings` | /axis-cgi/audiomixer.cgi |
| `mediaclip_list` / `mediaclip_play` / `mediaclip_stop` / `mediaclip_remove` / `mediaclip_rename` | /axis-cgi/mediaclip.cgi, playclip.cgi, stopclip.cgi (upload/download binary transfer not covered) |
Audio control service API, Audio relay service API (both deprecated since AXIS OS 10.12), Audio
Multicast Controller (BETA, multi-speaker paging networks), Auto speaker test service API (speaker
calibration hardware), and the AXIS Audio Manager Edge/Pro APIs (separate site-management software
products, not a camera-resident CGI) were surveyed and deliberately excluded — see ROADMAP.md.
### Device configuration
All of these use the newer `/config/rest/<api>/<version>/...` "Device Configuration API" REST framework
(`tools/deviceConfigApi.ts`), not the `{apiVersion, context, method, params}` JSON-RPC shape.
| Tool | Endpoint |
|---|---|
| `dcparam_get` / `dcparam_export` / `dcparam_import` | /config/rest/param/v2beta/... (BETA — generic param.cgi wrapper) |
| `devicemode_get` / `devicemode_set_mode` | /config/rest/device-mode/v1beta (BETA) |
| `objectsnapshot_get` / `objectsnapshot_set` | /config/rest/object-snapshot/v1 |
| `coordconv_list_spaces` / `coordconv_convert` | /config/rest/coordinate-conversion-api/v1 |
| `eventschedule_list` / `eventschedule_create` / `eventschedule_update` / `eventschedule_remove` | /config/rest/event-schedules/v2 |
| `recordinggroup_list` / `recordinggroup_create` / `recordinggroup_delete` | /config/rest/recording-group/v2 (cloud recording pipeline, separate from storage.ts's local SD recording) |
| `remotestorage_list` / `remotestorage_add` / `remotestorage_update` / `remotestorage_remove` / `remotestorage_get_failover` / `remotestorage_set_failover` | /config/rest/remote-object-storage/v1 (S3/Azure destinations for recording groups) |
| `netpairing_list` / `netpairing_add` / `netpairing_remove` / `netpairing_set_nice_name` / `netpairing_get_features` / `netpairing_set_capability_enabled` / `netpairing_sirenlight_*` | /config/rest/networkpairing/v1beta (BETA — pair with e.g. an AXIS D4100-E siren/light) |
| `camerapairing_list` / `camerapairing_add` / `camerapairing_get` / `camerapairing_get_status` / `camerapairing_update` / `camerapairing_remove` | /config/rest/camera-pairing/v1beta (BETA — pull video from one paired external camera/intercom) |
| `analyticsmqtt_list_data_sources` / `analyticsmqtt_list_publishers` / `analyticsmqtt_add_publisher` / `analyticsmqtt_remove_publisher` | /config/rest/analytics-mqtt/v1 |
| `datatransform_list_topics` / `datatransform_list` / `datatransform_create` / `datatransform_update` / `datatransform_remove` / `datatransform_get_statistics` | /config/rest/data-transformation/v1beta (BETA — JQ transforms between Device Data Hub topics) |
Autopilot API, Directional audio detection API, Cellular routing API (all require hardware this box
camera doesn't have — a paired remote PTZ, an audio direction-sensing array, or a cellular modem), and
Speaker display settings/notification (a physical display screen on speaker/intercom products) were
surveyed and excluded. Network time synchronization configuration API was skipped as a newer duplicate
of the already-covered `ntp_get_info`/`ntp_set_client_configuration`. See ROADMAP.md.
See `ROADMAP.md` for what's next.
## Security & guardrails
See [SECURITY.md](SECURITY.md) for the full model. In short:
- **No user management** — the MCP cannot create users or touch credentials/network config
(`set_param` writes are allowlisted to Image/ImageSource/Brand/Time/AudioSource/Event/Overlay).
- **Access levels** — operator-set switch: *Read-only* / *Operate* (default) / *Full*.
Applies immediately (no restart). See the map below.
- **Rate limits** — 60 tool calls/min, 20 writes/min; PTZ presets capped at 20 via MCP.
- **Direct port off by default** — enabling it auto-generates a bearer token.
- **Full audit trail** — every call and every guardrail refusal in the Live Log + AXIS system log.
### What the client actually sees
Three things follow from the access level, and they are visible from the client side:
- **`tools/list` is filtered.** The server does not advertise what it would refuse. At *Operate*
that is 232 of 269 tools; at *Read-only*, 154. This is why `reboot_camera` simply is not in the
list on a default install — the tool exists, the camera declines to offer it.
- Side effect worth knowing: a client holding a list from before you lowered the level gets
`-32602 Tool not found` rather than the guardrail's "disabled by the camera operator"
explanation. Still refused, less informative.
- **Every tool carries annotations** — `readOnlyHint`, `destructiveHint`, `idempotentHint`,
`openWorldHint` — derived from the same classification the guardrails enforce, so a tool cannot
advertise itself as read-only while being treated as a write. Clients use these to decide when
to ask you before calling. 154 are read-only; 19 are destructive.
- **Rate limits are per caller**, keyed by listener and credential — an agent looping on the
direct LAN port cannot exhaust the budget for your own client on the authenticated proxy.
### Access-level map
Every tool is classified in `src/guardrails.ts` (explicit operate/full sets + a
read-only naming heuristic; **unknown tools fail closed to Full**). Each level
includes everything below it.
| Level | Tools | What's included |
|---|---|---|
| **Read-only** | 154 | Pure inspection — nothing changes on the camera. Device info & health, snapshots, view areas, param reads, PTZ position/status/limits, event topics, analytics reads (AOA, people counter, queue, demographics, LPV), audio/imaging/zipstream status, stream URLs & profile lists, recordings list, audit/system/access logs, MQTT/pairing/storage status, CamStreamer/CamSwitcher/CamOverlay listings |
| **Operate** (default) | +78 | Day-to-day control. PTZ moves & presets, guard/recorded tours, autotracking, orientation aid, focus/zoom, image tuning (day-night, stabilizer, rotation, rate control, dewarp), view-area geometry, native & CamOverlay overlays, audio operation & media clips, relay outputs, start/stop recordings, stream & profile control, zipstream tuning, event schedules, `find_my_device`, CamStreamer/CamSwitcher control |
| **Full** | +37 | Persistent configuration & maintenance. `reboot_camera`, `factory_default` (both also need `confirm: true`), `set_param`, `control_app`, config import (`dcparam_import`), device/capture mode, feature flags, regional/NTP/mDNS/geolocation settings, MQTT client & wiring, signed video, analytics MQTT publishers & data transforms, network/camera pairing, recording groups & remote storage topology, persistent-log administration |
Full list of Full-level tools (37):
`analyticsmqtt_add_publisher`, `analyticsmqtt_remove_publisher`, `camerapairing_add`, `camerapairing_remove`, `camerapairing_update`, `capturemode_set_mode`, `control_app`, `datatransform_create`, `datatransform_remove`, `datatransform_update`, `dcparam_import`, `devicemode_set_mode`, `factory_default`, `featureflag_set`, `geolocation_set_position`, `log_clear_persistent`, `log_set_persistent_enabled`, `mdnssd_set_configuration`, `mqtt_activate_client`, `mqtt_configure_client`, `mqtt_configure_event_publication`, `mqtt_configure_subscription`, `mqtt_deactivate_client`, `netpairing_add`, `netpairing_remove`, `netpairing_set_capability_enabled`, `ntp_set_client_configuration`, `reboot_camera`, `recordinggroup_create`, `recordinggroup_delete`, `regionalsettings_set`, `remotestorage_add`, `remotestorage_remove`, `remotestorage_set_failover`, `remotestorage_update`, `set_param`, `signedvideo_set`.
> These counts and this list come from the code, not from editing this file: `npm test` prints
> `269 tools across 34 modules (readonly 154, operate 78, full 37)` on every run. They were wrong
> here for a while — the table said 138/+76/+38 and listed `audio_set_global_settings` as Full,
> which it is not.

## Tests
```sh
cd app && npm test # tsc && node --test
```
44 tests, no framework. They exist because the tool table is 269 entries wide and every one of them
is classified by `guardrails.ts`, so the failure modes are silent rather than loud — a tool nobody
classified is not a compile error, it just fails closed and gets refused at the default access level
with no clue why. That is exactly how `ptzws_continuous_move` was broken.
| File | Covers |
|---|---|
| `test/tools.test.js` | every tool is deliberately classified (no silent fail-closed); no duplicates; annotations agree with the enforced level; the destructive set; deterministic ordering; the catalog is built once; access levels nest; rate-limit budgets are per caller |
| `test/manifest.test.js` | schema is v2 with every required field; removed fields absent; `vendorId` is 10-char hex; `vendor` is the My Axis account email (the ACAP000045 trap); `compatibleOsVersions` has min and max; manifest and `package.json` versions agree; the `/mcp` proxy entry survives; `appName` still matches the launcher |
| `test/protocol.test.js` | spawns the real app and speaks both protocol revisions to it over HTTP — 2025 `initialize`, 2026-07-28 `server/discover` with no handshake, the cache hint, and a malformed envelope being rejected |
| `test/security.test.js` | the direct listener's gates: a no-`Origin` client passes, a browser cross-site POST gets 403, the origin guard runs *before* the bearer compare, and a wrong token gets 401 with `WWW-Authenticate` |
Every check was validated by breaking the thing it guards — reverting a classification, dropping
`vendorId`, renaming `appName`, pointing the rate limiter back at one global bucket. A test that
does not fail when the bug returns is not protecting anything.
The suite also prints the tool counts on every run, which is where the numbers in this file come from.
## Build
Prefer not to build? Grab a prebuilt `.eap` from
[Releases](https://github.com/kotyzap/EdgeMCP-for-Axis/releases) and skip to **Install**.
Requires Docker Desktop on your Mac.
```sh
sh dev-loop.sh build # dev package -> build-arm64/
sh release.sh # release package + notes -> releases/
```
`release.sh` refuses to build if the manifest's `vendor` is not your My Axis account email — the
signing portal rejects anything else with ACAP000045, and since the manifest is baked in at build
time, a rejected upload means a full rebuild. It also verifies the built package: the launcher must
be named after `appName`, `dist/` and the node runtime must be present, and anything Needle-shaped
must not be.
## Install
Camera UI → **System → Apps** → enable *Allow unsigned apps* → **Add app** → upload the aarch64 `.eap` →
**Start**. Open the app's settings page, enter VAPIX admin credentials, click **Run self-test**.
## Connect an MCP client
Two endpoints:
- Reverse-proxied (camera enforces admin digest auth):
`http://<camera-ip>/local/edgemcp/mcp`
- Direct LAN port (for clients without digest; optional bearer token):
`http://<camera-ip>:8000/mcp`
```sh
claude mcp add --transport http axis-q1656 http://<camera-ip>:8000/mcp
```
The voice console lives at `http://<camera-ip>/local/edgemcp/console.html`.
Inspect with: `npx @modelcontextprotocol/inspector`
## Verification checklist
- [ ] `npm test` passes (44) before building anything
- [ ] `.eap` installs and starts on AXIS OS 12.10+ without manifest errors
- [ ] Settings page loads at `http://<ip>/local/edgemcp/index.html` — proves the schema-v2 `reverseProxy` survived
- [ ] `status.cgi` reports `"vapix":{"ok":true}` after credentials are entered
- [ ] `tools/list` returns 232 at the default Operate level, and `reboot_camera` is NOT among them
- [ ] `curl -X POST http://<ip>:8000/mcp` with an `initialize` body returns server info (2025 path)
- [ ] The same endpoint answers `server/discover` with a 2026-07-28 `_meta` envelope (modern path)
- [ ] MCP Inspector lists all tools and each returns live data
- [ ] `take_snapshot` returns a viewable JPEG
- [ ] `set_param` refuses non-allowlisted groups
- [ ] App survives respawn (save settings → app restarts → MCP still answers)
- [ ] Clean SIGINT exit
- [ ] The settings page loads no off-origin resource: DevTools → Network shows nothing outside the camera
- [ ] The package contains nothing Needle-shaped: `tar tzf <eap> | grep -i needle` is empty
## Notes
- Digest auth honours the `algorithm` directive (MD5 **and** SHA-256 + `-sess`) — an MD5-only client
gets a silent 401 on modern AXIS OS.
- The in-app server binds `process.env.HTTP_PORT` (AXIS OS assigns it; 32554 only for local dev).
- Direct-port reachability from the LAN depends on the camera firewall; the reverse-proxied path is the
sanctioned route. Keep a bearer token set if the direct port is enabled.
- The app version is single-sourced from `package.json` (`src/version.ts`); bump it there and in
`manifest.json` when releasing.
- `reboot_camera` and `factory_default` are exposed over MCP but always require `confirm: true`.
- `settings.cgi` rejects a `directPort` outside 1–65535 or colliding with the primary port.
> Two hard-won notes about the speech model and the neural voice moved out with the agent — see
> `Needle LLM on AXIS/src-extracted-from-edgemcp/README-EXTRACTION.md` and this repo's history at
> tag `v2.0.0`. They are about ONNX Runtime builds colliding and `kokoro-js` hard-coding a Hugging
> Face URL; neither applies to an MCP server.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessUnresponsive