---
key: mcp.server-foundation
title: '2026-07-20 — MCP server (`ErsatzTV.Mcp`) built fresh over frozen `/api/v1`: read + cautious writes (#58)'
status: active
since: '2026-07-20'
supersedes: none
superseded-by: none
rule: '`ErsatzTV.Mcp` is a fresh stdio JSON-RPC server wrapping frozen `/api/v1` with explicit narrow per-endpoint tools, read-only-by-default enforced at runtime (`ERSATZTV_ALLOW_WRITES`), machine-key auth, and opt-in `If-Match`.'
signals: 'MCP server, tool catalog, read-only default, ERSATZTV_ALLOW_WRITES, X-Api-Key, If-Match round-trip · paths: `ErsatzTV.Mcp`, `docs/mcp.md` · issues: #58, #286, #197, #76, #289, #63, #64, #65, #66, #67, #68'
mechanics: '`docs/mcp.md`; `ErsatzTV.Mcp` tool executor and catalog'
---

The MCP server (issue #58, gated on #286 route-freeze + #197 security review, both closed) is built
**fresh** as `ErsatzTV.Mcp` — a stdio JSON-RPC server wrapping `/api/v1` — superseding the closed
read-only PR #76 rather than rebasing it. Full doc: `docs/mcp.md`. Decisions frozen:

- **Explicit, narrow tools over a generic HTTP passthrough.** Each tool maps to one OpenAPI-backed
  endpoint; there is no "call any URL" tool. Each declared argument routes to exactly one place —
  path `{param}`, an explicit query parameter, the reserved `ifMatch` header, or (write verbs only)
  the JSON body — and `additionalProperties:false` rejects anything undeclared before a request is built.
- **Read-only is the default, enforced at runtime, not just by catalog shape.** The executor refuses
  any non-GET tool unless `ERSATZTV_ALLOW_WRITES=true`, so one wrong catalog entry cannot mutate. This
  is the PR #76/#289 posture carried forward verbatim, alongside the JSON-RPC DoS guards
  (`-32700/-32600/-32602/-32603`, bounded stdin line reader), the per-request CTS covering headers
  **and** the streamed body (the `ResponseHeadersRead` gotcha, #289), the response-size cap, and the
  reverse-proxy prefix preservation.
- **Machine-key auth, CSRF-exempt.** The server sends `X-Api-Key` on every request and no `X-CSRF`
  header (§9: key-authed requests are CSRF-exempt). `ERSATZTV_API_KEY` is effectively required.
- **`If-Match` is opt-in per call, and ETags are surfaced, not managed.** Only the replace-all PUTs
  honor `If-Match` (here `update_collection_custom_order`); a response `ETag` header is appended to the
  tool result as `[etag: "N"]` so an agent can round-trip it. All other writes force-write (§7a), so
  no ETag handshake is needed — notably `add_collection_items`, whose re-add is an idempotent no-op.
- **Channel create/update is exposed** (28-field DTO): only `name`/`number`/`ffmpegProfileId` are
  required, enums take the enum name and are API-validated, and `get_channel` makes the shape/current
  values discoverable. **Deferred as too-large for a cautious v0.1** (not a contract gap): the ~40-field
  schedule-item / playout replace-list writes. Redesign workflow tools (#63–#68) stay deferred until
  their backend endpoints exist. No `/api` endpoint was added, so no OpenAPI regen.
