Skip to content

Commit c77dd17

Browse files
feat(mcp)!: delete_record refuses a call without confirm: true (#30861)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D2aQHfXpH1igUtASYz9Xcje)_ domain:server#4 · os-elon-musk · session_016WV4czsSxuhcKFSWS5tLdW Fixes #30813 Clause-②: yes (narrowing) — the MCP `delete_record` tool refuses every call that does not carry `confirm: true`, where it deleted the record. The confirmation contract in `@objectstack/spec/contracts` (`AIActionConfirmation`, `ActionConfirmationRequiredDetails`) gains its second door in its docblock, and the error-code ledger lists `ACTION_CONFIRMATION_REQUIRED` under `@objectstack/mcp` as a second emitter; no shape changes. Metadata-protocol: none Skills-docs: #30863 (skills/objectstack-ai/SKILL.md L202) and #30865 (the Console mirror ui/apps/console/src/pages/developer/objectstack-skill.ts), both parked behind this PR Claim: 6677666991 · Branch: `claude/project-thread-o7rpxo` · Ruling: card body, maintainer 「同意」 twice (option B) · Triage: 6677209837 ## 维护者速读 - 改了什么:MCP 的 `delete_record` 工具现在每次调用都必须带 `confirm: true` 才删除;不带(或带 `false`、字符串 `"true"`)直接拒绝,返回与 `run_action` 完全相同的 428 `ACTION_CONFIRMATION_REQUIRED`(`details` 同形,`actionName` 写 `delete_record`),在到达数据桥之前就拒,什么都不读不删。工具 schema 公布 `confirm`,描述里让 agent 先问人再重试。HTTP 与 stdio 两种传输都拦。 - 为什么改:原来 agent 一次调用就能永久硬删一条记录,唯一的提示 `destructiveHint` 客户端可以无视;记录正文里植入的指令能诱导 agent 删掉用户有权删的数据而无人过问。按卡片裁决选项 B(维护者两次「同意」)。 - 风险与回滚:BREAKING,所有经 MCP 删除的客户端在补上 `confirm` 之前都会被拒;已能处理 `run_action` 这条拒绝的客户端零改动。REST `DELETE /api/v1/data/...` 与 `update_record` 不受影响。`confirm` 只是调用方的声明,门让「忘了问」变响,不证明真有人类。回滚即撤 PR。 - 先看:`packages/mcp/src/mcp-http-tools.ts`(`recordDeleteConfirmationRefusal` 与 `delete_record` 的注册处),`packages/mcp/src/mcp-delete-record-confirmation.test.ts`,`.changeset/30813-mcp-delete-record-confirmation.md`。 - 两分钟验证:`pnpm --filter @objectstack/mcp exec vitest run src/mcp-delete-record-confirmation.test.ts`,预期 9 条全绿:未确认被拒且记录仍在、`confirm: true` 删除、拒绝形状与 `run_action` 相同、stdio 长连接服务器同样拦、`update_record` 不受门约束。 - 需要你做的:对这条 BREAKING 改动给出本 PR 的一句同意(契约复审由席位做)。 ## What changes **`@objectstack/mcp` — `delete_record`** (`src/mcp-http-tools.ts`, `registerObjectTools`) - The tool's input declares the confirmation member, keyed off `AI_ACTION_CONFIRMATION_MEMBER` as `run_action` declares it: `confirm`, an optional boolean. Optional on purpose: a required member would refuse an unconfirmed call as a bare input-validation error with no code and no retry details. Before this change a call carrying `confirm` was refused as an unknown key by the strict input schema. - The handler asks `recordDeleteConfirmationRefusal(objectName, recordId, args)` after the system-object guard and BEFORE the bridge. A call whose member is not the boolean `true` answers `codedErrorResult({ code: 'ACTION_CONFIRMATION_REQUIRED', message, status: 428, details: { actionName: 'delete_record', objectName, confirmationMember: 'confirm' } })`, the renderer `run_action`'s refusal goes through (`errorResultFromThrown` ends in it), so the envelope keys and their order are the same. No bridge member is called on a refused call. - The code constant is typed `ErrorCode` (`@objectstack/spec/api`), so a misspelling does not compile; `details` is typed `ActionConfirmationRequiredDetails`. - The description: "Delete a record by id. This is destructive and permanent. A call is REFUSED unless it also sends confirm: true — ask the human first, then retry; nothing is deleted on a refused call." - Both transports gate: the HTTP door's per-request server and the long-lived server the stdio transport serves both register through `wireBridgeTools` → `registerObjectTools`. - `src/skill-md.ts` (the MCP-served `SKILL.md`): the `delete_record` line teaches `confirm` and the refusal. `README.md`: one tool-list comment. **`@objectstack/spec`** — comments, plus one provenance row: - `contracts/ai-service.ts`: the member's docblock names the third request shape (the `delete_record` input); `AIActionConfirmation` gains "The second gate: the MCP `delete_record` tool" (unconditional, before the bridge, same refusal, `actionName` is the tool name, and the four bounds); `ActionConfirmationRequiredDetails.actionName` says what it holds on that door. No type changes. - `api/error-code-ledger.zod.ts`: the `@objectstack/runtime` row's comment names the second emitter, and `@objectstack/mcp` lists `ACTION_CONFIRMATION_REQUIRED` (`check:error-code-provenance` requires a row under the stamping package). The code was already registered, so the vocabulary is unchanged. **Docs** — `content/docs/ai/connect-mcp.mdx`, "The security model": one bullet saying `delete_record` needs `confirm: true` on every call, what the refusal is, and that REST `DELETE` and `update_record` take none. **Changeset** — `.changeset/30813-mcp-delete-record-confirmation.md`, `patch` for `@objectstack/mcp` and `@objectstack/spec`, BREAKING banner, both declaration lines, the ADR-0087 disposition, Before / Now / Who moves / Remedy, and the four bounds (REST outside, `update_record` not gated, no per-object exemption, a future bulk-delete tool takes the same gate). ## Tests - **New `packages/mcp/src/mcp-delete-record-confirmation.test.ts`** (9), through the HTTP `/mcp` transport against the real stdio data bridge on an engine double that holds the row: the member is advertised as an optional boolean and the description says to ask the human; an unconfirmed call is refused with code, status 428 and the exact `details`, the engine's `delete` is never called and the row is intact; `false`, `"true"` and `1` are refused and nothing is deleted; `confirm: true` deletes once; the refusal's code, status, envelope keys and `details` keys equal `run_action`'s served through the same door; `update_record` writes without the member (control); the long-lived server (the stdio transport's) refuses and then deletes on the retry, over an in-memory MCP client. - **New `packages/qa/dogfood/test/mcp-delete-record-confirmation.dogfood.test.ts`** (4), on a booted showcase with `MCPServerPlugin`, one admin caller: unconfirmed `delete_record` refused and the record still reads `200` over REST; the refusal held against the REAL runtime gate's refusal (`run_action` on `showcase_portfolio_snapshot`, flagged `ai.requiresConfirmation: true` in a derived configuration) through the same transport: same code, status, envelope keys, confirmation member, and every `details` key the action refusal carries; `confirm: true` deletes (REST then reads `404`); REST `DELETE /data/:object/:id` with no confirmation deletes (control). - **Stale fixtures, same PR** (calls that pin a gate or behaviour behind the confirmation gate now send `confirm: true`): `mcp-coded-refusal.test.ts`, `mcp-http-tools.unknown-argument-keys.test.ts` (the sweep's VALID_ARGS), `mcp-server-runtime.http.test.ts`, `mcp-unique-violation-wire-code.test.ts`, `mcp-update-delete-record-stale-summary.test.ts` (8 tests, 5 files), and dogfood `mcp-stdio-package-object-discovery.test.ts`, `mcp-stdio-runtime-object-exposure.test.ts`. Results: the new MCP file 9/9 green (5 red on `main`), `@objectstack/mcp` 57 files / 689 tests green, the touched dogfood files 3 files / 94 tests green, the full dogfood battery 424 passed and 3 skipped of 427 files (3537 passed and 41 skipped of 3578 tests) on head `36030eabba`. ## Acceptance notes - **Where the gate sits, and the order it answers in.** In the tool, after the system-object guard and before the bridge, so it holds whichever bridge serves the server (the runtime's, the stdio one, a host's own). `run_action`'s gate is the LAST pre-dispatch check; this one comes before the bridge's exposure, credential, permission and not-found refusals. So an unconfirmed delete of an object whose API excludes `delete` answers `428` first and the `405` only after the human confirms. Accepted because the requirement is unconditional and advertised in the schema and description, so a well-behaved agent sends the member on its first call; moving it behind those checks would mean gating inside each bridge. - **`details.actionName` is `delete_record`.** The details shape has a required `actionName` and no other slot for the door; the tool's own name is what the refused call addressed, and the contract docblock now says so. No key was added. - **No shared builder with the runtime.** `@objectstack/runtime` and `@objectstack/mcp` do not depend on each other, so `actionConfirmationRefusal` is out of reach. One shape is held by the contract types (`AI_ACTION_CONFIRMATION_MEMBER`, `ActionConfirmationRequiredDetails`, `ErrorCode`), the shared renderer `codedErrorResult`, and the dogfood file serving both refusals to one caller. - **Changeset level `patch`, where the card and the dispatch said `minor`.** `Metadata-protocol: none` grades every introduced entry `patch` (AGENTS.md Post-Task Checklist step 3; `check-changeset-no-major.mjs` refuses `minor` beside `none`). An MCP tool's input is not something a metadata author writes, so `none` is the kind the claim declared. The BREAKING banner and the ADR-0087 disposition stay. - **No literal FROM → TO block, where the dispatch asked for one.** Measured: with a `FROM → TO` label the ADR-0087 gate reads the body as a ledger prescription and refuses `not-required (no-migration-prescription)` beside it. Registering a ledger entry instead would put a client tool-call rewrite into the ledger that `objectstack migrate meta`, `spec-changes.json` and the upgrade guide project, which carry metadata migrations only. The migration ships as Before / Now / Who moves / The remedy prose, the shape stock narrowing changesets use (`29236-meta-list-names-filter.md`). - **Files beyond the claim's listed surface, reported to the seat:** `packages/mcp/src/skill-md.ts` and `packages/mcp/README.md` (they teach the tool, and this PR made their `delete_record` lines false), the two stale dogfood fixtures and the new dogfood file, and `scripts/engine-double-contract.pinned.json` (gate-forced: `check:engine-double-contract` asks for the new test file's pinned doubles to be recorded; generated by `--write`, three rows added, none lost). - **Not edited, outside the claim's surface:** `skills/objectstack-ai/SKILL.md` L202 (Tier H; the `Skills-docs:` follow-up) and the Console's mirror of the MCP skill, `ui/apps/console/src/pages/developer/objectstack-skill.ts` L98, L109, L117, which still teaches `delete_record({ objectName, recordId })`; an agent following either gets the `428` with its retry details. - **On `main` the three `it.each` refusal cases were already green**, for another reason: the strict input schema refused `confirm` as an unknown key. The other five pins were red on `main` (5 failed, 4 passed with the `update_record` control); they are what this change turns green. - Deferred and not built, per the card: the `data:delete` scope split, a record-bound confirmation token, MCP elicitation. ## Verification Head `36030eabba` (branch merged with `origin/main` at `8f601501f2`). Every command below ran in the worktree under `NODE_OPTIONS=--max-old-space-size=4096`; tests and builds ran under `scripts/pm/os-verify-lock.sh`. <details> <summary>Red on main, then green: the new pins</summary> Against the `main` source (the gate reverted), `pnpm --filter @objectstack/mcp exec vitest run src/mcp-delete-record-confirmation.test.ts`: ```text × advertises the confirmation member on the tool schema, so a model can discover the retry × refuses a call without the member: code, status and the retry details; the record is intact × deletes on the retry with confirm: true, once × answers with run_action's refusal shape: the same code, status, envelope keys and details keys × the long-lived server the stdio transport serves gates the same way Test Files 1 failed (1) Tests 5 failed | 4 passed (9) ``` On this head: 9 passed (9). </details> <details> <summary>Package suite, typecheck, build</summary> ```text pnpm --filter @objectstack/mcp test Test Files 57 passed (57) · Tests 689 passed (689) · VERDICT command-exit 0 pnpm --filter @objectstack/mcp run typecheck exit 0 pnpm --filter @objectstack/mcp run check:test-typecheck exit 0 (6 files / 53 errors / 8 pinned signatures held in the debt ledger, unchanged) pnpm --filter @objectstack/dogfood run typecheck exit 0 pnpm --filter @objectstack/spec exec tsc --noEmit exit 0 pnpm exec turbo run build --filter=!@objectstack/docs --concurrency=2 Tasks 118 successful, 118 total · VERDICT command-exit 0 pnpm --filter @objectstack/spec check:generated All 14 generated artifacts are up to date ``` </details> <details> <summary>Dogfood: the full battery, once, on the final head</summary> `pnpm --filter @objectstack/dogfood test` (no shard, no path): ```text Test Files 424 passed | 3 skipped (427) Tests 3537 passed | 41 skipped (3578) os-verify-lock: VERDICT command-exit 0 · held the lock 1643s (27m23s) · waited 0s ``` The new and the two touched files, verbose: `mcp-delete-record-confirmation.dogfood.test.ts` 4/4, `mcp-stdio-package-object-discovery.test.ts` and `mcp-stdio-runtime-object-exposure.test.ts` green; 3 files / 94 tests passed. </details> <details> <summary>Local gates: 112 derived, 112 run, all exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 104 commands at `7723700f04`; all 104 ran there and 103 exited 0. `check:engine-double-contract` exited 1 asking for the new test file's pinned doubles to be recorded; `node scripts/check-engine-double-contract.mjs --write` recorded them (`36030eabba`). That path added 8 families (`check:agent-test-spelling`, `check:bash32-floor`, `check:cli-command-ids`, `check:entry-guard`, `check:parse-guard`, `check:pnpm-filter-targets`, `check:pnpm-flag-spellings`, `check:scripts-symbol-anchors`); those 8 and `check:engine-double-contract` ran at `36030eabba`, all exit 0. ```text ✓ dispatch-gates --ran: 112 derived famil(ies) accounted for — 112 run, 0 NOT-MEASURED (a DERIVED zero — all 112 recorded an exit code and none of them is 3). ✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. .changeset/30813-mcp-delete-record-confirmation.md [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription) ``` Not measured locally, CI's to answer: the four value-bearing families (`check-issue-citations --census`, the two dogfood shard attestations, `check-test-completeness` on CI's log), the path-scheduled CI jobs and the type-check lanes beyond the three package typechecks above. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4czsSxuhcKFSWS5tLdW)_ <!-- signed: domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1de7def commit c77dd17

18 files changed

Lines changed: 627 additions & 20 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/mcp': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(mcp)!: the MCP `delete_record` tool refuses a call without `confirm: true`, with `run_action`'s confirmation refusal
7+
8+
Clause-②: yes (narrowing) — the MCP `delete_record` tool refuses every call that does not carry `confirm: true`, where it deleted the record. The confirmation contract in `@objectstack/spec/contracts` (`AIActionConfirmation`, `ActionConfirmationRequiredDetails`) gains its second door in its docblock, and the error-code ledger lists `ACTION_CONFIRMATION_REQUIRED` under `@objectstack/mcp` as a second emitter; no shape changes.
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and nothing at rest records a tool call: what moves is the answer an MCP client gets for a delete_record call that does not carry the confirmation member. The fix is in the calling client, which adds one member to the call; there is nothing for a metadata migration to rewrite. -->
12+
13+
**BREAKING** accept-set narrowing at the MCP `delete_record` tool (`@objectstack/mcp`), over both transports (Streamable HTTP `/api/v1/mcp` and stdio): every MCP client that deletes is refused until it sends the confirmation member.
14+
15+
- **Before.** `delete_record({ objectName, recordId })` deleted the record. A delete is hard and permanent, and the tool's only safety signal was `annotations.destructiveHint`, a hint a client may ignore. A call carrying `confirm` was refused as an unknown key.
16+
- **Now.** A call without `confirm`, or with `confirm: false`, is refused before the data bridge is reached, and nothing is read or deleted. A non-boolean `confirm` (the string `"true"`, `1`) is refused before that by the tool's closed input schema, with no code and no `details`, exactly as on `run_action`. The answer to an absent or `false` member is the refusal `run_action` gives an action whose author declared `ai.requiresConfirmation: true`: an `isError` result whose text is `{ "error": { "code": "ACTION_CONFIRMATION_REQUIRED", "message", "status": 428, "details": { "actionName": "delete_record", "objectName", "confirmationMember": "confirm" } } }`. `delete_record({ objectName, recordId, confirm: true })` deletes as before, under the same exposure, credential and permission checks. The tool schema advertises `confirm` as an optional boolean, and the tool description tells the agent to ask the human first, then retry.
17+
- **Who moves.** An MCP client or agent that calls `delete_record`. A client that already handles `run_action`'s `ACTION_CONFIRMATION_REQUIRED` (read `details.confirmationMember`, ask the human, resend the same call with that member set to `true`) needs no change.
18+
19+
**The remedy.** Ask the human in the loop, then send the same `delete_record` call with `confirm: true` added beside `objectName` and `recordId`. The flag asserts an approval; it does not obtain one, and the gate makes forgetting loud without proving a human.
20+
21+
**The bounds, as decided.**
22+
23+
- REST `DELETE /api/v1/data/:object/:id` (the human path) is outside the gate and unchanged.
24+
- `update_record` is not gated.
25+
- No object is exempt: every object a caller may delete through `delete_record` takes the gate.
26+
- Any future bulk-delete tool takes the same gate.
27+
28+
The member, the code, the status and the `details` shape are the contract `run_action` already serves (`AI_ACTION_CONFIRMATION_MEMBER` and `ActionConfirmationRequiredDetails` in `@objectstack/spec/contracts`), so an agent learns one retry for both tools. The MCP server's own skill (`GET /api/v1/mcp/skill`) teaches the member on `delete_record`.

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,13 @@ drive this MCP server. Same word, different layer.
376376
an action as a code-review-worthy act — that's the real security boundary.
377377
- An action can also declare `ai.requiresConfirmation`; destructive-looking
378378
actions (`confirmText`, danger variants) default to requiring it.
379+
- **`delete_record` needs `confirm: true` on every call.** A record delete over
380+
MCP is permanent, so the tool refuses a call without it with `run_action`'s
381+
refusal (`428` `ACTION_CONFIRMATION_REQUIRED`, its `details.actionName`
382+
naming `delete_record`) and deletes nothing. The agent asks its human, then retries
383+
with `confirm: true`; the flag asserts an approval, it does not obtain one.
384+
REST `DELETE /api/v1/data/:object/:id` and `update_record` take no
385+
confirmation.
379386

380387
## Verify the connection
381388

‎packages/mcp/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ apply):
117117
'get_record' // Fetch one by id
118118
'create_record' // Create
119119
'update_record' // Update by id
120-
'delete_record' // Delete by id (destructive)
120+
'delete_record' // Delete by id (destructive; refused without confirm: true)
121121

