Files
ersatztv/docs/mcp.md
T
timothyandClaude Opus 5 5648f8e92e docs(616): scope the pageSize cap per endpoint — it is not one number
Self-review of the previous commit caught an overclaim I introduced. I wrote the
paging docs as if the pageSize cap were uniformly 100. It is not:

  most reads (collections, library browse, logs, playouts)  Math.Clamp(.., 1, 100)
  GetAutoTuneChannelMembers                                 pageSize <= 0 ? 100 : Min(.., 200)
  GET /api/v1/search/all-items                              default 500, cap 1000

That made a concrete example in docs/mcp.md simply false. It claimed
`pageSize=500&pageNum=1` returns items 101-200 "not 501-1000" — but
ersatztv_search_all_items also takes Page(), and 500 is UNDER its cap, so there
page 1 really is items 501-1000. A caller following that example on the one tool
most likely to be paged hard would have mis-derived its offsets, which is the
same class of silent-short-set error this issue is about.

The invariant that actually holds everywhere is the derivation, not any single
cap: the offset comes from the EFFECTIVE (bounded) page size, never the
requested one. Reworded to say that, in docs/mcp.md, the api.paging-zero-based
record (rule + mechanics + body), and the ToolCatalog Page() comment. Catalog
regenerated. The record's mechanics line no longer claims every controller uses
Math.Clamp — ChannelController does not.

No behaviour change; MCP suite still 59/59.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:24:40 +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)

The paged tools take pageNum/pageSize and pass them through unchanged — the MCP layer does not translate them, so they mean exactly what they mean on /api/v1:

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

Rows that reference another entity carry that entity's id, not just its display fields — 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).

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.