Skip to main content
Glama
zahlenhelfer

movieplexx-mcp

by zahlenhelfer
README.md
# movieplexx-mcp

MCP server that mirrors and historically archives the current cinema program of
**Movieplexx Buchholz**.

It polls the cinema's internal JSON endpoint
(`https://movieplexx.de/programm/api/filtered-films`) — which returns every
current film with all showtimes, booking links, formats and metadata in a single
response — normalizes it into SQLite, keeps an append-only history log, and
exposes the data read-only to an MCP client.

## Architecture

One image, two roles selected by command:

```
scraper (CLI)  ── hourly ──▶  SQLite (volume)  ── read-only ──▶  mcp-server (stdio) ──▶ Claude
```

- `scrape` — fetch, upsert current performances, append a history snapshot
- `serve` — run the MCP server (stdio), reading the same DB read-only

## Layout

- `src/movieplexx/scrape.py` — HTTP fetch + normalization of the API response
- `src/movieplexx/store.py` — SQLite schema, upsert, append-only history
- `src/movieplexx/cli.py` — `scrape [--loop]`, `serve`
- `src/movieplexx/server.py` — FastMCP tools

## Local usage

```bash
uv sync
DB_PATH=./movieplexx.db uv run movieplexx scrape      # one fetch + store
DB_PATH=./movieplexx.db uv run movieplexx serve       # MCP server over stdio
```

## Configuration (environment)

| Variable | Default | Purpose |
| --- | --- | --- |
| `DB_PATH` | `/data/movieplexx.db` | SQLite file location |
| `TARGET_URL` | `.../programm/api/filtered-films` | Source endpoint |
| `USER_AGENT` | `MovieplexxProgrammMirror/0.1 (+kontakt@zahlenhelfer.de)` | Self-identifying UA |
| `POLL_INTERVAL_SECONDS` | `3600` | Loop interval for `scrape --loop` |
| `METRICS_PORT` | `9000` | Prometheus endpoint port in loop mode (`<=0` disables) |
| `MCP_TRANSPORT` | `stdio` | `serve` transport: `stdio` (local) or `http` (remote) |
| `MCP_HOST` | `0.0.0.0` | HTTP bind address (`http` transport) |
| `MCP_PORT` | `8000` | HTTP port (`http` transport) |
| `MCP_PATH` | `/mcp` | HTTP endpoint path (`http` transport) |
| `MCP_ALLOWED_HOSTS` | — | Comma-separated `host:port` values to accept in the `Host` header, in addition to `localhost`/`127.0.0.1`/`::1` (`http` transport) |
| `MCP_AUTH_TOKEN` | — | Bearer token; **required** for `http` transport (fail-closed) |
| `MCP_TLS_CERTFILE` | — | Path to a TLS certificate; terminates HTTPS directly in the server. Must be set together with `MCP_TLS_KEYFILE` (`http` transport) |
| `MCP_TLS_KEYFILE` | — | Path to the matching TLS private key (`http` transport) |
| `LOG_LEVEL` | `INFO` | Logging level |

## MCP tools

- `list_showtimes(date?, film_slug?, only_upcoming?)` — performances, filterable
- `get_film(film_slug)` — full film record
- `list_films(only_current?)` — all known films
- `search_films(query)` — substring search over title/genre/director/distributor
- `film_history(film_slug)` — append-only scrape history (sold-out / status drift)

## Registering with an MCP client

The server speaks stdio. Add it to your client's server config (e.g. Claude
Desktop's `claude_desktop_config.json`). It only **reads** the DB, so populate it
first with at least one `scrape`.

Local (via `uv`):

```json
{
  "mcpServers": {
    "movieplexx": {
      "command": "uv",
      "args": ["run", "movieplexx", "serve"],
      "cwd": "/absolute/path/to/movieplexx-mcp",
      "env": { "DB_PATH": "/absolute/path/to/movieplexx-mcp/movieplexx.db" }
    }
  }
}
```

Docker (reads the shared `moviedata` volume the scraper writes):

```json
{
  "mcpServers": {
    "movieplexx": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "-v", "moviedata:/data:ro", "movieplexx-mcp", "serve"]
    }
  }
}
```

## Docker

```bash
docker compose up -d          # runs scraper (hourly loop) + shared volume
```

The MCP server is typically launched on demand by the client, e.g.:

```bash
docker run -i --rm -v moviedata:/data:ro movieplexx-mcp serve
```

## Remote serving on a NAS (HTTP transport)

By default `serve` speaks stdio and is spawned locally by the client. To run the
server long-lived on a NAS and reach it from your local Claude over the LAN, set
`MCP_TRANSPORT=http`. The endpoint is protected by a static bearer token and
**refuses to start without `MCP_AUTH_TOKEN`**.

Generate a token and start the networked `mcp` service (see `docker-compose.yml`,
which binds the port to the NAS LAN IP — adjust `192.168.1.50`):

