eufy-cam-mcp
# eufy-camera-mcp
[](https://github.com/thefiredev-cloud/eufy-camera-mcp/actions/workflows/ci.yml)
A Python [FastMCP](https://gofastmcp.com) server that gives an AI agent view-only access to an allowlisted set of local Eufy cameras through [MediaMTX](https://github.com/bluenviron/mediamtx): live status, a single JPEG snapshot, and recorded motion clips. It has no PTZ, no talkback, and no cloud API.
## Why it exists
An agent that can see a camera should not be able to reach cameras it was not given, call out to the internet, or move the camera. This server keeps the surface small: four known camera names, localhost MediaMTX only, and one frame at a time.
## Tools
The server registers six tools (`src/eufy_cam_mcp/server.py`).
| Tool | What it returns |
|---|---|
| `list_cameras` | Each known camera with its ready state and tracks from MediaMTX |
| `camera_status` | Ready state, tracks, received bytes, and inbound frames in error for one camera |
| `snapshot_camera` | Path to one JPEG frame grabbed with `ffmpeg` from local RTSP, plus a `<name>_latest.jpg` symlink to it |
| `recordings_info` | Segment count, total bytes, first and last file name in the recordings folder, and a fixed retention note |
| `motion_clips` | Paths of the newest `<name>_*.mp4` recordings for one camera by modification time, `limit` clamped to 1 to 50 |
| `clip_frame` | Path to one JPEG frame at `t` seconds into a named clip of that camera in the recordings folder |
Every per-camera tool rejects names outside the allowlist, including path traversal attempts such as `../../outside`, before any MediaMTX call or `ffmpeg` process. `clip_frame` also rejects clip names that are not a bare `*.mp4` file name, clips whose name does not start with `<name>_` (a camera cannot read another camera's recording), and a negative or non-finite `t`. It returns `clip not found` for a name that does not exist in the recordings folder, and `ffmpeg extraction failed` when `t` is past the end of the clip.
`snapshot_camera` and `clip_frame` write JPEGs to the snapshot folder. Nothing prunes them. On a successful capture, `snapshot_camera` unlinks any existing `<name>_latest.jpg` file or symlink before creating the new symlink. Captures of the same camera within one UTC second reuse the timestamped JPEG path, and `ffmpeg -y` can overwrite that JPEG. `clip_frame` removes its own earlier output before a re-run writes it again.
`clip_frame` names its file `<name>_<clip stem>_t<t>.jpg`, where `<t>` is `t` formatted with Python `:g` (`5.0` gives `t5`, `5.5` gives `t5.5`). `recordings_info` sorts by file name, so with several cameras its first and last entries are alphabetical, not oldest and newest in time.
## Requirements
- Python 3.12 or newer and [uv](https://docs.astral.sh/uv/)
- `ffmpeg` on `PATH`
- A MediaMTX instance on the same host with its API enabled, publishing your cameras as `eufy_cam1` to `eufy_cam4`
- A recorder that writes `<name>_*.mp4` segments into the recordings folder, if you want the recording tools (not part of this repo)
## Install and run
```bash
git clone https://github.com/thefiredev-cloud/eufy-camera-mcp.git
cd eufy-camera-mcp
uv sync --frozen
uv run eufy-cam-mcp
```
This starts the MCP server on stdio. Register it with your MCP client as a stdio command, for example:
```json
{
"mcpServers": {
"eufy-cam": {
"command": "uv",
"args": ["--directory", "/path/to/eufy-camera-mcp", "run", "eufy-cam-mcp"]
}
}
}
```
## Configuration
Settings are constants at the top of `src/eufy_cam_mcp/server.py`:
| Constant | Default |
|---|---|
| `MEDIA_MTX_API` | `http://127.0.0.1:9997` |
| `MEDIA_MTX_RTSP` | `rtsp://127.0.0.1:8554` |
| `KNOWN_CAMS` | `eufy_cam1`, `eufy_cam2`, `eufy_cam3`, `eufy_cam4` |
| `SNAP_DIR` | `~/meshvault/camera/snapshots` (snapshots are written here) |
| `REC_DIR` | `~/meshvault/camera/recordings` (recordings are read from here) |
The `~/meshvault/...` folders are the defaults used on the author's machines. Edit the constants to match your layout; there are no environment variable overrides yet.
## Check it without cameras
The boundary check calls `camera_status` and `snapshot_camera` with four bad camera names and confirms that no MediaMTX call, network connection, `ffmpeg` process, or snapshot folder was created. The pytest suite has nine tests: unknown camera rejection, newest-first clip listing, `clip_frame` rejection of unknown cameras, missing clips, path escapes, another camera's clip and negative `t`, and one `clip_frame` run on a synthetic video that is skipped when `ffmpeg` is not installed.
```bash
uv run python scripts/check-camera-boundaries.py
uv run pytest -v
```
CI runs Ruff, the boundary check, and the test suite on pushes and pull requests to `main`.
## Scope
In scope: allowlisted names, MediaMTX ready state from `/v3/paths/list`, one-frame JPEG capture, and listing and sampling local motion clips.
Out of scope: PTZ, talkback, Eufy cloud APIs, recording, and retention. Recording belongs to a separate recorder service; this server only reads files already on disk.
## Status
Early, version 0.1.0. The tests and the boundary check cover input rejection and clip listing, and one test runs real `ffmpeg` on a generated clip through `clip_frame`. No test talks to MediaMTX, captures from RTSP, or uses a camera. If MediaMTX is unreachable, `list_cameras` and `camera_status` raise an error instead of returning a not-ready state. `ffmpeg` calls are limited to 60 seconds, and a timeout also raises. Not published to PyPI and not deployed as a hosted service.
<!-- arch:begin -->
## Architecture
A view-only MCP server that lets an AI agent see four allowlisted local Eufy cameras through MediaMTX.
```mermaid
flowchart LR
agent(["AI agent<br/>MCP client"]):::actor
server["MCP server<br/>6 view-only tools"]:::app
mtx{{"MediaMTX<br/>localhost only"}}:::ext
ffmpeg["ffmpeg<br/>one frame<br/>60 s cap"]:::job
snaps[("Snapshots<br/>JPEG files")]:::store
recs[("Recordings<br/>mp4, read-only")]:::store
agent -->|"asks"| server
server -->|"status"| mtx
server -->|"grab"| ffmpeg
ffmpeg -->|"writes"| snaps
recs -->|"clips"| server
classDef actor fill:#fde68a,stroke:#b45309,color:#111827
classDef app fill:#bfdbfe,stroke:#1d4ed8,color:#0b1220
classDef ext fill:#e5e7eb,stroke:#4b5563,color:#0b1220,stroke-dasharray:4 3
classDef job fill:#fbcfe8,stroke:#be185d,color:#0b1220
classDef store fill:#bbf7d0,stroke:#15803d,color:#0b1220
```
[Full architecture](docs/ARCHITECTURE.md) · Hi-res: [SVG](docs/architecture/whiteboard.svg) · [PNG 3x](docs/architecture/whiteboard@3x.png) · [dark PNG](docs/architecture/whiteboard.dark@3x.png) · [Explorable (Archify)](docs/architecture/eufy-camera-mcp.archify.html) · [Graphify report](docs/architecture/graphify/GRAPH_REPORT.md)
<!-- arch:end -->
## License
MIT. See [LICENSE](LICENSE).
TDQS
Scored across 6 tools
Tools mostly target distinct resources: list all cameras, get one camera status, snapshot live, get recordings overview, list motion clips, and extract a clip frame. However, snapshot_camera and clip_frame both return a JPEG file path, and camera_status partially overlaps list_cameras' ready state, so minor confusion is possible.
All names use snake_case, but the patterns are mixed: verbs (list_cameras, snapshot_camera, clip_frame) alternate with noun phrases (camera_status, recordings_info, motion_clips). Still readable, but not a consistent verb_noun convention.
Six tools is well within the ideal 3–15 range for a camera monitoring server, and each tool appears to serve a distinct purpose.
Core monitoring and frame-extraction workflows are covered, but there is no direct tool to retrieve or download a full motion clip (only frame extraction), and no camera control or configuration operations. These are minor gaps for the apparent monitoring-focused purpose.