Files
ersatztv/docs/decisions/records/mcp/tool-schema-openapi-parity.md
T
timothyandtimothy 9881d1ff81
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m56s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 7m1s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m51s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m37s
fix(754,757): declare graphicsElementIds + padToNearestMinute, and pin every MCP tool to its OpenAPI contract (#760)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-06 20:42:37 +00:00

8.9 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
mcp.tool-schema-openapi-parity 2026-08-06 — every MCP tool declares exactly its endpoint's OpenAPI request-body fields and query parameters, asserted in CI (#754, #757) active 2026-08-06 none none 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. 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 `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<T>}, 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.