Skip to content

Commit 5cb7d7e

Browse files
feat(mcp): delete_records deletes many records of one object in one confirmed call (#31024)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao9VoosHMokHL8ds8NeDWBdp)_ Clause-②: yes (widening) Metadata-protocol: none Fixes #30891 ## 维护者速读 - **改了什么**:MCP 新增 `delete_records` 工具,按筛选条件或 id 列表,一次删除同一对象的多条记录。 - **为什么改**:AI 清理一批记录时只能逐条调 `delete_record`、逐条确认;现在先看一次预览(共几条、样例 id),人点头后一次确认删除。 - **风险与回滚**:只新增一个工具,现有工具不变。每条记录走 `delete_record` 的同一删除路径(权限、行级安全、包的锁定规则),管理员也绕不过;每次最多 200 条(部署的读取页上限更小时按它)。遇到故障(驱动错误、代码钩子崩溃)立即停止并报告已删条数。OAuth 令牌需同时有 `data:read` 和 `data:write`。回滚即 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`(27 条);真实栈上的锁定规则见 `packages/qa/dogfood/test/mcp-delete-records.dogfood.test.ts`。 - **需要你做的**:无需决定。注意一点:预览无法预先说出哪些记录会被锁定规则拒绝(引擎没有删除试运行),确认后的回答按原因分组列出被拒的记录和 id。 ## What changes One new tool on both MCP transports (HTTP `/api/v1/mcp` and the stdio server, which share `registerObjectTools`): `delete_records`. Over OAuth it is offered only to a token that carries both `data:read` and `data:write`. **Request** ```jsonc { "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 and `details` keys; `details.actionName` is `delete_records`) with a `preview` beside `error`: ```jsonc { "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 member `delete_record` deletes with, one at a time, under the caller's resolved context. Refused records stay and are grouped by code, status and reason: ```jsonc { "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 } ``` - **The cap is 200 per call.** That is the bound the platform's other many-row delete door already uses: REST `POST /data/:object/deleteMany` refuses more ids than `batch.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 read in id order at the cap, never one row past it, and the bridge's `aggregate` count says whether more match. When more than 200 match, `remaining` and `next` say how many are left and that the same call continues. When refused rows sit inside the first 200, `next` names `{"id": {"$nin": [...]}}` to move past them. A bridge without `aggregate` keeps a cap+1 probe and answers `matchedMoreThan: 200`, not a guess. - **The cap follows the deployment's read window down.** The data door refuses (never clamps) a read over `OS_DATA_MAX_PAGE_SIZE` or past `OS_DATA_QUERY_ROW_BUDGET`, and the caller has no `limit` to lower. So on a deployment that sets either under 200, one call runs at that size (`deleteRecordsCap`), and the answer's `cap` names it. An id list longer than that cap is refused before anything is read. - **A fault stops the call; a rule's refusal does not.** Each row's throw is judged with the REST `/data` door's own classifier (`mapDataError`, `@objectstack/types`), so the two doors cannot disagree on a row. A 4xx verdict is a refusal and is grouped: a coded rule refusal under the code and message `delete_record` answers, a sandboxed hook body's uncoded throw as `400 HOOK_ABORTED` with the body's sentence. A 5xx verdict is a fault: a code hook's bare `throw new Error`, a lost connection, a driver error, and a driver's own string code (`ECONNREFUSED`, a SQL state, `SQLITE_BUSY`), which the door answers `500` too. The first fault ends the call: `isError: true`, an `error` naming the fault, the record it stopped at, how many were deleted (they stay deleted) and how many were not attempted, and a `progress` member with the call's answer so far (`stoppedAt`, `notAttempted`, no `next`). - **No bypass, for an administrator either** (ruling 6687860173, 「不绕过」). The tool has no delete path of its own. It calls the same `remove` as `delete_record`, with the context the principal resolver answered, so the engine judges each row's `beforeDelete` hooks, restricted relations, permissions and RLS as it does for a one-row delete. - **`details` withheld.** A refusal is read with the one-row tool's reader (`thrownRefusalEnvelope`: code, message, status), so a refusal's `details` (e.g. a permission refusal's positions) stays off the wire, as on every other tool. - **ADR-0090 D10.** A narrowed delegated read is stated on the preview and on the answer, as `query_records` does. Also: the SKILL.md tool list and the package README name the tool. `content/docs/ai/connect-mcp.mdx` gets the table row and a security-model bullet. In `@objectstack/spec`, only doc comments change: `AIActionConfirmation`'s docblock names `delete_records` as the third door, and the error-code ledger's `ACTION_CONFIRMATION_REQUIRED` rows name both delete tools. There is one changeset, patch level, for `@objectstack/mcp`, `@objectstack/spec` and `@object-ui/console`. **Files** - `packages/mcp/src/mcp-http-tools.ts`: the tool, `resolveDeleteSelection`, `deleteEach`, `rowRefusalOf`, `deleteRecordsCap`. - `packages/mcp/src/skill-md.ts`, `packages/mcp/README.md`, `content/docs/ai/connect-mcp.mdx`: teaching. - `packages/spec/src/contracts/ai-service.ts`, `packages/spec/src/api/error-code-ledger.zod.ts`: doc comments only. - `ui/apps/console/src/pages/developer/objectstack-skill.ts` and one line of `objectstack-skill.test.ts`: the console's "Download skill" text, a byte-for-byte mirror of `skill-md.ts` under #31015's drift pin, carries the same `delete_records` bullet. This is outside the card's `ui/**` fence, under seat claim amendments 6692914335 and 6692945782. - Tests: `mcp-delete-records.test.ts` (new), the dogfood file (new), and the tool-surface pins listed under Tests. - `scripts/engine-double-contract.pinned.json`: the new engine double's ledger row. - `.changeset/30891-mcp-delete-records.md`. ## Tests - `packages/mcp/src/mcp-delete-records.test.ts` (27 tests, real stdio bridge on an engine double whose delete refuses locked rows with `RECORD_LOCKED`/409, and other users' rows with `PERMISSION_DENIED`/403 carrying `details`): - advertisement: destructive hint, optional boolean `confirm`, `recordIds.maxItems` 200; - preview: 428 refusal plus preview (count, sample), `engine.delete` never called, all rows intact; `false`, `"true"` and `1` are refused too; - the refusal has `delete_record`'s code, status, envelope keys and `details` keys; - confirmed: allowed rows deleted, refused rows grouped with ids, `details` withheld, one engine delete per row; - every engine read and delete carries the caller's context object unchanged (no `isSystem`, no copy); - id list: duplicates collapse, `notFound`; - cap: 205 rows, the first call deletes 200 with `remaining: 5` and `next`, the second finishes; over the cap with refused rows, `next` names `$nin`; - no `aggregate`: `matchedMoreThan: 200`; - a stale roll-up counts as deleted; `success: false` lands in `notRemoved`; - selection xor; an id list over 200 is refused at the input; - the read window, through the platform's own `planQueryReadWindow` in front of the bridge: a page size equal to the cap reads at 200 and counts 205 (a lit control shows the window refusing 201); exactly 200 matching has no `next`; a decimal-string count is read; a page size or row budget of 50 lowers the cap to 50; an id list longer than a lowered cap is refused before any read; - faults: an uncoded throw, a coded 5xx and a driver's `ECONNREFUSED` each stop the call at that row (`isError`, the record, the deleted count, `progress`, later rows never asked); a sandboxed body's uncoded refusal is grouped as `HOOK_ABORTED`/400 and the call goes on; - the long-lived stdio server (InMemoryTransport) behaves the same. - `packages/qa/dogfood/test/mcp-delete-records.dogfood.test.ts`: a real stack with a package `beforeDelete` lock in HotCRM's shape, called by the administrator over the live `/api/v1/mcp`. It covers: - the preview (REST still reads every row); - the REST `DELETE` control (409 for the same admin); - the confirmed call: unlocked rows gone, locked rows one `RECORD_LOCKED`/409 group with the hook's own sentence, and still readable; - the cap on 203 real rows with the runtime's real aggregate count, on a host whose `OS_DATA_MAX_PAGE_SIZE` is 200 (lit control: REST `?limit=201` answers 400 `MAX_RECORDS_EXCEEDED`); - a host whose page size is 2: the call runs at 2 and says so, and a 3-id list is refused; - a sandboxed hook body's refusal grouped as `HOOK_ABORTED`/400 while the walk goes on, then a code hook's bare `throw new Error` stopping it, with REST `DELETE` as the control (400 and 500 on the same rows). - Tool-surface pins learn the thirteenth tool: the scopes, authoring-scope, HTTP and stdio tool lists, unknown-argument keys, the coded-refusal sites, the SKILL.md list and the stored-metadata-body read-site census. The scopes pin now has `delete_records` only under both data scopes, and a `data:write`-only `tools/call delete_records` refused with no `query`, `aggregate` or `remove` reaching the bridge. - The console's SKILL.md drift pin (#31015) and its tool-name list include `delete_records`. ## Acceptance notes - **Card premise, partly falsified: the preview cannot name per-record refusals.** The engine has no delete dry run (`BatchOptions.validateOnly` was retired for promising one). Running the delete in a rolled-back transaction would still fire `afterDelete` hooks, 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. - **Groups key on code + status + message, not on a rule name.** The engine attaches no hook or rule name to a refusal, so two rules that throw the same code, status and sentence share a group. - **Both data scopes (seat decision on review round 1).** The preview reads ids and a count, a list-class read, so a token granted `data:write` without `data:read` is not offered the tool. `delete_record` stays under `data:write` alone. - **The cap follows the read window down: an extension beyond the review's literal ask.** The review asked to read at the cap instead of cap+1, which fixes a window of exactly 200. A window under 200 would still refuse every filter selection, with a message naming a `limit` the caller never sent, so the cap takes the window's size instead. - **Fault vs refusal follows the data door, not "coded vs uncoded".** The review's rule was "an uncoded throw is a fault". Measured on a booted stack, a sandboxed hook body's deliberate refusal also arrives uncoded (REST answers it `400 HOOK_ABORTED`), and a driver's errors often carry string codes (`ECONNREFUSED`, `57P01`, `SQLITE_BUSY`; REST answers them `500`). So the tool asks the REST door's classifier and stops on its 5xx verdicts. - **An object whose `apiMethods` exclude `list` cannot use `delete_records`.** The selection is a list-class read, so the exposure gate refuses it. `delete_record` still works there. - **Out of scope, follow-ups:** a delete dry run in the engine, which would let the preview name refusals (the seat files it as its own card); `skills/objectstack-ai/SKILL.md` teaching `delete_records` (`skills/**` is outside this card). - **Serial with #30892 / PR #31009 (MCP attachments).** It touches the same files: three count/prose conflicts (`connect-mcp.mdx`, the unknown-argument-keys test, the `skill-md.ts` imports), and the merged tool count is 15. Whichever lands second takes them, and must also mirror its own `skill-md.ts` lines into the console copy. This branch does not merge #31009's. origin/main was merged in again at `17a02908` (#31015's drift pin arrived there). ## Verification Head `4e5b0f2d` (origin/main merged at `17a02908`): - `pnpm --filter @objectstack/mcp test`: 58 files, 725 tests passed. `mcp-delete-records.test.ts` has 27. - `pnpm --filter @objectstack/mcp run typecheck` (`tsc --noEmit` plus `check:test-typecheck`, debt ledger held) exited 0. - Dogfood: `mcp-delete-records.dogfood.test.ts` (7) and `mcp-delete-record-confirmation.dogfood.test.ts` (4) passed, 11/11, after a full `./packages/**` + `@objectstack/dogfood^...` build. The dogfood resolves `@objectstack/mcp` from dist. - Console: `pnpm --dir ui exec vitest run apps/console/src/pages/developer/objectstack-skill.test.ts`, 13/13. - eslint over every touched `.ts` file (packages and the two console files) exited 0. <details><summary>Ablations, round 1: four new negative pins, each mutated once through <code>scripts/ablation-replace.mjs</code> (WRAP mode), each red, each restored. <code>mcp-http-tools.ts</code> is byte-identical on <code>4e5b0f2d</code> (blob <code>d06c686d</code>).</summary> **B1. A `data:write`-only token is refused.** Mutation: ` if (canRead) {` becomes ` if (true) {`, so the tool registers under `data:write` alone. ``` × data:write alone exposes only the write family, without delete_records × [#30891] delete_records with a data:write-only grant is refused WITHOUT reading or deleting × a data:write token can neither list nor call an authoring tool, and the registry is never touched Tests 3 failed | 27 passed (30) AssertionError: expected [ [ 'query', 'task', { …(4) } ] ] to deeply equal [] ``` B1 ran before the round's commit; its restore put the file back to the older HEAD. The round's edits were replayed and proved byte-identical (blob `d06c686d`, the blob B1 mutated), then committed before B2–B4. **B2. A fault stops the call.** Mutation: ` if (door.status >= 500) return undefined;` becomes ` if (false) return undefined;`, so a 5xx verdict is grouped like a refusal. ``` unit: × an uncoded throw … / × a coded 5xx … / × a driver's own string code … ends the call at that row Tests 3 failed | 24 passed (27) dogfood: × confirmed: the refusal is grouped and the walk goes on; the fault stops it, naming the row and what was deleted Tests 1 failed | 6 passed (7) ablation-replace: ok restored: blob == HEAD (d06c686) and `git diff HEAD` is empty ``` **B3. The selection is read at the cap.** Mutation: ` const ids = await readIds(cap);` becomes ` const ids = (await readIds(cap + 1)).slice(0, cap);`, the old probe. ``` unit: × a host whose maximum page size is the cap … / × exactly the cap matching … / × (both lowered-cap cases) Tests 4 failed | 23 passed (27) dogfood: × the cap, on a host whose maximum page size IS the cap … / × a host whose maximum page size is under the cap … Tests 2 failed | 5 passed (7) [data] read refused: code=MAX_RECORDS_EXCEEDED surface=list object=dlr_bulk_row rows=201 limit=200 ablation-replace: ok restored: blob == HEAD (d06c686) and `git diff HEAD` is empty ``` **B4. The cap follows the read window down.** Mutation: `deleteRecordsCap` returns `DELETE_RECORDS_MAX_PER_CALL` unconditionally. ``` × a maximum page size under the cap lowers this call's cap to it … / × a query row budget … / × an id list longer than a lowered cap … Tests 3 failed | 24 passed (27) AssertionError: expected 200 to be 50 // Object.is equality ``` The dogfood runs for B2 and B3 rebuilt `@objectstack/mcp`'s dist inside the mutation; it was rebuilt again after the restore. </details> <details><summary>Ablations from the first round (A1–A3), on <code>ef1973df</code>, unchanged by this round</summary> - **A1.** The preview runs the deletes: 7 red. - **A2.** The confirmation gate removed: 7 red. - **A3.** The runtime bridge's `remove` hands the engine `{ ...ec, skipAutomations: true }` (the admin-bypass shape): the dogfood admin pin red (`deleted` 4, `refused` 0); the REST control stayed green. </details> Gates on `4e5b0f2d`: - `dispatch-gates.mjs --commands` derived 116 commands. - 8 artifact-roster families that name a path of this diff were added: `check-published-list-mirrors.mjs`, spec `check:meta-url-spelling`, `check:authz-resolver`, `check:changeset-fixed`, `check:facade-delegation-only`, `check:filter-alias-parity`, `check:i18n-stale-fill` and `check:published-readme-exports`. - All 124 ran with stdin from `/dev/null`, and every one exited 0. - `dispatch-gates --ran` reconciled 116 derived, 116 run, 0 NOT-MEASURED. - The record is `server-seat-2/r2026-11-06/out/30891-gates-ran-4e5b0f2d.list` in the project files. - The changeset: one added file; `changeset status` and `check:changeset-fixed` exited 0. <details><summary>Notes on the gate runs</summary> - **First sweep, on `e223b854`.** `check:where-matcher` exited 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 its `every` callback (`a7989233`). - **Round 1.** `check:test-typecheck` exited 1 on a real finding: the new scope pin read two JSON-RPC answers as `unknown`, growing the package's debt. They are typed (`4e5b0f2d`). - **Prerequisites, not findings.** Spec dist, `client-react` dist, the dual-build dists and pinned commit `621a4876` were built or fetched first, as in round 0. - **The sweep** ran after a full `./packages/**` + `@objectstack/dogfood^...` build on the merged head. </details> <!-- signed: domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eb96480 commit 5cb7d7e

20 files changed

Lines changed: 1768 additions & 30 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/mcp': patch
3+
'@objectstack/spec': patch
4+
'@object-ui/console': patch
5+
---
6+
7+
feat(mcp): `delete_records` deletes many records of one object in one confirmed call, by a filter or an id list (#30891)
8+
9+
Clause-②: yes (widening) — the MCP surface (HTTP `/api/v1/mcp` and the stdio server) registers one more tool, `delete_records`, offered to an OAuth token that carries both `data:read` and `data:write`; no existing tool, input or answer changes.
10+
Metadata-protocol: none
11+
12+
An agent that had to clear a set of records called `delete_record` once per row, and confirmed each one. `delete_records` takes the selection as `where` (a filter, `{}` for every record) or `recordIds` (an id list), never both.
13+
14+
- **The preview is the refusal.** A call without `confirm: true` deletes nothing. It answers `delete_record`'s refusal (`428` `ACTION_CONFIRMATION_REQUIRED`, the same `details` keys, `details.actionName` naming `delete_records`) with a `preview` member beside `error`: how many records the selection matches for this caller (`matched`), how many a confirmed call attempts (`wouldAttempt`), up to ten sample ids and, for an id list, the ids that name no readable record (`notFound`). The agent shows that to its human and retries with `confirm: true` once they approve.
15+
- **A confirmed call deletes each record as `delete_record` does**, through the same bridge member, under the caller's permissions, row-level security and the object's delete rules (`beforeDelete` hooks, restricted relations). No caller bypasses a package's locking rule, an administrator included. Records a rule refuses stay, and the answer groups them by code, status and reason, with their ids: `{ matched, attempted, deleted, refused, refusals: [{ code, status, reason, count, recordIds }], remaining, cap }`. A refusal's `details` stays off the wire, as on every other tool.
16+
- **A fault is not a refusal.** A rule's refusal and a fault are told apart the way the REST `/data` door tells them apart (its own classifier): a 4xx verdict (a coded rule refusal, a sandboxed hook body's `400 HOOK_ABORTED`) is grouped and the call goes on; a 5xx verdict (a code hook's bare `throw new Error`, a lost connection, a driver error, a driver's own string code) stops the call at that record. The tool error names the fault and the record, and carries the call's `progress` (`deleted`, `stoppedAt`, `notAttempted`): the records deleted before it stay deleted.
17+
- **At most 200 records per call**, the bound REST `POST /data/:object/deleteMany` applies by default (`batch.maxBatchSize`). A filter is read in id order at the cap, and the count says whether more match; when more than 200 match, the answer says how many remain and the next call with the same selection continues. When the deployment has no aggregate support, the answer says `matchedMoreThan: 200` instead of guessing a count. On a deployment whose read page size or row budget (`OS_DATA_MAX_PAGE_SIZE`, `OS_DATA_QUERY_ROW_BUDGET`) is under 200, one call runs at that size, since the data door refuses a larger read, and the answer's `cap` names it; a longer id list is refused before anything is read.
18+
- **Scopes.** Over OAuth the tool needs both `data:read` and `data:write`: its preview reads the selection's ids and count, a read a `data:write`-only token is not granted.
19+
20+
What a preview cannot say: which records a delete rule will refuse. The engine has no delete dry run, so the rules are judged when the confirmed call runs, and the preview says so.
21+
22+
`@object-ui/console`: the Integrations page's "Download skill" SKILL.md is a byte-for-byte mirror of the server's (#31015's drift pin), so it carries the same `delete_records` bullet.
23+
24+
`@objectstack/spec` changes only doc comments: `AIActionConfirmation` names `delete_records` as the third door of the confirmation member, and the error-code ledger's `ACTION_CONFIRMATION_REQUIRED` rows name both delete tools.

‎content/docs/ai/connect-mcp.mdx‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ decision.
245245

246246
## What the agent gets
247247

248-
Twelve tools, generated from your metadata:
248+
Thirteen tools, generated from your metadata:
249249

250250
| Tool | What it does |
251251
|:---|:---|
@@ -254,6 +254,7 @@ Twelve tools, generated from your metadata:
254254
| `query_records` / `get_record` | Read data (list queries are capped at 50 rows per page by default) |
255255
| `aggregate_records` | Grouped aggregation (registered when the active driver supports it) |
256256
| `create_record` / `update_record` / `delete_record` | Write data |
257+
| `delete_records` | Delete many records of one object, by a filter or an id list, at most 200 per call (an OAuth token needs both `data:read` and `data:write`) |
257258
| `list_actions` / `run_action` | Discover and invoke your business actions by name |
258259
| `resume_run` | Finish a flow action that paused on a screen, by submitting the screen's values |
259260

@@ -383,6 +384,25 @@ drive this MCP server. Same word, different layer.
383384
with `confirm: true`; the flag asserts an approval, it does not obtain one.
384385
REST `DELETE /api/v1/data/:object/:id` and `update_record` take no
385386
confirmation.
387+
- **`delete_records` takes the same gate, and its refusal is the preview.** A
388+
call without `confirm: true` reads the selection as the caller and deletes
389+
nothing: the same `428` refusal (`details.actionName` naming
390+
`delete_records`) arrives with a `preview` beside it, saying how many records
391+
match, how many a confirmed call attempts and a sample of their ids, for the
392+
agent to show its human. A confirmed call deletes at most 200 records, each
393+
exactly as `delete_record` would: under the caller's permissions and
394+
row-level security, and through the object's delete rules (a package's
395+
locking hook, a restricted relation), which no caller bypasses, an
396+
administrator included. Records a rule refuses stay, and the answer counts
397+
them grouped by reason with their ids; when more than 200 match it says how
398+
many remain, and the next call continues. On a deployment whose read page
399+
size or row budget (`OS_DATA_MAX_PAGE_SIZE`, `OS_DATA_QUERY_ROW_BUDGET`) is
400+
under 200, one call runs at that size instead, and the answer's `cap` says
401+
so. A fault (a store error, a crash)
402+
is not a refusal: it stops the call, and the error names the record it
403+
stopped at and how many were already deleted. Because the preview reads ids
404+
and a count, an OAuth token is offered the tool only when it carries both
405+
`data:read` and `data:write`.
386406

387407
## Verify the connection
388408

@@ -411,7 +431,7 @@ call '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":
411431
# → {"result":{"serverInfo":{"name":"objectstack",...},"capabilities":{"tools":{...},"prompts":{}}},...}
412432

413433
call '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | grep -o '"name":"[a-z_]*"'
414-
# → the twelve tools, list_objects … resume_run
434+
# → the thirteen tools, list_objects … resume_run
415435

416436
call '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"query_records","arguments":{"objectName":"crm_opportunity","limit":3}}}'
417437
# → {"result":{"content":[{"type":"text","text":"{ \"object\": \"crm_opportunity\", \"records\": [ ... ], \"total\": 23, \"hasMore\": true }"}]}}

‎packages/mcp/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ apply):
118118
'create_record' // Create
119119
'update_record' // Update by id
120120
'delete_record' // Delete by id (destructive; refused without confirm: true)
121+
'delete_records' // Delete many by filter or id list, ≤200 per call (refused with a preview without confirm: true)
121122

