uvc-ptz-camera-mcp
An MCP server for USB (UVC) pan/tilt/zoom cameras: aimthem, nudge them, sweep them smoothly, zoom, and look through them — with every moveconfirmed by comparing the picture before and after.
No account, no cloud, no vendor SDK. One device, one USB cable.
camera_status the camera, its axes and ranges, and whether it is real
aim point an axis at an absolute value, confirmed from the picture
nudge move relative to where the device says it is
sweep a smooth timed move, streamed at 15 Hz
zoom set zoom as a multiplier (1x .. the camera's maximum)
recentre every axis back to its default
look one frame, returned as an image
aim_learn remember "this direction is the desk"
aim_list what has been recorded for this camera
go_to point at a recorded direction
run_shot a multi-step move, verified after every waypoint
mark_view store the current picture under a label
check_view has the picture changed since that label?
Why this exists
This class of camera reports positions it never moved to. Measured on the reference device(a DJI Osmo Pocket 4P in webcam mode, over its standard UVC controls):
- a command to pan to
180read back180while the video proved the camera had not movedat all; - a whole 32-second run read back
0on every sample while the frame demonstrably changed; - pan
120read back119, pan200read back199.
An agent acting on those values reports moves that never happened. So this server treats everydevice-reported value as a hint — returned, labelled, never trusted — and decides movedby comparing frames.
The threshold is not a guess either. It was calibrated on labelled hardware frames: every"same view" pair scored ≤ 0.104 (a 96-second quiet baseline, and two commands the hardwareignored), every "view changed" pair scored ≥ 0.624 (a pan, a tilt, a 12× zoom, two aimsteps). The shipped threshold is the midpoint, 0.364 — a 6× separation — and the test suiteasserts it still separates them. The frames themselves are kept in the repository(tests/fixtures/), so the number can be re-checked rather than believed, andthe measurements behind it are in docs/measurements.md.
What keeps the agent honest
movedcomes from the picture.device_reportedis returned beside it, labelled a hint.- A move that produced no change is an error, not a quiet success — after a bounded retry,the tool fails naming what was requested and what was observed.
- Already being at the target is success without movement:
moved: false, no error. - Blocking work never runs on the event loop (COM calls, ffmpeg captures), pinned by a testthat records which thread the camera was touched on.
- Observation is explicit: nothing takes a picture except a tool you called.
- A refused write aborts a shot rather than continuing to drive a camera that stoppedlistening.
Install
uvx uvc-ptz-camera-mcp # run without installing
pip install uvc-ptz-camera-mcp # or install it
For a real camera on Windows you also need the DirectShow extras:
pip install "uvc-ptz-camera-mcp[dshow]"
Then point your host at it — --print-config emits the snippet with the right interpreter:
uvc-ptz-mcp --print-config
uvc-ptz-mcp --list-devices # what to pass to --device
uvc-ptz-mcp --backend simulator # try it with no camera attached
Where to put it
Claude Desktop, Cursor, VS Code and friends all take the same shape — this is the wholeconfiguration, because there is nothing to authenticate:
{
"mcpServers": {
"ptz-camera": {
"command": "uvx",
"args": ["uvc-ptz-camera-mcp"]
}
}
}
Add "--device", "Osmo" (any substring of the name --list-devices prints) when the machine hasmore than one camera, and "--backend", "simulator" to work with none. Both can also be set in theenvironment instead of the args — UVC_PTZ_DEVICE, UVC_PTZ_BACKEND and UVC_PTZ_STATE_DIR — whichis what the Claude Desktop bundle uses, and which survives a host restart without editing its configagain. A flag wins over the environment, and an empty value means "not set" (a host that leaves anoption blank writes "", not nothing). Claude Desktop also accepts the .mcpb bundle attached toeach release as a one-click install.
Modes, and how you can tell which one you are in
| Backend | What it is |
|---|---|
auto (default) |
Prefer a real camera; fall back to the simulator, saying why |
dshow |
Windows DirectShow: the real device path |
simulator |
No hardware at all: a model calibrated from the measurements below |
Every result carries "simulated": true or false, and camera_status reports the reason whenthe simulator is standing in. Nothing quietly pretends to be hardware: a caller must neverbelieve it is driving a camera when it is driving a model.
The simulator is calibrated from the reference device, not invented: ~0.4 s from write tofirst motion, a pan completing in about a second, a 4× zoom in about 2.5 s, silently droppedwrites, and a read-back that echoes a request the hardware never applied. It renders frames bycropping a wide panorama, so a simulated pan genuinely changes the pixels — a simulator with astatic picture would let verification pass vacuously.
Honest limitations
- The real-device path is Windows-only (DirectShow). A Linux backend would use
v4l2; theinterface is in place, the implementation is not written. - No exposure, focus or white balance. The reference camera exposes no Processing Unit atall — only pan/tilt/roll/zoom.
- The vendor Extension Unit is unreachable on Windows, so features that live there (on thereference camera, its built-in subject tracking) cannot be driven from software. Measured:
IKsControlis refused on the device filter,IKsTopologyInfolists three nodes and novendor node, andCreateNodeInstancefails on all of them. Linux can reach it throughuvcvideo'sUVCIOC_CTRL_QUERY; that is a separate backend. - A moving subject looks like a moving camera. Frame comparison cannot tell the two apart;the metric is calibrated so that a static scene behaves, and
check_viewis there forjudging a view rather than a move. - One unusable device must not hide the others. A registered-but-unavailable virtual cameraraised when its DirectShow moniker was bound, which aborted a listing that should havereturned two working cameras. Enumeration now skips what it cannot load and says so, and thesame rule applies inside the backend when it looks for the camera you named.
- Measured latency sets the ceiling: ~0.4 s from command to motion, so control loops run ata couple of hertz, not tens. That is the hardware's floor, not the software's.
- The DirectShow path is tested but dormant. The reference device was returned partwaythrough development, so the hardware path is exercised up to its interface and typedcorrectly against it, while the simulator carries the test load. Treat the first run againsta real camera as the true acceptance test.
Development
pip install -e ".[dshow]" numpy pytest pytest-asyncio ruff
python -m pytest -q # 39 tests, three layers
python -m ruff check . && python -m ruff format --check .
The suite is layered deliberately: the tool surface in-process against the simulator (mapping,validation, honest failure), pure unit tests for the compiler and the metric, and a real stdiohandshake as a subprocess — including one run with a device name that cannot exist, because aserver that dies at startup is invisible to every host and directory that lists it.
Reference device
Built against a DJI Osmo Pocket 4P in webcam mode: VID_2CA3 / PID_0023, exposing pan−38…215°, tilt −33…105°, roll ±35°, zoom 100…1200 (1×–12×) as standard UVC camera controls.Any UVC PTZ camera exposes the same surface; the ranges are read from the device at startuprather than assumed, and nothing vendor-specific is hard-coded.
Not affiliated with, endorsed by, or supported by DJI.