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>
12 KiB
ErsatzTV MCP Server
ErsatzTV.Mcp is a stdio JSON-RPC MCP 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/v1contract (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
dotnet build ErsatzTV.Mcp/ErsatzTV.Mcp.csproj
Configure an MCP client to start the server over stdio:
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-CSRFheader 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(idnull); a malformed request object →-32600; a bad tool call →-32602; a transport/timeout failure →-32603for the id (a compliant client never hangs). TheProgram.Mainread loop also catches any unexpected per-line error. - Bounded input and output. A hostile client cannot exhaust memory with a giant unterminated line
(
BoundedLineReadercaps + drains it), and API bodies are read up toERSATZTV_MAX_RESPONSE_BYTESand 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 7232If-Matchrequest 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.)
pageNumis 0-based. The first page is0. Starting at1silently 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).pageSizeis 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 forersatztv_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. SopageSize=500on a 100-capped tool gives 100-wide pages (page 1 = items 101–200), while the same value onsearch_all_itemsis under its cap and is honored (page 1 = items 501–1000). Don't assume your requested size held — page to completeness againsttotalCount.
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 simpleupdate_collection_custom_orderreplace is exposed as theIf-Matchexemplar. - 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.