Files
ersatztv/docs/mcp.md
T
timothyandtimothy 9881d1ff81
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m56s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 7m1s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m51s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m37s
fix(754,757): declare graphicsElementIds + padToNearestMinute, and pin every MCP tool to its OpenAPI contract (#760)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-06 20:42:37 +00:00

242 lines
15 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.
### 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.