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

12 KiB
Raw Blame History

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/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

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-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.