From 1535ea4315f7d22089f6a01ee751b7eae0e16a2e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 16:02:56 +0000 Subject: [PATCH 1/4] wip(runtime,mcp): enforce ai.requiresConfirmation at the AI-facing action door The runtime half of the confirmation gate, plus the MCP door member the gate is unsatisfiable without. Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude --- packages/mcp/src/mcp-http-tools.ts | 96 +++++++++++++- packages/mcp/src/skill-md.ts | 16 ++- packages/runtime/src/action-execution.ts | 151 ++++++++++++++++++++++- packages/runtime/src/domains/mcp.ts | 8 +- packages/runtime/src/http-dispatcher.ts | 14 ++- 5 files changed, 271 insertions(+), 14 deletions(-) diff --git a/packages/mcp/src/mcp-http-tools.ts b/packages/mcp/src/mcp-http-tools.ts index da4d6b7663..948ad578f4 100644 --- a/packages/mcp/src/mcp-http-tools.ts +++ b/packages/mcp/src/mcp-http-tools.ts @@ -40,6 +40,14 @@ import { MCP_OAUTH_SCOPE_DATA_WRITE, MCP_OAUTH_SCOPE_ACTIONS, } from '@objectstack/spec/ai'; +// [#15942 / #16293] The confirmation member the `run_action` door accepts and +// forwards. Imported, never hand-spelled: the door that refuses (the runtime's +// `actionConfirmationRefusal`) and the client that retries have to agree on the +// spelling, and this door is the one that has to advertise it. +import { + AI_ACTION_CONFIRMATION_MEMBER, + type AIActionConfirmation, +} from '@objectstack/spec/contracts'; import { validateExpression, introspectScope, @@ -224,7 +232,7 @@ export interface McpActionBridge { */ runAction( name: string, - input: { objectName?: string; recordId?: string; params?: Record }, + input: { objectName?: string; recordId?: string; params?: Record } & AIActionConfirmation, ): Promise; } @@ -254,6 +262,45 @@ function errorResult(message: string) { return { content: [{ type: 'text' as const, text: message }], isError: true as const }; } +/** + * A tool error that PRESERVES an ADR-0112 envelope when the thrown value + * carries one. + * + * [#15942] `errorResult` above flattens a throw to its message, which is right + * for the plain `Error`s most bridge failures are. It is wrong for a refusal + * whose whole point is machine-readability: the confirmation gate answers + * `ACTION_CONFIRMATION_REQUIRED` with `details` naming the action and the exact + * member to set, precisely so a refused agent can rebuild the retry WITHOUT + * re-parsing prose. Flattened to a sentence, that contract is delivered to + * nobody and the agent is back to guessing the member's spelling from + * documentation. + * + * Uncoded throws fall through to `errorResult` unchanged, so this widens what a + * caller can read and narrows nothing. + */ +function errorResultFromThrown(err: unknown) { + const code = (err as { code?: unknown } | null | undefined)?.code; + if (typeof code !== 'string' || code.length === 0) return errorResult(messageOf(err)); + const details = (err as { details?: unknown }).details; + const status = (err as { status?: unknown }).status; + return { + content: [ + { + type: 'text' as const, + text: jsonText({ + error: { + code, + message: messageOf(err), + ...(typeof status === 'number' ? { status } : {}), + ...(details !== undefined ? { details } : {}), + }, + }), + }, + ], + isError: true as const, + }; +} + /** * [ADR-0090 D10 — ruling 2026-09-08, consequence 2] Attach the D10 narrowing * notice to a query result WITHOUT reshaping it. @@ -791,7 +838,9 @@ export function registerActionTools( 'Invoke a business action by name (see list_actions). Runs the app\'s registered business logic — ' + 'this can mutate data or trigger flows. Invocation is gated (author AI opt-in + your capabilities), ' + 'but the action body itself runs as trusted application code with the app\'s full data authority. ' + - 'Supply recordId for actions that operate on a specific record, and params for any declared inputs.', + 'Supply recordId for actions that operate on a specific record, and params for any declared inputs. ' + + 'An action the author gated (list_actions reports requiresConfirmation) is REFUSED unless you also ' + + 'send confirm: true — ask the human first, then retry; nothing runs on a refused call.', inputSchema: { actionName: z.string().describe('The action name from list_actions, e.g. "complete_task"'), objectName: z @@ -806,6 +855,31 @@ export function registerActionTools( .record(z.string(), z.unknown()) .optional() .describe('Input parameters declared by the action.'), + // [#15942 / #16293] The confirmation member — a CLOSED boolean at the + // TOP LEVEL of the request, keyed off the contract's own constant so + // this door cannot spell it differently from the door that refuses. + // + // WHY IT IS DECLARED HERE AND NOT ONLY ENFORCED IN THE RUNTIME. Under + // zod an undeclared key is DROPPED, not rejected: before this member + // existed a client that sent `confirm: true` had it silently stripped + // by the SDK's shape wrap and then again by this handler's forward, so + // enforcing the gate alone would have made every action declaring + // `ai.requiresConfirmation: true` permanently un-invokable over MCP — + // refused, retried with the member, stripped, refused again. The door + // grows the member in the same change that enforces it. + // + // It is also how the model DISCOVERS the retry: an agent reads the tool + // schema, so a member that lives only in the refusal prose (or in a + // transport header) is one it cannot see. + [AI_ACTION_CONFIRMATION_MEMBER]: z + .boolean() + .optional() + .describe( + 'Set to true to confirm a call the app author gated with ai.requiresConfirmation ' + + '(list_actions reports requiresConfirmation for each action). Assert this only when ' + + 'the human in the loop has approved THIS call; without it a gated action is refused ' + + 'and nothing runs.', + ), }, // Actions execute app-defined business logic with side effects (writes, // flows, outbound calls), so we mark the tool destructive + open-world: @@ -813,7 +887,11 @@ export function registerActionTools( // is further surfaced via `requiresConfirmation` in list_actions. annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, }, - async ({ actionName, objectName, recordId, params }) => { + async (args) => { + const { actionName, objectName, recordId, params } = args; + // Read off the constant, so the member's spelling has exactly one + // authority in this package (the schema key above is the same constant). + const confirm = args[AI_ACTION_CONFIRMATION_MEMBER]; if (!actionName || typeof actionName !== 'string') { return errorResult('actionName is required'); } @@ -821,10 +899,18 @@ export function registerActionTools( return errorResult(`Object "${objectName}" is a system object and its actions are not exposed via MCP`); } try { - const result = await bridge.runAction(actionName, { objectName, recordId, params }); + const result = await bridge.runAction(actionName, { + objectName, + recordId, + params, + // [#15942] Forwarded, not rebuilt-without. This line and the schema + // key above are the two halves of one change: dropping either one + // restores the strip that made the gate unsatisfiable. + [AI_ACTION_CONFIRMATION_MEMBER]: confirm, + }); return textResult(result); } catch (err) { - return errorResult(messageOf(err)); + return errorResultFromThrown(err); } }, ); diff --git a/packages/mcp/src/skill-md.ts b/packages/mcp/src/skill-md.ts index 336664fd6e..76ba9d79c2 100644 --- a/packages/mcp/src/skill-md.ts +++ b/packages/mcp/src/skill-md.ts @@ -158,12 +158,16 @@ create/update payload. the app author exposed to AI and you are permitted to run), with each action's declared parameters, whether it operates on a record, and whether it is flagged destructive. -- **run_action({ actionName, objectName?, recordId?, params? })** — invoke a - business action by name. Invocation is permission-gated, but the action body - executes the app's registered logic as trusted code (it can mutate data or - trigger flows with the app's own authority). Pass \`recordId\` for - record-scoped actions and \`params\` for declared inputs; \`objectName\` only - disambiguates a name shared by multiple objects. +- **run_action({ actionName, objectName?, recordId?, params?, confirm? })** — + invoke a business action by name. Invocation is permission-gated, but the + action body executes the app's registered logic as trusted code (it can + mutate data or trigger flows with the app's own authority). Pass + \`recordId\` for record-scoped actions and \`params\` for declared inputs; + \`objectName\` only disambiguates a name shared by multiple objects. Pass + \`confirm: true\` on an action \`list_actions\` reports as + \`requiresConfirmation\` — the server REFUSES such a call without it + (\`ACTION_CONFIRMATION_REQUIRED\`) and nothing runs. Ask your human first; + the flag asserts an approval, it does not obtain one. ## Conventions & gotchas diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index bcf429e0fd..109403b7d9 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -18,6 +18,15 @@ import { validateActionParams, type ActionSession, type ResolvedActionParam } from '@objectstack/spec/ui'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; +// [#15942 / #16293] The confirmation member's SPELLING is the contract, so it +// is imported, never hand-spelled: the door that refuses and the client that +// retries have to agree on it, and two doors each writing their own 'confirm' +// is how one contract becomes two dialects (Prime Directive #12). +import { + AI_ACTION_CONFIRMATION_MEMBER, + type AIActionConfirmation, + type ActionConfirmationRequiredDetails, +} from '@objectstack/spec/contracts'; import { checkApiExposure } from './api-exposure.js'; // [#9446] The ONE #9378 status table. Imported rather than re-read here: this // door's blanket `FLOW_FAILED` was the second of three readings of one engine @@ -981,6 +990,116 @@ export function actionLooksDestructive(_deps: ActionExecutionDeps, action: any): return Boolean(action?.mode === 'delete' || action?.variant === 'danger'); } +/** + * [#15942 / #16293 / ADR-0049] The action-confirmation GATE — the refusal an + * AI-facing action door answers when the author declared + * `ai.requiresConfirmation: true` and the request carries no confirmation. + * + * ## Why this exists at all + * + * `ai.requiresConfirmation` was a SAFETY-shaped flag with no execution path: + * read once, projected into the `list_actions` summary, and never consulted by + * `run_action`. That is the exact class ADR-0049 retired + * `tool.requiresConfirmation` for — "a SAFETY flag that is merely accepted is + * false compliance" — and the retirement's own prescription pointed authors at + * THIS key. Maintainer ruling, decision batch #54: enforce it, in the minimal + * shape — an explicit confirmation on the request, a loud registered refusal + * when it is absent, and NO approval queue. Nothing is parked, nothing is held + * for an operator to find later, and there is no resume path: a refused call + * simply did not run, and the caller confirms and retries. + * + * ## Which predicate gates the refusal — the DECLARED flag, never the heuristic + * + * This reads `action.ai.requiresConfirmation === true` and nothing else. It is + * deliberately NARROWER than {@link actionLooksDestructive} directly above, + * and the two MUST NOT be collapsed even though they share a name: + * + * - {@link actionLooksDestructive} answers "should a client ASK the human + * before calling?" for the LISTING, and falls back to the `mode: 'delete'` / + * `variant: 'danger'` heuristic when the author declared nothing. + * - this one answers "will the server REFUSE without an attestation?" — and an + * author who declared nothing has asked for nothing. Gating on the heuristic + * would start refusing calls that work today, on a guess the author never + * made. + * + * So `ai.requiresConfirmation: false` never refuses whatever the action looks + * like, and an undeclared `mode: 'delete'` action is listed as + * `requiresConfirmation: true` while remaining invokable without the member. + * A client that confirms whenever the listing says `true` is always correct; + * the reverse inference is sound only because the listing predicate is wider. + * + * ## Only the boolean `true` is an attestation + * + * A truthy string is a transport artefact, not a decision (`AIActionConfirmation`). + * Absent, `false`, `'true'`, `1` — none of them confirm. + * + * ## What this gate is NOT + * + * `confirm: true` is an UNVERIFIABLE CALLER CLAIM. An agent that always sends + * it bypasses the gate entirely; the ruling accepted that model knowingly. + * ⇒ **The gate makes forgetting loud. It does not prove a human.** + * + * ## The enforced set is bounded by `ai.exposed` + * + * The doors that enforce this are the doors that enforce the author's AI opt-in + * — today {@link invokeBusinessAction}, reached from the MCP `run_action` tool. + * REST `/actions` (`domains/actions.ts`) is NOT `ai.exposed`-gated and sits + * OUTSIDE this gate: an API-key agent on REST is understood to be outside it + * rather than silently assumed inside it. Widening the set is its own decision, + * not a thing to infer from this comment. + * + * Returns `undefined` when the call may proceed. + */ +export const ACTION_CONFIRMATION_REQUIRED_CODE = 'ACTION_CONFIRMATION_REQUIRED'; +/** + * 428 Precondition Required — the request is well-formed and the caller is + * entitled; what is missing is a precondition the caller can add and retry + * with. The code is registered rather than borrowing the standard + * `PRECONDITION_REQUIRED` because this one says WHICH precondition, and the + * `details` below make the retry mechanical. + */ +export const ACTION_CONFIRMATION_REQUIRED_STATUS = 428; + +/** The shape the door serves — the ADR-0112 envelope plus its machine-readable `details`. */ +export interface ActionConfirmationRefusal { + code: typeof ACTION_CONFIRMATION_REQUIRED_CODE; + status: typeof ACTION_CONFIRMATION_REQUIRED_STATUS; + message: string; + details: ActionConfirmationRequiredDetails; +} + +export function actionConfirmationRefusal( + _deps: ActionExecutionDeps, + action: any, + request: AIActionConfirmation | undefined, + objectName?: string, +): ActionConfirmationRefusal | undefined { + // The DECLARED flag only — see the docblock. `!== true` on purpose: an + // author's `false`, and an absent key, both mean "no gate asked for". + if (action?.ai?.requiresConfirmation !== true) return undefined; + // Only the boolean `true` attests. + if (request?.[AI_ACTION_CONFIRMATION_MEMBER] === true) return undefined; + + const actionName = String(action?.name ?? ''); + const details: ActionConfirmationRequiredDetails = { + actionName, + ...(objectName ? { objectName } : {}), + // Echoed off the constant so a refused caller reads the member's + // spelling from the refusal instead of hard-coding it from docs. + confirmationMember: AI_ACTION_CONFIRMATION_MEMBER, + }; + const on = objectName ? ` on '${objectName}'` : ''; + return { + code: ACTION_CONFIRMATION_REQUIRED_CODE, + status: ACTION_CONFIRMATION_REQUIRED_STATUS, + message: + `Action '${actionName}'${on} declares ai.requiresConfirmation: true — nothing was run. ` + + `Confirm with the human in the loop, then retry this call with ` + + `'${AI_ACTION_CONFIRMATION_MEMBER}': true on the request.`, + details, + }; +} + export function summarizeAction(deps: ActionExecutionDeps, action: any, obj: any, objectName: string, flow?: any): any { // [#15079] `operation` before `type`, on the LISTING face. A declarative // update always requires a current record — that is contract point 7, and @@ -1774,7 +1893,14 @@ export async function executeDeclarativeUpdateAction( export async function invokeBusinessAction(deps: ActionExecutionDeps, requestContext: HttpProtocolContext, name: string, - input: { objectName?: string; recordId?: string; params?: Record }, + // [#15942] The confirmation member is MIXED IN from the contract rather + // than restated here, so this door and the MCP door cannot drift apart on + // its spelling or its type. It rides at the TOP LEVEL, never inside + // `params`: that bag is closed against the action author's own declared + // vocabulary, so a platform member riding there is refused as an unknown + // param on any action that declares params, and silently accepted on one + // that declares none — one placement, two opposite behaviours. + input: { objectName?: string; recordId?: string; params?: Record } & AIActionConfirmation, wiring: { driver: any; envId?: string; @@ -1850,6 +1976,29 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, const paramError = enforceActionParams(deps, action, obj, params, { objectName, actionName: name }); if (paramError) throw new Error(paramError); + // [#15942 / #16293] CONFIRMATION GATE — the last pre-dispatch check, and + // the first one that can refuse a well-formed, fully-entitled request. + // + // Placed HERE deliberately: AFTER the param contract, so a caller with a + // malformed bag still gets the located 400 that tells it how to fix the + // call; and BEFORE `loadActionSubjectRecord` below, so a refusal reads + // nothing and writes nothing — "the action body does not run, no record is + // read or written" is the contract's own sentence, and a gate that refuses + // after the subject read would only describe the defect instead of ending + // it. Nothing is queued and nothing is parked (ruling batch #54): the + // caller confirms with its human and retries the same call. + const confirmationRefusal = actionConfirmationRefusal(deps, action, input, objectName); + if (confirmationRefusal) { + // Thrown with `code` + `status` + `details` so the ADR-0112 envelope + // survives the bridge intact — the `details` are what let a refused + // agent rebuild the retry without re-parsing the prose it was handed. + throw Object.assign(new Error(confirmationRefusal.message), { + code: confirmationRefusal.code, + status: confirmationRefusal.status, + details: confirmationRefusal.details, + }); + } + // Load the subject record under RLS when row-context (engages the same // permission path as get_record — an unseen record reads as not-found). // [#14143] Through the ONE shared producer, so this door and the REST diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts index 50a7d92f69..0b451c018d 100644 --- a/packages/runtime/src/domains/mcp.ts +++ b/packages/runtime/src/domains/mcp.ts @@ -12,6 +12,7 @@ import { isMcpServerEnabled } from '@objectstack/types'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; import type { MetadataProtocol } from '@objectstack/spec/api'; import type { ISecurityService } from '@objectstack/spec/contracts'; +import type { AIActionConfirmation } from '@objectstack/spec/contracts'; import { buildApiError } from '../error-envelope.js'; import * as actionExec from '../action-execution.js'; import { isSystemObjectName } from '../action-execution.js'; @@ -715,9 +716,14 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon } return out; }, + // [#15942] `confirm` rides through UNTOUCHED. The bridge forwards the + // whole request object, so widening the type here is the whole change: + // the MCP door grew the member in the same change that enforces it, and + // a bridge that quietly rebuilt `{ objectName, recordId, params }` was + // the second of the two strip layers that made the member unreachable. runAction: async ( name: string, - input: { objectName?: string; recordId?: string; params?: Record }, + input: { objectName?: string; recordId?: string; params?: Record } & AIActionConfirmation, ) => actionExec.invokeBusinessAction(deps, context, name, input ?? {}, { driver, envId, ec, getMeta, callData }), }; } diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 4aa3b05467..9aa8794b8e 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1183,7 +1183,19 @@ export class HttpDispatcher { /** Thin delegate — body extracted to `./action-execution.ts` (D11③ PR-8). */ - /** True when an action is destructive by author signal/heuristic (HITL hint). */ + /** + * True when an action is destructive by author signal/heuristic — the + * LISTING predicate, which advises a client to ask its human. + * + * [#15942] No longer a bare "HITL hint": the platform now ENFORCES the + * declared flag. An action whose author set `ai.requiresConfirmation: true` + * is REFUSED at the AI-facing door (`ACTION_CONFIRMATION_REQUIRED`, 428) + * unless the request carries the confirmation member — see + * `actionConfirmationRefusal` in `./action-execution.ts`. This predicate is + * the WIDER of the two and is not the one that refuses: it also answers + * `true` on the `mode:'delete'` / `variant:'danger'` heuristic, where the + * author declared nothing and so asked for no gate. + */ /** Thin delegate — body extracted to `./action-execution.ts` (D11③ PR-8). */ /** Project an action's declarative metadata into a lean MCP summary. */ From dc350d0b50be8ee631d9cc78f02bfb4f28edf77e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 16:06:23 +0000 Subject: [PATCH 2/4] wip: tests for the confirmation gate Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude --- examples/app-todo/test/mcp-actions.e2e.ts | 65 +++++ .../mcp-action-confirmation-member.test.ts | 219 ++++++++++++++++ .../src/action-confirmation-gate.test.ts | 245 ++++++++++++++++++ 3 files changed, 529 insertions(+) create mode 100644 packages/mcp/src/mcp-action-confirmation-member.test.ts create mode 100644 packages/runtime/src/action-confirmation-gate.test.ts diff --git a/examples/app-todo/test/mcp-actions.e2e.ts b/examples/app-todo/test/mcp-actions.e2e.ts index b978b235fd..4784e677f3 100644 --- a/examples/app-todo/test/mcp-actions.e2e.ts +++ b/examples/app-todo/test/mcp-actions.e2e.ts @@ -180,6 +180,71 @@ function mcpRequest(body: unknown): Request { check(unexposedRun.result?.isError === true, 'run_action refuses the unexposed action (fail-closed)'); check(/not exposed to AI/i.test(unexposedRun.result?.content?.[0]?.text ?? ''), 'refusal names the AI-exposure gate'); + // ── Step 7 — the confirmation gate (#15942), BOTH directions ─────── + // + // The one drive that spans the whole path. A unit test on either side is + // blind to the other: `invokeBusinessAction` called directly never sees the + // MCP door strip the member (the SDK's shape wrap drops unknown keys, and + // the handler forwards a rebuilt object), and a door test with a stubbed + // bridge never sees the runtime gate. Here the member travels from a real + // JSON-RPC `tools/call`, through both of those layers, into the real gate — + // and the record afterwards says whether anything ran. + console.log('\n🔒 Step 7 — ai.requiresConfirmation is ENFORCED, and satisfiable'); + // Same app, but complete_task now declares the author's gate. + const gatedConfirmObjects = mergedObjects.map((o) => + o.name !== 'todo_task' + ? o + : { + ...o, + actions: o.actions.map((a: any) => + a.name === 'complete_task' ? { ...a, ai: { ...(a.ai ?? {}), requiresConfirmation: true } } : a, + ), + }, + ); + const confirmBridge = bridgeFor(user, gatedConfirmObjects); + + // The listing tells a client the gate is there (unchanged behaviour). + const gatedList = JSON.parse((await callMcp(confirmBridge, toolsCall(11, 'list_actions', {}))).result.content[0].text).actions as any[]; + check( + gatedList.find((a) => a.name === 'complete_task')?.requiresConfirmation === true, + 'list_actions still reports requiresConfirmation:true (unchanged)', + ); + + const gatedTask: any = await engine.insert('todo_task', { subject: 'Needs a human', status: 'not_started', priority: 'high' }); + const gatedId = gatedTask?.id ?? gatedTask?.record?.id; + + // 7a — WITHOUT the member: refused, with the declared code, and NOTHING ran. + const refused = await callMcp(confirmBridge, toolsCall(12, 'run_action', { actionName: 'complete_task', recordId: gatedId })); + check(refused.result?.isError === true, 'run_action WITHOUT confirm is refused'); + let refusedEnvelope: any = {}; + try { + refusedEnvelope = JSON.parse(refused.result?.content?.[0]?.text ?? '{}'); + } catch { + refusedEnvelope = {}; + } + check(refusedEnvelope?.error?.code === 'ACTION_CONFIRMATION_REQUIRED', `refusal carries code ACTION_CONFIRMATION_REQUIRED (got ${refusedEnvelope?.error?.code})`); + check(refusedEnvelope?.error?.status === 428, `refusal carries status 428 (got ${refusedEnvelope?.error?.status})`); + check(refusedEnvelope?.error?.details?.actionName === 'complete_task', 'refusal names the action'); + check(refusedEnvelope?.error?.details?.confirmationMember === 'confirm', 'refusal names the member to set'); + const afterRefusal: any[] = await engine.find('todo_task', { where: { id: gatedId } }); + check(afterRefusal?.[0]?.status === 'not_started', `nothing ran — status is still '${afterRefusal?.[0]?.status}'`); + + // 7b — WITH the member: the identical call succeeds and the handler runs. + // This is the leg that proves the member is not stripped: before the door + // grew it, this call was refused exactly like 7a and the action was + // permanently un-invokable. + const confirmed = await callMcp(confirmBridge, toolsCall(13, 'run_action', { actionName: 'complete_task', recordId: gatedId, confirm: true })); + check(confirmed.result?.isError !== true, 'run_action WITH confirm:true succeeds'); + const afterConfirm: any[] = await engine.find('todo_task', { where: { id: gatedId } }); + check(afterConfirm?.[0]?.status === 'completed', `the handler ran — status is now '${afterConfirm?.[0]?.status}'`); + + // 7c — the heuristic must NOT gate: delete_completed is `variant:'danger'` + // and the listing calls it requiresConfirmation, but its author declared + // nothing, so it stays invokable with no member (this change narrows a + // published accept set; refusing here would be the regression). + const undeclared = await callMcp(confirmBridge, toolsCall(14, 'run_action', { actionName: 'delete_completed' })); + check(undeclared.result?.isError !== true, 'a destructive-LOOKING action with no declared flag is NOT gated'); + console.log('\n────────────────────────────────────────────────────────────────────────────────'); if (failures > 0) { console.error(`❌ MCP action E2E FAILED — ${failures} check(s) failed`); diff --git a/packages/mcp/src/mcp-action-confirmation-member.test.ts b/packages/mcp/src/mcp-action-confirmation-member.test.ts new file mode 100644 index 0000000000..20bf1ec808 --- /dev/null +++ b/packages/mcp/src/mcp-action-confirmation-member.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15942 / #16293] The `run_action` door carries the confirmation member — + * and the ADR-0112 refusal that member exists to satisfy. + * + * ## Why this file drives the REAL door + * + * The gate itself lives in `@objectstack/runtime` + * (`actionConfirmationRefusal`), and a test that calls `invokeBusinessAction` + * directly is blind to the thing that decides whether the feature works at + * all: THE MEMBER NEVER REACHED IT. Measured on this tree before the change, + * with a client sending `{ actionName, recordId, confirm: true }` through a + * real JSON-RPC `tools/call`, the bridge received `{ recordId: 'r1' }` — + * `confirm` was stripped twice over: + * + * 1. the SDK wraps the raw `inputSchema` shape via `objectFromShape`, and + * under zod a plain object DROPS unknown keys — no error, no reject; + * 2. the handler then forwarded only `{ objectName, recordId, params }`. + * + * `recordId` surviving the same round trip is the lit control on that reading: + * the transport works, and only the undeclared member was lost. So enforcing + * the gate WITHOUT this door change would have made every action declaring + * `ai.requiresConfirmation: true` permanently un-invokable over MCP — refused, + * retried with the member, stripped, refused again — which is strictly worse + * than the silent no-gate it replaced. That is what this file pins. + * + * It drives `MCPServerRuntime.handleHttpRequest` over JSON-RPC — the same code + * path an external MCP client hits, both strip layers included — rather than + * calling `registerActionTools`' handler directly, which would see neither. + * + * The bridge here is a double: it stands in for the runtime gate so this + * package can assert the DOOR's half (schema, forward, envelope) without + * depending on `@objectstack/runtime`, which deliberately does not depend back. + * The gate's own predicate is pinned in + * `packages/runtime/src/action-confirmation-gate.test.ts`, and the two halves + * are driven together, against a real engine, in + * `examples/app-todo/test/mcp-actions.e2e.ts`. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { AI_ACTION_CONFIRMATION_MEMBER } from '@objectstack/spec/contracts'; + +import { MCPServerRuntime } from './mcp-server-runtime.js'; +import type { McpDataBridge, McpActionBridge } from './mcp-http-tools.js'; + +/** The action the double treats as author-gated. */ +const GATED = 'archive_account'; + +/** + * A bridge that reproduces the runtime gate's OBSERVABLE contract: it refuses + * the gated action unless the request carries the member as boolean `true`, + * throwing the same `code` / `status` / `details` envelope + * `actionConfirmationRefusal` produces. + */ +function makeBridge(): McpDataBridge & McpActionBridge & { calls: any[] } { + const calls: any[] = []; + return { + calls, + async listObjects() { + return []; + }, + async describeObject() { + return null; + }, + async query() { + return { records: [] }; + }, + async get() { + return null; + }, + async create() { + return {}; + }, + async update() { + return {}; + }, + async remove() { + return {}; + }, + async listActions() { + return [ + { name: GATED, objectName: 'account', type: 'script', requiresRecord: true, requiresConfirmation: true }, + ]; + }, + async runAction(name: string, input: any) { + calls.push([name, input]); + if (name === GATED && input?.[AI_ACTION_CONFIRMATION_MEMBER] !== true) { + throw Object.assign( + new Error( + `Action '${GATED}' on 'account' declares ai.requiresConfirmation: true — nothing was run.`, + ), + { + code: 'ACTION_CONFIRMATION_REQUIRED', + status: 428, + details: { + actionName: GATED, + objectName: 'account', + confirmationMember: AI_ACTION_CONFIRMATION_MEMBER, + }, + }, + ); + } + return { ok: true, action: name, objectName: 'account', result: { archived: true } }; + }, + }; +} + +function mcpRequest(body: unknown): Request { + return new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body), + }); +} + +const toolsCall = (id: number, name: string, args: Record) => ({ + jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args }, +}); + +describe('run_action carries the confirmation member through the real MCP door (#15942)', () => { + let runtime: MCPServerRuntime; + let bridge: ReturnType; + + const call = async (body: unknown) => { + const res = await runtime.handleHttpRequest(mcpRequest(body), { bridge, parsedBody: body }); + return (await res.json()) as any; + }; + + beforeEach(() => { + runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + bridge = makeBridge(); + }); + + it('advertises the member on the tool schema, so a model can DISCOVER the retry', async () => { + const json = await call({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); + const runAction = json.result.tools.find((t: any) => t.name === 'run_action'); + const props = runAction.inputSchema.properties; + // Control: the pre-existing members are still advertised, so a missing + // `confirm` below would be a real absence and not an empty read. + expect(Object.keys(props)).toEqual( + expect.arrayContaining(['actionName', 'objectName', 'recordId', 'params']), + ); + expect(Object.keys(props)).toContain(AI_ACTION_CONFIRMATION_MEMBER); + expect(props[AI_ACTION_CONFIRMATION_MEMBER].type).toBe('boolean'); + }); + + it('REFUSES a gated action with no member — code, status and the retry details', async () => { + const json = await call(toolsCall(2, 'run_action', { actionName: GATED, recordId: 'a1' })); + expect(json.result.isError).toBe(true); + const envelope = JSON.parse(json.result.content[0].text); + expect(envelope.error.code).toBe('ACTION_CONFIRMATION_REQUIRED'); + expect(envelope.error.status).toBe(428); + // The machine-readable half: a refused agent rebuilds the retry from this + // WITHOUT re-parsing the message prose. + expect(envelope.error.details).toEqual({ + actionName: GATED, + objectName: 'account', + confirmationMember: AI_ACTION_CONFIRMATION_MEMBER, + }); + // …and the door really did forward a request with no confirmation. + expect(bridge.calls).toHaveLength(1); + expect(bridge.calls[0][1][AI_ACTION_CONFIRMATION_MEMBER]).toBeUndefined(); + }); + + it('SUCCEEDS on the retry — the member survives both strip layers', async () => { + const json = await call( + toolsCall(3, 'run_action', { actionName: GATED, recordId: 'a1', [AI_ACTION_CONFIRMATION_MEMBER]: true }), + ); + // The assertion the whole card turns on: before this change the member was + // dropped here and this call was refused exactly like the one above. + expect(bridge.calls[0][1][AI_ACTION_CONFIRMATION_MEMBER]).toBe(true); + expect(json.result.isError).toBeFalsy(); + expect(JSON.parse(json.result.content[0].text)).toMatchObject({ ok: true, result: { archived: true } }); + }); + + it('forwards `recordId` and `params` unchanged beside the member', async () => { + await call( + toolsCall(4, 'run_action', { + actionName: GATED, + objectName: 'account', + recordId: 'a1', + params: { reason: 'dupe' }, + [AI_ACTION_CONFIRMATION_MEMBER]: true, + }), + ); + expect(bridge.calls[0][1]).toEqual({ + objectName: 'account', + recordId: 'a1', + params: { reason: 'dupe' }, + [AI_ACTION_CONFIRMATION_MEMBER]: true, + }); + }); + + it('is a CLOSED boolean — a truthy string is refused by the door, not passed on', async () => { + const json = await call( + toolsCall(5, 'run_action', { actionName: GATED, recordId: 'a1', [AI_ACTION_CONFIRMATION_MEMBER]: 'true' }), + ); + expect(json.result.isError).toBe(true); + // A transport artefact must never read as an attestation, and the wrong + // failure here would be it reaching the bridge as a truthy value. + expect(bridge.calls).toHaveLength(0); + }); + + it('leaves an UNCODED bridge failure as a plain message (the envelope widens, it narrows nothing)', async () => { + const failing = { + ...bridge, + async runAction() { + throw new Error('handler exploded'); + }, + }; + const body = toolsCall(6, 'run_action', { actionName: 'other', recordId: 'x' }); + const res = await runtime.handleHttpRequest(mcpRequest(body), { bridge: failing, parsedBody: body }); + const json: any = await res.json(); + expect(json.result.isError).toBe(true); + expect(json.result.content[0].text).toBe('handler exploded'); + }); +}); diff --git a/packages/runtime/src/action-confirmation-gate.test.ts b/packages/runtime/src/action-confirmation-gate.test.ts new file mode 100644 index 0000000000..738112f837 --- /dev/null +++ b/packages/runtime/src/action-confirmation-gate.test.ts @@ -0,0 +1,245 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#15942 / #16293 / ADR-0049] `ai.requiresConfirmation` is ENFORCED at the +// AI-facing action door — the gate half of the confirmation contract. +// +// ## What was wrong +// +// The flag was read in exactly one place and consumed in exactly one place: +// `actionLooksDestructive` → the `list_actions` summary. `run_action` never +// consulted it. An author read the spec sentence promising a pause, set the +// flag, saw it accepted, saw it echoed truthfully in the listing, and shipped +// believing a human was in the loop — every intermediate signal green, only +// the last thing not happening. That is the class ADR-0049 retired +// `tool.requiresConfirmation` for, reappearing on the very key the +// retirement's own ledger entry pointed authors at. +// +// ## What every case here owes +// +// A refusal asserts the ADR-0112 envelope — `code` AND `status` — AND that +// NOTHING dispatched AND that no record was read. A gate that refused after +// the subject load, or after the handler ran, satisfies a code-only assertion +// and is still the defect. `expect(...).toThrow()` alone would be satisfied by +// any unrelated failure and is not used. +// +// And the acceptance half is pinned just as hard: an action that declares +// nothing, or declares `false`, must keep running exactly as it does today. +// This change NARROWS a published accept set, so the one regression it could +// cause is refusing calls no author ever asked to gate — which is what the +// heuristic cases below exist to catch. + +import { describe, it, expect, vi } from 'vitest'; + +import { AI_ACTION_CONFIRMATION_MEMBER } from '@objectstack/spec/contracts'; + +import type { HttpProtocolContext } from './http-dispatcher.js'; +import { + invokeBusinessAction, + actionConfirmationRefusal, + actionLooksDestructive, + ACTION_CONFIRMATION_REQUIRED_CODE, + ACTION_CONFIRMATION_REQUIRED_STATUS, +} from './action-execution.js'; + +const OBJECT = 'crm_lead'; + +/** Author-gated: `ai.requiresConfirmation: true`, declared on purpose. */ +const GATED = { + name: 'archive_lead', label: 'Archive Lead', objectName: OBJECT, type: 'script', + target: 'archive_lead_impl', + ai: { exposed: true, description: 'Archive a lead.', requiresConfirmation: true }, +}; +/** Ungated, and DESTRUCTIVE-LOOKING — the heuristic must not gate it. */ +const LOOKS_DESTRUCTIVE = { + name: 'delete_lead', label: 'Delete Lead', objectName: OBJECT, type: 'script', + target: 'delete_lead_impl', mode: 'delete', + ai: { exposed: true, description: 'Delete a lead.' }, +}; +/** The author's explicit `false` — "safe unattended", and it overrides the heuristic. */ +const DECLARED_SAFE = { + name: 'purge_lead', label: 'Purge Lead', objectName: OBJECT, type: 'script', + target: 'purge_lead_impl', variant: 'danger', + ai: { exposed: true, description: 'Purge a lead.', requiresConfirmation: false }, +}; +/** A gated FLOW action — the gate must sit ahead of `dispatchFlowAction` too. */ +const GATED_FLOW = { + name: 'route_lead', label: 'Route Lead', objectName: OBJECT, type: 'flow', target: 'crm_lead_router', + ai: { exposed: true, description: 'Route a lead.', requiresConfirmation: true }, +}; + +function boot() { + const executeAction = vi.fn(async () => ({ ran: 'script' })); + const execute = vi.fn(async () => ({ success: true, output: {} })); + // The subject-record read. Its call count is the proof that a refusal + // touched no data — "no record is read or written" is the contract's own + // sentence about this gate. + // The protocol's own shape: a found row arrives wrapped in `record`. + const callData = vi.fn(async () => ({ record: { id: 'lead_1', name: 'Acme' } })); + + const objectDef = { name: OBJECT, actions: [GATED, LOOKS_DESTRUCTIVE, DECLARED_SAFE, GATED_FLOW] }; + const ql: any = { + executeAction, + getSchema: (n: string) => (n === OBJECT ? objectDef : undefined), + registry: { getObject: (n: string) => (n === OBJECT ? objectDef : undefined), getItem: () => undefined }, + find: vi.fn(async () => []), insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + }; + const metadata: any = { + load: vi.fn(async () => null), + loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })), + loadMany: vi.fn(async () => []), + listObjects: vi.fn(async () => [objectDef]), + getObject: vi.fn(async () => objectDef), + }; + const automation: any = { handlerReady: true, execute, getFlow: vi.fn(async () => ({ name: GATED_FLOW.target })) }; + return { ql, metadata, automation, executeAction, execute, callData }; +} + +const CTX = { request: {}, environmentId: 'platform', executionContext: { userId: 'u_agent' } } as unknown as HttpProtocolContext; + +/** The MCP door, wired as `domains/mcp.ts` assembles it. */ +const runViaMcp = (h: ReturnType, name: string, input: Record = {}) => { + const deps: any = { + resolveService: async (_c: unknown, service: string) => + (service === 'metadata' ? h.metadata : service === 'automation' ? h.automation : h.ql), + getObjectQL: async () => h.ql, + }; + return invokeBusinessAction(deps, CTX as any, name, input as any, { + driver: h.ql, + ec: { userId: 'u_agent', systemPermissions: [] }, + getMeta: () => h.metadata, + callData: h.callData, + }); +}; + +describe('the AI-facing door refuses an unconfirmed call on a gated action (#15942)', () => { + it('refuses with the ADR-0112 envelope, dispatches nothing, and reads no record', async () => { + const h = boot(); + + const thrown: any = await runViaMcp(h, GATED.name, { recordId: 'lead_1' }).catch((e) => e); + + expect(thrown).toBeInstanceOf(Error); + expect(thrown.code).toBe(ACTION_CONFIRMATION_REQUIRED_CODE); + expect(thrown.code).toBe('ACTION_CONFIRMATION_REQUIRED'); + expect(thrown.status).toBe(428); + // The machine-readable retry, so a refused agent never re-parses prose. + expect(thrown.details).toEqual({ + actionName: GATED.name, + objectName: OBJECT, + confirmationMember: AI_ACTION_CONFIRMATION_MEMBER, + }); + // The prose still names both, for the human reading a transcript. + expect(String(thrown.message)).toContain(GATED.name); + expect(String(thrown.message)).toContain(AI_ACTION_CONFIRMATION_MEMBER); + // Nothing ran, and nothing was read: the gate sits ahead of both. + expect(h.executeAction).not.toHaveBeenCalled(); + expect(h.callData).not.toHaveBeenCalled(); + }); + + it('runs the SAME call once the member rides on the request', async () => { + const h = boot(); + + const result: any = await runViaMcp(h, GATED.name, { + recordId: 'lead_1', + [AI_ACTION_CONFIRMATION_MEMBER]: true, + }); + + expect(result?.ok).toBe(true); + expect(h.executeAction).toHaveBeenCalledTimes(1); + }); + + it('gates a FLOW action too — ahead of the type branch, so no run is created', async () => { + const h = boot(); + + const thrown: any = await runViaMcp(h, GATED_FLOW.name, {}).catch((e) => e); + + expect(thrown.code).toBe('ACTION_CONFIRMATION_REQUIRED'); + expect(thrown.status).toBe(428); + expect(h.execute).not.toHaveBeenCalled(); + // Control: the same action DOES dispatch once confirmed, so the + // assertion above is about the gate and not about a flow that never + // could have run in this harness. + await runViaMcp(h, GATED_FLOW.name, { [AI_ACTION_CONFIRMATION_MEMBER]: true }); + expect(h.execute).toHaveBeenCalledTimes(1); + }); + + it('admits ONLY the boolean `true` — a truthy string and `false` are not attestations', async () => { + for (const value of ['true', 1, {}, false, null]) { + const h = boot(); + const thrown: any = await runViaMcp(h, GATED.name, { + [AI_ACTION_CONFIRMATION_MEMBER]: value, + }).catch((e) => e); + expect(thrown.code, `\`${JSON.stringify(value)}\` must not confirm`).toBe( + 'ACTION_CONFIRMATION_REQUIRED', + ); + expect(h.executeAction).not.toHaveBeenCalled(); + } + }); +}); + +describe('the gate reads the DECLARED flag and never the listing heuristic (#15942)', () => { + it('does NOT refuse an undeclared action the listing calls destructive', async () => { + const h = boot(); + + // Control, and the whole reason this case exists: the LISTING predicate + // answers `true` for this action. If the two predicates were collapsed, + // the call below would be refused — a call that works today, broken on + // a guess its author never made. + expect(actionLooksDestructive({} as any, LOOKS_DESTRUCTIVE)).toBe(true); + + const result: any = await runViaMcp(h, LOOKS_DESTRUCTIVE.name, { recordId: 'lead_1' }); + + expect(result?.ok).toBe(true); + expect(h.executeAction).toHaveBeenCalledTimes(1); + }); + + it('honours an explicit `false` on a danger-variant action', async () => { + const h = boot(); + + expect(actionLooksDestructive({} as any, DECLARED_SAFE)).toBe(false); + const result: any = await runViaMcp(h, DECLARED_SAFE.name, { recordId: 'lead_1' }); + + expect(result?.ok).toBe(true); + expect(h.executeAction).toHaveBeenCalledTimes(1); + }); +}); + +describe('the shared refusal producer itself (#15942)', () => { + const deps: any = {}; + + it('is silent unless the author declared the flag as `true`', () => { + expect(actionConfirmationRefusal(deps, LOOKS_DESTRUCTIVE, {}, OBJECT)).toBeUndefined(); + expect(actionConfirmationRefusal(deps, DECLARED_SAFE, {}, OBJECT)).toBeUndefined(); + expect(actionConfirmationRefusal(deps, {}, {}, OBJECT)).toBeUndefined(); + // Lit control on the same call: the one shape that DOES refuse. + expect(actionConfirmationRefusal(deps, GATED, {}, OBJECT)?.code).toBe( + ACTION_CONFIRMATION_REQUIRED_CODE, + ); + }); + + it('echoes the member off the CONTRACT constant, not a hand-spelled string', () => { + const refusal = actionConfirmationRefusal(deps, GATED, undefined, OBJECT); + + expect(refusal).toMatchObject({ + code: ACTION_CONFIRMATION_REQUIRED_CODE, + status: ACTION_CONFIRMATION_REQUIRED_STATUS, + details: { + actionName: GATED.name, + objectName: OBJECT, + confirmationMember: AI_ACTION_CONFIRMATION_MEMBER, + }, + }); + // The status is the registered one, and the code is not a synonym the + // door invented locally. + expect(ACTION_CONFIRMATION_REQUIRED_STATUS).toBe(428); + }); + + it('omits `objectName` for an object-less action rather than emitting an empty one', () => { + const refusal = actionConfirmationRefusal(deps, GATED, {}, undefined); + + expect(refusal?.details).toEqual({ + actionName: GATED.name, + confirmationMember: AI_ACTION_CONFIRMATION_MEMBER, + }); + expect('objectName' in (refusal?.details ?? {})).toBe(false); + }); +}); From be62a3d038a54bcc43392eccca2ec146bdb8923d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 16:30:07 +0000 Subject: [PATCH 3/4] chore: changeset for the confirmation gate Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude --- .../action-confirmation-gate-enforced.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/action-confirmation-gate-enforced.md diff --git a/.changeset/action-confirmation-gate-enforced.md b/.changeset/action-confirmation-gate-enforced.md new file mode 100644 index 0000000000..1b1e44cb00 --- /dev/null +++ b/.changeset/action-confirmation-gate-enforced.md @@ -0,0 +1,28 @@ +--- +"@objectstack/runtime": minor +"@objectstack/mcp": minor +--- + +fix(runtime,mcp): `action.ai.requiresConfirmation` is ENFORCED at the AI-facing action door — an unconfirmed call is refused, and `run_action` grows the `confirm` member that satisfies it (#15942) + +**Behaviour change — read this if any of your actions declare `ai.requiresConfirmation: true`.** An AI-facing invocation of such an action (`invokeBusinessAction`, reached from the MCP `run_action` tool) is now REFUSED unless the request carries the confirmation member. A call that succeeded before starts answering `428 ACTION_CONFIRMATION_REQUIRED`, and nothing dispatches: the action body does not run, and the subject record is not even read. + +FROM → TO, for a caller of a gated action: + +``` +run_action({ actionName: 'archive_lead', recordId: 'lead_1' }) // was: ran +run_action({ actionName: 'archive_lead', recordId: 'lead_1', confirm: true }) // now: required +``` + +The refusal is machine-readable so the retry is mechanical rather than guessed — `error.details` carries `{ actionName, objectName?, confirmationMember }`, and `confirmationMember` echoes the member's exact spelling (`AI_ACTION_CONFIRMATION_MEMBER`, `@objectstack/spec/contracts`). The `run_action` tool schema advertises `confirm` as an optional boolean, so an agent discovers the retry from the tool definition rather than from prose. + +**What is NOT gated**, because this narrows a published accept set and the narrowing is deliberately as small as the author's own declaration: + +- Only the DECLARED flag gates. `ai.requiresConfirmation: true`, set by the action's author, and nothing else. The wider `list_actions` heuristic — `mode: 'delete'` / `variant: 'danger'` on an action whose author declared nothing — still reports `requiresConfirmation: true` to advise a client, and still does NOT refuse. An explicit `ai.requiresConfirmation: false` never refuses. +- Only the boolean `true` confirms. `'true'`, `1` and `false` are not attestations. +- Only the AI-facing doors. The enforced set is the doors that enforce `ai.exposed` — today `invokeBusinessAction` via MCP `run_action`. REST `/actions` is not `ai.exposed`-gated and sits outside this gate. +- `list_actions` is unchanged. + +**A gate, not a queue.** Nothing is parked, nothing is held for an operator, and there is no resume path: a refused call simply did not run, and the caller confirms with its human and retries. And `confirm: true` is an unverifiable caller claim — an agent that always sends it bypasses the gate. The gate makes FORGETTING loud; it does not prove a human. + +Why it is worth the break: the flag was read once and consumed once, to fill a field of the `list_actions` summary. It stopped nothing. That is the failure ADR-0049 retired `tool.requiresConfirmation` for — "a SAFETY flag that is merely accepted is false compliance" — reappearing on the very key the retirement's own ledger entry told authors to move to. The contract this implements landed in `@objectstack/spec` first (#16293). From e19fe3a78dcb5a6ab5e70e50175f3fb010cb37d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 16:39:08 +0000 Subject: [PATCH 4/4] test(mcp): the skill-md signature pin owes the confirmation member Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude --- packages/mcp/src/skill-md.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/mcp/src/skill-md.test.ts b/packages/mcp/src/skill-md.test.ts index 5decd1c6e3..97964eb304 100644 --- a/packages/mcp/src/skill-md.test.ts +++ b/packages/mcp/src/skill-md.test.ts @@ -72,7 +72,12 @@ describe('renderSkillMarkdown', () => { it('teaches action preference — run a matching business action instead of hand-editing records', () => { const md = renderSkillMarkdown(); expect(md).toContain('Prefer actions over hand-edits'); - expect(md).toContain('run_action({ actionName, objectName?, recordId?, params? })'); + expect(md).toContain('run_action({ actionName, objectName?, recordId?, params?, confirm? })'); + // [#15942] The signature owes `confirm`, and the skill owes the sentence + // that makes it usable: an agent that learns the member exists but not that + // the server REFUSES without it has learned nothing it can act on. + expect(md).toContain('ACTION_CONFIRMATION_REQUIRED'); + expect(md).toContain('requiresConfirmation'); }); it('is generic — it does not enumerate any concrete schema', () => {