Skip to content
Draft
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
19 changes: 19 additions & 0 deletions .changeset/30891-mcp-delete-records.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/mcp': patch
'@objectstack/spec': 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 `data:write` tool, `delete_records`; 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.
- **At most 200 records per call**, the bound REST `POST /data/:object/deleteMany` applies by default (`batch.maxBatchSize`). A filter is worked in id order; 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.

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.

`@objectstack/spec` changes only a doc comment: `AIActionConfirmation` names `delete_records` as the third door of the confirmation member.
17 changes: 15 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 |
| `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,18 @@ 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.

## Verify the connection

Expand Down Expand Up @@ -411,7 +424,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
3 changes: 2 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 @@ -130,7 +131,7 @@ and `resume_run` only when it implements `resumeRun`; a bridge without those
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
list/describe/query/aggregate/get, `data:write` covers create/update/delete (both delete tools), and
`actions:execute` covers `list_actions` / `run_action` / `resume_run`. 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