Files
ersatztv/docs/mcp.md
T
timothyandClaude Opus 5 78ec997eae
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 29s
review-verdict/h10 Review-verdict: MERGEABLE @ 78ec997
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m59s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 15m10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 18m41s
docs(616): stop the record title and mcp.md from contradicting their own bodies
Two LOWs from the re-review of af39123e. Both are the same defect I have already
corrected twice on this branch: a universal claim that the corrected body right
below it disproves. These read as normative, so a reader who stops at the title
gets the false version.

- The record was titled "...and every wrapper says so" while its own body admits
  the 12 OpenAPI pageNum parameters carry no description at all (tracked as
  #633). Retitled to state the target and name the exception.
- docs/mcp.md led with "Rows that reference another entity carry that entity's
  id" — but PlayoutListItemResponseModel.ScheduleName ships with no schedule id.
  Restated as a rule about ACTIONABLE references, matching the wording the record
  already uses, with the known exception named.

The two remaining LOWs are accepted deferrals, not fixed here: the >100 rerun
collection truncation needs paging-to-completeness plus a multi-page fixture
(#634), and the catalog test's forward-looking gap (it compares tool names, not
PathTemplate against the paged-endpoint set) would need the MCP test project to
reference the controllers to close properly.

Docs only; catalog regenerated. No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:51:14 +02:00

204 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 101200), while the same
value on `search_all_items` is under its cap and is honored (page 1 = items 5011000). 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.
## 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.