122122
// Business actions — operate the app, not just its rows
123123
'list_actions' // Invokable business actions the caller may run

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ const SITES: readonly Site[] = [
100100
{ tool: 'get_record', args: { objectName: 'task', recordId: 't9' }, member: 'get' },
101101
{ tool: 'create_record', args: { objectName: 'task', data: { title: 'x' } }, member: 'create' },
102102
{ tool: 'update_record', args: { objectName: 'task', recordId: 't9', data: { title: 'x' } }, member: 'update' },
103-
{ tool: 'delete_record', args: { objectName: 'task', recordId: 't9' }, member: 'remove' },
103+
// `confirm: true` passes the confirmation gate, so the call reaches `remove`.
104+
{ tool: 'delete_record', args: { objectName: 'task', recordId: 't9', confirm: true }, member: 'remove' },
104105
{ tool: 'list_actions', args: {}, member: 'listActions' },
105106
{ tool: 'run_action', args: { actionName: 'complete' }, member: 'runAction', actionDoor: true },
106107
{ tool: 'resume_run', args: { runId: 'r1' }, member: 'resumeRun', actionDoor: true },
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The MCP `delete_record` tool takes `run_action`'s confirmation gate: a call
5+
* without `confirm: true` is REFUSED with `ACTION_CONFIRMATION_REQUIRED` and
6+
* nothing is deleted.
7+
*
8+
* ## Why
9+
*
10+
* A delete over MCP is hard and permanent, and before this gate one call did
11+
* it: `annotations.destructiveHint` is a hint a client may ignore, and the
12+
* bridge's own checks (API exposure, credentials, the caller's permissions)
13+
* say whether the caller MAY delete, never whether a human asked for it. An
14+
* instruction planted in a record's text could lead an agent to delete rows
15+
* its user may delete with no human asked. The maintainer chose option B: the
16+
* server-side backstop `run_action` already has, widened to this one tool,
17+
* with the same member, code, status and `details`.
18+
*
19+
* ## What is pinned, and where
20+
*
21+
* - The door, over the HTTP `/mcp` transport, against the REAL stdio data
22+
* bridge on an engine double that holds the row: the member is advertised,
23+
* an unconfirmed call (absent, `false`, the string `'true'`) is refused and
24+
* the row is still there, a confirmed call deletes it.
25+
* - The refusal's code, status and `details` keys are `run_action`'s, served
26+
* through the same door from a bridge throwing what the runtime gate
27+
* throws. The real runtime gate beside the real `delete_record` refusal, and
28+
* REST `DELETE /data/:object/:id` (outside the gate), are driven together in
29+
* `packages/qa/dogfood/test/mcp-delete-record-confirmation.dogfood.test.ts`.
30+
* - The long-lived server the stdio transport serves gates too: both
31+
* transports register their tools through `wireBridgeTools`.
32+
* - Controls: `update_record` is not gated, and the bridge is never reached
33+
* by a refused call.
34+
*/
35+
36+
import { describe, it, expect, vi, beforeEach } from 'vitest';
37+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
38+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
39+
import { AI_ACTION_CONFIRMATION_MEMBER } from '@objectstack/spec/contracts';
40+
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
41+
import type { ExecutionContext } from '@objectstack/spec/kernel';
42+
import {
43+
assertEngineDeleteDispatch,
44+
assertEngineUpdateDispatch,
45+
assertEngineFindOnePredicate,
46+
type EngineFindOneQueryInput,
47+
} from '@objectstack/metadata-core';
48+
49+
import { MCPServerRuntime } from './mcp-server-runtime.js';
50+
import { createStdioDataBridge } from './stdio-data-bridge.js';
51+
import type { McpActionBridge, McpDataBridge } from './mcp-http-tools.js';
52+
53+
const OBJECT = 'dlc_note';
54+
55+
const NOTE = {
56+
name: OBJECT,
57+
label: 'Note',
58+
fields: {
59+
id: { name: 'id', type: 'text' },
60+
title: { name: 'title', type: 'text' },
61+
},
62+
enable: {},
63+
};
64+
65+
/** A store of `dlc_note` rows behind an engine that applies every write it is handed. */
66+
function engineDouble(rows: Map<string, Record<string, unknown>>) {
67+
return {
68+
find: vi.fn(async (_o: string, query?: any) => {
69+
const id = query?.where?.id;
70+
const all = [...rows.values()].filter((r) => id === undefined || r.id === id);
71+
return typeof query?.limit === 'number' ? all.slice(0, query.limit) : all;
72+
}),
73+
findOne: vi.fn(async (object: string, query?: EngineFindOneQueryInput) => {
74+
assertEngineFindOnePredicate(object, query);
75+
return rows.get(String((query as any)?.where?.id)) ?? null;
76+
}),
77+
insert: vi.fn(async () => {
78+
throw new Error('not used');
79+
}),
80+
update: vi.fn(async (_o: string, data: Record<string, unknown>, options?: any) => {
81+
assertEngineUpdateDispatch(data, options);
82+
const id = String(options?.where?.id);
83+
rows.set(id, { ...rows.get(id), ...data });
84+
return rows.get(id);
85+
}),
86+
delete: vi.fn(async (_o: string, options?: any) => {
87+
assertEngineDeleteDispatch(options);
88+
rows.delete(String(options?.where?.id));
89+
return { deleted: 1 };
90+
}),
91+
count: vi.fn(async () => rows.size),
92+
};
93+
}
94+
95+
function metadataDouble(): IMetadataService {
96+
return {
97+
listObjects: vi.fn(async () => [NOTE]),
98+
getObject: vi.fn(async () => NOTE),
99+
} as unknown as IMetadataService;
100+
}
101+
102+
function stdioBridge(engine: ReturnType<typeof engineDouble>): McpDataBridge {
103+
return createStdioDataBridge({
104+
engine: engine as unknown as IDataEngine,
105+
metadataService: metadataDouble(),
106+
resolvePrincipal: async () => ({ userId: 'u1' }) as unknown as ExecutionContext,
107+
});
108+
}
109+
110+
/** What the runtime's `run_action` gate throws for an author-gated action. */
111+
function runtimeGateRefusal(): Error {
112+
return Object.assign(
113+
new Error(
114+
"Action 'archive_note' on 'dlc_note' declares ai.requiresConfirmation: true — nothing was run. "
115+
+ "Confirm with the human in the loop, then retry this call with 'confirm': true on the request.",
116+
),
117+
{
118+
code: 'ACTION_CONFIRMATION_REQUIRED',
119+
status: 428,
120+
details: { actionName: 'archive_note', objectName: OBJECT, confirmationMember: AI_ACTION_CONFIRMATION_MEMBER },
121+
},
122+
);
123+
}
124+
125+
let nextId = 1;
126+
async function rpc(bridge: McpDataBridge, method: string, params?: Record<string, unknown>) {
127+
const body = { jsonrpc: '2.0', id: nextId++, method, ...(params ? { params } : {}) };
128+
const runtime = new MCPServerRuntime({ name: 'pin', version: '0.0.0' });
129+
const res = await runtime.handleHttpRequest(
130+
new Request('http://localhost/api/v1/mcp', {
131+
method: 'POST',
132+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
133+
body: JSON.stringify(body),
134+
}),
135+
{ bridge: bridge as any, parsedBody: body },
136+
);
137+
expect(res.status).toBe(200);
138+
const json = (await res.json()) as { result?: any; error?: unknown };
139+
expect(json.error, JSON.stringify(json.error)).toBeUndefined();
140+
return json.result;
141+
}
142+
143+
async function callTool(bridge: McpDataBridge, name: string, args: Record<string, unknown>) {
144+
return (await rpc(bridge, 'tools/call', { name, arguments: args })) as {
145+
content: Array<{ text: string }>;
146+
isError?: boolean;
147+
};
148+
}
149+
150+
const envelopeOf = (result: { content: Array<{ text: string }> }) => JSON.parse(result.content[0]!.text) as { error: any };
151+
152+
describe('MCP delete_record refuses a call without confirm: true (ACTION_CONFIRMATION_REQUIRED)', () => {
153+
let rows: Map<string, Record<string, unknown>>;
154+
let engine: ReturnType<typeof engineDouble>;
155+
let bridge: McpDataBridge;
156+
157+
beforeEach(() => {
158+
rows = new Map([['n1', { id: 'n1', title: 'keep me' }]]);
159+
engine = engineDouble(rows);
160+
bridge = stdioBridge(engine);
161+
});
162+
163+
it('advertises the confirmation member on the tool schema, so a model can discover the retry', async () => {
164+
const listed = await rpc(bridge, 'tools/list');
165+
const tool = (listed.tools as any[]).find((t) => t.name === 'delete_record');
166+
expect(tool, 'delete_record is registered').toBeDefined();
167+
const props = tool.inputSchema.properties;
168+
// Control: the members that were already there are still advertised.
169+
expect(Object.keys(props)).toEqual(expect.arrayContaining(['objectName', 'recordId']));
170+
expect(props[AI_ACTION_CONFIRMATION_MEMBER]?.type).toBe('boolean');
171+
// Optional in the schema: a call without it must reach the handler, which
172+
// answers the coded refusal. Required there, it would be refused as a bare
173+
// input-validation error naming no code and no retry.
174+
expect(tool.inputSchema.required ?? []).not.toContain(AI_ACTION_CONFIRMATION_MEMBER);
175+
// The description tells the agent the order: ask the human, then retry.
176+
expect(tool.description).toContain('confirm: true');
177+
expect(tool.description).toMatch(/ask the human/i);
178+
expect(tool.annotations?.destructiveHint).toBe(true);
179+
});
180+
181+
it('refuses a call without the member: code, status and the retry details; the record is intact', async () => {
182+
const result = await callTool(bridge, 'delete_record', { objectName: OBJECT, recordId: 'n1' });
183+
184+
expect(result.isError).toBe(true);
185+
const { error } = envelopeOf(result);
186+
expect(error.code).toBe('ACTION_CONFIRMATION_REQUIRED');
187+
expect(error.status).toBe(428);
188+
expect(error.details).toEqual({
189+
actionName: 'delete_record',
190+
objectName: OBJECT,
191+
confirmationMember: AI_ACTION_CONFIRMATION_MEMBER,
192+
});
193+
expect(String(error.message)).toContain('nothing was deleted');
194+
195+
// Nothing was deleted: the engine was never asked, and the row is there.
196+
expect(engine.delete).not.toHaveBeenCalled();
197+
expect(rows.get('n1')).toEqual({ id: 'n1', title: 'keep me' });
198+
});
199+
200+
it.each([
201+
['false', false],
202+
['the string "true"', 'true'],
203+
['1', 1],
204+
])('refuses %s as the member: only the boolean true attests, and the record is intact', async (_label, value) => {
205+
const result = await callTool(bridge, 'delete_record', {
206+
objectName: OBJECT,
207+
recordId: 'n1',
208+
[AI_ACTION_CONFIRMATION_MEMBER]: value,
209+
});
210+
211+
expect(result.isError).toBe(true);
212+
expect(engine.delete).not.toHaveBeenCalled();
213+
expect(rows.has('n1')).toBe(true);
214+
});
215+
216+
it('deletes on the retry with confirm: true, once', async () => {
217+
const result = await callTool(bridge, 'delete_record', {
218+
objectName: OBJECT,
219+
recordId: 'n1',
220+
[AI_ACTION_CONFIRMATION_MEMBER]: true,
221+
});
222+
223+
expect(result.isError, result.content[0]?.text).toBeFalsy();
224+
expect(JSON.parse(result.content[0]!.text)).toEqual({ object: OBJECT, id: 'n1', success: true });
225+
expect(engine.delete).toHaveBeenCalledTimes(1);
226+
expect(rows.has('n1')).toBe(false);
227+
});
228+
229+
it('answers with run_action\'s refusal shape: the same code, status, envelope keys and details keys', async () => {
230+
const actionBridge: McpDataBridge & McpActionBridge = {
231+
...bridge,
232+
async listActions() {
233+
return [{ name: 'archive_note', objectName: OBJECT, requiresRecord: true, requiresConfirmation: true }];
234+
},
235+
async runAction() {
236+
throw runtimeGateRefusal();
237+
},
238+
};
239+
240+
const viaAction = envelopeOf(await callTool(actionBridge, 'run_action', { actionName: 'archive_note', objectName: OBJECT, recordId: 'n1' })).error;
241+
const viaDelete = envelopeOf(await callTool(actionBridge, 'delete_record', { objectName: OBJECT, recordId: 'n1' })).error;
242+
243+
// Lit control: the run_action half really is the gate's refusal.
244+
expect(viaAction.code).toBe('ACTION_CONFIRMATION_REQUIRED');
245+
246+
expect(viaDelete.code).toBe(viaAction.code);
247+
expect(viaDelete.status).toBe(viaAction.status);
248+
expect(Object.keys(viaDelete)).toEqual(Object.keys(viaAction));
249+
expect(Object.keys(viaDelete.details).sort()).toEqual(Object.keys(viaAction.details).sort());
250+
expect(viaDelete.details.confirmationMember).toBe(viaAction.details.confirmationMember);
251+
expect(viaDelete.details.objectName).toBe(viaAction.details.objectName);
252+
expect(rows.has('n1')).toBe(true);
253+
});
254+
255+
it('control: update_record is not gated, and writes without the member', async () => {
256+
const result = await callTool(bridge, 'update_record', { objectName: OBJECT, recordId: 'n1', data: { title: 'changed' } });
257+
258+
expect(result.isError, result.content[0]?.text).toBeFalsy();
259+
expect(engine.update).toHaveBeenCalledTimes(1);
260+
expect(rows.get('n1')).toMatchObject({ title: 'changed' });
261+
});
262+
263+
it('the long-lived server the stdio transport serves gates the same way', async () => {
264+
const runtime = new MCPServerRuntime({ name: 'pin-stdio', version: '0.0.0' });
265+
runtime.bridgeDataTools(bridge);
266+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
267+
await runtime.server.connect(serverTransport);
268+
const client = new Client({ name: 'pin-client', version: '0.0.0' });
269+
await client.connect(clientTransport);
270+
try {
271+
const refused: any = await client.callTool({ name: 'delete_record', arguments: { objectName: OBJECT, recordId: 'n1' } });
272+
expect(refused.isError).toBe(true);
273+
const { error } = JSON.parse(refused.content[0].text);
274+
expect(error.code).toBe('ACTION_CONFIRMATION_REQUIRED');
275+
expect(error.status).toBe(428);
276+
expect(error.details).toEqual({ actionName: 'delete_record', objectName: OBJECT, confirmationMember: AI_ACTION_CONFIRMATION_MEMBER });
277+
expect(rows.has('n1')).toBe(true);
278+
279+
const confirmed: any = await client.callTool({
280+
name: 'delete_record',
281+
arguments: { objectName: OBJECT, recordId: 'n1', [AI_ACTION_CONFIRMATION_MEMBER]: true },
282+
});
283+
expect(confirmed.isError, confirmed.content?.[0]?.text).toBeFalsy();
284+
expect(rows.has('n1')).toBe(false);
285+
} finally {
286+
await client.close();
287+
}
288+
});
289+
});

0 commit comments

Comments
 (0)