Skip to main content
Glama
README.md
# eufy-camera-mcp

[![CI](https://github.com/thefiredev-cloud/eufy-camera-mcp/actions/workflows/ci.yml/badge.svg)](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

B3.3/5.0

Scored across 6 tools

Disambiguation4/5

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.

Naming Consistency3/5

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.

Tool Count5/5

Six tools is well within the ideal 3–15 range for a camera monitoring server, and each tool appears to serve a distinct purpose.

Completeness4/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues