From 46870fc06a964907fa2ee9fa37ce342f81b1b51f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:23:41 +0000 Subject: [PATCH 1/4] feat(mcp): resume_run continues the caller's own paused screen run, behind run_action's gates WIP: runtime bridge + shared resume refusal table + MCP tool registration. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn --- packages/mcp/src/mcp-http-tools.ts | 133 ++++++++- packages/mcp/src/mcp-server-runtime.ts | 3 +- .../mcp/src/skill-md-surface-guard.test.ts | 5 + packages/mcp/src/skill-md.test.ts | 1 + packages/mcp/src/skill-md.ts | 7 + packages/runtime/src/domains/automation.ts | 201 ++++++++------ packages/runtime/src/domains/mcp.ts | 258 ++++++++++++++++++ 7 files changed, 520 insertions(+), 88 deletions(-) diff --git a/packages/mcp/src/mcp-http-tools.ts b/packages/mcp/src/mcp-http-tools.ts index bf6d34d5b15..48c07ff637d 100644 --- a/packages/mcp/src/mcp-http-tools.ts +++ b/packages/mcp/src/mcp-http-tools.ts @@ -241,6 +241,26 @@ export interface McpActionBridge { name: string, input: { objectName?: string; recordId?: string; params?: Record } & AIActionConfirmation, ): Promise; + /** + * [#15705] Continue a run that `runAction` answered with `status: 'paused'` + * and a `screen`, submitting that screen's field values as `values`. + * + * The host admits the call only where `runAction` would admit starting the + * same flow on the same record for this caller. That means the run was + * started by this caller, an AI-exposed flow action the caller may run + * targets the run's flow, the subject record is still readable by the + * caller, and the run is parked on a screen. Resolves to `runAction`'s + * envelope (`{ ok, action, objectName, recordId?, result }`). A run that + * pauses on its next screen comes back paused again. Throws on refusal, with + * the ADR-0112 `code` / `status` on the thrown value. + * + * OPTIONAL, like {@link McpDataBridge.aggregate}. A host that cannot resume + * runs omits it, and `resume_run` is then not registered. + */ + resumeRun?( + runId: string, + input: { values?: Record } & AIActionConfirmation, + ): Promise; } export interface RegisterActionToolsOptions { @@ -469,7 +489,8 @@ const RECORD_ID_ALIASES = { id: 'recordId', record_id: 'recordId' } as const; * * Object CRUD always; the business-action pair only when the bridge implements * `listActions` + `runAction` (graceful degradation — a host with no action - * mechanism keeps serving object tools unchanged). Whoever owns the server + * mechanism keeps serving object tools unchanged), and `resume_run` beside + * them when it also implements `resumeRun` (#15705). Whoever owns the server * decides nothing else: the tool set is a function of the BRIDGE, so the same * bridge yields the same tools on stdio and over HTTP, which is the property * `transport-parity` pins. @@ -976,7 +997,8 @@ export function registerObjectTools( } /** - * Register the business-action tool set (`list_actions`, `run_action`) on an + * Register the business-action tool set (`list_actions`, `run_action`, and + * `resume_run` when the bridge implements `resumeRun`) on an * {@link McpServer}. This is the action analogue of * {@link registerObjectTools}: it owns the tool *shape* and delegates all * resolution + dispatch + security to `bridge`, which the runtime binds to the @@ -1009,6 +1031,9 @@ export function registerActionTools( if (options.grantedScopes && !options.grantedScopes.includes(MCP_OAUTH_SCOPE_ACTIONS)) { return registered; } + // [#15705] `resume_run` is registered only when the bridge can resume. The + // same test decides whether `run_action`'s description mentions it. + const canResume = typeof bridge.resumeRun === 'function'; server.registerTool( note('list_actions'), @@ -1043,7 +1068,14 @@ export function registerActionTools( '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. ' + '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.', + 'send confirm: true — ask the human first, then retry; nothing runs on a refused call.' + + // [#15705] Named only where `resume_run` is actually registered below, + // so this description never points an agent at a tool it cannot call. + (canResume + ? ' A flow action can answer result.status "paused" with a runId and a screen (a form to fill ' + + 'in): the run has stopped to wait for that form, and resume_run submits its field values to ' + + 'continue it.' + : ''), inputSchema: strictToolInput( { surface: 'this run_action call', @@ -1134,6 +1166,101 @@ export function registerActionTools( }, ); + // [#15705] `resume_run`: the other half of a screen flow. `run_action` on a + // flow whose screen still needs input answers `status: 'paused'` with a + // `runId` and the `screen` to fill in. Before this tool, nothing on this + // surface could submit that screen, so the run stayed parked and the agent + // could start the action but never finish it. + // + // This tool owns the SHAPE only, like `run_action`. The bridge decides + // whether the call is allowed, and it applies `run_action`'s own gates to the + // run (see `McpActionBridge.resumeRun`). Registered in this family on + // purpose: the same `actions:execute` scope grants both, and both transports + // get it through `wireBridgeTools`, as they get `run_action`. + if (canResume) { + const resumeRun = bridge.resumeRun!.bind(bridge); + server.registerTool( + note('resume_run'), + { + description: + 'Continue a flow run that run_action (or an earlier resume_run) answered with result.status ' + + '"paused" and a screen: submit the screen\'s field values, keyed by each screen.fields[].name. ' + + 'The run continues from that screen. The result has run_action\'s shape: the run completes, or ' + + 'pauses again on its NEXT screen with a new screen to fill (call resume_run again for it). Only ' + + 'the user whose call started the run can resume it, and it is gated exactly as run_action is ' + + '(author AI opt-in, your capabilities, your read access to the record). The rest of the flow runs ' + + 'the app\'s business logic and can mutate data. When list_actions reports requiresConfirmation for ' + + 'the action that started the run, send confirm: true (ask the human first); without it the call ' + + 'is refused and the run stays paused.', + inputSchema: strictToolInput( + { + surface: 'this resume_run call', + // The spellings a caller who knows the REST resume door (`inputs` + // / `variables`) or the other tools (`params`, `data`) reaches + // for. Each is named in the refusal so the caller can resend with + // `values`; none is accepted. + aliases: { + id: 'runId', + run_id: 'runId', + run: 'runId', + inputs: 'values', + variables: 'values', + params: 'values', + data: 'values', + fields: 'values', + answers: 'values', + }, + }, + { + runId: z + .string() + .describe('The runId from a result whose status is "paused" and that carries a screen.'), + values: z + .record(z.string(), z.unknown()) + .optional() + .describe( + 'The screen\'s field values, keyed by field name (the names in screen.fields). Omit only when ' + + 'every field on the screen is optional.', + ), + // The confirmation member, keyed off the contract's constant exactly + // as `run_action` declares it, for the same reason: a member this + // shape did not declare would be refused, and the gate that asks + // for it could never be satisfied. + [AI_ACTION_CONFIRMATION_MEMBER]: z + .boolean() + .optional() + .describe( + 'Set to true to confirm resuming a run of an action the app author gated with ' + + 'ai.requiresConfirmation (list_actions reports requiresConfirmation). Assert this only when ' + + 'the human in the loop has approved THIS call; without it the call is refused and the run ' + + 'stays paused.', + ), + }, + ), + // The rest of the flow runs here, so this carries the same annotations + // as `run_action`: the client should confirm before calling it. + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, + }, + async (args) => { + const { runId, values } = args; + const confirm = args[AI_ACTION_CONFIRMATION_MEMBER]; + if (!runId || typeof runId !== 'string') { + return errorResult('runId is required'); + } + try { + const result = await resumeRun(runId, { + values, + // Forwarded, never rebuilt without it: see `run_action` above. + [AI_ACTION_CONFIRMATION_MEMBER]: confirm, + }); + return textResult(result); + } catch (err) { + return errorResultFromThrown(err); + } + }, + ); + } + return registered; } diff --git a/packages/mcp/src/mcp-server-runtime.ts b/packages/mcp/src/mcp-server-runtime.ts index 292ece4f4ab..5698e0f29aa 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -1557,7 +1557,8 @@ export class MCPServerRuntime { * cross-request session/request-id collision and keeps each call isolated). * The tool set is the object-CRUD bridge plus — when the bridge can resolve * the framework's action mechanism — the business-action tools - * (`list_actions` / `run_action`), all bound to the **caller's principal** + * (`list_actions` / `run_action`, and `resume_run` when the bridge can + * resume a paused run, #15705), all bound to the **caller's principal** * via `bridge`; the runtime wires that bridge to the existing permission + * RLS path, so an external agent can never exceed the key's authority. * diff --git a/packages/mcp/src/skill-md-surface-guard.test.ts b/packages/mcp/src/skill-md-surface-guard.test.ts index e481da1fe4d..778987cea16 100644 --- a/packages/mcp/src/skill-md-surface-guard.test.ts +++ b/packages/mcp/src/skill-md-surface-guard.test.ts @@ -56,6 +56,11 @@ function makeFullBridge(): McpDataBridge & McpActionBridge { async runAction() { return {}; }, + // [#15705] Optional on the bridge, and present here so `resume_run` is on + // the surface this guard reads: a full host implements it. + async resumeRun() { + return {}; + }, }; } diff --git a/packages/mcp/src/skill-md.test.ts b/packages/mcp/src/skill-md.test.ts index 97964eb3043..33a73b9e6a2 100644 --- a/packages/mcp/src/skill-md.test.ts +++ b/packages/mcp/src/skill-md.test.ts @@ -63,6 +63,7 @@ describe('renderSkillMarkdown', () => { 'delete_record', 'list_actions', 'run_action', + 'resume_run', ]) { expect(md).toContain(tool); } diff --git a/packages/mcp/src/skill-md.ts b/packages/mcp/src/skill-md.ts index 76ba9d79c2b..3d301cfe159 100644 --- a/packages/mcp/src/skill-md.ts +++ b/packages/mcp/src/skill-md.ts @@ -168,6 +168,13 @@ create/update payload. \`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. +- **resume_run({ runId, values?, confirm? })** — continue a flow run that + \`run_action\` answered with \`status: "paused"\` and a \`screen\` (a form the + flow needs filled in). Send the screen's field values in \`values\`, keyed by + the names in \`screen.fields\`. The run completes, or pauses again on its next + screen, which you fill with another \`resume_run\`. Only the user whose call + started the run can resume it, under the same gates as \`run_action\` + (including \`confirm: true\` for an action flagged \`requiresConfirmation\`). ## Conventions & gotchas diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 8667f62e65c..03f56b6c5cd 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -1511,6 +1511,115 @@ async function consumedSuspensionSurvives( } } +/** + * [#15705] What a resume door serves for a REFUSED or FAILED engine result. + * `details` is passed to the door's error builder as it is, so a `code` in it + * is promoted. Without one, the builder derives the code from `status`. + */ +export interface ResumeRefusal { + message: string; + status: number; + details?: Record; +} + +/** + * [#15705] The engine refusals a resume answers with, keyed by the engine's + * own `code`. Each row gives the status and the message used when the engine + * sent none. A `Map`, not an object literal, so an engine code that happens to + * be spelled like an `Object.prototype` member can never match a row. + */ +const RESUME_REFUSAL_ROWS: ReadonlyMap = new Map([ + ['PERMISSION_DENIED', { status: 403, fallback: 'Resume forbidden' }], + ['INVALID_SIGNAL', { status: 400, fallback: 'Invalid resume signal' }], + ['INVALID_SCREEN_INPUT', { status: 400, fallback: 'Invalid screen input' }], + ['RUN_NOT_FOUND', { status: 404, fallback: 'No such suspended run' }], + ['STORE_UNAVAILABLE', { status: 503, fallback: 'Suspended-run store unavailable' }], + ['RESUME_IN_PROGRESS', { status: 409, fallback: 'Run is already being resumed' }], +]); + +/** + * [#15705] Classify what `IAutomationService.resume` returned. Answers + * `undefined` for a success, which is a run that completed or paused again on + * its next screen, and the {@link ResumeRefusal} to serve otherwise. + * + * ONE table for two doors: `POST /:name/runs/:runId/resume` below, and the MCP + * `resume_run` tool (`./mcp.ts`). Both hand the refusal to the same + * `deps.error` builder, so one engine result gets one code, one status and one + * message on either door. This used to be inline in the REST arm. It moved here + * unchanged, and the arm's wire answers are byte-identical. + * + * The six coded rows are REFUSALS the engine made BEFORE consuming the + * suspension, so the run is still parked and the caller can retry (the codes + * and why each has its status are listed at the REST arm). + * + * [#8684] TERMINAL RUN FAILURE → 400 `FLOW_FAILED`, inheriting #3962's ruling + * for `/actions` (maintainer, 2026-08-15): a business failure must not ride + * HTTP 200 inside a double envelope. It did here until then — + * `{success:true,data:{success:false,error:"Node 'x' failed: …"}}` — so a + * scripted or integration caller that branches on the HTTP status alone read a + * failed run as a successful one. + * + * Every coded row is a REFUSAL that left the suspension intact and can be + * retried; what reaches the last row consumed its pause and ran. Two engine + * exits produce it — the flow itself failed, or a subflow child failed + * terminally — and both are the "ran and was rejected" row, hence 400. The two + * NEVER-DISPATCHED exits are answered 404 by the `RUN_NOT_FOUND` row because + * the ENGINE classifies them (#8684, producer-first): this table never sniffs + * the result for `summary`/`durationMs` to tell the two classes apart, which is + * the tolerant-consumer shape PD #12 forbids. + * + * `FLOW_FAILED` is the code `/actions` already answers for a flow that ran and + * rejected (`../action-execution.ts`), and the ADR-0112 ledger registers it to + * `@objectstack/runtime` — the door, not the engine, is where the wire + * vocabulary is named. + * + * ⚠️ `errorMessage` is the flow AUTHOR's own failure text (`flow.errorMessage`, + * engine `resumeInternal`) and it travels in `details`, which is the one place + * the console reads it from (objectui `flowResponse.ts` / PR #4899 — no alias + * chain). The ADR-0112 envelope carries no `data`, so a producer that builds + * its message out of `result.error` alone drops the author's words silently; + * `/actions`'s producer does exactly that, and this deliberately does not copy + * it. `summary` rides along for the same reason it was on the 200 body: a + * failed run's per-node accounting is how a caller finds WHICH node failed. + * + * [#15221] And the engine's VERDICT rides with them. Of the two exits above, + * only the flow-itself-failed one can be `status: 'stranded'` (#14384 / #13937: + * the pause a durable decision was waiting on is gone and an operator verb can + * re-arm the run) — and until then this arm copied `errorMessage` and `summary` + * off the result and dropped `status`, so `'stranded'` could not reach the wire + * through any door and an HTTP-only caller read "beyond reach" and "repair + * waiting" as one and the same 400. The #16472 ruling (option A) carries it + * here, in the details of the EXISTING code: `runId`, `status` (verbatim, when + * stamped) and `repairable` (always present; [#17541] the stamped exits are + * answered by the stamp and the status-LESS ones by asking the engine's + * `inspectConsumedSuspension`, see {@link resumeFailureDetails}), declared once + * as `ResumeFailureDetailsSchema` in `@objectstack/spec/api`. ⛔ No + * `FLOW_STRANDED` sibling code: the console treats `400 FLOW_FAILED` as + * terminal (#8684) and a client that wants to branch reads + * `details.repairable`, never a regex over the message. + */ +export async function classifyResumeResult( + deps: DomainHandlerDeps, + automationService: IAutomationService, + runId: string, + result: AutomationResult | null | undefined, +): Promise { + if (result?.success !== false) return undefined; + const row = typeof result.code === 'string' ? RESUME_REFUSAL_ROWS.get(result.code) : undefined; + if (row) return { message: result.error ?? row.fallback, status: row.status }; + const verdict = await resumeFailureDetails(deps, automationService, runId, result); + return { + message: result.error ?? 'Flow run failed', + status: 400, + details: { + code: 'FLOW_FAILED', + ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}), + ...(result.summary !== undefined ? { summary: result.summary } : {}), + ...verdict, + }, + }; +} + /** * Handles Automation requests * path: sub-path after /automation/ @@ -2283,90 +2392,14 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str if (b.output !== undefined) signal.output = b.output; if (b.branchLabel !== undefined) signal.branchLabel = b.branchLabel; const result = await automationService.resume(parts[2], signal); - if (result?.success === false && result.code === 'PERMISSION_DENIED') { - return { handled: true, response: deps.error(result.error ?? 'Resume forbidden', 403) }; - } - if (result?.success === false && result.code === 'INVALID_SIGNAL') { - return { handled: true, response: deps.error(result.error ?? 'Invalid resume signal', 400) }; - } - if (result?.success === false && result.code === 'INVALID_SCREEN_INPUT') { - return { handled: true, response: deps.error(result.error ?? 'Invalid screen input', 400) }; - } - if (result?.success === false && result.code === 'RUN_NOT_FOUND') { - return { handled: true, response: deps.error(result.error ?? 'No such suspended run', 404) }; - } - if (result?.success === false && result.code === 'STORE_UNAVAILABLE') { - return { handled: true, response: deps.error(result.error ?? 'Suspended-run store unavailable', 503) }; - } - if (result?.success === false && result.code === 'RESUME_IN_PROGRESS') { - return { handled: true, response: deps.error(result.error ?? 'Run is already being resumed', 409) }; - } - // [#8684] TERMINAL RUN FAILURE → 400 `FLOW_FAILED`, inheriting - // #3962's ruling for `/actions` (maintainer, 2026-08-15): a - // business failure must not ride HTTP 200 inside a double - // envelope. It did here until now — `{success:true,data:{success: - // false,error:"Node 'x' failed: …"}}` — so a scripted or - // integration caller that branches on the HTTP status alone read - // a failed run as a successful one. - // - // Every arm above is a REFUSAL that left the suspension intact - // and can be retried; what reaches HERE consumed its pause and - // ran. Two engine exits produce it — the flow itself failed, or a - // subflow child failed terminally — and both are the "ran and was - // rejected" row, hence 400. The two NEVER-DISPATCHED exits are - // answered 404 by the `RUN_NOT_FOUND` arm above because the - // ENGINE classifies them (#8684, producer-first): this route - // never sniffs the result for `summary`/`durationMs` to tell the - // two classes apart, which is the tolerant-consumer shape PD #12 - // forbids. - // - // `FLOW_FAILED` is the code `/actions` already answers for a flow - // that ran and rejected (`../action-execution.ts`), and the - // ADR-0112 ledger registers it to `@objectstack/runtime` — this - // door, not the engine's, is where the wire vocabulary is named. - // - // ⚠️ `errorMessage` is the flow AUTHOR's own failure text - // (`flow.errorMessage`, engine `resumeInternal`) and it travels in - // `details`, which is the one place the console reads it from - // (objectui `flowResponse.ts` / PR #4899 — no alias chain). The - // ADR-0112 envelope carries no `data`, so a producer that builds - // its message out of `result.error` alone drops the author's words - // silently; `/actions`'s producer does exactly that, and this - // deliberately does not copy it. `summary` rides along for the - // same reason it was on the 200 body: a failed run's per-node - // accounting is how a caller finds WHICH node failed. - // - // [#15221] And the engine's VERDICT rides with them. Of the - // two exits above, only the flow-itself-failed one can be - // `status: 'stranded'` (#14384 / #13937: the pause a durable - // decision was waiting on is gone and an operator verb can - // re-arm the run) — and until now this arm copied - // `errorMessage` and `summary` off the result and dropped - // `status`, so `'stranded'` could not reach the wire through - // any door and an HTTP-only caller read "beyond reach" and - // "repair waiting" as one and the same 400. The #16472 - // ruling (option A) carries it here, in the details of the - // EXISTING code: `runId`, `status` (verbatim, when stamped) - // and `repairable` (always present; [#17541] the stamped - // exits are answered by the stamp and the status-LESS ones by - // asking the engine's `inspectConsumedSuspension`, see - // `resumeFailureDetails`), declared once as - // `ResumeFailureDetailsSchema` in `@objectstack/spec/api`. - // ⛔ No `FLOW_STRANDED` sibling code: the console treats - // `400 FLOW_FAILED` as terminal (#8684) and a client that - // wants to branch reads `details.repairable`, never a regex - // over the message. - if (result?.success === false) { - const verdict = await resumeFailureDetails(deps, automationService, parts[2], result); - return { - handled: true, - response: deps.error(result.error ?? 'Flow run failed', 400, { - code: 'FLOW_FAILED', - ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}), - ...(result.summary !== undefined ? { summary: result.summary } : {}), - ...verdict, - }), - }; + // [#15705] The engine's answer is classified by + // `classifyResumeResult`, the one table this door shares with + // the MCP `resume_run` tool (`./mcp.ts`), so the two doors + // cannot answer one engine result two ways. Every row, and why + // it answers what it answers, is documented there. + const refusal = await classifyResumeResult(deps, automationService, parts[2], result); + if (refusal) { + return { handled: true, response: deps.error(refusal.message, refusal.status, refusal.details) }; } return { handled: true, response: deps.success(result) }; } diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts index 0b451c018db..539b71b8a8d 100644 --- a/packages/runtime/src/domains/mcp.ts +++ b/packages/runtime/src/domains/mcp.ts @@ -18,6 +18,10 @@ import * as actionExec from '../action-execution.js'; import { isSystemObjectName } from '../action-execution.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +// [#15705] The resume door's one refusal table, shared with +// `POST /automation/:name/runs/:runId/resume` so the two doors answer one +// engine result identically. +import { classifyResumeResult } from './automation.js'; /** * The legacy branches matched `/mcp/skill` (exact or `?`-suffixed) BEFORE @@ -725,5 +729,259 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon name: string, input: { objectName?: string; recordId?: string; params?: Record } & AIActionConfirmation, ) => actionExec.invokeBusinessAction(deps, context, name, input ?? {}, { driver, envId, ec, getMeta, callData }), + // [#15705] `resume_run`: continue the caller's own paused screen run. + // Admitted by the same gates as `run_action`; see + // {@link resumeActionRun}. + resumeRun: async ( + runId: string, + input: { values?: Record } & AIActionConfirmation, + ) => resumeActionRun(deps, context, runId, input ?? {}, { driver, envId, ec, getMeta, callData }), }; } + +/** + * [#15705] Build the error a resume refusal throws, through the SAME + * `deps.error` builder the REST resume door answers with. So the code + * (explicit, promoted from `details`, or derived from the status), the message + * (with the 5xx leak guard) and the `details` are exactly what the REST door + * would serve. The MCP tool layer turns `code` / `status` / `details` back into + * the ADR-0112 envelope of its tool error. + */ +function resumeDoorError( + deps: DomainHandlerDeps, + message: string, + httpStatus: number, + details?: Record, +): Error { + const response = deps.error(message, httpStatus, details); + const envelope = (response?.body as { error?: { code?: unknown; message?: unknown; details?: unknown } } | undefined)?.error; + return Object.assign(new Error(typeof envelope?.message === 'string' ? envelope.message : message), { + code: typeof envelope?.code === 'string' ? envelope.code : undefined, + status: response?.status ?? httpStatus, + ...(envelope?.details !== undefined ? { details: envelope.details } : {}), + }); +} + +/** + * [#15705] The one answer for "this is not a paused run you can resume". It is + * given when no run has the id, when the run is no longer paused, and when + * someone else started it. The three read the same on purpose (the reason is + * in {@link resumeActionRun}'s step 2). + */ +function notResumableRunMessage(runId: string): string { + return ( + `Run '${runId}' is not a paused run you can resume: no run has this id, the run is no longer paused, ` + + 'or it was started by a different user. Only the user whose call started a run can resume it.' + ); +} + +/** + * [#15705] The MCP `resume_run` door. It continues a paused SCREEN run with + * the screen's field values, so an agent that got `status: 'paused'` plus a + * `screen` from `run_action` can finish the run. + * + * ## The ruling this implements + * + * Maintainer, on #15705: add the resume verb, and make it pass the SAME + * authorization and caller-scope checks as `run_action`. The #16370 fix (a + * record the caller cannot read is refused at the door) is named as the rule + * resume must not get around. So a call is admitted only when `run_action` + * would admit starting this same flow on this same record, for this caller, + * now. + * + * ## Why the REST resume door alone is not enough + * + * `POST /automation/:name/runs/:runId/resume` checks two things: the caller is + * not anonymous, and the node the run is parked on declares `resumeAuthority: + * 'any'` (a `screen` does). It never asks who is resuming. That matters + * because a resumed run continues under the identity stored in the run, not + * the caller's. The run's data nodes run as the user who STARTED it. So + * "any authenticated caller with the run id" would let one user continue + * another user's run as that other user. The steps below close that for this + * door. The REST door is not changed here. + * + * ## The steps, in order + * + * 1. The service must implement `resume`, `getRun` and `getSuspendedScreen`. + * Otherwise 501. Ownership cannot be checked without `getRun`, so this + * fails closed. + * 2. **Whose run.** `getRun(runId).trigger.userId` must be the caller's + * `userId`, and the run must be `paused`. Otherwise the ONE not-found + * answer, 404. An unknown id, a finished run and another user's run all + * get the same code, status and message. This is the existence + * non-disclosure #16370 used for records: an agent learns nothing about a + * run id it did not start. + * 3. **Which action admits it.** The run records its flow and its object, not + * the action that started it. So the candidates are the `type: 'flow'` + * actions whose `target` is the run's flow, on the run's object (the + * object-less key when the run carries no object, because + * `dispatchFlowAction` sets no `object` for one). Each candidate goes + * through `run_action`'s gates in `run_action`'s order, using the same + * helpers: system-object guard, `ai.exposed`, `requiredPermissions`, the + * ADR-0126 activation switch, and `ai.requiresConfirmation`. The first + * candidate that passes admits the call. If none passes, the first + * candidate's refusal is served. If there is no candidate at all, the run + * was not started by a flow action, and the call is refused 403. + * The confirmation gate applies here too because the flow's writes happen + * after the screen, so on resume. A run started with `confirm: true` still + * needs `confirm: true` to resume. + * 4. **The subject record, read again as the caller.** This is #16370's rule, + * with the same two shared functions: `loadActionSubjectRecord` and + * `refuseDeniedSubjectLoad`. A record the caller could read when the run + * started but cannot read now is refused `RECORD_NOT_FOUND` / 404, and + * the run is not resumed. + * 5. **A screen pause only.** `getSuspendedScreen` must return a screen. + * The verb submits screen values. A run parked on any other node (a + * timer `wait`, for example) is refused 409 and left alone. + * 6. **Resume.** The signal is built one field at a time and the input is + * never spread, as the REST door does (#3801). The engine's answer goes + * through {@link classifyResumeResult}, the table both doors share. + * + * Every refusal is thrown before `resume()` is called, so a refused call + * consumes nothing and the run stays parked. + * + * The success value is `run_action`'s envelope: `{ ok, action, objectName, + * recordId?, result }`. `result` is the engine's own answer, so a run that + * pauses on its NEXT screen comes back as `status: 'paused'` with a `runId` + * and a `screen`, and a multi-screen wizard is walked by calling this again. + */ +export async function resumeActionRun( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + runId: string, + input: { values?: Record } & AIActionConfirmation, + wiring: { + driver: any; + envId?: string; + ec: any; + getMeta: () => Promise; + callData: (action: string, params: any, dataDriver?: any, scopeId?: string, ec?: any) => Promise; + }, +): Promise { + const { driver, envId, ec, getMeta, callData } = wiring; + if (typeof runId !== 'string' || runId === '') { + throw resumeDoorError(deps, 'runId is required', 400); + } + const values = input?.values; + if (values !== undefined && (values === null || typeof values !== 'object' || Array.isArray(values))) { + throw resumeDoorError(deps, 'values must be an object that maps screen field names to values', 400); + } + + // ── 1. the service, and the three members this door reads ──────────────── + const automation: any = await actionExec.resolveAutomationService(deps, context, envId); + if ( + !automation + || typeof automation.resume !== 'function' + || typeof automation.getRun !== 'function' + || typeof automation.getSuspendedScreen !== 'function' + ) { + throw resumeDoorError( + deps, + 'Resuming a run is not supported here: the automation service must implement resume, getRun ' + + 'and getSuspendedScreen, and getRun is how this door checks who started the run.', + 501, + ); + } + + // ── 2. whose run: the caller's own, and still paused ───────────────────── + const callerId = typeof ec?.userId === 'string' && ec.userId !== '' ? ec.userId : undefined; + const run: any = await automation.getRun(runId); + const trigger = run?.trigger; + if (!run || run.status !== 'paused' || !callerId || trigger?.userId !== callerId) { + throw resumeDoorError(deps, notResumableRunMessage(runId), 404); + } + + // ── 3. the flow action that admits it, through run_action's gates ──────── + const flowName: unknown = run.flowName; + const runObject = typeof trigger?.object === 'string' && trigger.object !== '' ? trigger.object : undefined; + const meta: any = await getMeta(); + const candidates = (await actionExec.collectActionDeclarations(deps, meta)).filter(({ action, objectName }) => + action?.type === 'flow' + && !actionExec.isDeclarativeUpdateAction(action) + && typeof action?.target === 'string' + && action.target === flowName + && (runObject === undefined ? actionExec.isObjectLessActionKey(objectName) : objectName === runObject)); + const activationEngine: any = await deps.getObjectQL(context, envId).catch(() => undefined); + let admitted: { action: any; objectName: string } | undefined; + let firstRefusal: Error | undefined; + for (const candidate of candidates) { + const refusal = resumeAdmissionRefusal(deps, candidate, ec, input, activationEngine); + if (!refusal) { + admitted = candidate; + break; + } + firstRefusal ??= refusal; + } + if (!admitted) { + throw firstRefusal ?? resumeDoorError( + deps, + `Run '${runId}' is a run of flow '${String(flowName)}'${runObject ? ` on '${runObject}'` : ''}, ` + + 'and no flow action targets that flow there. resume_run only continues a run that an action ' + + 'exposed to AI could have started.', + 403, + ); + } + const { action, objectName } = admitted; + + // ── 4. the subject record, read again in the caller's own scope ────────── + const recordId = typeof trigger?.recordId === 'string' && trigger.recordId !== '' ? trigger.recordId : undefined; + const subject = await actionExec.loadActionSubjectRecord(objectName, recordId, () => + callData('get', { object: objectName, id: recordId }, driver, envId, ec)); + actionExec.refuseDeniedSubjectLoad(objectName, recordId, subject); + + // ── 5. a screen pause, and nothing else ────────────────────────────────── + const screen = await automation.getSuspendedScreen(runId); + if (!screen) { + throw resumeDoorError( + deps, + `Run '${runId}' is paused, but not on a screen. resume_run submits the values of a screen; it does ` + + 'not continue a run that is waiting on anything else.', + 409, + ); + } + + // ── 6. resume, answered through the table the REST door uses ───────────── + const signal: { variables?: Record } = {}; + if (values !== undefined) signal.variables = values; + const result = await automation.resume(runId, signal); + const refusal = await classifyResumeResult(deps, automation, runId, result); + if (refusal) throw resumeDoorError(deps, refusal.message, refusal.status, refusal.details); + return { ok: true, action: action.name, objectName, ...(recordId ? { recordId } : {}), result: result ?? null }; +} + +/** + * [#15705] `run_action`'s admission gates, in `run_action`'s order, for ONE + * candidate action. Answers the refusal, or `undefined` when the candidate + * admits the call. Each gate uses the shared helper `invokeBusinessAction` + * uses, so the reasons cannot drift apart. The system-object, exposure and + * permission refusals are thrown as plain errors there. Here they carry + * `PERMISSION_DENIED` / 403 (the code and status REST `/actions` answers for + * the permission gate), because a refusal on a new door should be + * machine-readable. + */ +function resumeAdmissionRefusal( + deps: DomainHandlerDeps, + candidate: { action: any; objectName: string }, + ec: any, + request: AIActionConfirmation, + activationEngine: any, +): Error | undefined { + const { action, objectName } = candidate; + if (isSystemObjectName(objectName)) { + return resumeDoorError(deps, `Action '${action?.name}' is on a system object and is not exposed via MCP`, 403); + } + const exposure = actionExec.actionAiExposureError(deps, action, objectName); + if (exposure) return resumeDoorError(deps, exposure, 403); + const permission = actionExec.actionPermissionError(deps, action, ec, objectName); + if (permission) return resumeDoorError(deps, permission, 403); + const disabled = actionExec.disabledActionRefusal(deps, activationEngine, action); + if (disabled) return resumeDoorError(deps, disabled.message, disabled.status, { code: disabled.code }); + const confirmation = actionExec.actionConfirmationRefusal(deps, action, request, objectName); + if (confirmation) { + return resumeDoorError(deps, confirmation.message, confirmation.status, { + code: confirmation.code, + ...confirmation.details, + }); + } + return undefined; +} From 807d3ab5006d5c53cf355720f06f649536db08af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:28:44 +0000 Subject: [PATCH 2/4] test(mcp): pin resume_run on the runtime bridge and on both MCP transports Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn --- ...p-http-tools.unknown-argument-keys.test.ts | 27 +- packages/mcp/src/mcp-resume-run-tool.test.ts | 165 ++++++ packages/mcp/src/mcp-stdio-tools.test.ts | 37 ++ packages/runtime/src/mcp-resume-run.test.ts | 486 ++++++++++++++++++ 4 files changed, 713 insertions(+), 2 deletions(-) create mode 100644 packages/mcp/src/mcp-resume-run-tool.test.ts create mode 100644 packages/runtime/src/mcp-resume-run.test.ts diff --git a/packages/mcp/src/mcp-http-tools.unknown-argument-keys.test.ts b/packages/mcp/src/mcp-http-tools.unknown-argument-keys.test.ts index ea9ba49905b..c5af4ceb459 100644 --- a/packages/mcp/src/mcp-http-tools.unknown-argument-keys.test.ts +++ b/packages/mcp/src/mcp-http-tools.unknown-argument-keys.test.ts @@ -63,6 +63,7 @@ function makeBridge(): StubBridge { async remove(object: string, id: string) { calls.push(['remove', object, id]); return { object, id, success: true }; }, async listActions() { calls.push(['listActions']); return [{ name: 'complete_task', objectName: 'crm_opportunity' }]; }, async runAction(name: string, input: any) { calls.push(['runAction', name, input]); return { ok: true }; }, + async resumeRun(runId: string, input: any) { calls.push(['resumeRun', runId, input]); return { ok: true }; }, }; } @@ -108,6 +109,7 @@ const VALID_ARGS: Record> = { delete_record: { objectName: 'crm_opportunity', recordId: 'r1' }, list_actions: {}, run_action: { actionName: 'complete_task' }, + resume_run: { runId: 'run_1' }, }; describe('MCP tool arguments — undeclared keys are refused (#16913)', () => { @@ -181,6 +183,26 @@ describe('MCP tool arguments — undeclared keys are refused (#16913)', () => { }); }); + // [#15705] The REST resume door's body key is `inputs`, so it is the first + // spelling a caller who knows that door sends. It is refused and the + // refusal names `values`; the bridge is never reached. + it('resume_run refuses the REST door\'s `inputs` key, and names `values` as the spelling to send', async () => { + const { refused, text } = await callTool(runtime, bridge, 'resume_run', { + runId: 'run_1', + inputs: { subject: 'Call back' }, + }); + expect(refused).toBe(true); + expect(text).toContain('inputs'); + expect(text).toContain('values'); + expect(bridge.calls.find((c: any[]) => c[0] === 'resumeRun')).toBeUndefined(); + + // CONTROL — the declared spelling reaches the bridge unchanged. + const ok = await callTool(runtime, bridge, 'resume_run', { runId: 'run_1', values: { subject: 'Call back' } }); + expect(ok.refused).toBe(false); + expect(bridge.calls.find((c: any[]) => c[0] === 'resumeRun')?.[2]) + .toEqual({ values: { subject: 'Call back' }, confirm: undefined }); + }); + it('an argument-less tool still accepts an empty argument object', async () => { // The control for the closure: `{}` is a valid payload for a shape that // declares nothing, and closing the shape must not turn it into a refusal. @@ -191,11 +213,12 @@ describe('MCP tool arguments — undeclared keys are refused (#16913)', () => { // ── The sweep: every advertised tool holds the same posture ──────────────── - it('advertises the eleven tools this sweep covers', async () => { + it('advertises the twelve tools this sweep covers', async () => { const json = await rpc(runtime, bridge, 'tools/list'); const names = (json.result?.tools ?? []).map((t: any) => t.name).sort(); expect(names).toEqual(Object.keys(VALID_ARGS).sort()); - expect(names).toHaveLength(11); + // [#15705] Twelve with `resume_run`, which this stub's bridge implements. + expect(names).toHaveLength(12); }); it('every advertised tool ACCEPTS its declared arguments (the sweep control)', async () => { diff --git a/packages/mcp/src/mcp-resume-run-tool.test.ts b/packages/mcp/src/mcp-resume-run-tool.test.ts new file mode 100644 index 00000000000..f18f1ad6f4c --- /dev/null +++ b/packages/mcp/src/mcp-resume-run-tool.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * MCP `resume_run`, tool half (#15705): the tool's shape on the wire. + * + * `run_action` on a screen flow answers `status: 'paused'` with a `runId` and a + * `screen`. Before this tool, nothing on the MCP surface could submit that + * screen, so the run stayed parked. `resume_run` is the verb that submits it. + * Who may resume what is the bridge's decision, pinned on the runtime bridge in + * `@objectstack/runtime` (`mcp-resume-run.test.ts`). This file pins what this + * package owns: + * + * - the tool is registered exactly where it can work: beside `run_action`, + * when the bridge implements `resumeRun`, under the `actions:execute` scope; + * - `tools/list` declares its closed input schema, and `run_action`'s + * description names it only where it is registered; + * - the call reaches the bridge with exactly `{ values, confirm }`; + * - a coded refusal keeps its ADR-0112 envelope on the way back. + * + * The unknown-key refusal is swept with every other tool in + * `mcp-http-tools.unknown-argument-keys.test.ts`, and the stdio transport's + * listing is pinned in `mcp-stdio-tools.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { MCPServerRuntime } from './mcp-server-runtime.js'; +import type { McpDataBridge, McpActionBridge } from './mcp-http-tools.js'; + +function makeBridge(resumeRun?: McpActionBridge['resumeRun']): McpDataBridge & McpActionBridge { + return { + 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 []; }, + async runAction() { return { ok: true }; }, + ...(resumeRun ? { resumeRun } : {}), + }; +} + +let nextId = 1; + +async function rpc(bridge: unknown, method: string, params?: unknown, grantedScopes?: string[]) { + const runtime = new MCPServerRuntime({ name: 't', version: '1.0.0' }); + const body = { jsonrpc: '2.0', id: nextId++, method, ...(params === undefined ? {} : { params }) }; + const res = await runtime.handleHttpRequest( + new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body), + }), + { bridge: bridge as any, parsedBody: body, ...(grantedScopes ? { toolOptions: { grantedScopes } } : {}) }, + ); + return (await res.json()) as any; +} + +async function listTools(bridge: unknown, grantedScopes?: string[]): Promise> { + const json = await rpc(bridge, 'tools/list', undefined, grantedScopes); + return Object.fromEntries((json.result?.tools ?? []).map((t: any) => [t.name, t])); +} + +describe('MCP resume_run — where it is registered', () => { + it('is registered beside run_action when the bridge implements resumeRun, and not otherwise', async () => { + const withResume = await listTools(makeBridge(vi.fn())); + expect(withResume.run_action).toBeDefined(); + expect(withResume.resume_run).toBeDefined(); + + const withoutResume = await listTools(makeBridge()); + expect(withoutResume.run_action).toBeDefined(); + expect(withoutResume.resume_run).toBeUndefined(); + }); + + it('belongs to the actions:execute family: granted with it, absent without it', async () => { + const bridge = makeBridge(vi.fn()); + expect((await listTools(bridge, ['actions:execute'])).resume_run).toBeDefined(); + const readOnly = await listTools(bridge, ['data:read']); + expect(readOnly.resume_run).toBeUndefined(); + expect(readOnly.run_action).toBeUndefined(); + }); + + it('run_action\'s description names resume_run only where resume_run is registered', async () => { + expect((await listTools(makeBridge(vi.fn()))).run_action.description).toContain('resume_run'); + expect((await listTools(makeBridge())).run_action.description).not.toContain('resume_run'); + }); +}); + +describe('MCP resume_run — its declared shape', () => { + it('declares runId (required), values and confirm, closed against anything else', async () => { + const tool = (await listTools(makeBridge(vi.fn()))).resume_run; + expect(tool.inputSchema.required).toEqual(['runId']); + expect(Object.keys(tool.inputSchema.properties).sort()).toEqual(['confirm', 'runId', 'values']); + expect(tool.inputSchema.properties.values.type).toBe('object'); + expect(tool.inputSchema.properties.confirm.type).toBe('boolean'); + expect(tool.inputSchema.additionalProperties).toBe(false); + }); + + it('carries run_action\'s annotations: the rest of the flow runs here', async () => { + const tool = (await listTools(makeBridge(vi.fn()))).resume_run; + expect(tool.annotations).toEqual({ readOnlyHint: false, destructiveHint: true, openWorldHint: true }); + }); +}); + +describe('MCP resume_run — the call', () => { + it('reaches the bridge with exactly the runId, the values and the confirmation', async () => { + const resumeRun = vi.fn(async () => ({ ok: true, result: { status: 'completed' } })); + const json = await rpc(makeBridge(resumeRun), 'tools/call', { + name: 'resume_run', + arguments: { runId: 'run_1', values: { subject: 'Call back', dueDate: '2026-10-01' }, confirm: true }, + }); + expect(json.result.isError).toBeFalsy(); + expect(resumeRun).toHaveBeenCalledTimes(1); + expect(resumeRun).toHaveBeenCalledWith('run_1', { + values: { subject: 'Call back', dueDate: '2026-10-01' }, + confirm: true, + }); + }); + + it('returns the bridge\'s answer unchanged, so a run paused on its next screen reads as paused', async () => { + const answer = { + ok: true, + action: 'followup_wizard', + objectName: 'crm_lead', + recordId: 'lead_1', + result: { success: true, status: 'paused', runId: 'run_1', screen: { nodeId: 'screen_2', fields: [] } }, + }; + const json = await rpc(makeBridge(vi.fn(async () => answer)), 'tools/call', { + name: 'resume_run', + arguments: { runId: 'run_1', values: { subject: 'x' } }, + }); + expect(JSON.parse(json.result.content[0].text)).toEqual(answer); + }); + + it('refuses an empty runId before the bridge is reached', async () => { + const resumeRun = vi.fn(); + const json = await rpc(makeBridge(resumeRun), 'tools/call', { name: 'resume_run', arguments: { runId: '' } }); + expect(json.result.isError).toBe(true); + expect(json.result.content[0].text).toMatch(/runId is required/); + expect(resumeRun).not.toHaveBeenCalled(); + }); + + it('keeps a coded refusal\'s ADR-0112 envelope — code, status and details — as a tool error', async () => { + const refusal = Object.assign(new Error("Action 'close_lead' declares ai.requiresConfirmation: true"), { + code: 'ACTION_CONFIRMATION_REQUIRED', + status: 428, + details: { actionName: 'close_lead', confirmationMember: 'confirm' }, + }); + const json = await rpc(makeBridge(vi.fn(async () => { throw refusal; })), 'tools/call', { + name: 'resume_run', + arguments: { runId: 'run_1', values: {} }, + }); + expect(json.result.isError).toBe(true); + expect(JSON.parse(json.result.content[0].text)).toEqual({ + error: { + code: 'ACTION_CONFIRMATION_REQUIRED', + message: "Action 'close_lead' declares ai.requiresConfirmation: true", + status: 428, + details: { actionName: 'close_lead', confirmationMember: 'confirm' }, + }, + }); + }); +}); diff --git a/packages/mcp/src/mcp-stdio-tools.test.ts b/packages/mcp/src/mcp-stdio-tools.test.ts index 60ae4ac3a20..251a93c3f3c 100644 --- a/packages/mcp/src/mcp-stdio-tools.test.ts +++ b/packages/mcp/src/mcp-stdio-tools.test.ts @@ -249,6 +249,43 @@ describe('#8034 stdio transport: tools/list', () => { expect(names).toContain('list_actions'); expect(names).toContain('run_action'); + // CONTROL for the case below: a bridge with no `resumeRun` gets no + // `resume_run`, so the listing there is caused by the member. + expect(names).not.toContain('resume_run'); + }); + + // [#15705] `resume_run` exists wherever `run_action` does: both come from + // `wireBridgeTools`, so the long-lived server lists it, with its closed input + // schema, and a `tools/call` over the pipe reaches the bridge. + it('registers resume_run beside run_action, and a stdio tools/call reaches the bridge', async () => { + const resumeRun = vi.fn(async (runId: string) => ({ ok: true, result: { status: 'completed', runId } })); + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + runtime.bridgeDataTools({ + ...makeBridge(), + async listActions() { + return [{ name: 'complete_task', objectName: 'task' }]; + }, + async runAction() { + return { ok: true }; + }, + resumeRun, + }); + + const session = await connect(runtime); + await handshake(session); + const listed = await session.rpc('tools/list'); + const tool = (listed.result.tools as Array<{ name: string; inputSchema: any }>).find((t) => t.name === 'resume_run'); + expect(tool).toBeDefined(); + expect(tool!.inputSchema.required).toEqual(['runId']); + expect(Object.keys(tool!.inputSchema.properties).sort()).toEqual(['confirm', 'runId', 'values']); + expect(tool!.inputSchema.additionalProperties).toBe(false); + + const called = await session.rpc('tools/call', { + name: 'resume_run', + arguments: { runId: 'run_7', values: { subject: 'Call back' } }, + }); + expect(called.result.isError).toBeFalsy(); + expect(resumeRun).toHaveBeenCalledWith('run_7', { values: { subject: 'Call back' }, confirm: undefined }); }); it('serves NO tools and advertises none when no bridge was given', async () => { diff --git a/packages/runtime/src/mcp-resume-run.test.ts b/packages/runtime/src/mcp-resume-run.test.ts new file mode 100644 index 00000000000..2567e07b37b --- /dev/null +++ b/packages/runtime/src/mcp-resume-run.test.ts @@ -0,0 +1,486 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * MCP `resume_run`, runtime half (#15705): the bridge member that continues a + * paused screen run, and the admission it applies. + * + * The maintainer's ruling on #15705 asked for a resume verb that passes the + * SAME authorization and caller-scope checks as `run_action`, with #16370's + * record-scope refusal named as the rule it must not get around. This file + * drives the real bridge (`buildMcpBridge`, through `HttpDispatcher`) against + * a stateful automation double. The double keeps paused runs, their trigger + * identity and their screen, as the real engine does, and records a side + * effect when a run completes. So every pin reads what the bridge made the + * engine do, not only what it answered. + * + * The double stands in for the engine's own resume rules (the screen field + * contract, `resumeAuthority`, the claim). Those are pinned on the real engine + * in `@objectstack/service-automation`. What is pinned here is the door: + * + * (a) a screen flow started by `run_action` WITHOUT its inputs pauses, and + * `resumeRun` with the values completes it. The side effect lands once. + * A two-screen wizard is walked by resuming twice. + * (b) a caller who may not resume is refused BEFORE the engine is asked: + * another user's run, a record the caller can no longer read, a missing + * capability, an action that is not AI-exposed, a run no flow action + * could have started, a disabled action, and a confirmation-gated action + * without `confirm`. Each refusal is asserted as `code` + `status`, and + * the run is shown to be still parked afterwards. + * (c) an unknown `runId` is refused 404, with the SAME envelope as another + * user's run: the door does not tell the two apart. + * (d) the engine's own answers reach the caller with the code and status the + * REST resume door gives for the same engine result, compared door + * against door for every row of the shared table. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from './http-dispatcher.js'; + +const OBJECT = 'crm_lead'; + +const SCREEN_1 = { + nodeId: 'screen_1', + title: 'Schedule Follow-up', + fields: [ + { name: 'subject', label: 'Subject', type: 'text', required: true }, + { name: 'dueDate', label: 'Due date', type: 'date', required: false }, + ], +}; +const SCREEN_2 = { + nodeId: 'screen_2', + title: 'Confirm', + fields: [{ name: 'priority', label: 'Priority', type: 'text', required: true }], +}; + +/** The card's specimen: an AI-exposed flow action whose flow opens on a screen. */ +const SCHEDULE = { + name: 'schedule_followup', + label: 'Schedule Follow-up', + objectName: OBJECT, + type: 'flow', + target: 'schedule_followup', + locations: ['record_header'], + ai: { exposed: true, description: 'Schedule a follow-up task for this lead.' }, +}; +/** Two screens in a row, so a resume can pause again. */ +const WIZARD = { + name: 'followup_wizard', + label: 'Follow-up Wizard', + objectName: OBJECT, + type: 'flow', + target: 'followup_wizard', + locations: ['record_header'], + ai: { exposed: true, description: 'Walk through a two-step follow-up wizard.' }, +}; +/** Gated on a capability the default caller does not hold. */ +const GATED = { + name: 'escalate_lead', + label: 'Escalate', + objectName: OBJECT, + type: 'flow', + target: 'escalate_lead', + requiredPermissions: ['crm.escalate'], + ai: { exposed: true, description: 'Escalate this lead to a manager.' }, +}; +/** The author asked for a human's confirmation. */ +const CONFIRMED = { + name: 'close_lead', + label: 'Close Lead', + objectName: OBJECT, + type: 'flow', + target: 'close_lead', + ai: { exposed: true, requiresConfirmation: true, description: 'Close this lead for good.' }, +}; +/** A console-only flow action: not exposed to AI. */ +const CONSOLE_ONLY = { + name: 'merge_lead', + label: 'Merge', + objectName: OBJECT, + type: 'flow', + target: 'merge_lead', +}; + +const FLOW_SCREENS: Record = { + schedule_followup: [SCREEN_1], + followup_wizard: [SCREEN_1, SCREEN_2], + escalate_lead: [SCREEN_1], + close_lead: [SCREEN_1], + merge_lead: [SCREEN_1], + orphan_flow: [SCREEN_1], +}; + +interface Run { + runId: string; + flowName: string; + status: 'paused' | 'completed'; + userId?: string; + object?: string; + recordId?: string; + /** The screens still to answer; `null` for a pause on a non-screen node. */ + screens: unknown[] | null; +} + +/** + * The automation double. `execute` pauses on the flow's first screen, as the + * real engine does for a caller that did not answer it. `resume` checks the + * screen's required fields (refusing with the engine's `INVALID_SCREEN_INPUT` + * and leaving the run parked), then moves to the next screen or completes and + * records the task the flow would create. + */ +function makeEngine() { + const runs = new Map(); + const tasks: Array> = []; + let n = 0; + const engine = { + runs, + tasks, + getFlow: vi.fn(async (name: string) => (FLOW_SCREENS[name] ? { name } : null)), + execute: vi.fn(async (flowName: string, ctx: any) => { + const runId = `run_${++n}`; + runs.set(runId, { + runId, + flowName, + status: 'paused', + userId: ctx?.userId, + object: ctx?.object, + recordId: ctx?.record?.id, + screens: [...(FLOW_SCREENS[flowName] ?? [])], + }); + return { success: true, status: 'paused', runId, durationMs: 1, screen: FLOW_SCREENS[flowName]?.[0] }; + }), + getRun: vi.fn(async (runId: string) => { + const run = runs.get(runId); + if (!run) return null; + return { + id: run.runId, + flowName: run.flowName, + status: run.status, + startedAt: '2026-09-24T00:00:00.000Z', + trigger: { type: 'manual', userId: run.userId, object: run.object, recordId: run.recordId }, + steps: [], + }; + }), + getSuspendedScreen: vi.fn(async (runId: string) => { + const run = runs.get(runId); + return run?.status === 'paused' && run.screens ? (run.screens[0] as any) ?? null : null; + }), + resume: vi.fn(async (runId: string, signal: any): Promise => { + const run = runs.get(runId); + if (!run || run.status !== 'paused') { + return { success: false, code: 'RUN_NOT_FOUND', error: `No suspended run '${runId}'` }; + } + const screen: any = run.screens?.[0]; + const values = signal?.variables ?? {}; + const missing = (screen?.fields ?? []).filter((f: any) => f.required && values[f.name] === undefined); + if (missing.length > 0) { + return { + success: false, + code: 'INVALID_SCREEN_INPUT', + error: `Invalid screen input: required field '${missing[0].name}' is missing`, + }; + } + run.screens = run.screens?.slice(1) ?? null; + if (run.screens && run.screens.length > 0) { + return { success: true, status: 'paused', runId, durationMs: 1, screen: run.screens[0] }; + } + run.status = 'completed'; + tasks.push({ flow: run.flowName, lead: run.recordId, by: run.userId, values }); + return { success: true, status: 'completed', output: { taskCreated: true }, durationMs: 2 }; + }), + }; + return engine; +} + +type Engine = ReturnType; + +/** Lead rows with an owner; the data double answers a caller only its own rows (RLS). */ +function makeHarness(opts: { actions?: any[]; engine?: Engine } = {}) { + const engine = opts.engine ?? makeEngine(); + const leads: Array<{ id: string; owner: string; name: string }> = [ + { id: 'lead_1', owner: 'u1', name: 'Ada' }, + { id: 'lead_2', owner: 'u2', name: 'Grace' }, + ]; + let disabled = new Set(); + const object = { + name: OBJECT, + label: 'Lead', + fields: {}, + actions: opts.actions ?? [SCHEDULE, WIZARD, GATED, CONFIRMED, CONSOLE_ONLY], + }; + const ql: any = { + executeAction: vi.fn(), + registry: { getObject: () => object }, + // Row-level security, as the data plane applies it: a caller reads only + // the leads it owns, and a row it cannot read is simply not there. + find: vi.fn(async (_o: string, q: any) => { + const caller = q?.context?.userId; + return leads.filter((l) => l.owner === caller && (q?.where?.id === undefined || l.id === q.where.id)); + }), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + isActionEnabled: (name: string) => !disabled.has(name), + }; + const metadata: any = { + listObjects: vi.fn(async () => [object]), + getObject: vi.fn(async () => object), + }; + const services: Record = { objectql: ql, data: ql, metadata, automation: engine }; + const resolve = (name: string) => services[name] ?? null; + const kernel: any = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { getService: resolve }, + }; + const dispatcher = new HttpDispatcher(kernel); + const ctxFor = (userId: string, systemPermissions: string[] = []): any => ({ + request: {}, + environmentId: 'platform', + executionContext: { userId, systemPermissions }, + }); + return { + engine, + leads, + dispatcher, + ctxFor, + disable: (name: string) => { disabled = new Set([...disabled, name]); }, + bridgeFor: (userId: string, systemPermissions: string[] = []) => + (dispatcher as any).buildMcpBridge(ctxFor(userId, systemPermissions)), + }; +} + +/** What a refusal carries: the ADR-0112 fields the MCP tool layer renders. */ +async function refusalOf(p: Promise): Promise<{ code: unknown; status: unknown; message: string; details?: any }> { + try { + await p; + } catch (err: any) { + return { code: err?.code, status: err?.status, message: String(err?.message), details: err?.details }; + } + throw new Error('expected the call to be refused, and it resolved'); +} + +/** Start the card's specimen through `run_action`, WITHOUT the screen's inputs. */ +async function startPaused(h: ReturnType, action = SCHEDULE.name, userId = 'u1', extra: any = {}) { + const started = await h.bridgeFor(userId, extra.systemPermissions).runAction(action, { + recordId: extra.recordId ?? 'lead_1', + ...(extra.confirm ? { confirm: true } : {}), + }); + expect(started.result.status).toBe('paused'); + expect(started.result.screen).toBeDefined(); + return started.result.runId as string; +} + +describe('MCP resumeRun — (a) a paused screen flow is completed', () => { + it('run_action without the inputs pauses; resumeRun with the values completes it and the task lands once', async () => { + const h = makeHarness(); + const runId = await startPaused(h); + expect(h.engine.tasks).toHaveLength(0); + + const values = { subject: 'Call back', dueDate: '2026-10-01' }; + const resumed = await h.bridgeFor('u1').resumeRun(runId, { values }); + + // run_action's envelope, with the engine's own answer inside it. + expect(resumed).toEqual({ + ok: true, + action: 'schedule_followup', + objectName: OBJECT, + recordId: 'lead_1', + result: { success: true, status: 'completed', output: { taskCreated: true }, durationMs: 2 }, + }); + // The values reached the engine as the screen's variables, built one + // field at a time: nothing else rides on the signal. + expect(h.engine.resume).toHaveBeenCalledTimes(1); + expect(h.engine.resume).toHaveBeenCalledWith(runId, { variables: values }); + // The side effect: the flow's task, for this lead, as this caller. + expect(h.engine.tasks).toEqual([{ flow: 'schedule_followup', lead: 'lead_1', by: 'u1', values }]); + }); + + it('a two-screen wizard pauses again on its next screen, and a second resumeRun finishes it', async () => { + const h = makeHarness(); + const runId = await startPaused(h, WIZARD.name); + + const first = await h.bridgeFor('u1').resumeRun(runId, { values: { subject: 'Step one' } }); + expect(first.result).toMatchObject({ status: 'paused', runId, screen: { nodeId: 'screen_2' } }); + expect(h.engine.tasks).toHaveLength(0); + + const second = await h.bridgeFor('u1').resumeRun(runId, { values: { priority: 'high' } }); + expect(second.result.status).toBe('completed'); + expect(h.engine.tasks).toHaveLength(1); + }); + + it('CONTROL — a submission missing a required field is the engine\'s 400, and the run stays parked', async () => { + const h = makeHarness(); + const runId = await startPaused(h); + + const refused = await refusalOf(h.bridgeFor('u1').resumeRun(runId, { values: { dueDate: '2026-10-01' } })); + expect(refused).toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); + expect(refused.message).toMatch(/required field 'subject'/); + // Retry with the field: it completes. + await h.bridgeFor('u1').resumeRun(runId, { values: { subject: 'Now complete' } }); + expect(h.engine.tasks).toHaveLength(1); + }); +}); + +describe('MCP resumeRun — (b) a caller who may not resume is refused before the engine is asked', () => { + it('ANOTHER user\'s run: 404, even for a caller who can read the record — and the run stays parked', async () => { + const h = makeHarness(); + const runId = await startPaused(h); + // u2 can read lead_1 too, so only the ownership check is being tested. + h.leads[0].owner = 'u2'; + const stranger = await refusalOf(h.bridgeFor('u2').resumeRun(runId, { values: { subject: 'hijack' } })); + expect(stranger).toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404 }); + expect(h.engine.resume).not.toHaveBeenCalled(); + expect(h.engine.tasks).toHaveLength(0); + + // Nothing was consumed: the run's own starter can still finish it. + h.leads[0].owner = 'u1'; + await h.bridgeFor('u1').resumeRun(runId, { values: { subject: 'mine' } }); + expect(h.engine.tasks).toEqual([expect.objectContaining({ by: 'u1' })]); + }); + + it('a record the caller can no longer read: RECORD_NOT_FOUND / 404, run_action\'s own refusal', async () => { + const h = makeHarness(); + const runId = await startPaused(h); + // The lead is reassigned after the run started; RLS now hides it from u1. + h.leads[0].owner = 'u3'; + const refused = await refusalOf(h.bridgeFor('u1').resumeRun(runId, { values: { subject: 'x' } })); + expect(refused).toMatchObject({ code: 'RECORD_NOT_FOUND', status: 404 }); + expect(h.engine.resume).not.toHaveBeenCalled(); + + // The same answer run_action gives for that record, from the same caller. + const viaRunAction = await refusalOf(h.bridgeFor('u1').runAction(SCHEDULE.name, { recordId: 'lead_1' })); + expect(viaRunAction).toMatchObject({ code: refused.code, status: refused.status, message: refused.message }); + }); + + it('a capability the caller does not hold: PERMISSION_DENIED / 403, with run_action\'s reason', async () => { + const h = makeHarness(); + const runId = await startPaused(h, GATED.name, 'u1', { systemPermissions: ['crm.escalate'] }); + const refused = await refusalOf(h.bridgeFor('u1').resumeRun(runId, { values: { subject: 'x' } })); + expect(refused).toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(refused.message).toMatch(/requires capability \[crm\.escalate\]/); + expect(h.engine.resume).not.toHaveBeenCalled(); + + // CONTROL — the same caller holding the capability resumes it. + await h.bridgeFor('u1', ['crm.escalate']).resumeRun(runId, { values: { subject: 'x' } }); + expect(h.engine.tasks).toHaveLength(1); + }); + + it('a run whose only action is not exposed to AI: PERMISSION_DENIED / 403, even though it is the caller\'s own', async () => { + const h = makeHarness(); + // Started from the console (the REST trigger door would do the same): + // the caller's own run, on a flow only a console-only action targets. + const started: any = await h.engine.execute('merge_lead', { userId: 'u1', object: OBJECT, record: { id: 'lead_1' } }); + const refused = await refusalOf(h.bridgeFor('u1').resumeRun(started.runId, { values: { subject: 'x' } })); + expect(refused).toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(refused.message).toMatch(/is not exposed to AI/); + expect(h.engine.resume).not.toHaveBeenCalled(); + }); + + it('a run no flow action could have started: PERMISSION_DENIED / 403', async () => { + const h = makeHarness(); + const started: any = await h.engine.execute('orphan_flow', { userId: 'u1', object: OBJECT, record: { id: 'lead_1' } }); + const refused = await refusalOf(h.bridgeFor('u1').resumeRun(started.runId, { values: { subject: 'x' } })); + expect(refused).toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(refused.message).toMatch(/no flow action targets that flow/); + expect(h.engine.resume).not.toHaveBeenCalled(); + }); + + it('an action switched off since the run started: ACTION_DISABLED / 409', async () => { + const h = makeHarness(); + const runId = await startPaused(h); + h.disable(SCHEDULE.name); + const refused = await refusalOf(h.bridgeFor('u1').resumeRun(runId, { values: { subject: 'x' } })); + expect(refused).toMatchObject({ code: 'ACTION_DISABLED', status: 409 }); + expect(h.engine.resume).not.toHaveBeenCalled(); + }); + + it('a confirmation-gated action without confirm: ACTION_CONFIRMATION_REQUIRED / 428; with it, the run completes', async () => { + const h = makeHarness(); + const runId = await startPaused(h, CONFIRMED.name, 'u1', { confirm: true }); + const refused = await refusalOf(h.bridgeFor('u1').resumeRun(runId, { values: { subject: 'x' } })); + expect(refused).toMatchObject({ code: 'ACTION_CONFIRMATION_REQUIRED', status: 428 }); + expect(refused.details).toEqual({ actionName: 'close_lead', objectName: OBJECT, confirmationMember: 'confirm' }); + expect(h.engine.resume).not.toHaveBeenCalled(); + + await h.bridgeFor('u1').resumeRun(runId, { values: { subject: 'x' }, confirm: true }); + expect(h.engine.tasks).toHaveLength(1); + }); + + it('a run paused on something other than a screen: 409, and the engine is not asked', async () => { + const h = makeHarness(); + const runId = await startPaused(h); + h.engine.runs.get(runId)!.screens = null; // now waiting on, say, a timer `wait` + const refused = await refusalOf(h.bridgeFor('u1').resumeRun(runId, {})); + expect(refused).toMatchObject({ code: 'RESOURCE_CONFLICT', status: 409 }); + expect(h.engine.resume).not.toHaveBeenCalled(); + }); +}); + +describe('MCP resumeRun — (c) an unknown runId', () => { + it('is refused 404, with exactly the envelope another user\'s run gets', async () => { + const h = makeHarness(); + const runId = await startPaused(h); + + const unknown = await refusalOf(h.bridgeFor('u2').resumeRun('run_nope', { values: { subject: 'x' } })); + const foreign = await refusalOf(h.bridgeFor('u2').resumeRun(runId, { values: { subject: 'x' } })); + expect(unknown).toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404 }); + // The id is the only difference, so a caller cannot tell a run it did + // not start from a run that does not exist. + expect({ ...foreign, message: foreign.message.replace(runId, 'ID') }) + .toEqual({ ...unknown, message: unknown.message.replace('run_nope', 'ID') }); + + // A FINISHED run of the caller's own answers the same way. + await h.bridgeFor('u1').resumeRun(runId, { values: { subject: 'done' } }); + const finished = await refusalOf(h.bridgeFor('u1').resumeRun(runId, { values: { subject: 'again' } })); + expect({ ...finished, message: finished.message.replace(runId, 'ID') }) + .toEqual({ ...unknown, message: unknown.message.replace('run_nope', 'ID') }); + expect(h.engine.resume).toHaveBeenCalledTimes(1); + }); + + it('an automation service that cannot say who started a run is refused 501, fail-closed', async () => { + const engine = makeEngine(); + delete (engine as any).getRun; + const h = makeHarness({ engine }); + const runId = await startPaused(h); + const refused = await refusalOf(h.bridgeFor('u1').resumeRun(runId, { values: { subject: 'x' } })); + expect(refused).toMatchObject({ code: 'NOT_IMPLEMENTED', status: 501 }); + expect(engine.resume).not.toHaveBeenCalled(); + }); +}); + +describe('MCP resumeRun — (d) the engine\'s answers match the REST resume door, door against door', () => { + const ROWS: Array<[string, any]> = [ + ['PERMISSION_DENIED', { success: false, code: 'PERMISSION_DENIED', error: 'only its owning service may resume' }], + ['INVALID_SIGNAL', { success: false, code: 'INVALID_SIGNAL', error: 'reserved by the flow engine' }], + ['INVALID_SCREEN_INPUT', { success: false, code: 'INVALID_SCREEN_INPUT', error: 'Unknown screen field "nickname"' }], + ['RUN_NOT_FOUND', { success: false, code: 'RUN_NOT_FOUND', error: "Suspended node 'collect' no longer exists" }], + ['STORE_UNAVAILABLE', { success: false, code: 'STORE_UNAVAILABLE' }], + ['RESUME_IN_PROGRESS', { success: false, code: 'RESUME_IN_PROGRESS', error: 'Run is already being resumed' }], + ['FLOW_FAILED (stranded)', { + success: false, error: 'tail blew up', status: 'stranded', + errorMessage: 'Please contact support', summary: { nodes: [] }, + }], + ['FLOW_FAILED (plain)', { success: false, error: 'node blew up' }], + ]; + + for (const [label, engineResult] of ROWS) { + it(`${label}: same code, status, message and details on both doors`, async () => { + const h = makeHarness(); + const runId = await startPaused(h); + h.engine.resume.mockResolvedValue(engineResult); + + const mcp = await refusalOf(h.bridgeFor('u1').resumeRun(runId, { values: { subject: 'x' } })); + const rest = await h.dispatcher.handleAutomation( + `schedule_followup/runs/${runId}/resume`, 'POST', { inputs: { subject: 'x' } }, h.ctxFor('u1'), + ); + expect(rest.response?.status).toBeGreaterThanOrEqual(400); + expect(mcp).toEqual({ + code: rest.response?.body?.error?.code, + status: rest.response?.status, + message: rest.response?.body?.error?.message, + details: rest.response?.body?.error?.details, + }); + }); + } +}); From cd9a9387c452a2b8130f5c7640d0f6b89e049389 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:37:27 +0000 Subject: [PATCH 3/4] docs(mcp): document resume_run on the MCP pages, the package README and a changeset Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn --- .changeset/15705-mcp-resume-run.md | 21 +++++++++++ content/docs/ai/actions-as-tools.mdx | 54 ++++++++++++++++++++++++++-- content/docs/ai/connect-mcp.mdx | 5 +-- packages/mcp/README.md | 15 +++++--- 4 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 .changeset/15705-mcp-resume-run.md diff --git a/.changeset/15705-mcp-resume-run.md b/.changeset/15705-mcp-resume-run.md new file mode 100644 index 00000000000..2f830232e8f --- /dev/null +++ b/.changeset/15705-mcp-resume-run.md @@ -0,0 +1,21 @@ +--- +'@objectstack/mcp': minor +'@objectstack/runtime': minor +--- + +feat(mcp): `resume_run` continues a flow run that paused on a screen, behind the same gates as `run_action` (#15705) + +Clause-②: yes + +**What changed.** `run_action` on a flow action whose flow stops on a `screen` node answers `status: "paused"` with a `runId` and the `screen` to fill in. Until now nothing on the MCP surface could submit that screen, so the run stayed parked: an agent could start such an action but never finish it. The new MCP tool `resume_run({ runId, values?, confirm? })` submits the screen's field values (keyed by the names in `screen.fields`) and the run continues. It answers with `run_action`'s envelope, `{ ok, action, objectName, recordId?, result }`. A run that pauses on its next screen comes back paused again, so a multi-screen wizard is walked by calling `resume_run` once per screen. + +**Which runs it continues, and no others.** The runtime's bridge admits a call only where `run_action` would admit starting the same flow on the same record for this caller now: + +- **Only the caller's own run.** The run's trigger identity must be the caller. Another user's run, an unknown id and a finished run all answer the same `404 RESOURCE_NOT_FOUND`. A resumed run continues under the identity of the user who started it, so without this check one user could continue another user's run as that user. +- **`run_action`'s gates, with `run_action`'s helpers.** A `type: 'flow'` action whose `target` is the run's flow, on the run's object, must be AI-exposed (`ai.exposed`), must pass the caller's `requiredPermissions` and must not be switched off (`ACTION_DISABLED`, `409`). An action flagged `ai.requiresConfirmation` needs `confirm: true` on the resume too (`ACTION_CONFIRMATION_REQUIRED`, `428`), because the flow's writes happen after the screen. The exposure and permission refusals answer `403 PERMISSION_DENIED`. So does a run that no flow action targets. +- **The subject record is read again as the caller.** A record the caller can no longer read is refused `404 RECORD_NOT_FOUND`, which is how `run_action` refuses it. +- **Screen pauses only.** A run waiting on anything else (a timer `wait`, an approval) is refused `409 RESOURCE_CONFLICT` and left as it is. + +Every refusal happens before the engine is asked, so the run stays parked. The engine's own answers (a screen submission missing a required field, a concurrent resume, a run that resumed and then failed) reach the caller with the code, status, message and `details` that `POST /api/v1/automation/:name/runs/:runId/resume` gives for the same result. The two doors now share one classification, `classifyResumeResult` in `@objectstack/runtime`. It was moved out of the REST route unchanged, and the route's answers are byte-identical. + +**For hosts.** `McpActionBridge` gains an OPTIONAL member, `resumeRun(runId, { values?, confirm? })`. A bridge that implements it gets `resume_run` beside `run_action`, under the same `actions:execute` OAuth scope, on both the HTTP and the stdio transport. A bridge without it is unchanged and does not list the tool. `run_action`'s description names `resume_run` only where it is registered. `@objectstack/runtime`'s MCP bridge implements the member. diff --git a/content/docs/ai/actions-as-tools.mdx b/content/docs/ai/actions-as-tools.mdx index 8b937dbc974..b5790de7c9c 100644 --- a/content/docs/ai/actions-as-tools.mdx +++ b/content/docs/ai/actions-as-tools.mdx @@ -11,8 +11,8 @@ Any business `Action` you already have — a `script` action or a Flow — can b reached by an LLM as a callable tool. On the **open edition** this happens through [`@objectstack/mcp`](/docs/ai): your own AI (Claude, Cursor, any MCP client, or a local model) connects over the Model Context Protocol, and the -server exposes two business-action tools — `list_actions` and `run_action` — -bound to the caller's principal. The agent invokes actions the same way the +server exposes three business-action tools — `list_actions`, `run_action` and +`resume_run` — bound to the caller's principal. The agent invokes actions the same way the Console toolbar does — but only actions the author explicitly exposed to AI, and only ones the caller is permitted to run. No cloud service and no ObjectOS runtime are required. @@ -50,6 +50,7 @@ bound to the caller's principal (the API key acts as the user): |:---|:---| | `list_actions` | Enumerates the business actions that are **AI-exposed** (`ai.exposed: true`) **and** the caller is permitted to run — name, target object, description, whether it needs a `recordId`, whether it is destructive, and its declared params. | | `run_action` | Invokes an action by name with `{ recordId, params }`. Invocation is gated (author opt-in + capabilities); the action body then runs the app's registered logic as trusted code. | +| `resume_run` | Continues a flow run that `run_action` answered with `status: "paused"` and a `screen`, by submitting that screen's field values as `{ runId, values }`. Only the user who started the run can resume it, behind the same gates as `run_action`. See [Completing a paused screen flow](#completing-a-paused-screen-flow). | `run_action` resolves the action and dispatches it through the framework's own action mechanism — `IDataEngine.executeAction` for `script` / inline-`body` @@ -161,6 +162,53 @@ A BYO-AI client invokes `run_action` the same way it calls any MCP tool: // → invoke-gated as the caller; case_triage is a flow, so it honours runAs. Returns the flow result. ``` +### Completing a paused screen flow + +A flow action whose flow opens on a `screen` node stops there when the call did +not already answer the screen. `run_action` then answers with the run paused and +the form it is waiting for: + +```jsonc +// tools/call → run_action +{ "actionName": "schedule_followup", "recordId": "lead_42" } +// → { "ok": true, "action": "schedule_followup", "objectName": "crm_lead", "recordId": "lead_42", +// "result": { "success": true, "status": "paused", "runId": "run_…", +// "screen": { "nodeId": "screen_1", "fields": [ { "name": "subject", "required": true }, … ] } } } +``` + +`resume_run` submits the screen's values, keyed by the names in `screen.fields`, +and the run continues from that screen: + +```jsonc +// tools/call → resume_run +{ "runId": "run_…", "values": { "subject": "Call back", "dueDate": "2026-10-01" } } +// → the same envelope as run_action: the run completed, or it paused again on its +// next screen, which you answer with another resume_run. +``` + +The call is admitted only where `run_action` would admit starting the same flow +on the same record for this caller: + +- **Only your own run.** The user whose call started the run is the only one + who can resume it. Another user's run id, a finished run and an unknown id all + answer the same `404`, so a run id says nothing about runs you did not start. +- **The same gates as `run_action`.** A flow action exposed to AI + (`ai.exposed`) must target the run's flow on the run's object, and the caller + must pass its `requiredPermissions` and its activation switch. An action + flagged `ai.requiresConfirmation` needs `confirm: true` on the resume as well, + because the flow's writes happen after the screen. +- **The record is read again as you.** A subject record you can no longer read + is refused `RECORD_NOT_FOUND` (`404`), as `run_action` refuses it. +- **Screens only.** A run waiting on anything other than a screen (a timer + `wait`, an approval) is refused `409` and left as it is. + +A submission that does not satisfy the screen (a required field missing, a +field the screen does not declare) is refused `400` and the run stays paused, +so you can correct the values and call `resume_run` again. Every refusal leaves +the run parked. The engine's answers carry the same code and status the REST +resume route (`POST /api/v1/automation/:name/runs/:runId/resume`) gives for the +same result. + ## Human-in-the-loop approval Destructive actions are too risky to let an LLM execute unattended, but locking @@ -212,6 +260,8 @@ On the open MCP path the action gate works like this: `run_action` refuses anything the user cannot invoke. 4. The subject record (for record-context actions) is loaded under the caller's RLS, so an action over a record the user cannot see reads as not-found. + `resume_run` applies steps 2–4 again before a paused run continues, and only + the user who started the run can resume it. 5. The action body then executes with the app's full data authority (flows honour `runAs`), and the dispatch is audit-logged against the real user. diff --git a/content/docs/ai/connect-mcp.mdx b/content/docs/ai/connect-mcp.mdx index cf4326eb0ce..de6e0b65886 100644 --- a/content/docs/ai/connect-mcp.mdx +++ b/content/docs/ai/connect-mcp.mdx @@ -170,7 +170,7 @@ decision. ## What the agent gets -Eleven tools, generated from your metadata: +Twelve tools, generated from your metadata: | Tool | What it does | |:---|:---| @@ -180,6 +180,7 @@ Eleven tools, generated from your metadata: | `aggregate_records` | Grouped aggregation (registered when the active driver supports it) | | `create_record` / `update_record` / `delete_record` | Write data | | `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 | The tools are a fixed ~10-tool **spine** with the object name as a *parameter* (`query_records(objectName, …)`), not one tool per object — so the list stays @@ -296,7 +297,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 eleven tools, list_objects … run_action +# → the twelve 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 }"}]}} diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 925b1281833..eb37d94b03e 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -122,14 +122,16 @@ apply): // Business actions — operate the app, not just its rows 'list_actions' // Invokable business actions the caller may run 'run_action' // Invoke an action by name with { recordId, params } +'resume_run' // Continue a run paused on a screen with { runId, values } ``` -`aggregate_records` is registered only when the bridge implements `aggregate`; -a bridge without that seam serves the rest and advertises nothing it cannot do. +`aggregate_records` is registered only when the bridge implements `aggregate`, +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 -`actions:execute` covers `list_actions` / `run_action`. A tool outside the grant +`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. @@ -141,7 +143,10 @@ listed, declared `requiredPermissions` (ADR-0066 D4) are enforced, and action by name and dispatches it through the framework's own action mechanism (`engine.executeAction` / automation flow runner), so a BYO-AI MCP client (Claude Code, Cursor, …) can trigger real business logic — e.g. "complete this -task", "convert this lead". +task", "convert this lead". When a flow action stops on a screen, `run_action` +answers `status: 'paused'` with a `runId` and the `screen`, and `resume_run` +submits that screen's values to continue the run. The runtime's bridge admits +it only for the caller's own run, behind the same gates as `run_action`. > **Security model (#2849):** gating happens at *invoke* time (`ai.exposed` + > capability gate + record-context loads under the caller's RLS). Once invoked, @@ -252,7 +257,7 @@ await runtime.start(); `grantedScopes`. `McpDataBridge` is the data seam (`listObjects`, `describeObject`, `query`, `get`, `create`, `update`, `remove`, and the optional `aggregate` / `listObjectsDiagnosed`); `McpActionBridge` adds `listActions` and -`runAction`; `McpSkillBridge` is a single `listSkills`. +`runAction` (plus the optional `resumeRun`); `McpSkillBridge` is a single `listSkills`. Also exported for hosts that render the skill surface themselves: `renderSkillMarkdown`, `listSkillPrompts`, `projectSkillPrompt`, From 0b6b2d782f7465679ddf8e634c895a1b74c2ec20 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:37:48 +0000 Subject: [PATCH 4/4] docs(changeset): state the resume_run admission without naming internals Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn --- .changeset/15705-mcp-resume-run.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/15705-mcp-resume-run.md b/.changeset/15705-mcp-resume-run.md index 2f830232e8f..c9fb13218e7 100644 --- a/.changeset/15705-mcp-resume-run.md +++ b/.changeset/15705-mcp-resume-run.md @@ -11,11 +11,11 @@ Clause-②: yes **Which runs it continues, and no others.** The runtime's bridge admits a call only where `run_action` would admit starting the same flow on the same record for this caller now: -- **Only the caller's own run.** The run's trigger identity must be the caller. Another user's run, an unknown id and a finished run all answer the same `404 RESOURCE_NOT_FOUND`. A resumed run continues under the identity of the user who started it, so without this check one user could continue another user's run as that user. +- **Only the caller's own run.** The run's trigger identity must be the caller. Another user's run, an unknown id and a finished run all answer the same `404 RESOURCE_NOT_FOUND`. A resumed run continues under the identity of the user who started it, so only that user may continue it. - **`run_action`'s gates, with `run_action`'s helpers.** A `type: 'flow'` action whose `target` is the run's flow, on the run's object, must be AI-exposed (`ai.exposed`), must pass the caller's `requiredPermissions` and must not be switched off (`ACTION_DISABLED`, `409`). An action flagged `ai.requiresConfirmation` needs `confirm: true` on the resume too (`ACTION_CONFIRMATION_REQUIRED`, `428`), because the flow's writes happen after the screen. The exposure and permission refusals answer `403 PERMISSION_DENIED`. So does a run that no flow action targets. - **The subject record is read again as the caller.** A record the caller can no longer read is refused `404 RECORD_NOT_FOUND`, which is how `run_action` refuses it. - **Screen pauses only.** A run waiting on anything else (a timer `wait`, an approval) is refused `409 RESOURCE_CONFLICT` and left as it is. -Every refusal happens before the engine is asked, so the run stays parked. The engine's own answers (a screen submission missing a required field, a concurrent resume, a run that resumed and then failed) reach the caller with the code, status, message and `details` that `POST /api/v1/automation/:name/runs/:runId/resume` gives for the same result. The two doors now share one classification, `classifyResumeResult` in `@objectstack/runtime`. It was moved out of the REST route unchanged, and the route's answers are byte-identical. +Every refusal happens before the engine is asked, so the run stays parked. The engine's own answers (a screen submission missing a required field, a concurrent resume, a run that resumed and then failed) reach the caller with the code, status, message and `details` that `POST /api/v1/automation/:name/runs/:runId/resume` gives for the same result. The two doors now share one classification of the engine's answer. It was moved out of the REST route unchanged, and the route's answers are byte-identical. **For hosts.** `McpActionBridge` gains an OPTIONAL member, `resumeRun(runId, { values?, confirm? })`. A bridge that implements it gets `resume_run` beside `run_action`, under the same `actions:execute` OAuth scope, on both the HTTP and the stdio transport. A bridge without it is unchanged and does not list the tool. `run_action`'s description names `resume_run` only where it is registered. `@objectstack/runtime`'s MCP bridge implements the member.