# 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. ### Paging (`api.paging-zero-based`) Tools whose endpoint is paged declare `pageNum`/`pageSize` and pass them through unchanged — the MCP layer does **not** translate them, so they mean exactly what they mean on `/api/v1`. (A tool that wraps a paged endpoint *without* declaring these is a bug, not a "no paging needed" signal: `ToolArgumentValidator` rejects undeclared arguments, so the caller is hard-capped at the first page with no way to ask for more. `ersatztv_list_playouts` and `ersatztv_get_playout_items` were capped that way until #616. `ToolCatalogTests` pins the expected set.) - **`pageNum` is 0-based.** The first page is `0`. Starting at `1` silently skips a page and returns a short set with no error, which is easy to misread as missing data (ersatztv#616 — the catalog used to describe it as 1-based, and #487 lost a verification pass to it). - **`pageSize` is clamped server-side, and the cap is per-endpoint** — 100 for most reads (collections, library browse, logs, playouts), 200 for auto-tune channel members, 1000 for `ersatztv_search_all_items` (whose *default* is 500). The invariant is not a single number: it is that the offset is derived from the **effective** page size, never the requested one. So `pageSize=500` on a 100-capped tool gives 100-wide pages (page 1 = items 101–200), while the same value on `search_all_items` is under its cap and is honored (page 1 = items 501–1000). Don't assume your requested size held — page to completeness against `totalCount`. Where a row is meant to be acted on, it carries the *id* of the entity you act on and not only its display name — take the channel id for `ersatztv_reset_channel_playout` from a playout row's `channelId`, never from its `id` (that is the *playout* id, and the two id spaces overlap numerically, so the wrong one silently succeeds against a different channel). This is a rule for actionable references, not a claim that every row is fully id-annotated: `scheduleName` on a playout row still ships without a schedule id, because nothing asks you to address a schedule from there. ### 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. ### Full-replace writes drop what you omit (`mcp.tool-schema-openapi-parity`) **Check each tool's own description — the write tools are not uniform, and one is not uniform with itself.** Three are full replaces, where a field you leave out is not "left unchanged" but written as empty: `ersatztv_update_channel`, `ersatztv_update_schedule`, `ersatztv_update_collection_custom_order`. `ersatztv_update_playout` is **mixed, and this is the easy one to get wrong**: `scheduleFile` is leave-unchanged, but `dailyRebuildTime` is always applied — `UpdatePlayoutHandler` sets it to `null` unconditionally before re-applying a supplied value, so calling this tool to set `scheduleFile` while omitting `dailyRebuildTime` **silently clears the daily reset**. Send both, or neither. The rest are additive or leave-unchanged and say so: `ersatztv_add_collection_items` is an idempotent add (it does **not** replace membership), `ersatztv_update_collection` leaves an omitted `useCustomPlaybackOrder` alone, and `ersatztv_enable_jellyfin_library_sync` leaves an absent row untouched. For the full-replace ones, the GET → edit one field → PUT flow is only safe if the tool can express the whole state, and `ersatztv_update_channel` could not — it omitted `graphicsElementIds`, so that flow silently detached every graphics element (including the On Now/Next overlay) with a `200` and no error, visible only as missing pixels at the next transition. `ersatztv_update_schedule` cleared `padToNearestMinute` the same way (ersatztv#754). Both are fixed, and the class is now guarded by two tests in `ToolCatalogTests`, comparing against the generated `ErsatzTV/wwwroot/openapi/v1.json`: - Every POST/PUT/PATCH tool declares **exactly** the request-body fields its endpoint accepts, each with a matching type. A new property on a request DTO fails until the catalog declares it. - Every tool — read **and** write — declares **exactly** its endpoint's query parameters. An omitted one is not merely undocumented but *unreachable*, since `ToolArgumentValidator` rejects undeclared arguments; that is how #616 hard-capped two paged tools at the first page, and how `ersatztv_list_playouts` (`query`) and `ersatztv_get_playout_items` (`showFiller`) lost their filters until ersatztv#757. When adding a write tool, regenerate the spec (`./scripts/update-openapi.sh`) and add the tool to the pinned list in the body test. ## Deferred Channel create/update (`ersatztv_create_channel` / `ersatztv_update_channel`) wrap a large DTO with nine enum fields — 27 body fields on create, and 28 on update, which additionally carries `graphicsElementIds`. 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 — and copy its `graphicsElementIds` through unless you mean to detach them. 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.