Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/30891-mcp-delete-records.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/mcp': patch
'@objectstack/spec': patch
'@object-ui/console': patch
---

feat(mcp): `delete_records` deletes many records of one object in one confirmed call, by a filter or an id list (#30891)

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.
Metadata-protocol: none

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.

- **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.
- **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.
- **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.
- **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.
- **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.

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.

`@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.

`@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.
24 changes: 22 additions & 2 deletions content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ decision.

## What the agent gets

Twelve tools, generated from your metadata:
Thirteen tools, generated from your metadata:

| Tool | What it does |
|:---|:---|
Expand All @@ -254,6 +254,7 @@ Twelve tools, generated from your metadata:
| `query_records` / `get_record` | Read data (list queries are capped at 50 rows per page by default) |
| `aggregate_records` | Grouped aggregation (registered when the active driver supports it) |
| `create_record` / `update_record` / `delete_record` | Write data |
| `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`) |
| `list_actions` / `run_action` | Discover and invoke your business actions by name |
| `resume_run` | Finish a flow action that paused on a screen, by submitting the screen's values |

Expand Down Expand Up @@ -383,6 +384,25 @@ drive this MCP server. Same word, different layer.
with `confirm: true`; the flag asserts an approval, it does not obtain one.
REST `DELETE /api/v1/data/:object/:id` and `update_record` take no
confirmation.
- **`delete_records` takes the same gate, and its refusal is the preview.** A
call without `confirm: true` reads the selection as the caller and deletes
nothing: the same `428` refusal (`details.actionName` naming
`delete_records`) arrives with a `preview` beside it, saying how many records
match, how many a confirmed call attempts and a sample of their ids, for the
agent to show its human. A confirmed call deletes at most 200 records, each
exactly as `delete_record` would: under the caller's permissions and
row-level security, and through the object's delete rules (a package's
locking hook, a restricted relation), which no caller bypasses, an
administrator included. Records a rule refuses stay, and the answer counts
them grouped by reason with their ids; when more than 200 match it says how
many remain, and the next call continues. 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 instead, and the answer's `cap` says
so. A fault (a store error, a crash)
is not a refusal: it stops the call, and the error names the record it
stopped at and how many were already deleted. Because the preview reads ids
and a count, an OAuth token is offered the tool only when it carries both
`data:read` and `data:write`.

## Verify the connection

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

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

call '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"query_records","arguments":{"objectName":"crm_opportunity","limit":3}}}'
# → {"result":{"content":[{"type":"text","text":"{ \"object\": \"crm_opportunity\", \"records\": [ ... ], \"total\": 23, \"hasMore\": true }"}]}}
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ apply):
'create_record' // Create
'update_record' // Update by id
'delete_record' // Delete by id (destructive; refused without confirm: true)
'delete_records' // Delete many by filter or id list, ≤200 per call (refused with a preview without confirm: true)

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

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

Expand Down
3 changes: 3 additions & 0 deletions packages/mcp/src/mcp-coded-refusal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ const SITES: readonly Site[] = [
{ tool: 'update_record', args: { objectName: 'task', recordId: 't9', data: { title: 'x' } }, member: 'update' },
// `confirm: true` passes the confirmation gate, so the call reaches `remove`.
{ tool: 'delete_record', args: { objectName: 'task', recordId: 't9', confirm: true }, member: 'remove' },
// [#30891] The selection read is the member a bulk delete calls first; a
// refusal there refuses the whole call, rendered as the data tools render it.
{ tool: 'delete_records', args: { objectName: 'task', where: {}, confirm: true }, member: 'query' },
{ tool: 'list_actions', args: {}, member: 'listActions' },
{ tool: 'run_action', args: { actionName: 'complete' }, member: 'runAction', actionDoor: true },
{ tool: 'resume_run', args: { runId: 'r1' }, member: 'resumeRun', actionDoor: true },
Expand Down
Loading
Loading