MCP write tools SILENTLY DROP a key their own schema forbids and report success — a card filed with `description` instead of `descriptionMd` lands with no body
Repo · motir-core. The MCP write tools accept a key their own schema forbids, silently discard it, and report success. A card filed with the wrong field name lands as a bare title and the caller is told it worked.
What happened (2026-08-20/21, four occurrences in one session)
create_work_item and update_work_item name the body descriptionMd. Passing description — the obvious guess, and the name the REST DTOs use — produces:
create_work_item({projectKey, kind, title, parentKey, description: "<2 000 words>"})
→ "Created MOTIR-3283 [bug] A story whose deliverable was a PAGE…"
…and the card has NO BODY AT ALL.
update_work_item({key: "MOTIR-3334", description: "probe"})
→ "Updated MOTIR-3334 [bug] childStatusCascadeService exempts…"
"Patched: nothing"
update_work_item({key: "MOTIR-3334", title: "…", description: "<body>"})
→ "Patched: title" ← the title applied, the body vanished
Three bug cards — MOTIR-3283, MOTIR-3313, MOTIR-3334 — were filed with long, evidence-carrying bodies and landed as titles only (measured on read-back: 292, 272 and 312 characters, all of it header). They were reported to the user as filed with their evidence. The loss was found only because a later read of one card came back suspiciously short; nothing in any of the four responses says a field was dropped.
⚠️ The tools' own schema says this should be REJECTED
Both create_work_item and update_work_item publish "additionalProperties": false in their JSON Schema. That is a promise that an unknown key is an ERROR. The runtime does the opposite: it takes the call, drops the key, and returns a success line.
And the validator is not simply absent — it is inconsistent, which is what makes the failure so quiet. A MISSING REQUIRED field fails loudly and usefully:
link_work_items({fromKey, toKey, linkType: "relates_to"})
→ MCP error -32602: Input validation error: [ { "path": ["relationship"],
"expected": "'blocked_by' | 'blocks' | 'relates_to' | 'duplicates' | 'clones'",
"received": "undefined", "message": "Required" } ]
Note that this error names the missing relationship and says nothing about the unknown linkType sitting right beside it. So required-key validation works; unknown-key rejection does not, despite being advertised.
Likely mechanism, to be CONFIRMED rather than assumed: the tools pass a plain shape to server.registerTool(..., { inputSchema }, ...), and a non-.strict() zod object strips unknown keys, while zodToJsonSchema emits additionalProperties: false for that same object. The advertised contract and the runtime would then disagree by construction, for every tool. That is a hypothesis from reading lib/mcp/tools/updateWorkItem.ts and the published schemas — check it at the SDK boundary before fixing.
The second half: a NO-OP is reported as a success
summarize() in lib/mcp/tools/updateWorkItem.ts renders Patched: ${patchedKeys.join(', ') || 'nothing'} and the tool returns toolOk. So "Patched: nothing" is a successful call that changed nothing — the one line that could have caught this, styled exactly like the line that reports a real edit.
An update that patches no field is never what a caller meant. It is either a typo'd key (this bug) or a caller with nothing to say, and neither deserves a success.
Why it is worth a card rather than a note
- It is silent data loss on a WRITE path, and the payload lost is the most expensive thing an agent produces — a card's reasoning, evidence and acceptance criteria.
- It defeats the obvious check. Reading the tool's success line is the natural verification, and the success line is wrong. Only a read-back with a length check catches it.
- The wrong guess is the likely guess:
descriptionis what the field is called nearly everywhere else, so the failure selects for callers who did not memorise the schema. - It scales with agent use. Every card an agent files goes through this door.
Proposed fix
- Honour the declared schema: reject an unknown key with an
-32602naming it and, ideally, the nearest valid field (description→descriptionMd,linkType→relationship). If the strip is the SDK's behaviour rather than ours, the schema should stop advertisingadditionalProperties: false— the two must not disagree, and rejecting is the better direction. Patched: nothingmust not be a success. Either a-32602(the caller asked for no change) or, at minimum, a result the caller cannot mistake for an edit.- A test per write tool that passes one unknown key and asserts the call is refused — the class is per-tool, not per-field, so a fix that only special-cases
descriptionleaveslinkTypeand every future rename open.
Reproduction
create_work_item({projectKey: "TEST", kind: "task", title: "probe",
description: "this text will vanish"})
→ succeeds; the created card has an empty body.
Context refs
lib/mcp/tools/updateWorkItem.ts—inputSchema,summarize()(thePatched: nothingline), andregisterTool.lib/mcp/tools/createWorkItem.ts— the same shape on the create path.lib/mcp/toolResult.ts—toolOk/toToolError, where the verdict is chosen.- The cards this cost: MOTIR-3283, MOTIR-3313, MOTIR-3334 (bodies restored by hand on 2026-08-21 with
descriptionMd).