Backlog: MCP server for ErsatzTV/ChicoryTV API #58

Closed
opened 2026-07-01 20:21:20 +02:00 by timothy · 7 comments
Owner

Backlog

Now that ErsatzTV has a real API surface, investigate and build an MCP server so agents/tools can safely inspect and operate the system without scraping UI or touching SQLite directly.

Rough scope

  • Expose useful read actions first: channels, schedules, playouts, collections, media sources, health/status.
  • Add write actions only where the API contract is stable and operations are safe/idempotent enough for tool use.
  • Prefer the app API over direct DB access.
  • Include auth/config story for local homelab use.
  • Document common workflows for agents.

Depends on

  • API surface being sufficiently complete and stable.

Notes

This is separate from server-management operational automation. It belongs in the app ecosystem because it wraps the ErsatzTV/ChicoryTV API.

## Backlog Now that ErsatzTV has a real API surface, investigate and build an MCP server so agents/tools can safely inspect and operate the system without scraping UI or touching SQLite directly. ## Rough scope - Expose useful read actions first: channels, schedules, playouts, collections, media sources, health/status. - Add write actions only where the API contract is stable and operations are safe/idempotent enough for tool use. - Prefer the app API over direct DB access. - Include auth/config story for local homelab use. - Document common workflows for agents. ## Depends on - API surface being sufficiently complete and stable. ## Notes This is separate from server-management operational automation. It belongs in the app ecosystem because it wraps the ErsatzTV/ChicoryTV API.
Author
Owner

Scope clarification

The MCP can start now, but it should be scoped as a thin foundation over the current stable REST/OpenAPI surface, not as the final redesign-aware automation layer.

Recommended increment split:

  • v0 — read-first MCP foundation: expose safe read/inspection tools for current API-backed resources: channels, collections/smart collections, schedules and schedule items, playout lookup, FFmpeg profiles/resolution, sessions, version, and limited status/operational reads where the current API supports them. Generate or validate tool contracts against ErsatzTV/wwwroot/openapi/v1.json; do not scrape the UI or read/write SQLite directly.
  • v0.1 — cautious writes: add stable REST CRUD/write tools only where the API contract is already settled and the operation is safe enough for tool use. Use the existing X-Api-Key write-auth story when configured. Prefer explicit, narrow tools over generic arbitrary HTTP calls.
  • Later redesign tools: add workflow-level tools only after the UI-driven backend gaps land under #62/#63-#68. In particular, do not build a fake MCP-side create channel from lineup workflow before #63 exists; that should wrap the composite backend endpoint once available.

Known future API gaps from the ChicoryTV design brief:

  • #63 composite create-channel-from-lineup endpoint
  • #64 Channel Templates
  • #65 library browse/search API with artwork
  • #66/#67 image upload/logo/watermark handling
  • #68 resume/bookmark playback behavior with EPG consistency

Worktree guidance: this should be developed in a separate git worktree so #59 design work can continue on docs/59-ui-redesign-brief without branch switching.

## Scope clarification The MCP can start now, but it should be scoped as a thin foundation over the **current stable REST/OpenAPI surface**, not as the final redesign-aware automation layer. Recommended increment split: - **v0 — read-first MCP foundation**: expose safe read/inspection tools for current API-backed resources: channels, collections/smart collections, schedules and schedule items, playout lookup, FFmpeg profiles/resolution, sessions, version, and limited status/operational reads where the current API supports them. Generate or validate tool contracts against `ErsatzTV/wwwroot/openapi/v1.json`; do not scrape the UI or read/write SQLite directly. - **v0.1 — cautious writes**: add stable REST CRUD/write tools only where the API contract is already settled and the operation is safe enough for tool use. Use the existing `X-Api-Key` write-auth story when configured. Prefer explicit, narrow tools over generic arbitrary HTTP calls. - **Later redesign tools**: add workflow-level tools only after the UI-driven backend gaps land under #62/#63-#68. In particular, do **not** build a fake MCP-side `create channel from lineup` workflow before #63 exists; that should wrap the composite backend endpoint once available. Known future API gaps from the ChicoryTV design brief: - #63 composite create-channel-from-lineup endpoint - #64 Channel Templates - #65 library browse/search API with artwork - #66/#67 image upload/logo/watermark handling - #68 resume/bookmark playback behavior with EPG consistency Worktree guidance: this should be developed in a separate git worktree so #59 design work can continue on `docs/59-ui-redesign-brief` without branch switching.
Author
Owner

