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

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

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.