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 Β· Releases (prebuilt .eap)
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)
| 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.
| 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.
| 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 |
| 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.
| 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.
| 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.
| 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 |
| 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) |
| 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. |
| 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.
| 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 |
| 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.
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.
See SECURITY.md for the full model. In short:
- No user management β the MCP cannot create users or touch credentials/network config
(
set_paramwrites 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.
Three things follow from the access level, and they are visible from the client side:
tools/listis filtered. The server does not advertise what it would refuse. At Operate that is 232 of 269 tools; at Read-only, 154. This is whyreboot_camerasimply 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 foundrather than the guardrail's "disabled by the camera operator" explanation. Still refused, less informative.
- Side effect worth knowing: a client holding a list from before you lowered the level gets
- 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.
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 testprints269 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 listedaudio_set_global_settingsas Full, which it is not.
cd app && npm test # tsc && node --test44 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.
Prefer not to build? Grab a prebuilt .eap from
Releases and skip to Install.
Requires Docker Desktop on your Mac.
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.
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.
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
claude mcp add --transport http axis-q1656 http://<camera-ip>:8000/mcpThe voice console lives at http://<camera-ip>/local/edgemcp/console.html.
Inspect with: npx @modelcontextprotocol/inspector
-
npm testpasses (44) before building anything -
.eapinstalls and starts on AXIS OS 12.10+ without manifest errors - Settings page loads at
http://<ip>/local/edgemcp/index.htmlβ proves the schema-v2reverseProxysurvived -
status.cgireports"vapix":{"ok":true}after credentials are entered -
tools/listreturns 232 at the default Operate level, andreboot_camerais NOT among them -
curl -X POST http://<ip>:8000/mcpwith aninitializebody returns server info (2025 path) - The same endpoint answers
server/discoverwith a 2026-07-28_metaenvelope (modern path) - MCP Inspector lists all tools and each returns live data
-
take_snapshotreturns a viewable JPEG -
set_paramrefuses 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 needleis empty
- Digest auth honours the
algorithmdirective (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 inmanifest.jsonwhen releasing. reboot_cameraandfactory_defaultare exposed over MCP but always requireconfirm: true.settings.cgirejects adirectPortoutside 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.mdand this repo's history at tagv2.0.0. They are about ONNX Runtime builds colliding andkokoro-jshard-coding a Hugging Face URL; neither applies to an MCP server.