Done

What was done: Built the v0 read-only MCP foundation over the current stable REST/OpenAPI surface in PR #76. Added ErsatzTV.Mcp as a stdio JSON-RPC MCP server with explicit tools for channels, collections, smart collections, schedules/items, playout lookup, FFmpeg profiles/resolution, sessions, and version. Added API client handling for configured X-Api-Key and focused MCP/API-client/tool-catalog tests.

Root cause: n/a.

Files changed: ErsatzTV.Mcp/*, ErsatzTV.Mcp.Tests/*, ErsatzTV.sln, docs/mcp.md, docs/superpowers/plans/2026-07-02-ersatztv-mcp-v0-plan.md.

Verification: TZ=UTC dotnet restore ErsatzTV.sln -v minimal; TZ=UTC dotnet build ErsatzTV.sln --no-restore -v minimal; TZ=UTC dotnet test ErsatzTV.Mcp.Tests/ErsatzTV.Mcp.Tests.csproj -v minimal (12 passed); TZ=UTC dotnet test ErsatzTV.sln --no-build -v minimal (exit 0, includes MCP tests). Existing NuGet audit warnings remain for Scriban.Signed and Microsoft.OpenApi.

Deferred: v0.1 write tools are not included. Redesign-aware workflow tools remain deferred until #63-#68 backend contracts exist.

Follow-up issues: Existing future backend gaps #63-#68. A separate v0.1 write-tools issue can be opened if desired.

Docs updated: docs/mcp.md documents configuration, current v0 tool list, auth header behavior, and deferred workflow scope.

Leaving #58 open because this PR completes the requested v0 foundation but not the optional v0.1 write-tool increment or later redesign workflow layer.

## Done **What was done**: Built the v0 read-only MCP foundation over the current stable REST/OpenAPI surface in PR #76. Added `ErsatzTV.Mcp` as a stdio JSON-RPC MCP server with explicit tools for channels, collections, smart collections, schedules/items, playout lookup, FFmpeg profiles/resolution, sessions, and version. Added API client handling for configured `X-Api-Key` and focused MCP/API-client/tool-catalog tests. **Root cause**: n/a. **Files changed**: `ErsatzTV.Mcp/*`, `ErsatzTV.Mcp.Tests/*`, `ErsatzTV.sln`, `docs/mcp.md`, `docs/superpowers/plans/2026-07-02-ersatztv-mcp-v0-plan.md`. **Verification**: `TZ=UTC dotnet restore ErsatzTV.sln -v minimal`; `TZ=UTC dotnet build ErsatzTV.sln --no-restore -v minimal`; `TZ=UTC dotnet test ErsatzTV.Mcp.Tests/ErsatzTV.Mcp.Tests.csproj -v minimal` (12 passed); `TZ=UTC dotnet test ErsatzTV.sln --no-build -v minimal` (exit 0, includes MCP tests). Existing NuGet audit warnings remain for `Scriban.Signed` and `Microsoft.OpenApi`. **Deferred**: v0.1 write tools are not included. Redesign-aware workflow tools remain deferred until #63-#68 backend contracts exist. **Follow-up issues**: Existing future backend gaps #63-#68. A separate v0.1 write-tools issue can be opened if desired. **Docs updated**: `docs/mcp.md` documents configuration, current v0 tool list, auth header behavior, and deferred workflow scope. Leaving #58 open because this PR completes the requested v0 foundation but not the optional v0.1 write-tool increment or later redesign workflow layer.
Author
Owner

HARD GATE: do not close this issue or point the MCP server at prod before the cold adversarial review in #197 (REST API contract + security posture, run from the adversarial-reviewer project) has been executed and its MUST-FIX findings resolved. The MCP server is an AI-facing consumer of an API whose auth posture has known deferred soft spots — #197 has the full scope. The review request gets filed in the adversarial-reviewer repo only when it can actually be run (post PR #76 stabilization), not before.

**HARD GATE**: do not close this issue or point the MCP server at prod before the cold adversarial review in #197 (REST API contract + security posture, run from the `adversarial-reviewer` project) has been executed and its MUST-FIX findings resolved. The MCP server is an AI-facing consumer of an API whose auth posture has known deferred soft spots — #197 has the full scope. The review request gets filed in the adversarial-reviewer repo only when it can actually be run (post PR #76 stabilization), not before.
timothy added the apienhancementpriority: low labels 2026-07-11 19:03:42 +02:00
timothy added the in-progress label 2026-07-12 23:43:39 +02:00
Author
Owner

Re-scope (2026-07-12) — fresh read+write build against frozen /api/v1, superseding PR #76

Owner decision: PR #76 (v0 read-only foundation) is closed as superseded rather than merged/rebased. The MCP is rebuilt fresh here once the API contract freezes.

Sequencing (the ## Depends on: API surface being sufficiently complete and stable in this body, made concrete):

  1. #286 (/api/v1 versioning) freezes + versions the route surface first — it rewrites every route, so building the MCP before it would mean regenerating the tool contracts immediately after.
  2. Then build the MCP fresh against /api/v1: read tools + cautious-write CRUD tools (channels / collections / smart collections / schedules + items / playouts / media-sources — all now have settled write contracts with ETag/If-Match optimistic concurrency).
  3. Deferred as before: composite/workflow tools (create-channel-from-lineup #63, templates #64, library browse+artwork #65, image upload #66/#67, resume/bookmark #68) — wait for those backend endpoints.

Carry forward from PR #76 / #289 (do not re-derive): runtime read-only posture toggle (ERSATZTV_ALLOW_WRITES, default false) as the write backstop, JSON-RPC DoS guards (-32700/-32600/-32603, bounded stdin reader), per-request CTS timeout covering headers and streamed body (ResponseHeadersRead gotcha), response-size cap, arg-validation vs InputSchema, reverse-proxy prefix preservation, untrusted-data/injection framing in docs. These are the security baseline.

Unchanged HARD GATE: MCP does not go live / point at prod until #197's security-review MUST-FIX findings are resolved (this build gets folded into that pass).

Un-claiming (in-progress off) — the build is deferred to after #286; this session is doing #286.

## Re-scope (2026-07-12) — fresh read+write build against frozen `/api/v1`, superseding PR #76 Owner decision: PR #76 (v0 read-only foundation) is **closed as superseded** rather than merged/rebased. The MCP is rebuilt fresh here once the API contract freezes. **Sequencing** (the `## Depends on: API surface being sufficiently complete and stable` in this body, made concrete): 1. **#286** (`/api/v1` versioning) freezes + versions the route surface first — it rewrites every route, so building the MCP before it would mean regenerating the tool contracts immediately after. 2. Then build the MCP fresh against **`/api/v1`**: read tools **+ cautious-write CRUD** tools (channels / collections / smart collections / schedules + items / playouts / media-sources — all now have settled write contracts with ETag/If-Match optimistic concurrency). 3. **Deferred as before**: composite/workflow tools (create-channel-from-lineup #63, templates #64, library browse+artwork #65, image upload #66/#67, resume/bookmark #68) — wait for those backend endpoints. **Carry forward from PR #76 / #289** (do not re-derive): runtime read-only *posture* toggle (`ERSATZTV_ALLOW_WRITES`, default false) as the write backstop, JSON-RPC DoS guards (`-32700`/`-32600`/`-32603`, bounded stdin reader), per-request CTS timeout covering headers **and** streamed body (`ResponseHeadersRead` gotcha), response-size cap, arg-validation vs `InputSchema`, reverse-proxy prefix preservation, untrusted-data/injection framing in docs. These are the security baseline. **Unchanged HARD GATE**: MCP does not go live / point at prod until #197's security-review MUST-FIX findings are resolved (this build gets folded into that pass). Un-claiming (`in-progress` off) — the build is deferred to after #286; this session is doing #286.
timothy removed the in-progress label 2026-07-12 23:46:43 +02:00
timothy added the in-progress label 2026-07-20 23:27:56 +02:00
Author
Owner

Claiming (Claude Code orchestrator session, 2026-07-20).

All three gates from the 2026-07-12 re-scope are now clear:

  • #286 (/api/v1 versioned + frozen) — closed 07-13, so the route surface won't shift under the tool contracts.
  • #197 (cold adversarial security review, the HARD GATE for prod) — closed 07-13.
  • #474 (Jellyfin music-video ingestion, the data precondition for the #487 acceptance case) — closed tonight 23:14 (1437 music videos / 198 artists live on prod).

Plan per the re-scope: build fresh (PR #76 stays superseded/closed) as ErsatzTV.Mcp, a stdio JSON-RPC server over the frozen /api/v1 surface — read tools + cautious-write CRUD (channels / collections / smart collections / schedules+items / playouts / media-sources, all now with settled ETag/If-Match write contracts). Carrying forward the PR #76/#289 security baseline verbatim (do not re-derive): ERSATZTV_ALLOW_WRITES default-false posture, JSON-RPC DoS guards (-32700/-32600/-32603, bounded stdin reader), per-request CTS covering headers and streamed body, response-size cap, arg-validation vs InputSchema, reverse-proxy prefix preservation, untrusted-data/injection framing in the docs. Composite/workflow tools (#63–#68) stay deferred.

Then #487 (populate the empty music collections through the MCP write path) is the live acceptance case. Working in worktree feat/58-mcp-v1 off origin/main.

Claiming (Claude Code orchestrator session, 2026-07-20). All three gates from the 2026-07-12 re-scope are now clear: - **#286** (`/api/v1` versioned + frozen) — closed 07-13, so the route surface won't shift under the tool contracts. - **#197** (cold adversarial security review, the HARD GATE for prod) — closed 07-13. - **#474** (Jellyfin music-video ingestion, the data precondition for the #487 acceptance case) — closed tonight 23:14 (1437 music videos / 198 artists live on prod). Plan per the re-scope: build fresh (PR #76 stays superseded/closed) as `ErsatzTV.Mcp`, a stdio JSON-RPC server over the frozen `/api/v1` surface — **read tools + cautious-write CRUD** (channels / collections / smart collections / schedules+items / playouts / media-sources, all now with settled ETag/If-Match write contracts). Carrying forward the PR #76/#289 security baseline verbatim (do not re-derive): `ERSATZTV_ALLOW_WRITES` default-false posture, JSON-RPC DoS guards (`-32700`/`-32600`/`-32603`, bounded stdin reader), per-request CTS covering headers **and** streamed body, response-size cap, arg-validation vs `InputSchema`, reverse-proxy prefix preservation, untrusted-data/injection framing in the docs. Composite/workflow tools (#63–#68) stay deferred. Then #487 (populate the empty music collections through the MCP write path) is the live acceptance case. Working in worktree `feat/58-mcp-v1` off `origin/main`.
Author
Owner

Live acceptance case passed (#487, Vaporwave) — plus one write-surface finding

Drove the v0.1 write path end-to-end against prod (v26.11.0) to populate the empty Vaporwave collection: search_all_items tag:"Vaporwave" → 204 musicVideoIdsadd_collection_items (collection 20) → get_collection_items shows 0 → 204, a re-run stays 204 (idempotent, no dup rows), and playout 28 rebuilt success=true with 47 items. Full write-through-MCP, no SQL/SPA. Details on #487. The read tools, machine-key auth, ETag surfacing ([etag: "0"] on the empty collection), and the ERSATZTV_ALLOW_WRITES gate all behaved correctly against real prod data.

Finding — paged list tools don't expose pagination (follow-up)

ersatztv_list_playouts maps to GET /api/v1/playouts, which is paged ({totalCount, page[]}, default page ~10), but the tool declares no query args — so pageSize/pageNum are rejected (additionalProperties:false) and you cannot reach page 2+. I worked around it by addressing the playout by id. Any paged list endpoint the MCP wraps (list_playouts, and check list_channels/list_schedules/list_sessions) should declare pageNum/pageSize as query params — same as the search tools already do. Small, additive follow-up; noting it here rather than filing a separate issue since #58 stays open for exactly this kind of v0.1 increment.

## Live acceptance case passed (#487, Vaporwave) — plus one write-surface finding Drove the v0.1 write path end-to-end against **prod** (v26.11.0) to populate the empty Vaporwave collection: `search_all_items tag:"Vaporwave"` → 204 `musicVideoIds` → `add_collection_items` (collection 20) → `get_collection_items` shows **0 → 204**, a re-run stays **204** (idempotent, no dup rows), and playout 28 rebuilt `success=true` with 47 items. Full write-through-MCP, no SQL/SPA. Details on #487. The read tools, machine-key auth, ETag surfacing (`[etag: "0"]` on the empty collection), and the `ERSATZTV_ALLOW_WRITES` gate all behaved correctly against real prod data. ### Finding — paged list tools don't expose pagination (follow-up) `ersatztv_list_playouts` maps to `GET /api/v1/playouts`, which is **paged** (`{totalCount, page[]}`, default page ~10), but the tool declares no query args — so `pageSize`/`pageNum` are rejected (`additionalProperties:false`) and you cannot reach page 2+. I worked around it by addressing the playout by id. Any paged list endpoint the MCP wraps (`list_playouts`, and check `list_channels`/`list_schedules`/`list_sessions`) should declare `pageNum`/`pageSize` as query params — same as the search tools already do. Small, additive follow-up; noting it here rather than filing a separate issue since #58 stays open for exactly this kind of v0.1 increment.
timothy removed the in-progress label 2026-07-21 18:06:30 +02:00
Author
Owner

MCP write-path findings from the #487 acceptance case (second, larger run)

The Vaporwave pilot exercised one collection / 204 items. This run drove 11 collections / 1,184 item-adds through the MCP, which surfaced things the small run could not. Reporting them here per #487's "feed findings back to #58" box.

What worked well

  • ersatztv_add_collection_items is genuinely idempotent and existence-checked. Re-applying all 11 collections changed nothing (15→15, 246→246, 394→394, 274→274, 27→27, 204→204, 124→124, 29→29, 49→49, 1→1, 17→17), no duplicate CollectionItem rows.
  • It fans out the playout rebuild itself — all 11 channels rebuilt without an explicit reset.
  • ersatztv_scan_library + ersatztv_search_all_items + ersatztv_get_collection_items covered the whole read/decide/write/verify loop with no fallback to SQL or the SPA.

Bugs / gaps found

1. pageNum is 0-based, but the tool description says "1-based". ToolCatalog.cs:235 declares Int("pageNum", "1-based page number (optional)."). It is actually 0-based. This is the dangerous kind of wrong: a loop starting at pageNum=1 silently skips the first 100 items and returns a short set with no error. It cost me a verification pass that looked exactly like data loss — collections of ≤100 items came back as 0 items, and a 204-item collection came back as 104. Either fix the description or make the param 1-based.

2. pageSize is capped at 100 for the returned page, but the offset still honors the requested value.

GET /collections/20/items?pageSize=500            -> totalCount 204, returned 100
GET /collections/20/items?pageSize=500&pageNum=2  -> totalCount 204, returned 4     # offset used 500-ish, not 100

So an over-large pageSize doesn't clamp coherently — it silently changes the paging arithmetic. Combined with (1), paging is treacherous to drive from a tool. Suggest clamping pageSize and deriving the offset from the clamped value.

3. No way to express an exact tag match. tag: is an analyzed Lucene field, so a phrase query on a single-word tag matches any tag containing that token:

  • tag:"Glastonbury" → 246 hits (its own 59 plus all 187 Glastonbury Concerts)
  • tag:"Electronic" → 27 hits (its own 26 plus X-mix 5 - Mr. C - The Electronic Storm)

For folder-tag-driven collections this means you cannot say "items whose tag is exactly X". Harmless here (both cases were thematically fine and one was the intended union), but it makes tag-driven automation imprecise. A non-analyzed tag.raw-style field, or an exactTag: operator, would close it.

4. ersatztv_reset_channel_playout takes a channel id, but ersatztv_list_playouts rows carry only the playout id (id, channelNumber, channelName — no channelId). I conflated the two and reset the wrong entity on my first attempt (harmless — a rebuild — but it hit an unrelated channel). Adding channelId to the playout list row, or naming the arg channelId in the schema, would remove the trap.

5. Paged list tools still can't reach page 2+ (list_playouts, list_collections, …) — they expose no pageNum/pageSize. Already noted from the Vaporwave run; re-confirmed. I had to drop to raw /api/v1 with an inline key to enumerate 43 playouts.

Suggested follow-ups

(1), (2) and (4) are small and worth fixing — (1) especially, because it fails silently and plausibly. (3) and (5) are additive API surface. Happy to file them individually if you'd rather track them separately than on this closed issue.

## MCP write-path findings from the #487 acceptance case (second, larger run) The Vaporwave pilot exercised one collection / 204 items. This run drove **11 collections / 1,184 item-adds** through the MCP, which surfaced things the small run could not. Reporting them here per #487's "feed findings back to #58" box. ### What worked well - `ersatztv_add_collection_items` is genuinely idempotent and existence-checked. Re-applying all 11 collections changed **nothing** (`15→15, 246→246, 394→394, 274→274, 27→27, 204→204, 124→124, 29→29, 49→49, 1→1, 17→17`), no duplicate `CollectionItem` rows. - It fans out the playout rebuild itself — all 11 channels rebuilt without an explicit reset. - `ersatztv_scan_library` + `ersatztv_search_all_items` + `ersatztv_get_collection_items` covered the whole read/decide/write/verify loop with no fallback to SQL or the SPA. ### Bugs / gaps found **1. `pageNum` is 0-based, but the tool description says "1-based".** `ToolCatalog.cs:235` declares `Int("pageNum", "1-based page number (optional).")`. It is actually 0-based. This is the dangerous kind of wrong: a loop starting at `pageNum=1` **silently skips the first 100 items** and returns a short set with no error. It cost me a verification pass that looked exactly like data loss — collections of ≤100 items came back as `0 items`, and a 204-item collection came back as 104. Either fix the description or make the param 1-based. **2. `pageSize` is capped at 100 for the returned page, but the offset still honors the requested value.** ``` GET /collections/20/items?pageSize=500 -> totalCount 204, returned 100 GET /collections/20/items?pageSize=500&pageNum=2 -> totalCount 204, returned 4 # offset used 500-ish, not 100 ``` So an over-large `pageSize` doesn't clamp coherently — it silently changes the paging arithmetic. Combined with (1), paging is treacherous to drive from a tool. Suggest clamping `pageSize` and deriving the offset from the *clamped* value. **3. No way to express an exact tag match.** `tag:` is an analyzed Lucene field, so a phrase query on a single-word tag matches any tag *containing* that token: - `tag:"Glastonbury"` → 246 hits (its own 59 **plus** all 187 `Glastonbury Concerts`) - `tag:"Electronic"` → 27 hits (its own 26 **plus** `X-mix 5 - Mr. C - The Electronic Storm`) For folder-tag-driven collections this means you cannot say "items whose tag is exactly X". Harmless here (both cases were thematically fine and one was the intended union), but it makes tag-driven automation imprecise. A non-analyzed `tag.raw`-style field, or an `exactTag:` operator, would close it. **4. `ersatztv_reset_channel_playout` takes a *channel* id, but `ersatztv_list_playouts` rows carry only the *playout* id** (`id`, `channelNumber`, `channelName` — no `channelId`). I conflated the two and reset the wrong entity on my first attempt (harmless — a rebuild — but it hit an unrelated channel). Adding `channelId` to the playout list row, or naming the arg `channelId` in the schema, would remove the trap. **5. Paged list tools still can't reach page 2+** (`list_playouts`, `list_collections`, …) — they expose no `pageNum`/`pageSize`. Already noted from the Vaporwave run; re-confirmed. I had to drop to raw `/api/v1` with an inline key to enumerate 43 playouts. ### Suggested follow-ups (1), (2) and (4) are small and worth fixing — (1) especially, because it fails *silently and plausibly*. (3) and (5) are additive API surface. Happy to file them individually if you'd rather track them separately than on this closed issue.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: timothy/ersatztv#58