--- key: mcp.tool-schema-openapi-parity title: '2026-08-06 — every MCP tool declares exactly its endpoint''s OpenAPI request-body fields and query parameters, asserted in CI (#754, #757)' status: active since: '2026-08-06' supersedes: none superseded-by: none rule: 'Every POST/PUT/PATCH tool in `ToolCatalog` declares exactly the request-body properties its endpoint accepts, each with a matching type, and EVERY tool (read and write) declares exactly its endpoint''s query parameters, both asserted against the generated `ErsatzTV/wwwroot/openapi/v1.json` (linked into `ErsatzTV.Mcp.Tests`) by `Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields` and `Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters`. A field the endpoint accepts but the tool omits is a DEFECT, not a deferral: on the full-replace tools (channel update, schedule update, custom-order) the omission is silently applied as a clear. The write tools are NOT uniformly full-replace — add-collection-items is additive, and several leave an omitted field unchanged — so each tool description states its own semantics. An omitted query parameter is UNREACHABLE, not merely undocumented, because `ToolArgumentValidator` rejects undeclared arguments.' signals: 'MCP tool schema drift, full-replace write, silently dropped field, graphicsElementIds, padToNearestMinute, additionalProperties false · paths: `ErsatzTV.Mcp/ToolCatalog.cs`, `ErsatzTV.Mcp.Tests/ToolCatalogTests.cs`, `docs/mcp.md` · issues: #754, #757, #58, #616' mechanics: '`ErsatzTV.Mcp.Tests/ToolCatalogTests.cs`; `ErsatzTV.Mcp.Tests.csproj` links `openapi/v1.json`' --- `ToolCatalog.ChannelFields()` declared 27 of `UpdateChannelRequest`'s 28 properties. The missing one was `graphicsElementIds`, which attaches channel-level graphics elements including the built-in On Now/Next overlay (`graphics.channel-level-attachment`). The cost was not "one field you cannot set". `PUT /api/v1/channels/{id}` is a **full replace**, and the tool's own description instructs the caller to *"send the full desired state"* — which the schema could not express. An agent that faithfully GET-edit-PUT a channel detached every attached graphics element, with a `200` and no error. Nothing surfaced until the overlay stopped rendering at the next transition, hours later. That is the `optional-parameter-on-shared-primitive-is-opt-out` shape: the omission is invisible at the call site and only observable as missing pixels. Fixing the one field would have left the mechanism intact, and the mechanism had already produced a second instance: `ScheduleFlags()` omitted `padToNearestMinute`, which both `CreateScheduleRequest` and `UpdateScheduleRequest` carry and `UpdateProgramScheduleHandler` writes unconditionally — so `ersatztv_update_schedule` silently cleared a configured pad the same way. Nothing tied a tool's declared arguments to the contract it wraps, so the next added DTO property would have drifted too. So the guard is the decision, and it is asserted against the **generated OpenAPI document** rather than the DTO types: `v1.json` is the actual wire contract, it is already regenerated by `scripts/update-openapi.sh` as part of the API checklist, and asserting against it keeps `ErsatzTV.Mcp.Tests` free of a project reference to the whole ASP.NET host. The test derives each tool's body set exactly as `ErsatzTvApiClient` does — declared arguments minus path parameters, minus query parameters, minus the reserved `ifMatch` header — so the guard cannot disagree with the routing it guards. Three anti-vacuity properties are deliberate, per the repo's standing "a test that filters on the property it asserts cannot see what is missing" rule: - The **covered write-tool set is pinned by name**, not merely filtered. A tool that stops being a write verb, or a new one that is added, changes this list rather than silently leaving the loop. - A **missing or unrecognised spec is a failure**, never an empty comparison: an absent `v1.json` fails with the path it looked in, and a request body that is not a plain `$ref` (an `allOf`, `oneOf`, or inline schema), or a property whose type is a union this guard has not been taught, fails asking to be taught the shape instead of comparing against `{}`. - **Names are compared with types**, not alone. A name-only guard is the same defect one level down: the tool would advertise `string` for an `int?`, the agent would send `"30"`, and the API would reject it — green test, broken tool. The generator's `["null", T]` nullable form and its `$ref` (enum → `string`, model → `object`) are normalized onto the catalog's vocabulary, arrays down to their element type. All were verified by mutation rather than assumed: dropping `graphicsElementIds`, dropping `padToNearestMinute`, retyping either field, drifting an array's element type, and removing the copied spec each turn the suite red, and each failure names the field or path at fault. **Query parameters are guarded the same way, across every tool (#757).** A second test compares each tool's routed `QueryParameters` against the spec's `parameters[in=query]` for its path and verb, reads included — the drift that existed when this was written was entirely on reads. An omitted parameter there is worse than an undeclared body field: `additionalProperties:false` means the caller cannot pass it *at all*, so the capability is unreachable rather than merely undocumented (`ersatztv_list_playouts` had lost its channel-name `query` filter and `ersatztv_get_playout_items` its `showFiller`; #616 was the same shape with paging). That test **accumulates** its mismatches and asserts once, so a run reports the whole drift set — failing on the first would invite fixing one tool at a time, which is how the twin in this very issue stayed hidden. It also **composes with** the older `Every_Query_Parameter_Should_Be_A_Declared_Property`, and the pair is the clearest illustration in this repo of why "a test that filters on the property it asserts cannot see what is missing" is a rule. That older test filters `Where(t => t.QueryParameters is { Count: > 0 })` — so a tool that lost its query parameters entirely escaped it, which is exactly how `list_playouts` and `get_playout_items` hid. The new test has no filter and reports them as *unreachable*; the old one then checks that a routed parameter is also a declared argument. Neither subsumes the other, and the inner duplicate of the old check was deliberately removed from the new test rather than kept as a second copy. **Scope, stated so it is not mistaken for more.** Request bodies are compared for POST/PUT/PATCH only. DELETE is uncovered because `ErsatzTvApiClient` builds a body for POST/PUT/PATCH only, so a body argument on a DELETE tool would be silently dropped; no tool has one today. Header arguments (`ifMatch`) and per-parameter *descriptions* are not compared either — `api.paging-zero-based` is pinned by its own test. The type comparison is **lossy by design, at the catalog's ceiling**: the catalog's vocabulary is `{string, integer, number, boolean, object, array}`, so every object component collapses to `object` and every enum to `string`. Swapping one model or enum for another is therefore invisible here (verified by repointing `logo` at a structurally unrelated model — the suite stays green), as is `format` (`int32` vs `int64`). That is the right ceiling rather than a gap to close: comparing deeper than the catalog can express would assert a distinction no tool schema carries, and an opaque object like `logo` is copied through from a GET verbatim, so nested drift cannot cause the silent-clear this record exists to prevent. `integer` vs `number` IS distinguished. The `>1` non-null type-union assertion is a fail-loud guard for a shape this generator does not currently emit, so it is deliberate but **unexercised**. The guard is also a **two-job conjunction**, not self-contained: it compares against a checked-in `v1.json`, so it is only as fresh as the regeneration. What keeps it honest is the `api-docs` CI job, whose `^ErsatzTV/Controllers/Api/` path filter covers the directory every request DTO lives in — a new DTO property cannot leave `v1.json` stale without that job going red. That holds for a DTO's OWN properties and no further: a NESTED model such as `ArtworkContentTypeModel` lives in `ErsatzTV.Application/Artworks/`, outside that filter, so changing it can leave `v1.json` stale without the job firing. Pre-existing, and harmless to this guard only because nested shape is not compared. `graphicsElementIds` is declared on the **update tool only**, not in the shared `ChannelFields()`: `CreateChannelRequest` has no such property, and the tool schemas are `additionalProperties:false`, so sharing it would make every create call send an unknown property. `padToNearestMinute` is on both schedule requests, so it does belong in the shared `ScheduleFlags()`. The parity test is what makes that per-field placement checkable rather than a matter of care.