122123
// Business actions — operate the app, not just its rows
123124
'list_actions' // Invokable business actions the caller may run
@@ -131,7 +132,8 @@ seams serves the rest and advertises nothing it cannot do.
131132

132133
OAuth scopes narrow the families at consent time: `data:read` covers
133134
list/describe/query/aggregate/get, `data:write` covers create/update/delete, and
134-
`actions:execute` covers `list_actions` / `run_action` / `resume_run`. A tool outside the grant
135+
`actions:execute` covers `list_actions` / `run_action` / `resume_run`. `delete_records` needs both
136+
`data:read` and `data:write`: its preview reads the selection's ids and count. A tool outside the grant
135137
is **not registered at all**, so the SDK rejects it as an unknown tool — the
136138
grant doubles as dispatch-time enforcement.
137139

‎packages/mcp/src/mcp-coded-refusal.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ const SITES: readonly Site[] = [
102102
{ tool: 'update_record', args: { objectName: 'task', recordId: 't9', data: { title: 'x' } }, member: 'update' },
103103
// `confirm: true` passes the confirmation gate, so the call reaches `remove`.
104104
{ tool: 'delete_record', args: { objectName: 'task', recordId: 't9', confirm: true }, member: 'remove' },
105+
// [#30891] The selection read is the member a bulk delete calls first; a
106+
// refusal there refuses the whole call, rendered as the data tools render it.
107+
{ tool: 'delete_records', args: { objectName: 'task', where: {}, confirm: true }, member: 'query' },
105108
{ tool: 'list_actions', args: {}, member: 'listActions' },
106109
{ tool: 'run_action', args: { actionName: 'complete' }, member: 'runAction', actionDoor: true },
107110
{ tool: 'resume_run', args: { runId: 'r1' }, member: 'resumeRun', actionDoor: true },

0 commit comments

Comments
 (0)