An MCP server that hands a model live still images from a webcam, packaged with cam2ip in a single container.
grab_frame— returns a JPEG of what the camera sees right now, whole or cropped to a region of itcamera_status— connection state, frame counts and last error, for when something looks wrong- Camera controls —
camera_zoom,camera_focus,camera_exposure,camera_white_balance,camera_set,camera_controlsandcamera_reset, driven straight on the V4L2 device beside cam2ip record_audio— a clip from the webcam's microphone with its level measured, off by default- Three transports: stdio, SSE, and Streamable HTTP
- Multi-arch:
linux/amd64andlinux/arm64
Hardware notes for the camera this was built against — what it can and cannot be told to do, and what its vendor extension unit turned out to contain — are in docs/emeet-s600.md.
This project was renamed from
cam2ip-mcptocam2mcp. GitHub redirects the old repository URL, but the container registry does not, so the oldghcr.io/impuls42/cam2ip-mcppackage has been deleted rather than left serving a build that would never be updated again. Repoint anything still pulling that path atghcr.io/impuls42/cam2mcp; it now fails outright instead of quietly handing back a stale image.
The MCP client launches the container and talks to it over stdin/stdout:
{
"mcpServers": {
"cam2mcp": {
"command": "docker",
"args": [
"run", "--rm", "-i",
"--device=/dev/video0:/dev/video0",
"ghcr.io/impuls42/cam2mcp:latest"
]
}
}
}-i is required: without it the container has no stdin and the server exits
immediately.
docker run --rm \
--device=/dev/video0:/dev/video0 \
-p 56000:56000 -p 3000:3000 \
-e MCP_MODE=streamable-http \
ghcr.io/impuls42/cam2mcp:latestClients connect to http://localhost:3000/mcp.
git clone --recursive https://github.com/impuls42/cam2mcp.git
cd cam2mcp
cp .env.example .env # optional; edit to taste
docker compose up -d
docker compose logs -fCompose defaults to streamable-http, since a background service has nothing
attached to its stdin.
The quick starts above are the shortest thing that works. This is what to run if you want it to keep working — every choice here comes from something that was measured on real hardware rather than from taste.
services:
cam2mcp:
image: ghcr.io/impuls42/cam2mcp:sha-abc1234 # pin; do not track latest
container_name: cam2mcp
restart: unless-stopped
devices:
# ls -l /dev/v4l/by-id/ to find yours. Not /dev/video0 — see below.
- /dev/v4l/by-id/usb-Acme_HD_Webcam_1234-video-index0:/dev/video0
ports:
- "127.0.0.1:${MCP_HTTP_PORT:-3000}:${MCP_HTTP_PORT:-3000}"
- "127.0.0.1:${CAM2IP_PORT:-56000}:${CAM2IP_PORT:-56000}"
env_file:
- path: .env
required: false
environment:
- MCP_MODE=streamable-http
- CAM2IP_LAZY=true
# See "Pick a capture resolution" below — the default is cam2ip's 640x480.
- CAM2IP_WIDTH=1920
- CAM2IP_HEIGHT=1080
# Leave MCP_HTTP_HOST at its 0.0.0.0 default — see below.A Linux host with a real V4L2 device. Not a preference: Docker Desktop on
macOS and Windows cannot see a webcam, and compose up there fails outright on
the device mapping. Those platforms need cam2ip and the server as two native
processes — see On macOS.
CAM2IP_LAZY=true. The counter-intuitive one, since it makes cold grabs
slower. It releases the device when nothing is subscribed, and that buys three
things: cam2ip stops being able to serve a stale frame at all, an unplug/replug
self-heals in under a second where holding the device open needs a container
restart, and the camera indicator light goes off when nobody is looking. The
price is about 450ms of device reopen on a cold grab, which is nothing for a tool
called a few times an hour.
A /dev/v4l/by-id/ device path. A devices: mapping is resolved once, at
container-create. After a replug the container is still pointing at a minor
number the kernel has moved on from — and the kernel may well return the camera
on a different minor, especially if it was open when it vanished. With
/dev/video0 that needs a compose edit; with a by-id path a plain restart finds
it, because the symlink follows the device.
If the camera is unplugged often enough that even a restart is annoying, swap the
mapping for a /dev bind-mount (see the notes in docker-compose.yml) and it
survives with no restart. That is not the default recommendation because it hands
the container every device on the machine, which is a lot of privilege for a
webcam.
Streamable HTTP, when it runs unattended. It keeps the stream warm between calls (~10ms grabs instead of ~700ms), the restart policy actually covers it, and the healthcheck means something in that mode. Prefer stdio when a single desktop client launches the container itself — then there is no port at all, which is the simplest thing that can work.
Published on 127.0.0.1 only — on the host side. The 127.0.0.1: prefix on
the ports: mapping is the boundary that matters: Docker forwards only what
arrives on the host's loopback, so nothing off-box reaches either port. If the
client is on the same host, that is the whole security story.
The bind address inside the container is a different question, and the answer
is to leave MCP_HTTP_HOST alone at 0.0.0.0. A published port is forwarded to
the container's bridge address, not to its loopback, so a server bound to
127.0.0.1 inside the container never sees it. That failure is quiet in the
worst way: the healthcheck runs inside the container and connects to loopback,
which is exactly where the server is listening, so it passes and docker ps
keeps saying healthy while nothing on the host can connect.
One consequence worth knowing: because the server is not bound to loopback, mcp
does not apply its automatic Host allowlist — it cannot know what Host header to
expect on a public interface. Inside this compose file nothing needs it, since
the port publish already limits who can connect. The moment you widen that
publish beyond 127.0.0.1, set MCP_ALLOWED_HOSTS to the names clients reach
the server by. Note it also becomes required the moment you set
MCP_ALLOWED_ORIGINS, since origins alone leaves an empty host allowlist that
rejects everything.
Ports through .env so compose moves both sides together: change
MCP_HTTP_PORT there and the publish follows it. Hardcoding one side is the
other way into that same healthy-but-unreachable state.
A pinned sha- tag rather than latest. This is a camera server; it should
not change under you because a merge landed.
The image default is cam2ip's own 640×480, which is safe on any camera and too
small to read anything off. Raising it is the single biggest improvement
available, and it is bought straight out of frame rate: cam2ip decodes every
frame to an image and re-encodes it — handlers/stream.go does both
unconditionally, so turning the timestamp off does not help — in one goroutine.
Measured on a 6-core host with the camera this was built against:
| Capture | Delivered | CPU | Per frame |
|---|---|---|---|
| 1280×720 | 27.4 fps | 0.74 core | 63 KB |
| 1920×1080 | 14.8 fps | 0.86 core | 121 KB |
| 2560×1440 | 8.2 fps | 0.92 core | 278 KB |
| 3840×2160 | 3.9 fps | 0.97 core | 554 KB |
1080p is the recommendation. Four times the pixels of 720p, and still fast
enough that the five warm-up frames cost 0.34s. Going to 4K spends a whole core
and a 1.3s warm-up to resolve detail that camera_zoom and grab_frame's
region recover on demand, and only when something actually needs it. Check
what your camera offers with v4l2-ctl --list-formats-ext; asking for a mode it
does not have is not a graceful failure.
The defaults drop 7 frames per reconnect against a queue that holds 4 — three frames of margin, confirmed on Linux, macOS and Windows. There are only three reasons to touch them:
MCP_STREAM_IDLE_Shigher if you grab often and want more calls served warm. This is the one knob with an obvious payoff.MCP_WARMUP_S=0only on macOS or Windows, where there is no stale queue to flush, to get ~250ms of cold latency back.MCP_WARMUP_FRAMEShigher only if you meet a driver that grants more than four buffers.uvcvideodoes not — it was measured granting exactly the count requested at every value from 1 to 32.
Watch camera_status → state, not the container healthcheck.
The healthcheck deliberately never touches the camera: probing a frame would
dequeue one and wake the device on every interval, defeating CAM2IP_LAZY. So it
tells you the processes are alive and nothing more. A camera that has been
unplugged leaves the container healthy, the HTTP connection to cam2ip up, and
both of camera_status's connection booleans reading true — the state field
is what says connected_but_no_frames. Alert on that and on failed; neither is
visible to docker ps.
This is worth understanding before changing any of the MCP_* frame settings,
because it is the bug this server exists to avoid.
V4L2 capture works through a queue of memory-mapped buffers — four of them, as
allocated by korandiz/v4l, which is what
cam2ip uses. Once the camera is streaming, the driver fills those buffers and
then stops: a filled buffer stays in the done-queue until somebody dequeues it,
and it is not overwritten in the meantime. Every frame read dequeues exactly
one buffer, oldest first.
The consequence is that a capture pipeline which sits idle between requests is
holding four pictures of whatever was in front of the lens the last time it
drained. A single GET /jpeg after an idle period returns one of those — a
picture from minutes or hours ago — and the next few requests walk forward
through the rest of the stale queue before catching up to the present. Nothing
is cached anywhere, so no amount of cache-busting on the HTTP request changes
it; the staleness is in the kernel's buffer queue.
So the server does not take one-shot snapshots. Instead:
-
It holds one MJPEG subscription open. cam2ip serves all its clients from a single capture loop, so one live subscriber keeps that loop dequeuing at the camera's frame rate. While the subscription is up the queue stays nearly empty, and an arriving frame really is a picture of now.
-
It discards the first frames after each connect (
MCP_WARMUP_FRAMES,MCP_WARMUP_S). Those are exactly the pre-idle ones the queue was sitting on. They arrive in an instant burst rather than at the frame rate, so a count alone would not flush a queue deeper than it assumes. This also covers a camera's auto-exposure ramp.The number actually dropped is
max(MCP_WARMUP_FRAMES, MCP_WARMUP_S × fps), and it has to exceed the driver's queue depth. Which term binds is not the one you might expect: at 30fps the 0.25s window is ~7.5 frames, so the window decides and the 5-frame count never does; below about 20fps the count takes over. Measured drops were 7–8 on both Linux/V4L2 and macOS/AVFoundation at 30fps — the window doing the work in both cases.On the depth it has to beat:
korandiz/v4lrequests four buffers and maps however many the driver grants, checking only that the count is non-zero. V4L2 permits a driver to grant more than asked, so in principle the depth is not fixed — butuvcvideo, which is what a USB webcam uses, was measured granting exactly the requested count at every value from 1 to 32, with no upward bump. Its depth is therefore pinned at the four requested, independently of frame rate, and the 5-frame floor covers it on its own. Measured directly fromv4l2_buffer.timestamp: after any stall from 0.5s to 30s, exactly four frames come back stale and the fifth is current, with only their age growing.If you do meet a driver that inflates the request beyond what the formula covers at your frame rate, raise
MCP_WARMUP_FRAMESto match. -
It caps the age of what it serves (
MCP_FRAME_MAX_AGE_S), waiting for a newer frame instead of answering with an old one. This age is measured from when a frame arrived, because HTTP carries no capture timestamp — so it cannot by itself detect a stale queued frame (that is step 2's job), but it does catch a pipeline that stalled or died and left an old frame in memory.
The subscription is dropped once nothing has asked for a frame in
MCP_STREAM_IDLE_S seconds, so the camera is released — and its indicator light
goes out — while the server is idle. The next request re-establishes it, paying
the warm-up cost once. That timeout applies while reconnecting too, so a stream
that cannot be established does not retry forever after the request that wanted
it has given up.
CAM2IP_TIMESTAMP is on by default, so each frame carries its time in the top
left corner and freshness can be read straight off the picture. Note that
cam2ip stamps a frame when it reads it out of the buffer queue, not when the
sensor captured it — which is only the same thing while the pipeline is being
drained continuously. Under the old snapshot approach the stamp would have read
as current on an hours-old picture; with the subscription held open the two
coincide. This was measured: a snapshot taken through the old path came back
stamped 13:27:29 while showing a scene clock reading 13:26:06.
One limitation of the overlay: cam2ip draws it as light glyphs with no contrast
plate, so it washes out against a bright scene — which is precisely when someone
is likely to be squinting at an overexposed stream trying to work out what it is
showing. Turn it off with CAM2IP_TIMESTAMP=false if it is not earning its keep.
The buffer queue described above is Linux's. cam2ip's macOS backend (AVFoundation) keeps a single always-overwritten slot and blocks until the next frame arrives, so it cannot go stale this way, and testing confirmed neither path returns an old frame there — including the one-shot snapshot that fails on Linux. Windows behaves like macOS in this respect.
The machinery here is harmless on those platforms rather than useless: the
max-age bound still catches a stalled pipeline, and it was observed refusing 15s-
and 71s-old cached frames on macOS. But if you are running on a backend with no
stale-queue problem and want the cold-start latency back, MCP_WARMUP_S=0 costs
you nothing there.
The queue only holds stale frames while something keeps the device open and
stops draining it. CAM2IP_LAZY decides the first half, and the default makes
this harder to observe, not easier:
CAM2IP_LAZY=falseholds the device open whether or not anything is subscribed, so the queue keeps four frames from whenever it last drained, however long ago. This is the setting under which a snapshot was measured returning a scene 68 seconds old.CAM2IP_LAZY=true(the default) releases the device once nothing is subscribed, which frees the buffers, so the next request reopens and captures fresh ones. Measured at 10s, 20s and 60s+ after a stream dropped,/jpegreturned the current scene every time — on the default settings a snapshot after an idle could not be made stale by any idle length tried.
So reproducing it through cam2ip needs CAM2IP_LAZY=false. Idling against the
defaults measures the reopen path instead.
Below cam2ip the behaviour is unconditional, and that is what the fix is sized
against. Reading the driver directly — VIDIOC_REQBUFS(4), stream on, stall,
then drain — returns exactly four stale frames and then a current one, for every
stall from 0.5s to 30s. Only their age grows; the depth never does.
Everything is set through environment variables; see .env.example for the annotated list.
cam2ip reads its own CAM2IP_* variables, one per flag, so any option it
supports works without this project knowing about it:
| Variable | Default | Meaning |
|---|---|---|
CAM2IP_ENABLED |
true |
Start the bundled cam2ip; false to use an external one |
CAM2IP_INDEX |
0 |
Camera index (0 = /dev/video0) |
CAM2IP_DEVICE |
— | Select by name instead, substring match; overrides index |
CAM2IP_WIDTH / CAM2IP_HEIGHT |
640 / 480 |
Frame size |
CAM2IP_QUALITY |
75 |
JPEG quality, 1–100 |
CAM2IP_DELAY |
10 |
Milliseconds between captures |
CAM2IP_ROTATE |
0 |
90, 180 or 270 |
CAM2IP_FLIP |
— | horizontal or vertical |
CAM2IP_TIMESTAMP |
true |
Draw the capture time onto the image |
CAM2IP_TIME_FORMAT |
2006-01-02 15:04:05 |
Stamp format, in Go's reference layout |
CAM2IP_LAZY |
true |
Open the camera only while a client is subscribed |
CAM2IP_BIND_ADDR |
0.0.0.0:56000 |
cam2ip listen address; if narrowed to one interface, CAM2IP_BASE_URL must name it too |
CAM2IP_HTPASSWD_FILE |
— | Enable basic auth on cam2ip's endpoints |
Run docker run --rm <image> --help for the authoritative list — any flag shown
there has a matching variable.
On Windows, these variables do nothing if the binary is named
cam2ip.exe. cam2ip derives the prefix from its own filename, and the mapping uppercases and replaces hyphens but not dots — socam2ip.exelooks forCAM2IP.EXE_WIDTHrather thanCAM2IP_WIDTH. Nothing warns you; the flags simply keep their defaults. Either build the binary with no extension (Windows runs an extensionless PE file fine) or pass command-line flags instead. This does not affect the container, where the binary iscam2ip.
| Variable | Default | Meaning |
|---|---|---|
MCP_MODE |
stdio |
stdio, sse or streamable-http |
MCP_HTTP_HOST / MCP_HTTP_PORT |
0.0.0.0 / 3000 |
Bind address for the HTTP transports |
CAM2IP_BASE_URL |
http://127.0.0.1:56000 |
Where to reach cam2ip |
CAM2IP_HTTP_TIMEOUT_S |
5.0 |
HTTP connect/read timeout toward cam2ip |
MCP_FRAME_MAX_AGE_S |
1.0 |
Never serve a frame that arrived longer ago than this |
MCP_GRAB_TIMEOUT_S |
15.0 |
Give up waiting for a fresh frame after this long |
MCP_WARMUP_FRAMES |
5 |
Frames to discard after connecting; must exceed the driver's buffer count |
MCP_WARMUP_S |
0.25 |
Also discard frames for this long after connecting |
MCP_STREAM_IDLE_S |
30.0 |
Hold the camera stream open this long after the last request |
MCP_LOG_LEVEL |
INFO |
Log level; logs always go to stderr |
MCP_ALLOWED_HOSTS |
— | Comma-separated Host allowlist for the HTTP transports |
MCP_ALLOWED_ORIGINS |
— | Comma-separated Origin allowlist; requires MCP_ALLOWED_HOSTS |
CAMERA_DEVICE |
/dev/video0 |
V4L2 node the control tools drive |
CAMERA_CONTROLS |
auto |
auto offers the control tools when that node exists; true / false decide outright |
MCP_CONTROL_IDLE_S |
120.0 |
Put changed controls back after this long with no camera activity; 0 never does |
AUDIO_CAPTURE |
false |
true / auto offer record_audio |
AUDIO_DEVICE |
first USB card | ALSA device name, or a substring of a card name |
Leaving both allowlists unset keeps mcp's defaults — a loopback allowlist when
bound to 127.0.0.1, no restriction when bound to a public interface. Setting
either replaces those defaults, and mcp then validates Host and Origin
together with no way to express "any host", so MCP_ALLOWED_HOSTS is required as
soon as you restrict anything; setting only MCP_ALLOWED_ORIGINS is refused at
startup rather than rejecting every request with a 421. Hosts alone is the
common case: clients sending no Origin header pass, and any request carrying
one is refused, so add MCP_ALLOWED_ORIGINS to permit specific browsers.
| Mode | Endpoint | Use case |
|---|---|---|
stdio |
stdin/stdout | The MCP client launches the container (docker run -i) |
sse |
http://host:3000/sse |
Clients without Streamable HTTP support |
streamable-http |
http://host:3000/mcp |
Current MCP spec |
In stdio mode, stdout is the JSON-RPC channel. Nothing else may write to it,
which is why every log line from both the entrypoint and cam2ip goes to stderr.
grab_frame takes two optional arguments:
max_age_s— maximum acceptable frame age in seconds. Defaults toMCP_FRAME_MAX_AGE_S. Raise it to trade freshness for a faster reply.region—[x, y, width, height]as fractions of the frame between 0 and 1, from the top left.[0.5, 0, 0.5, 0.5]is the top right quarter. Fractions rather than pixels so a caller can pick a region from having looked at the picture, without knowing the capture resolution, and so the same region keeps meaning the same thing if that resolution changes.
camera_status takes none, and is where to look first if grab_frame is
failing. It leads with state, which is the field to read:
state |
Meaning |
|---|---|
idle |
No subscription; the camera is released. Normal between requests. |
streaming |
Frames arriving at the frame rate. |
reconnecting |
The stream dropped and is being re-established. |
connected_but_no_frames |
cam2ip is reachable but has stopped sending — an unplugged or wedged camera. |
failed |
An error a retry cannot fix, such as a 404 or a non-MJPEG response. |
stream_connected and stream_running describe the HTTP conversation with
cam2ip, not the camera: both stay true when the camera is unplugged,
because cam2ip holds the response open and simply stops writing to it. Read
state and the frame counters instead. Also reported: the age of the frame in
memory, the last error, and whether that error is one a retry could fix.
frames_received and frames_published are cumulative for the life of the
server process, not per-connection. Their difference is every warm-up discard
since startup added together, so to see one cycle's discard take the delta across
that cycle rather than reading the totals.
last_error_is_permanent distinguishes the two kinds: a refused connection or a
timeout is worth retrying and gets ridden out until MCP_GRAB_TIMEOUT_S, while a
4xx or a response that is not MJPEG at all cannot be fixed by reconnecting, so
grab_frame reports it immediately instead of making the caller wait.
cam2ip owns the video stream and offers no way to change how the camera is
capturing. These tools do it themselves, by opening the same V4L2 node beside
cam2ip and writing controls directly — which V4L2 permits, because buffers,
formats and STREAMON are exclusive but VIDIOC_S_CTRL is not. Nothing in this
path can take the stream away from cam2ip.
| Tool | For |
|---|---|
camera_controls |
Every setting with its range, current value and default. Start here. |
camera_zoom |
Digital zoom, 0–100 on the S600. |
camera_focus |
Fixed focus, or back to continuous autofocus. |
camera_exposure |
Exposure time and gain, or back to automatic. |
camera_white_balance |
Fix a colour temperature, or back to automatic. |
camera_set |
Anything else by name — brightness, contrast, sharpness, power_line_frequency… |
camera_reset |
Undo. |
Two things about this are worth understanding before using it.
Setting a manual value switches off the automatic mode that would override
it. A driver reports an overridden control as inactive and then accepts writes
to it that do nothing — no error, no effect. So camera_focus(position=…)
turns autofocus off first, and camera_reset puts manual values back before it
re-enables the automatic modes, because doing it the other way round would
silently discard them.
Control state lives in the camera, not in this server. It survives the
container being replaced and applies to everything else that opens the device —
leave the camera zoomed and manually focused, and that is what the next video
call gets. So anything changed here is remembered and put back after
MCP_CONTROL_IDLE_S with no frame grab and no control call, and on shutdown.
camera_reset does it immediately; camera_reset(to_defaults=true) goes
further and resets every control to the driver default, which is how to clear
state this server did not create.
Both make something look bigger, and they are not interchangeable:
regioncrops a frame that has already been captured. It costs a re-encode, changes no camera state, cannot disturb anything else using the camera, and needs no undo. Reach for it first.camera_zoomcrops inside the camera, before the sensor image is scaled down to the capture resolution, so it resolves detail that is not in the full frame at all. It also narrows the field of view for every other user of the camera and has to be undone.
There is no pan or tilt on this camera, so a zoomed view is centred and cannot
be aimed. To look at something off-centre, stay zoomed out and use region.
record_audio(seconds) captures from the webcam's microphone — a separate ALSA
device sharing the cable, so it neither needs nor disturbs the video stream —
and returns the clip together with peak and RMS levels and a plain verdict. The
levels are the point for a text-only client: whether a machine is still running,
whether a room is occupied, and whether the microphone is connected at all are
all answered by a number, and a dead channel is indistinguishable from a quiet
room by ear.
It is off unless AUDIO_CAPTURE is set, and deliberately not auto like the
camera controls. A webcam's indicator light announces that it is watching, and
someone asking for a picture knows a camera is involved; a microphone announces
nothing, and the sound card happening to be visible inside the container is not a
reason to offer the room up. The container also needs --device=/dev/snd.
While the container runs, cam2ip is reachable directly:
http://localhost:56000/— links to the pages belowhttp://localhost:56000/jpeg— single snapshot (subject to the stale-queue behaviour described above; the MCP tool is the reliable path)http://localhost:56000/mjpeg— live MJPEG streamhttp://localhost:56000/html— viewer page
The suite needs no webcam: it runs a fake cam2ip that reproduces the V4L2 buffer-queue behaviour, and drives the server over its real transports.
python -m venv .venv && . .venv/bin/activate
pip install -r requirements-dev.txt
python -m pytestWorth knowing about the coverage:
tests/test_freshness.pyasserts on the frame's own capture sequence number against a watermark taken while the fake camera's queue was stalled, so "returned a stale frame" fails independently of timing. It also tests the old one-shot/jpegbehaviour, to keep a record of what the bug looked like.tests/test_entrypoint.pyrunsentrypoint.shwith a stand-in cam2ip and checks the container startup path — including that stdout stays clean.tests/test_transports.pylaunches the server as a subprocess and speaks MCP to it over stdio and Streamable HTTP.
The tests above run against the source tree. To check the built image instead —
that the pinned dependencies resolve inside python:3.12-alpine, that the
CGO-free cam2ip binary executes in a runtime stage with no build tools, and that
docker run -i gives the server a working stdin:
docker build -f Containerfile -t cam2mcp:dev .
CAM2MCP_IMAGE=cam2mcp:dev python -m pytest tests/test_image.py -vThese are skipped unless CAM2MCP_IMAGE is set, and need Linux, since they
use --network host to let the container reach the fake camera. CI runs them
before anything is published, so a green build alone cannot ship an image that
fails to start.
The container cannot see your camera. Docker Desktop runs containers in a Linux
VM with no passthrough for the host's AVFoundation devices, so there is no
/dev/video0 inside it — --list-devices returns nothing and docker compose up fails outright with error gathering device information while adding custom device "/dev/video0". This is a Docker Desktop limitation, not a problem with
the image, which builds and runs fine otherwise.
Run the two processes natively instead. cam2ip builds without CGO on darwin too
(it reaches AVFoundation through purego, not cgo):
mkdir -p bin
(cd cam2ip && GOTOOLCHAIN=auto CGO_ENABLED=0 go build \
-ldflags "-X main.version=$(git rev-parse --short HEAD)" -o ../bin/cam2ip ./cmd/cam2ip)
./bin/cam2ip --bind-addr 127.0.0.1:56000 &
python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
CAM2IP_BASE_URL=http://127.0.0.1:56000 ./.venv/bin/python cam2mcp_server.pyThe binary must be named exactly cam2ip — it derives its CAM2IP_* variable
prefix from its own filename, so any other name silently ignores that
configuration. The -ldflags are what make --version report cam2ip's revision
rather than this repo's; see the note in the Containerfile.
docker run --rm --device=/dev/video0:/dev/video0 \
ghcr.io/impuls42/cam2mcp:latest --list-devicesAny flag-shaped argument is passed straight to cam2ip, so --help and
--version work the same way.
git clone --recursive https://github.com/impuls42/cam2mcp.git
cd cam2mcp
docker build -f Containerfile -t cam2mcp \
--build-arg CAM2IP_VERSION=$(git -C cam2ip rev-parse --short HEAD) .BuildKit is required. The builder stage uses --platform=$BUILDPLATFORM so
it compiles natively and cross-compiles for the target, which the legacy builder
cannot parse — it fails with "" is an invalid OS component. Any current Docker
uses BuildKit by default; if yours does not, set DOCKER_BUILDKIT=1.
The --build-arg is what makes the startup banner report cam2ip's own revision.
Without it cam2ip falls back to Go's build info, which stamps whichever git tree
the build ran in — so it would report this repo's commit as the cam2ip version.
cam2ip builds with CGO_ENABLED=0 — it is pure Go now, V4L2 access included —
so the builder stage runs natively on the build host and cross-compiles for
arm64 rather than going through emulation.
Build (two stages):
golang:1.26-alpinebuilds the cam2ip binary, statically and without CGO.python:3.12-alpineinstalls the Python dependencies and receives just the binary.
Runtime: entrypoint.sh starts cam2ip in the background, waits for it to
accept connections, then execs the MCP server so it becomes PID 1 — inheriting
the container's stdin and stdout, and receiving SIGTERM directly on
docker stop.
grab_frame reports no frames. Call camera_status; last_error usually
says why. Then check the container logs for cam2ip's own complaints — a camera
it cannot open is the common case.
Container exits at startup saying cam2ip is not accepting connections. The
message prints both CAM2IP_BASE_URL and CAM2IP_BIND_ADDR, because a mismatch
between them is the usual cause: cam2ip listens only on the address it bound to,
so narrowing the bind to one interface without pointing the base URL at the same
place leaves the MCP server with nowhere to fetch from.
The camera was unplugged and replugged. Getting back from this needs three things, and the shipped compose file only gives you one of them:
CAM2IP_LAZY=true(the default) is the only setting that can self-heal. cam2ip closes the device while nothing is subscribed, so the next request reopens it. WithCAM2IP_LAZY=falseit holds the original handle and retries reads against a device that is gone — measured at every grab failing until the container is restarted, with no recovery on its own.- The container has to be able to see the replugged device. A compose
devices:mapping cannot: it is resolved once at container-create, so the node inside the container keeps pointing at a minor number the kernel has moved on from. Under a plain mapping neitherCAM2IP_LAZYsetting survives a replug, and the failure is not cam2ip's. - The kernel may not return the camera on the same minor, especially if it was
held open when it disappeared — it can come back as
video1. Mapping a/dev/v4l/by-id/...path instead of/dev/video0makes a restart enough, since the symlink follows the device. See the notes indocker-compose.ymlfor that and for the/devbind-mount that avoids the restart entirely.
camera_status reports state: connected_but_no_frames throughout, which is the
signature to look for. Note that stream_connected and stream_running both
stay true: they describe the HTTP conversation with cam2ip, which is untouched
by the camera going away. The container HEALTHCHECK stays green too, for the
same reason — it probes the two HTTP ports, neither of which depends on a camera.
It is a check that the processes are alive, not that the camera is.
Camera not accessible. Confirm the device is passed in
(--device=/dev/video0:/dev/video0) and that it exists on the host:
ls -l /dev/video*If the host requires it, add your user to the video group.
Frames look older than they should. Compare last_frame_age_s from
camera_status against frame_max_age_s. If warm-up has been lowered, restore
MCP_WARMUP_FRAMES to at least 5 — see "How frames stay fresh".
Port already in use. Remap it, and tell the MCP server where cam2ip went:
docker run --rm -i \
--device=/dev/video0:/dev/video0 \
-p 8080:8080 \
-e CAM2IP_BIND_ADDR=0.0.0.0:8080 \
-e CAM2IP_BASE_URL=http://127.0.0.1:8080 \
ghcr.io/impuls42/cam2mcp:latestThe MCP server, entrypoint, tests and packaging in this repository are MIT licensed; see LICENSE.
The bundled cam2ip is GPL-3.0 and
stays that way — its terms are in cam2ip/COPYING, and they govern the cam2ip
binary inside the published image regardless of the license on this repository.
The two are aggregated rather than combined: cam2ip is built from an unmodified
upstream submodule into its own binary, and the MCP server talks to it over
HTTP as a separate process, so no GPL work is linked into or derived from the
MIT-licensed code here.
If you redistribute the image, you are redistributing a GPL-3.0 binary and owe
its recipients the corresponding source. The submodule pin is what discharges
that: .gitmodules names the upstream repository and the checked-out commit
identifies the exact revision built.