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.
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)
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 andRequestUri == 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).
## 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
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.
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Builds the MCP server (#58) fresh as
ErsatzTV.Mcp— a stdio JSON-RPC server wrapping the frozen/api/v1surface — 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
search,search/all-itemsraw id lists,search/artists).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 vsInputSchema(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 reservedifMatchheader, 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
ifMatchthroughTryAddWithoutValidation(empirically reproduced on .NET 10:SocketsHttpHandlerwrites CR/LF verbatim). Fixed by rejecting control chars inifMatchbefore it reaches the header, plus two LOWs (undisposed empty-argsJsonDocument; validator now accepts explicit JSONnullso a nullable API field can be cleared as documented). AReview-verdictcomment 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/apiendpoint added, so no OpenAPI regen. Docs:docs/mcp.md,docs/README.mdindex,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
Independent review — complete
Round 1 (cold, full diff): returned BLOCKED on a confirmed HIGH — CRLF header-injection via the model-controlled
ifMatcharg throughTryAddWithoutValidation(empirically reproduced on .NET 10:SocketsHttpHandlerwrites CR/LF to the wire verbatim, smuggling headers onto theX-Api-Key-bearing request). Plus two LOWs (undisposed empty-argsJsonDocument;dailyRebuildTime: nulldocumented as "clears" but rejected by the validator).Fixes:
char.IsControl, covers CR/LF and\uXXXX-escaped) inifMatchbefore it reaches the header; regression test asserts throw andRequestUri == null(nothing sent).JsonDocument(no per-call pooled-doc leak).MatchesTypeaccepts explicit JSONnullso 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
eaa7b93765toa88240dcecRebased onto origin/main (main appended a decisions.md entry, #350). The only change vs the reviewed head
eaa7b937is 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