Repository navigation
feat(mcp): delete_records deletes many records of one object in one confirmed call - #31024
objectstack-fleet[bot] wants to merge 6 commits into
Conversation
…onfirmed call (WIP) The tool selects by a filter or an id list, answers an unconfirmed call with delete_record's confirmation refusal plus a preview (count, sample ids), and on confirm deletes each row through the bridge's remove, so every row meets the caller's permissions, row-level security and the package's delete rules; refusals come back grouped by reason. 200 per call. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibx1NhZMrqCvW7FBxg5
…(WIP) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibx1NhZMrqCvW7FBxg5
…xt and the 200 cap (WIP) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibx1NhZMrqCvW7FBxg5
…hangeset; dogfood pins (WIP) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibx1NhZMrqCvW7FBxg5
…es not model Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibx1NhZMrqCvW7FBxg5
📓 Docs Drift CheckThis PR changes 2 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 175 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1b613722dd04bf63ddd5c257ba5719e4ea6e733d && git checkout 1b613722dd04bf63ddd5c257ba5719e4ea6e733d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 33bc8ae5ff55cad16ca2c32ecf3161f5fda5da5c ef1973dfe1dc58bfd1bc870f2a507a3c17a0d9d6 && git checkout -B drift-repro 33bc8ae5ff55cad16ca2c32ecf3161f5fda5da5c && git merge --no-ff ef1973dfe1dc58bfd1bc870f2a507a3c17a0d9d6
node scripts/docs-audit/affected-docs.mjs --json 33bc8ae5ff55cad16ca2c32ecf3161f5fda5da5c
|
|
domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH Contract reviewServed-tier: Inputs read: the seat's order ① Derived judgments
② Semver levelBoth carriers read Check-runs on ③ Boundary flags
④ Design judgment
Findings (none must-fix):
Nits:
Implemented-by: VERDICT: PASS Record written 2026-11-06T18:35Z. |
Requested by Tesla · project thread
Clause-②: yes (widening)
Metadata-protocol: none
Fixes #30891
维护者速读
delete_records工具,按筛选条件或 id 列表,一次删除同一对象的多条记录。delete_record、逐条确认;现在先看一次预览(共几条、样例 id),人点头后一次确认删除。delete_record的同一删除路径(权限、行级安全、包的锁定规则),管理员也绕不过;每次最多 200 条。回滚即 revert 本 PR。packages/mcp/src/mcp-http-tools.ts里delete_records的注册段和deleteEach。pnpm --filter @objectstack/mcp exec vitest run src/mcp-delete-records.test.ts(17 条);真实栈上的锁定规则见packages/qa/dogfood/test/mcp-delete-records.dogfood.test.ts。What changes
One new
data:writetool on both MCP transports (HTTP/api/v1/mcpand the stdio server, which shareregisterObjectTools):delete_records.Request
{ "objectName": "crm_task", // exactly one of: "where": { "status": "closed" }, // a filter, as query_records takes it; {} = every record you can see "recordIds": ["t1", "t2"], // 1..200 ids "confirm": true // optional boolean; only true deletes }Unconfirmed call = the preview. It reads the selection as the caller and deletes nothing. It answers
delete_record's refusal from #30861 (same member, code, status, envelope keys anddetailskeys;details.actionNameisdelete_records) with apreviewbesideerror:{ "error": { "code": "ACTION_CONFIRMATION_REQUIRED", "message": "delete_records on 'crm_task' carries no confirmation — nothing was deleted. The selection matches 4 record(s); …", "status": 428, "details": { "actionName": "delete_records", "objectName": "crm_task", "confirmationMember": "confirm" } }, "preview": { "object": "crm_task", "matched": 4, "wouldAttempt": 4, "cap": 200, "sampleRecordIds": ["…"], "notFound": ["…"] /* id list only */, "note": "Nothing was deleted. …" } }Confirmed call. Each selected record is deleted through the bridge's
remove, the memberdelete_recorddeletes with, one at a time, under the caller's resolved context. Refused records stay and are grouped by code, status and reason:{ "object": "crm_task", "matched": 4, "attempted": 4, "deleted": 2, "refused": 2, "refusals": [ { "code": "RECORD_LOCKED", "status": 409, "reason": "<the rule's own sentence>", "count": 2, "recordIds": ["…", "…"] } ], "remaining": 2, "cap": 200, "next": "…" // only when more than 200 matched }POST /data/:object/deleteManyrefuses more ids thanbatch.maxBatchSize, default 200. Like that route, this tool deletes one row at a time, so the cap also bounds how long one call runs. A filter is worked in id order. When more than 200 match,remainingandnextsay how many are left and that the same call continues. When refused rows sit inside the first 200,nextnames{"id": {"$nin": [...]}}to move past them. The count comes from the bridge'saggregate; a bridge without it answersmatchedMoreThan: 200, not a guess.removeasdelete_record, with the context the principal resolver answered, so the engine judges each row'sbeforeDeletehooks, restricted relations, permissions and RLS as it does for a one-row delete.detailswithheld. A refusal is read with the one-row tool's reader (thrownRefusalEnvelope: code, message, status), so a refusal'sdetails(e.g. a permission refusal's positions) stays off the wire, as on every other tool.query_recordsdoes.Also: the SKILL.md tool list and the package README name the tool.
content/docs/ai/connect-mcp.mdxgets the table row and a security-model bullet.AIActionConfirmation's docblock in@objectstack/specnamesdelete_recordsas the third door (doc comment only). There is one changeset, patch level.Tests
packages/mcp/src/mcp-delete-records.test.ts(17 tests, real stdio bridge on an engine double whose delete refuses locked rows withRECORD_LOCKED/409, and other users' rows withPERMISSION_DENIED/403 carryingdetails):confirm,recordIds.maxItems200;engine.deletenever called, all rows intact;false,"true"and1are refused too;delete_record's code, status, envelope keys anddetailskeys;detailswithheld, one engine delete per row;isSystem, no copy);notFound;remaining: 5andnext, the second finishes; over the cap with refused rows,nextnames$nin;aggregate:matchedMoreThan: 200;success: falselands innotRemoved;packages/qa/dogfood/test/mcp-delete-records.dogfood.test.ts: a real stack with a packagebeforeDeletelock in HotCRM's shape, called by the administrator over the live/api/v1/mcp. It covers:DELETEcontrol (409 for the same admin);RECORD_LOCKED/409 group with the hook's own sentence, and still readable;Acceptance notes
BatchOptions.validateOnlywas retired for promising one). Running the delete in a rolled-back transaction would still fireafterDeletehooks, which do not wait for a commit. So the preview gives the count and a sample and says the rules are judged when the delete runs. The confirmed call's answer carries the grouped refusals with ids.data:write-only token can read ids and counts through the preview. The read runs through the bridge'squery/aggregateunder the caller's RLS, so it shows nothing the caller could not read. But a token granteddata:writewithoutdata:readnow gets ids it could not list before.apiMethodsexcludelistcannot usedelete_records. The selection is a list-class read, so the exposure gate refuses it.delete_recordstill works there.skills/objectstack-ai/SKILL.mdteachingdelete_records(skills/**is outside this card).ef1973df. Its actions declare their gate once, and REST /actions and MCP list_actions read it: list only what the caller can run (#30126 row 16, ruling A) #30771 change tomcp-http-tools.tsandruntime/src/domains/mcp.tsmerged cleanly and touches no delete path.Verification
Tests on the final head
ef1973df, which has origin/main merged in:pnpm --filter @objectstack/mcp test: 58 files, 713 tests passed. The new file has 17.pnpm --filter @objectstack/mcp run typecheck(tsc --noEmitpluscheck:test-typecheck, debt ledger held) exited 0.mcp-delete-records.dogfood.test.ts(4) andmcp-delete-record-confirmation.dogfood.test.ts(4) passed, 8/8, after a full./packages/**+@objectstack/dogfood^...build. The dogfood resolves@objectstack/mcpfrom dist.tsc --noEmitinpackages/qa/dogfoodexited 0.packages/mcp/src, the dogfood file and the spec file exited 0.Ablations: three negative pins, each mutated once through
scripts/ablation-replace.mjs(WRAP mode) on the final headef1973df, each red, each restored (blob == HEAD,git diff HEADempty). They were taken once before one223b854, before the merge, with the same result.A1. The preview deletes nothing. The forbidden behaviour put back: the unconfirmed branch runs the deletes, then still answers the refusal and the preview.
A2. An unconfirmed call deletes nothing. The forbidden behaviour put back: the confirmation gate is removed (
if (!isDeleteConfirmed(args))becomesif (false)), so an unconfirmed call deletes.(
"true"and1as the member stay green under A1 and A2. The closed input schema refuses them before the handler runs, as ondelete_record.)A3. An administrator is still refused by a locking rule. The forbidden behaviour put back: the bridge's delete takes the package's rules off the administrator's write. In
packages/runtime/src/domains/mcp.ts,removehands the engine{ ...ec, skipAutomations: true }. That is the flag that skips metadata-bound hooks, and the flag a bypass would set. The caller stays the same administrator. The run is the dogfood file, against runtime source (aliased).The REST
DELETEcontrol stayed green under A3: the lock still refused the administrator on the door the mutation did not touch.Gates on
ef1973df:dispatch-gates.mjs --commandsderived 111 commands.check-published-list-mirrors.mjs, speccheck:meta-url-spelling,check:authz-resolver,check:changeset-fixed,check:error-code-casing,check:facade-delegation-only,check:filter-alias-parity,check:i18n-stale-fillandcheck:published-readme-exports./dev/null, and every one exited 0.dispatch-gates --ranreconciled 111 derived, 111 run, 0 NOT-MEASURED and 0 UNRUN.server-seat-2/r2026-11-06/out/30891-gates-ran-ef1973df.listin the project files.Notes on the gate runs
e223b854.check:where-matcherexited 1 on a real finding: the new test's engine double read a combinator ($and,$or) as a field name. It now refuses any$-key inside itseverycallback (a7989233).check:api-surface/check:browser-reachable-entries(spec dist predated the docblock), speccheck:skill-examples(noclient-reactdist),check:dual-build-cjs-loads(six packages had no dist), andcheck-plugin-teardown-shape --self-test(pinned commit621a4876missing in the shallow clone)../packages/**build on the merged head.🤖 Generated with Claude Code
https://claude.ai/code/session_01L9qibx1NhZMrqCvW7FBxg5