fix(mcp): ok_untag must not call removing nothing a success - #39
Conversation
a04e2f5 to
19738de
Compare
`ok_untag` returned `jsonText({ ok: true, item, removed: before - nextTags.length })`
unconditionally, so an untag that matched nothing came back `ok: true, removed: 0` —
a success signal that cannot tell 1 removed from 0. Same defect class the CLI twin
was fixed for in #34/#35, still live on the MCP surface.
Reproduced over real stdio before touching anything, driving src/mcp.js with the MCP
SDK client. Three separate defects, not one:
(a) absent tag -> isError false, ok true, removed 0
-> and updated_at WAS bumped: store.update ran before any
check, so a no-op was recorded as a write
(b) tags:["a,b,c"] against an item holding a, b, c separately
-> removed 0, item unchanged. The CLI removes 3 there.
(c) tags:["alpha","nope"] on an item holding only alpha
-> removed 1, no mention of "nope" anywhere in the payload
Now, measured on the same probes after the change:
(a) -> isError true, 'No matching tag on k_absent: "gamma" not in ["alpha", "beta"]',
tags untouched, updated_at unchanged
(b) -> removed 3, tags ["keep"]
(c) -> removed 1, not_found ["nope"],
message 'Removed 1 tag from k_partial (not found: "nope")'
Ported from src/cli.ts `untag` rather than reinvented, so the two surfaces agree:
whole-value match short-circuits before the comma split, removed === 0 is an error,
and unmatched names are reported in both `not_found` and `message`.
The exclusivity is PER RAW VALUE, not per call. Measured, item holding
["a,b,c","a","b","c"]: tags:["a,b,c"] removes 1 and leaves the split names;
tags:["a,b,c","a","b","c"] removes 4 in one call; and the re-run contract holds
1 -> 3 -> isError. That claim has been misstated twice in this repo, so the test pins
it by execution instead of restating it in prose.
`list -t`'s union rule is deliberately NOT copied here — a filter has nothing to
destroy, a write does. (`ok_list` has neither rule; it is exact-match only. That gap
is real and tracked separately. Aligning this tool with `untag` does not close it.)
An empty or separator-only request is now its own error rather than falling into the
not-found path, where it would have printed `not in [...]` against an empty list and
read as the store's fault. The CLI cannot reach that case at all: collectTagFlag
rejects a separator-only value at parse time.
REMOVING NOTHING BEING AN ERROR IS A DELIBERATE TRADE, stated rather than buried: a
client that untags the same tag twice now gets isError on the second call. It is not
destructive — the item is untouched and the message names both the tags that missed
and the tags the item actually holds, so "already gone" is distinguishable from
"wrong name" without another round trip. The alternative, `ok: true, removed: 0`, is
the defect.
tests/mcp.test.ts had zero ok_untag coverage, which is how this survived two PRs
about the same bug. The new test asserts on the STORE read back as well as on the
payload — asserting the absence of an error is what let it through the first time —
and it starts from a positive control proving the fixture really holds one glued tag
rather than three. It strips HASNA_KNOWLEDGE_API_URL/KEY from the child env: either
one flips the server into cloud mode, which ignores store_path and would turn the
whole test into one that measures nothing.
bin/knowledge-mcp.js is rebuilt so the shipped artifact carries the fix; the diff is
28 insertions confined to this tool.
d86ec84 to
88b00aa
Compare
Adversarial review — APPROVE (no fixes required)Verified independently on spark01, cloud-flip vars neutralised ( All three defects confirmed fixed at the committed head
I re-ran mutation testing myself, against that committed file, and each plant flipped the result:
Tree restored clean after each ( The overcorrection risk was checked specifically, and it is not present"Removing nothing is an error" does not mean every no-change is a failure. Measured And it is parity, not invention. Scope boundary confirmed presentThe Suite and bundleThat is the known pre-existing failure, red on
Non-blocking observations, not fixed here
Task |
fix(mcp): land ok_untag truthful-removal on main (PR #39 merge-plumbing recovery)
Closes
68fc5c1c. Stacked on PR #38 (fix/hc-00151-regenerate-mcp-bundle) — see Ordering.The defect
src/mcp.js:1684returnedjsonText({ ok: true, item, removed: before - nextTags.length })unconditionally. An untag that matched nothing came back
ok: true, removed: 0— a successsignal that cannot tell 1 removed from 0. Same class the CLI twin was fixed for in #34/#35,
still live on the MCP surface.
Reproduced over real stdio before touching anything, driving
src/mcp.jswith the MCP SDKclient. It was three defects, not one:
["gamma"]on["alpha","beta"]isError false,ok true,removed 0— andupdated_atbumpedisError true,No matching tag on k_absent: "gamma" not in ["alpha", "beta"], tags untouched,updated_atunchanged["a,b,c"]on an item holdinga,b,cseparatelyremoved 0, item unchanged (CLI removes 3)removed 3, tags["keep"]["alpha","nope"]on an item holding onlyalpharemoved 1, no mention ofnopeanywhereremoved 1,not_found ["nope"],message 'Removed 1 tag from k_partial (not found: "nope")'The
updated_atbump is the part that was not in the filed report:store.updateran beforeany check, so a no-op was recorded as a write.
The fix
Ported from
untaginsrc/cli.tsrather than reinvented, so the two surfaces agree:whole-value match short-circuits before the comma split,
removed === 0is an error, andunmatched names appear in both
not_foundandmessage.The exclusivity is per raw VALUE, not per call. Measured on an item holding
["a,b,c","a","b","c"]:That claim has been misstated twice in this repo, so the test pins it by execution rather
than restating it in prose.
list -t's union rule is deliberately not copied here — a filter has nothing to destroy, awrite does. (
ok_listhas neither rule; it is exact-match only. That is real and tracked asbf0cd7b7. Aligning this tool withuntagdoes not close that gap and is not meant to.)An empty or separator-only request (
tags: [],[","],[" , "]) is now its own errorrather than falling into the not-found path, where it would print
not in [...]against anempty list and read as the store's fault. The CLI cannot reach that case at all —
collectTagFlagrejects a separator-only value at parse time.A deliberate trade, stated rather than buried
A client that untags the same tag twice now gets
isErroron the second call. That is theCLI's contract (
exit 1) and this file's own idiom (errorTextis how every other tool says itcould not do what was asked —
ok_archive,ok_restore,ok_bulk_deleteall use it). It is notdestructive: the item is left exactly as it was, and the message names both the tags that missed
and the tags the item actually holds, so "already gone" is distinguishable from "wrong name"
without another round trip. The alternative —
ok: true, removed: 0— is the defect.Verification
tests/mcp.test.tshad zerook_untagcoverage, which is how this survived two PRs aboutthe same bug. The new test asserts on the store read back as well as the payload — asserting
the absence of an error is what let it through the first time — and opens with a positive control
proving the fixture really holds one glued tag rather than three.
It strips
HASNA_KNOWLEDGE_API_URL/HASNA_KNOWLEDGE_API_KEYfrom the child env: either one flipsthe server into cloud mode, which ignores
store_pathand would make the whole test measurenothing. CI has neither set; a developer machine easily has both.
Mutation-tested, each plant reverted immediately, tree clean after:
M7 is the control that matters: it proves the suite is not simply red for any edit to the file.
Full suite,
env -u HASNA_KNOWLEDGE_API_URL -u HASNA_KNOWLEDGE_API_KEY bun test --timeout 60000:None of the four are mine, and three are load flakes rather than real failures. This branch
touches only
src/mcp.js,tests/mcp.test.tsandbin/knowledge-mcp.js, so it cannot affecttests/cli.test.ts— yet three of the four failures are in that file:tests/cli.test.tscarries 10 hardcoded per-test timeouts (}, 10000)/20000/15000)which
--timeout 60000does not override, and machine load average was 52-60 during therun. On an earlier run of the same suite at lower load the identical branch state produced
1 failure — the
context pack…one, which is the pre-existing failure already recorded onmainand on PR #36. Filed as a separate finding rather than folded in here.bin/knowledge-mcp.jsis rebuilt so the shipped artifact carries the fix — 28 insertions, allconfined to this tool.
Ordering
Stacked on PR #38. This branch changes
src/mcp.jsand therefore rebuildsbin/knowledge-mcp.js, which is the exact artifact #38 un-stales. Landing this againstmaindirectly would either ship a bundle still carrying #33-era
package.json, or silently absorb#38's zod churn into a PR about tag semantics. Merge order: #37, then #38, then this.
Task:
68fc5c1c. Not merged by me — needs an independent adversarial review first.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.