Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
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.jsonfails with the path it looked in, and a request body that is not a plain$ref(anallOf,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
stringfor anint?, 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.