fix(mcp): accept a fields object on pad_item create/update; reject undeclared input keys (#1066) - #1159
Conversation
…declared input keys (PerpetualSoftware#1066) Reads return fields as a native object (BUG-991 normalization), so writing that shape back is the obvious call — and it was a silent no-op: not a declared param, no additionalProperties, accepted, never mapped by BuildCLIArgs, dropped while the PATCH still bumped updated_at. Two halves, one contract change (ToolSurfaceVersion 0.21 -> 0.22): - pad_item create/update fold a fields OBJECT into the same path as field: ["key=value"] / the dedicated params, at the catalog layer so both transports get it. The same key in two places with conflicting values is refused with a structured error; equal duplicates collapse. Non-writer actions refuse a fields param loudly rather than letting the now-declared key be dropped at dispatch. - The fan-out handler rejects undeclared top-level keys across all catalog tools with a structured validation_failed naming them — closing the silent-drop mechanism for every future variant. Compat carve-out: pad_item's documented v0.16 assigned_user_id / agent_role_id remote clear form stays accepted. Docs updated in lockstep per the TASK-2005 drift guards. Co-Authored-By: Claude Fable 5 <[email protected]>
xarmian
left a comment
There was a problem hiding this comment.
Thank you for this — it's a model implementation of the #1066 contract. The refuse-on-ambiguity discipline, the deterministic key ordering, rejectFieldsParam preventing the silent no-op from reappearing per-action, and especially the paper-trail comment on the compat carve-out are all exactly the standard we want this codebase to hold. Review ran locally (mcp package green) plus an adversarial tool pass; two bugs to fix, one scope note, one intent question.
Bug 1 (blocking): the strict gate breaks pad_item action=export with an agent-supplied output. rejectUndeclaredKeys runs in the fan-out handler before any action handler, and output is neither schema-declared nor in compatAcceptedInputKeys — but actionItemExport deliberately overrides out["output"] = "-" (catalog_item.go:784) because agents send it. Those calls now fail with "unknown parameter(s): output" before the override can run. The existing TestPadItemExport_OverridesAgentOutput doesn't catch this because it calls actionItemExport directly, bypassing the gate. Fix is your choice — add output to the pad_item compat map with the same style of paper-trail comment, or schema-declare it — but please also add a test that exercises the real fan-out dispatch path so the gate and the handlers are tested together.
Bug 2: empty-string key generates a malformed entry. fields: {"": "v"} passes the =-in-key check and produces field: ["=v"]. Reject empty keys alongside the existing check.
Scope note (document or extend, your call): non-scalar custom-field values (multi_select arrays, JSON fields) are refused by stringifyFieldValue — loudly, which fits the file's disposition, but it means the read shape isn't fully round-trippable for those field types, which was the PR's own motivation. Fine to ship as a named limitation (one line in the file header + the tool description), with array/JSON encoding as a follow-up — just make the limitation discoverable rather than discovered.
Intent question: if a collection declares a custom field literally named one of the seven promoted keys (role, status, …), fields: {role: "x"} promotes to the reserved MCP param instead of writing the custom field — a silent redirect. Reading your merge-contract comment I believe this is an accepted trade-off (the catalog layer can't see collection schemas), but please confirm and, if so, add it to the merge-contract comment so the next reader doesn't re-derive it.
With bug 1 + 2 fixed this is mergeable from my side. Appreciate the care that went into this — the "please don't auto-close #879" style of coordination across your two PRs is noticed and welcome.
…y fields keys refused; limitations documented Per the PR PerpetualSoftware#1159 round-1 review: - Bug 1: add output to pad_item's compat allowlist with a paper-trail comment — actionItemExport exists to override that key to '-', so the strict gate was killing agent export calls before the override ran. New test drives the REAL fan-out dispatch path so the gate and handler are tested together. The analogous import/file key stays rejected deliberately: the schema steers agents to artifact, and the rejection hint names it. - Bug 2: refuse empty keys in a fields object — {"": "v"} previously passed the '='-in-key check and emitted a malformed field entry. - Scope note: non-scalar round-trip limitation named in the merge contract and the fields param description; array/JSON encoding stays a follow-up. - Intent question: the promoted-key shadowing trade-off is accepted and now written into the merge-contract comment. Co-Authored-By: Claude Fable 5 <[email protected]>
|
All four addressed in Bug 1 (export One deliberate non-fix while I was in there: the analogous Bug 2 (empty key) — fixed; Scope note — documented as a named limitation: the merge-contract comment carries a KNOWN LIMITATION block (non-scalar field types — multi_select, json — refuse loudly rather than round-trip), and the Intent question — confirmed: the promoted-key shadowing is an accepted trade-off. This layer can't see collection schemas, the dedicated params already shadow such fields everywhere on the tool, and the
|
…22 -> 0.24 (0.22/0.23 were taken by PerpetualSoftware#1165/PerpetualSoftware#1166) Mechanical drift absorption, maintainer-side: main took 0.22 (BUG-2674) and 0.23 (BUG-2627 part 2) while this PR was in review, so its fields- object + strict-validation bump becomes 0.24. Conflicts resolved in favor of main's newer refusal semantics; the PR's fields sentence and strict-validation line are grafted into the current docs. CLAUDE.md's MCP section (stale at 0.21 on main) gains brief 0.22/0.23 entries pointing at version.go. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
|
Pushed a maintainer commit (59646ee) rather than bouncing this back to you: while this sat in review, main took ToolSurfaceVersion 0.22 (#1165) and 0.23 (#1166), so your bump is now 0.24. The merge keeps all your code; conflicts were only in the docs/changelog, resolved in favor of main's newer text with your fields-object and strict-validation entries renumbered on top. No action needed from your side — review of the round-2 changes continues on the updated head. |
|
Merged, thank you. Strict input validation is the real prize here: every future undeclared-param typo now fails loudly across all eleven tools instead of returning success while doing nothing, which retires an entire bug class, and the compat carve-out map with a paper trail per entry is how that kind of gate stays honest over time. The |
What does this PR do?
Implements the contract agreed in #1066: the
fieldsobject becomes a real write form, and the silent-drop mechanism behind it is removed for good.Closes #1066
Half 1 —
fieldsobject alias (option 2).pad_itemcreate/update now accept afieldsOBJECT — the same shape reads return since the BUG-991 normalization — and fold it into the existing paths at the catalog layer (internal/mcp/catalog_item_fields.go), so both transports get it: keys with a dedicated top-level param (status,priority,category,parent,role,assign,tags) promote onto that param; every other key merges into thefield: ["key=value"]path, where server-side schema validation applies unchanged (an unknown custom field still fails withvalidation_failedrather than defaulting).Half 2 — strict input validation (option 1). The fan-out handler now rejects any top-level key outside the tool's declared schema, across all ten catalog tools, with a structured
validation_failednaming the offending key(s). This is the generalized fix: the original bug's mechanism was "accepted, never mapped byBuildCLIArgs, dropped, PATCH still runs", and that path is now closed for every future undeclared-param variant, not justfields.Your two constraints, as built:
fieldsAND at the top level (or in thefieldarray) with conflicting values returns a structured error and dispatches nothing. Equal duplicates are unambiguous and collapse to one write.Two decisions worth your eye:
assigned_user_id: ""/agent_role_id: ""), which is undeprecated and deliberately not schema-declared. Those two keys stay accepted forpad_itemvia an explicit, commented allowlist (compatAcceptedInputKeys). Happy to drop the carve-out and break that form instead if you'd rather the bump carry it.fieldson non-writer actions refuses loudly. Declaring the param at tool level would otherwise letfieldsflow to, say,action=listand be dropped byBuildCLIArgs— recreating per-action the exact silent no-op this PR removes. Every action except create/update wraps in an explicit refusal pointing at create/update.Values in
fieldsmust be scalars (nested objects/arrays/nulls are refused with the key named);tagsis the exception and promotes its native array.Docs updated in lockstep (the TASK-2005 drift guards enforce the version strings):
internal/mcp/instructions.md,README.md,CLAUDE.md, and the authoritative changelog ininternal/mcp/version.go.How to test
go test ./internal/mcp/ -run 'PadItem.*Fields|UndeclaredKey|CompatIDParams' -count=1— the new suite.TestPadItemUpdate_FieldsObjectAppliesis the MCP: pad_item create/update silently ignores valid fields.status / fields.priority writes (no error, updated_at still bumps) #1066 repro inverted: it was written first and failed on the pre-change code with "nothing dispatched — fields write was dropped", which reproduces the reported behaviour at the boundary both transports share.pad_item update ref=TASK-x fields={"status":"done","priority":"critical"}→ both values apply;fields={"status":"done"}+ top-levelstatus="cancelled"→ structured refusal, nothing written;statuss="done"→validation_failed: unknown parameter(s): statuss.go test ./internal/mcp/ -count=1— full package.Checklist
make buildpasses (go build ./...; web unchanged)make testpasses — with the caveat that the Windows dev box has a pre-existing red baseline (the same credential/HOME + exec//bin/sh+ chmod failures cnYui hit in fix(cli): wrap comment not-found errors with item context #910); captured on cleanmainbefore the change and identical after, withinternal/mcpcontributing no new failures🤖 Generated with Claude Code