```bash
echo "MCP_AUTH_TOKEN=$(openssl rand -hex 32)" >> .env   # .env is gitignored
echo "MCP_ALLOWED_HOSTS=192.168.1.50:8000" >> .env      # the LAN address clients connect to
docker compose up -d mcp
```

The server validates the incoming `Host` header (DNS-rebinding protection) and
otherwise only trusts `localhost`/`127.0.0.1`/`::1`. Without `MCP_ALLOWED_HOSTS`
set to the address in the URL above, remote requests fail with
`421 Invalid Host header` even though the bearer token is correct.

Register the remote server with your local Claude:

**Claude Code (CLI)** — native HTTP transport:

```bash
claude mcp add --transport http movieplexx http://192.168.1.50:8000/mcp \
  --header "Authorization: Bearer <TOKEN>"
```

**Claude Desktop** — no native remote-HTTP client, so use the `mcp-remote`
stdio↔HTTP bridge instead. Pass the full header
through `env` to avoid whitespace-splitting in `--header`:

```json
{
  "mcpServers": {
    "movieplexx": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "http://192.168.1.50:8000/mcp",
               "--header", "Authorization:${AUTH_HEADER}"],
      "env": { "AUTH_HEADER": "Bearer <TOKEN>" }
    }
  }
}
```

Quick smoke test (no token → 401):

```bash
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.1.50:8000/mcp   # 401
```

Without TLS, the bearer token travels in cleartext on the wire — anyone who can
observe the LAN segment can read it. To terminate HTTPS directly in the server,
set `MCP_TLS_CERTFILE`/`MCP_TLS_KEYFILE` (both required together) and use
`https://` in the client config and smoke test above. A self-signed cert is
enough for LAN use:

```bash
openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
  -keyout mcp-key.pem -out mcp-cert.pem -subj "/CN=192.168.1.50"
echo "MCP_TLS_CERTFILE=/data/mcp-cert.pem" >> .env
echo "MCP_TLS_KEYFILE=/data/mcp-key.pem" >> .env
```

This is intended for a trusted LAN. For off-LAN access put Tailscale/WireGuard in
front; for public exposure add a TLS reverse proxy. See `spec.md` §10.

## Container image (GHCR)

Multiarch images (`linux/amd64`, `linux/arm64`) are published to the GitHub
Container Registry by `.github/workflows/docker-publish.yml`:

```
ghcr.io/zahlenhelfer/movieplexx-mcp
```

| Tag | Published on | Meaning |
| --- | --- | --- |
| `dev` | every push to `main` | latest development build |
| `X.Y.Z`, `X.Y`, `X` | a published GitHub release `vX.Y.Z` | semver-pinned build |
| `latest` | a published GitHub release | newest release (never `dev`) |

Pull and run the published image instead of building locally:

```bash
docker pull ghcr.io/zahlenhelfer/movieplexx-mcp:latest

# scraper (hourly loop)
docker run -d --rm -v moviedata:/data ghcr.io/zahlenhelfer/movieplexx-mcp:latest scrape --loop

# MCP server (launched on demand by the client)
docker run -i --rm -v moviedata:/data:ro ghcr.io/zahlenhelfer/movieplexx-mcp:latest serve
```

The image is public — no `docker login` needed to pull. To cut a release image,
tag a commit and publish a GitHub release (`vX.Y.Z`); the workflow builds the
semver and `latest` tags.

## Metrics

In loop mode the scraper serves Prometheus metrics on `:${METRICS_PORT}/metrics`:

- `movieplexx_scrape_success_total` / `movieplexx_scrape_failure_total`
- `movieplexx_scrape_duration_seconds` (histogram)
- `movieplexx_films_seen` / `movieplexx_performances_seen` (last cycle)
- `movieplexx_parse_errors_total` — increments on a film that fails to normalize;
  alert on `> 0` to catch upstream schema drift.

## Tests

```bash
uv run pytest
```

`tests/test_contract.py` parses a checked-in golden snapshot
(`tests/fixtures/filtered-films.golden.json`) and asserts the exact field shape
the normalizer relies on. A failure means the upstream JSON drifted — regenerate
the snapshot (command in the test's docstring) once the change is understood.

## Etiquette

Honors robots.txt (`/programm/*` is allowed), uses a self-identifying User-Agent
with a contact address, and defaults to one request per hour.

TDQS

A3.7/5.0

Scored across 5 tools

Disambiguation4/5

Tools are mostly distinct: film_history covers performance history, get_film and list_films cover films, list_showtimes covers performances, and search_films covers search. However, list_films with upcoming filter and search_films could be confused for similar film queries.

Naming Consistency4/5

Four tools follow verb_noun pattern (get_film, list_films, list_showtimes, search_films), but film_history is a noun_noun exception, causing minor inconsistency.

Tool Count5/5

With 5 tools, the set is well-scoped for a movie information server, fitting the ideal range of 3-15 tools for focused functionality.

Completeness4/5

The server covers key read operations: listing, searching, detail, showtimes, and history. Minor gaps include lacking a dedicated performance detail tool, but the set is largely complete for its domain.

Maintenance

ActivityStale
ResponsivenessNo issues