Files
ersatztv/docs/mcp.md
T
timothyandClaude Opus 4.8 a88240dcec
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 17s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m38s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(#58): ErsatzTV.Mcp — read + cautious-write MCP server over /api/v1
Fresh stdio JSON-RPC MCP server wrapping the frozen /api/v1 surface,
superseding the closed read-only PR #76. 26 read tools (six families +
search/all-items & search/artists discovery) and cautious-write CRUD:
collections (incl. idempotent membership adds for #487), smart collections,
schedules, playouts, channels (create/update/delete/reset), and a
Jellyfin-focused media-source sync/scan slice. Writes gated behind
ERSATZTV_ALLOW_WRITES (default false, runtime-enforced).

Security baseline carried forward from PR #76/#289: read-only backstop,
JSON-RPC DoS guards + bounded stdin reader, per-request CTS over
headers+body, response-size cap, arg validation vs InputSchema,
reverse-proxy prefix preservation. Machine-key auth (X-Api-Key,
CSRF-exempt). If-Match/ETag round-trip for the one replace-all PUT that
honors it.

Cold-review fixes folded in:
- HIGH: reject control chars (CR/LF) in the ifMatch value before it reaches
  TryAddWithoutValidation — SocketsHttpHandler writes it verbatim, so a
  crafted value could smuggle headers onto the X-Api-Key request.
- Cache the empty-args JsonDocument (no per-call pooled-doc leak).
- Accept explicit JSON null for optional fields so a nullable API field
  (e.g. dailyRebuildTime) can be cleared as documented.

Deferred (documented): the ~40-field replace-list writes and redesign
workflow tools (#63-#68).

Docs: docs/mcp.md, docs/README.md index, docs/decisions.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 00:42:00 +02:00

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

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.