Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 17s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m38s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Fresh stdio JSON-RPC MCP server wrapping the frozen /api/v1 surface, superseding the closed read-only PR #76. 26 read tools (six families + search/all-items & search/artists discovery) and cautious-write CRUD: collections (incl. idempotent membership adds for #487), smart collections, schedules, playouts, channels (create/update/delete/reset), and a Jellyfin-focused media-source sync/scan slice. Writes gated behind ERSATZTV_ALLOW_WRITES (default false, runtime-enforced). Security baseline carried forward from PR #76/#289: read-only backstop, JSON-RPC DoS guards + bounded stdin reader, per-request CTS over headers+body, response-size cap, arg validation vs InputSchema, reverse-proxy prefix preservation. Machine-key auth (X-Api-Key, CSRF-exempt). If-Match/ETag round-trip for the one replace-all PUT that honors it. Cold-review fixes folded in: - HIGH: reject control chars (CR/LF) in the ifMatch value before it reaches TryAddWithoutValidation — SocketsHttpHandler writes it verbatim, so a crafted value could smuggle headers onto the X-Api-Key request. - Cache the empty-args JsonDocument (no per-call pooled-doc leak). - Accept explicit JSON null for optional fields so a nullable API field (e.g. dailyRebuildTime) can be cleared as documented. Deferred (documented): the ~40-field replace-list writes and redesign workflow tools (#63-#68). Docs: docs/mcp.md, docs/README.md index, docs/decisions.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
177 lines
10 KiB
Markdown
177 lines
10 KiB
Markdown
# ErsatzTV MCP Server
|
|
|
|
`ErsatzTV.Mcp` is a stdio JSON-RPC [MCP](https://modelcontextprotocol.io) server that wraps the
|
|
frozen ErsatzTV/ChicoryTV `/api/v1` REST surface as explicit, narrow tools for AI agents. It exposes
|
|
**read** tools by default and **cautious-write** tools behind an opt-in (issue #58).
|
|
|
|
It maps each tool to an OpenAPI-backed endpoint in `ErsatzTV/wwwroot/openapi/v1.json`. It does **not**
|
|
scrape the web UI and does **not** read or write SQLite directly.
|
|
|
|
> This is a fresh build against the versioned `/api/v1` contract (mounted by #286), superseding the
|
|
> read-only v0 foundation in the closed PR #76. The security baseline below is carried forward from
|
|
> PR #76 / #289 verbatim.
|
|
|
|
## Running
|
|
|
|
```bash
|
|
dotnet build ErsatzTV.Mcp/ErsatzTV.Mcp.csproj
|
|
```
|
|
|
|
Configure an MCP client to start the server over stdio:
|
|
|
|
```bash
|
|
dotnet run --project /path/to/ersatztv/ErsatzTV.Mcp/ErsatzTV.Mcp.csproj
|
|
```
|
|
|
|
Environment variables:
|
|
|
|
| Variable | Default | Purpose |
|
|
|---|---:|---|
|
|
| `ERSATZTV_URL` | `http://localhost:8409` | Base URL for the ErsatzTV API. A reverse-proxy path prefix (e.g. `https://host/etv/`) is preserved. |
|
|
| `ERSATZTV_API_KEY` | unset | Sent as `X-Api-Key` on every request. Effectively **required** (see Authentication). |
|
|
| `ERSATZTV_ALLOW_WRITES` | `false` | Write posture. While `false`, the executor refuses any non-GET tool before it reaches the API. Set `true` to enable the write tools below. |
|
|
| `ERSATZTV_MAX_RESPONSE_BYTES` | `1048576` | Cap on the API response body buffered back to the model; larger responses are truncated with a marker. |
|
|
| `ERSATZTV_REQUEST_TIMEOUT_SECONDS` | `30` | Per-request HTTP timeout (covers headers **and** the streamed body). |
|
|
|
|
## Authentication
|
|
|
|
ErsatzTV's `/api` surface is gated by a fail-closed session-or-key filter (`api-conventions.md` §9).
|
|
The MCP server is a **machine client**, so it authenticates with **`X-Api-Key`** on every request:
|
|
|
|
- **Every write** (POST/PUT/PATCH/DELETE) requires the key. There is no "open" write mode.
|
|
- **Reads** require the key too under the default `Api:RequireKeyForReads=true`.
|
|
- Key-authed requests are **CSRF-exempt** (the `X-CSRF` header the browser session path needs does not
|
|
apply to the machine key), so the MCP server sends no CSRF header.
|
|
|
|
So **`ERSATZTV_API_KEY` is effectively required**; without it tool calls return `401`. The key is the
|
|
server machine key — surfaced read-only by the SPA's machine-key screen
|
|
(`GET /api/v1/auth/machine-key`) or persisted at `/config/api.key`.
|
|
|
|
## Security posture
|
|
|
|
- **Read-only by default, runtime-enforced.** Even if a catalog entry were wrong, the executor refuses
|
|
any non-GET tool unless `ERSATZTV_ALLOW_WRITES=true` — a single bad entry cannot mutate or delete.
|
|
- **Malformed input never crashes the session.** Invalid JSON → JSON-RPC `-32700` (id `null`); a
|
|
malformed request object → `-32600`; a bad tool call → `-32602`; a transport/timeout failure →
|
|
`-32603` for the id (a compliant client never hangs). The `Program.Main` read loop also catches any
|
|
unexpected per-line error.
|
|
- **Bounded input and output.** A hostile client cannot exhaust memory with a giant unterminated line
|
|
(`BoundedLineReader` caps + drains it), and API bodies are read up to `ERSATZTV_MAX_RESPONSE_BYTES`
|
|
and truncated (on a UTF-8 code-point boundary). Every request has a timeout covering headers **and**
|
|
the streamed body.
|
|
- **Arguments are validated** against each tool's declared `InputSchema` (required present, no unknown
|
|
args — `additionalProperties:false` — basic types) before any request is built. Path params reject
|
|
`.`/`..` so a value can't canonicalize onto a different route.
|
|
- **Tool results are untrusted data.** Response bodies (media titles, file paths, error text) can be
|
|
attacker-influenced and are returned to the model verbatim. Treat all tool output as data, never as
|
|
instructions; the consuming agent's system prompt should frame it as such. This is the standard
|
|
prompt-injection caveat for any tool that surfaces external content.
|
|
|
|
## How tools map to the API
|
|
|
|
Each declared argument routes to exactly one place:
|
|
|
|
- **path** — a `{param}` in the path template (URL-encoded; `.`/`..` rejected).
|
|
- **query** — an argument listed in the tool's query-parameter set (URL-encoded onto the query string,
|
|
for any verb).
|
|
- **`ifMatch`** — the reserved header argument, carried as the RFC 7232 `If-Match` request header (see
|
|
Optimistic concurrency). A value containing control characters (CR/LF) is rejected before the request
|
|
is sent, so it cannot smuggle additional headers onto the API-key-bearing request.
|
|
- **body** — for write verbs (POST/PUT/PATCH), every remaining argument is serialized as the JSON
|
|
request body (`application/json`).
|
|
|
|
When a response carries an `ETag` header (versioned aggregates emit it on GET and on a successful
|
|
replace PUT), the tool result appends a `\n[etag: "N"]` marker so an agent can round-trip it as
|
|
`ifMatch` on a subsequent write.
|
|
|
|
### Optimistic concurrency (`api-conventions.md` §7a)
|
|
|
|
Only the **replace-all aggregate PUTs** honor `If-Match` — here that is
|
|
`ersatztv_update_collection_custom_order`. Read the ETag from the matching GET
|
|
(`ersatztv_get_collection_items`), pass it back as `ifMatch` (e.g. `"3"`); a stale tag → `412`, a
|
|
grammar violation → `400`, `"*"` or omitting it force-writes. All other writes ignore `If-Match` and
|
|
force-write, so no ETag handshake is needed for them.
|
|
|
|
## Read tools
|
|
|
|
| Tool | API route |
|
|
|---|---|
|
|
| `ersatztv_list_channels` | `GET /api/v1/channels` |
|
|
| `ersatztv_get_channel` | `GET /api/v1/channels/{id}` |
|
|
| `ersatztv_list_collections` | `GET /api/v1/collections` |
|
|
| `ersatztv_get_collection` | `GET /api/v1/collections/{id}` |
|
|
| `ersatztv_get_collection_items` | `GET /api/v1/collections/{id}/items` (paged; emits ETag) |
|
|
| `ersatztv_list_smart_collections` | `GET /api/v1/smart-collections` |
|
|
| `ersatztv_get_smart_collection` | `GET /api/v1/smart-collections/{id}` |
|
|
| `ersatztv_list_schedules` | `GET /api/v1/schedules` |
|
|
| `ersatztv_get_schedule` | `GET /api/v1/schedules/{id}` |
|
|
| `ersatztv_get_schedule_items` | `GET /api/v1/schedules/{id}/items` (emits ETag) |
|
|
| `ersatztv_list_playouts` | `GET /api/v1/playouts` |
|
|
| `ersatztv_get_playout` | `GET /api/v1/playouts/{id}` |
|
|
| `ersatztv_get_playout_items` | `GET /api/v1/playouts/{id}/items` |
|
|
| `ersatztv_list_ffmpeg_profiles` | `GET /api/v1/ffmpeg/profiles` |
|
|
| `ersatztv_get_ffmpeg_profile` | `GET /api/v1/ffmpeg/profiles/{id}` |
|
|
| `ersatztv_get_resolution_by_name` | `GET /api/v1/ffmpeg/resolution/by-name/{name}` |
|
|
| `ersatztv_list_sessions` | `GET /api/v1/sessions` |
|
|
| `ersatztv_get_version` | `GET /api/v1/version` |
|
|
| `ersatztv_list_media_sources` | `GET /api/v1/media-sources` |
|
|
| `ersatztv_get_jellyfin_libraries` | `GET /api/v1/media-sources/jellyfin/{id}/libraries` |
|
|
| `ersatztv_list_local_libraries` | `GET /api/v1/libraries/local` |
|
|
| `ersatztv_get_library_scan_status` | `GET /api/v1/libraries/scan-status` |
|
|
| `ersatztv_search` | `GET /api/v1/search` |
|
|
| `ersatztv_search_all_items` | `GET /api/v1/search/all-items` (raw id lists) |
|
|
| `ersatztv_search_artists` | `GET /api/v1/search/artists` |
|
|
|
|
## Write tools (require `ERSATZTV_ALLOW_WRITES=true`)
|
|
|
|
| Tool | API route |
|
|
|---|---|
|
|
| `ersatztv_create_collection` | `POST /api/v1/collections` |
|
|
| `ersatztv_update_collection` | `PUT /api/v1/collections/{id}` |
|
|
| `ersatztv_delete_collection` | `DELETE /api/v1/collections/{id}` |
|
|
| `ersatztv_add_collection_items` | `POST /api/v1/collections/{id}/items` (idempotent; existence-checked) |
|
|
| `ersatztv_remove_collection_item` | `DELETE /api/v1/collections/{id}/items/{mediaItemId}` |
|
|
| `ersatztv_update_collection_custom_order` | `PUT /api/v1/collections/{id}/custom-order` (honors `If-Match`) |
|
|
| `ersatztv_create_smart_collection` | `POST /api/v1/smart-collections` |
|
|
| `ersatztv_update_smart_collection` | `PUT /api/v1/smart-collections/{id}` |
|
|
| `ersatztv_delete_smart_collection` | `DELETE /api/v1/smart-collections/{id}` |
|
|
| `ersatztv_create_schedule` | `POST /api/v1/schedules` |
|
|
| `ersatztv_update_schedule` | `PUT /api/v1/schedules/{id}` |
|
|
| `ersatztv_delete_schedule` | `DELETE /api/v1/schedules/{id}` |
|
|
| `ersatztv_create_playout` | `POST /api/v1/playouts` |
|
|
| `ersatztv_update_playout` | `PUT /api/v1/playouts/{id}` |
|
|
| `ersatztv_delete_playout` | `DELETE /api/v1/playouts/{id}` |
|
|
| `ersatztv_create_channel` | `POST /api/v1/channels` |
|
|
| `ersatztv_update_channel` | `PUT /api/v1/channels/{id}` |
|
|
| `ersatztv_reset_channel_playout` | `POST /api/v1/channels/{id}/playout/reset` |
|
|
| `ersatztv_delete_channel` | `DELETE /api/v1/channels/{id}` |
|
|
| `ersatztv_enable_jellyfin_library_sync` | `PUT /api/v1/media-sources/jellyfin/{id}/libraries` |
|
|
| `ersatztv_refresh_jellyfin_libraries` | `POST /api/v1/media-sources/jellyfin/{id}/refresh-libraries` |
|
|
| `ersatztv_scan_jellyfin_collections` | `POST /api/v1/media-sources/jellyfin/{id}/scan-collections` |
|
|
| `ersatztv_scan_library` | `POST /api/v1/libraries/{id}/scan` |
|
|
|
|
### Populating a collection (the #487 acceptance case)
|
|
|
|
`ersatztv_add_collection_items` funnels every media kind through one endpoint — send only the id
|
|
buckets you need (`artistIds`, `musicVideoIds`, `songIds`, `movieIds`, …). Discover ids with
|
|
`ersatztv_search_all_items` (returns raw id lists for a Lucene query) or `ersatztv_search_artists`.
|
|
Re-adding an already-present item is an **idempotent no-op** (no duplicate rows, still `204`); if any
|
|
referenced id does not exist the whole batch is rejected (`422`). So the flow is: search → add ids →
|
|
re-run to confirm idempotence.
|
|
|
|
## Deferred
|
|
|
|
Channel create/update (`ersatztv_create_channel` / `ersatztv_update_channel`) wrap a 28-field DTO with
|
|
nine enum fields. Only `name`/`number`/`ffmpegProfileId` are required; the rest have server-side
|
|
defaults, and the enum fields take the enum **name** (the API validates them). Discover an existing
|
|
channel's shape and current enum values with `ersatztv_get_channel` before creating/updating.
|
|
|
|
Deliberately **not** exposed in this cautious first write pass:
|
|
|
|
- **The replace-list writes with large item DTOs** — schedule items (`PUT .../schedules/{id}/items`,
|
|
~40 fields per item) and playout alternate-schedules/templates. The simple
|
|
`update_collection_custom_order` replace is exposed as the `If-Match` exemplar.
|
|
- **Redesign-aware workflow tools** — create-channel-from-lineup (#63), Channel Templates (#64),
|
|
library browse/artwork (#65), image/logo/watermark (#66/#67), resume/bookmark (#68). These should
|
|
wrap the composite backend endpoints once those contracts exist, not recreate workflows in MCP.
|