feat(#58): ErsatzTV.Mcp — read + cautious-write MCP server over /api/v1 #517

Merged
timothy merged 1 commits from feat/58-mcp-v1 into main 2026-07-21 01:02:37 +02:00
Owner

Builds the MCP server (#58) fresh as ErsatzTV.Mcp — a stdio JSON-RPC server wrapping the frozen /api/v1 surface — superseding the closed read-only PR #76. Gated on #286 (route freeze) and #197 (security review), both closed; #474 (music-video ingestion) closed 2026-07-20, unblocking the #487 acceptance case.

What it does

  • 26 read tools across the six families (channels, collections, smart collections, schedules, playouts, media-sources) + discovery (search, search/all-items raw id lists, search/artists).
  • Cautious-write CRUD: collections (incl. idempotent membership adds — the #487 primitive), smart collections, schedules, playouts (create/update/delete/reset), channels (create/update/delete/reset), and a Jellyfin-focused media-source sync/scan slice. All writes gated behind ERSATZTV_ALLOW_WRITES (default false, runtime-enforced).

Security posture (carried forward from PR #76/#289, verbatim)

Read-only runtime backstop; JSON-RPC DoS guards (-32700/-32600/-32602/-32603) + bounded stdin reader; per-request CTS covering headers and the streamed body; response-size cap; arg validation vs InputSchema (additionalProperties:false); reverse-proxy prefix preservation. Machine-key auth (X-Api-Key, CSRF-exempt per §9). If-Match/ETag round-trip for the one replace-all PUT that honors it.

Argument routing

Each declared arg goes to exactly one place: path {param}, an explicit query parameter, the reserved ifMatch header, or (write verbs) the JSON body. ETags are surfaced as an [etag: "N"] marker for round-tripping.

Independent review

A cold adversarial review (fresh context, review-only) ran over the diff and returned BLOCKED on a confirmed HIGH — CRLF header-injection via ifMatch through TryAddWithoutValidation (empirically reproduced on .NET 10: SocketsHttpHandler writes CR/LF verbatim). Fixed by rejecting control chars in ifMatch before it reaches the header, plus two LOWs (undisposed empty-args JsonDocument; validator now accepts explicit JSON null so a nullable API field can be cleared as documented). A Review-verdict comment referencing the current head follows once CI is green.

Deferred (documented in docs/mcp.md)

The ~40-field schedule-item / playout replace-list writes, and the redesign workflow tools (#63–#68) — to be added when their backend contracts exist.

Tests / verification

58 focused tests (ErsatzTV.Mcp.Tests); full-solution build clean (0 errors); format + BOM gates clean. No /api endpoint added, so no OpenAPI regen. Docs: docs/mcp.md, docs/README.md index, docs/decisions.md.

Does not close #58 — leaving it open for the #487 acceptance case (live proof of the write path) and the deferred increments.

Refs #58 #487

🤖 Generated with Claude Code

Builds the MCP server (#58) fresh as `ErsatzTV.Mcp` — a stdio JSON-RPC server wrapping the frozen `/api/v1` surface — superseding the closed read-only PR #76. Gated on #286 (route freeze) and #197 (security review), both closed; #474 (music-video ingestion) closed 2026-07-20, unblocking the #487 acceptance case. ## What it does - **26 read tools** across the six families (channels, collections, smart collections, schedules, playouts, media-sources) + discovery (`search`, `search/all-items` raw id lists, `search/artists`). - **Cautious-write CRUD**: collections (incl. idempotent membership adds — the #487 primitive), smart collections, schedules, playouts (create/update/delete/reset), channels (create/update/delete/reset), and a Jellyfin-focused media-source sync/scan slice. All writes gated behind `ERSATZTV_ALLOW_WRITES` (default false, runtime-enforced). ## Security posture (carried forward from PR #76/#289, verbatim) Read-only runtime backstop; JSON-RPC DoS guards (`-32700/-32600/-32602/-32603`) + bounded stdin reader; per-request CTS covering headers **and** the streamed body; response-size cap; arg validation vs `InputSchema` (`additionalProperties:false`); reverse-proxy prefix preservation. Machine-key auth (`X-Api-Key`, CSRF-exempt per §9). `If-Match`/ETag round-trip for the one replace-all PUT that honors it. ## Argument routing Each declared arg goes to exactly one place: path `{param}`, an explicit query parameter, the reserved `ifMatch` header, or (write verbs) the JSON body. ETags are surfaced as an `[etag: "N"]` marker for round-tripping. ## Independent review A cold adversarial review (fresh context, review-only) ran over the diff and returned **BLOCKED** on a confirmed **HIGH** — CRLF header-injection via `ifMatch` through `TryAddWithoutValidation` (empirically reproduced on .NET 10: `SocketsHttpHandler` writes CR/LF verbatim). Fixed by rejecting control chars in `ifMatch` before it reaches the header, plus two LOWs (undisposed empty-args `JsonDocument`; validator now accepts explicit JSON `null` so a nullable API field can be cleared as documented). A `Review-verdict` comment referencing the current head follows once CI is green. ## Deferred (documented in `docs/mcp.md`) The ~40-field schedule-item / playout replace-list writes, and the redesign workflow tools (#63–#68) — to be added when their backend contracts exist. ## Tests / verification 58 focused tests (`ErsatzTV.Mcp.Tests`); full-solution build clean (0 errors); format + BOM gates clean. No `/api` endpoint added, so no OpenAPI regen. Docs: `docs/mcp.md`, `docs/README.md` index, `docs/decisions.md`. Does not close #58 — leaving it open for the #487 acceptance case (live proof of the write path) and the deferred increments. Refs #58 #487 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Independent review — complete

Round 1 (cold, full diff): returned BLOCKED on a confirmed HIGH — CRLF header-injection via the model-controlled ifMatch arg through TryAddWithoutValidation (empirically reproduced on .NET 10: SocketsHttpHandler writes CR/LF to the wire verbatim, smuggling headers onto the X-Api-Key-bearing request). Plus two LOWs (undisposed empty-args JsonDocument; dailyRebuildTime: null documented as "clears" but rejected by the validator).

Fixes:

  • Reject control chars (char.IsControl, covers CR/LF and \uXXXX-escaped) in ifMatch before it reaches the header; regression test asserts throw and RequestUri == null (nothing sent).
  • Cache the empty-args JsonDocument (no per-call pooled-doc leak).
  • MatchesType accepts explicit JSON null so a nullable API field can be cleared as documented; test asserts the body serializes {"dailyRebuildTime":null}.

Round 2 (cold, fix-delta re-review + the new channel create/update tools): all four items CONFIRMED-OK — the CRLF guard is the sole If-Match writer and throws before send with no bypass; the null relaxation only touches the type gate (required-presence unchanged); the channel tools route through the already-reviewed body-serialization path (no new code path). Tests non-vacuous.

CI: all jobs green on the head sha (image-push skipped as normal for a PR; no OpenAPI change → API-docs-in-sync green; decisions.md append-only green).

Review-verdict: MERGEABLE @ eaa7b9376534e136949263128d5427a198930a90

## Independent review — complete **Round 1 (cold, full diff):** returned **BLOCKED** on a confirmed HIGH — CRLF header-injection via the model-controlled `ifMatch` arg through `TryAddWithoutValidation` (empirically reproduced on .NET 10: `SocketsHttpHandler` writes CR/LF to the wire verbatim, smuggling headers onto the `X-Api-Key`-bearing request). Plus two LOWs (undisposed empty-args `JsonDocument`; `dailyRebuildTime: null` documented as "clears" but rejected by the validator). **Fixes:** - Reject control chars (`char.IsControl`, covers CR/LF and `\uXXXX`-escaped) in `ifMatch` before it reaches the header; regression test asserts throw **and** `RequestUri == null` (nothing sent). - Cache the empty-args `JsonDocument` (no per-call pooled-doc leak). - `MatchesType` accepts explicit JSON `null` so a nullable API field can be cleared as documented; test asserts the body serializes `{"dailyRebuildTime":null}`. **Round 2 (cold, fix-delta re-review + the new channel create/update tools):** all four items CONFIRMED-OK — the CRLF guard is the sole If-Match writer and throws before send with no bypass; the null relaxation only touches the type gate (required-presence unchanged); the channel tools route through the already-reviewed body-serialization path (no new code path). Tests non-vacuous. CI: all jobs green on the head sha (image-push skipped as normal for a PR; no OpenAPI change → API-docs-in-sync green; decisions.md append-only green). Review-verdict: MERGEABLE @ eaa7b9376534e136949263128d5427a198930a90
timothy force-pushed feat/58-mcp-v1 from eaa7b93765 to a88240dcec 2026-07-21 00:42:03 +02:00 Compare
Author
Owner

Rebased onto origin/main (main appended a decisions.md entry, #350). The only change vs the reviewed head eaa7b937 is the decisions.md conflict re-resolution (append-only — both entries preserved); all code is byte-identical, so the MERGEABLE re-review stands.

Review-verdict: MERGEABLE @ a88240dcec

Rebased onto origin/main (main appended a decisions.md entry, #350). The only change vs the reviewed head `eaa7b937` is the decisions.md conflict re-resolution (append-only — both entries preserved); **all code is byte-identical**, so the MERGEABLE re-review stands. Review-verdict: MERGEABLE @ a88240dcec078bd1716dc0bdcc7348fc7bc012f5
timothy merged commit d97e1dece2 into main 2026-07-21 01:02:37 +02:00
timothy deleted branch feat/58-mcp-v1 2026-07-21 01:02:37 +02:00
Sign in to join this conversation.