Skip to main content
Glama
Barrow1990

streamystats-mcp-server

by Barrow1990
README.md
# streamystats-mcp-server

A minimal [Model Context Protocol](https://modelcontextprotocol.io) server that
connects to [Streamystats](https://github.com/fredrikburmester/streamystats)
(a self-hosted watch-statistics dashboard for Jellyfin), packaged for Docker.

It runs as a standing network service (streamable-http transport, not stdio),
so any MCP client on your internal network can connect to
`http://<host>:<port>/mcp` — the container isn't spawned per-client, and
container lifecycle/updates can be handed off to a tool like
[Dockhand](https://dockhand.pro).

## Read this before trusting it for "watch stats"

**Streamystats has no documented public REST API.** Its dashboard, watch-time,
most-watched, and per-user history pages are server-rendered React components
that query its Postgres database directly — they are not exposed as JSON
endpoints any external client (including this one) can call. This was
confirmed by reading its `main` branch source directly (commit `1a154af6e59b`,
2026-09-13), not by guessing from the README, which doesn't document an API
at all.

Only six `app/api/**/route.ts` handlers exist in the whole codebase, and of
those, only three groups accept external-client authentication
(`requireAuth`/no-auth-at-all in `lib/api-auth.ts`) rather than requiring a
logged-in browser session cookie (`requireSession`/`requireAdmin`, which this
server cannot obtain and does not attempt to):

| Endpoint | Auth | Usable from here? |
|---|---|---|
| `GET /api/servers` | none | yes — `list_servers` |
| `GET /api/health` | none | yes — `system_status` |
| `GET /api/version` | none | yes — `system_status` |
| `GET /api/search` | external-client OK | yes — `search` |
| `GET /api/watchlists` | external-client OK | yes — `list_watchlists` |
| `POST /api/watchlists` | external-client OK | yes — `create_watchlist` |
| everything else (dashboard stats, history, sessions, libraries, ...) | session cookie only | **no** |

That means **this server cannot answer "what's my most-watched show" or
"how much has user X watched"** — despite that being Streamystats' whole
purpose — because there is no API for it to call. If Streamystats adds a
documented stats API in the future, this server should be extended to use it;
until then, use the Streamystats web dashboard directly for that data.

Treat everything here as **best-effort against an undocumented, unofficial
surface** that a Streamystats upgrade could change or remove with no notice.
`tests/test_live_streamystats.py` exists specifically to catch that kind of
drift against a real instance — see [Testing](#testing).

## Tools

| Tool | Description |
|---|---|
| `list_servers` | List the Jellyfin servers Streamystats is tracking, with sync status |
| `search` | Search Streamystats' indexed library items, users, watchlists, activity, sessions, and people |
| `list_watchlists` | List watchlists visible to the authenticated Jellyfin identity |
| `create_watchlist` | Create a new watchlist |
| `system_status` | Streamystats liveness and version/update-check info |

`create_watchlist` is the only tool that changes state. Everything else is
read-only.

## Authentication to Streamystats

Streamystats has **no API key of its own** for external clients. Per its
`lib/api-auth.ts`, it instead accepts a real Jellyfin API key or user access
token, sent as `Authorization: MediaBrowser Token="<token>"`, and validates
that token directly against the Jellyfin server(s) it has registered. Set
`STREAMYSTATS_JELLYFIN_TOKEN` to a Jellyfin API key (Jellyfin > Dashboard >
API Keys) — **not** anything issued by Streamystats itself, because it issues
nothing.

## Health endpoints

Two plain HTTP endpoints, reachable without `MCP_AUTH_TOKEN` (so Docker's
`HEALTHCHECK`, Dockhand, or any other monitor can poll them without the
secret):

| Endpoint | Checks | Healthy | Unhealthy |
|---|---|---|---|
| `GET /health` | The process is up and serving HTTP. Does **not** call Streamystats. | `200 {"status": "ok"}` | (doesn't respond) |
| `GET /ready` | `STREAMYSTATS_URL` is reachable (`/api/health`), *and* `STREAMYSTATS_JELLYFIN_TOKEN` is accepted by Streamystats' external-client auth (a 1-result `/api/search` probe). | `200 {"status": "ok", "reachable": true, "authenticated": true, "streamystats": {...}}` | `503 {"status": "error", "reachable": ..., "authenticated": ..., "error": "..."}` |

## Configuration

Environment variables (see `.env.example`):

| Variable | Required | Default | Description |
|---|---|---|---|
| `STREAMYSTATS_URL` | yes | — | e.g. `http://192.168.1.50:3000` |
| `STREAMYSTATS_JELLYFIN_TOKEN` | yes | — | A Jellyfin API key/token — see [Authentication](#authentication-to-streamystats) |
| `MCP_HOST` | no | `0.0.0.0` | Interface the server binds to inside the container |
| `MCP_PORT` | no | `8940` | Port the server listens on |
| `MCP_AUTH_TOKEN` | no | — | Shared secret required as `Authorization: Bearer <token>`. Unset = no auth |

## Image

Built and pushed to `ghcr.io/barrow1990/streamystats-mcp-server` by
[`.github/workflows/ci.yml`](.github/workflows/ci.yml) on every push to
`main` that passes tests, tagged `:latest` and `:<commit-sha>`.
`docker-compose.yml` pulls `:latest` by default; swap in `build: .` there
instead if you'd rather build locally from the `Dockerfile`.

Same three-stage scratch-flattened Alpine build as the sonarr/radarr MCP
servers (~98MB) — see those repos' READMEs for why that design shrinks the
image the way it does.

## Running with Docker Compose

```bash
cp .env.example .env   # fill in STREAMYSTATS_URL / STREAMYSTATS_JELLYFIN_TOKEN
docker compose up -d --pull always
```

The server is then reachable at `http://<docker-host>:8940/mcp` from anything
on your internal network.

## Managing with Dockhand

Point Dockhand at `ghcr.io/barrow1990/streamystats-mcp-server` and let it
track new tags. **Make the GHCR package public**, or every pull will need
`docker login ghcr.io` with a PAT on each deploy host.

## Connecting a client

### Claude Code

```bash
claude mcp add streamystats -s user --transport http http://<docker-host>:8940/mcp \
  --header "Authorization: Bearer <MCP_AUTH_TOKEN>"
```
(Drop the `--header` flag if you're running with `MCP_AUTH_TOKEN` unset.)

### Claude Desktop

```json
{
  "mcpServers": {
    "streamystats": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote", "http://<docker-host>:8940/mcp",
        "--header", "Authorization: Bearer <MCP_AUTH_TOKEN>"
      ]
    }
  }
}
```

## Running without Docker

```bash
pip install -r requirements.txt
STREAMYSTATS_URL=http://192.168.1.50:3000 STREAMYSTATS_JELLYFIN_TOKEN=your-jellyfin-key \
MCP_AUTH_TOKEN=your-shared-secret python server.py
```

## Testing

```bash
pip install -r requirements-dev.txt
python -m pytest tests/ -v
```

- `tests/test_tools.py` — each tool's logic against a mocked Streamystats
  (`httpx.MockTransport`, no extra mocking library needed).
- `tests/test_http.py` — `/health`, `/ready`, and the bearer-auth middleware,
  via `server.build_app()` (the exact app `__main__` runs) through Starlette's
  `TestClient`.
- `tests/test_live_streamystats.py` — **opt-in** contract tests against a real
  Streamystats instance, to catch this server's biggest real risk: that an
  upgrade removes or re-authenticates the undocumented endpoints this whole
  integration depends on. Skipped by default (no Streamystats in CI); run
  with:
  ```bash
  RUN_LIVE_STREAMYSTATS_TESTS=1 STREAMYSTATS_URL=https://streamystats.example.com \
  STREAMYSTATS_JELLYFIN_TOKEN=<real jellyfin api key> python -m pytest tests/test_live_streamystats.py -v
  ```

CI (`.github/workflows/ci.yml`) runs the mocked suite on every push/PR; the
GHCR build only runs after it passes.

## License

MIT