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
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues