Repository navigation
Commit 08b213e
feat(mcp): resume_run completes a paused screen flow — the caller's own run, behind run_action's gates (#19985)
Fixes #15705
Clause-②: yes
## What this adds
`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. This PR adds the MCP tool **`resume_run({ runId, values?, confirm?
})`**. It submits the screen's field values and the run continues.
This is item ① of the maintainer's ruling recorded on the card (comment
5793364219, 「15705同意」). Item ② (the explicit caller-provenance signal)
already landed separately and is not touched here. The two expectations
#15787 delivered (the headless screen verdict, and `list_actions`
publishing flow inputs) are not touched either. With this PR the card's
last open expectation is delivered, so it closes the card.
- **Tool** (`packages/mcp/src/mcp-http-tools.ts`): `resume_run` is
registered in `registerActionTools`, directly after `run_action`, when
the bridge implements the new OPTIONAL `McpActionBridge.resumeRun`. It
sits in the same `actions:execute` OAuth family. Both transports
register tools through the one `wireBridgeTools` composition, so it
exists wherever `run_action` does. Its input schema is closed
(`strictToolInput`, the existing unknown-key convention): `runId`
(required), `values` (a record) and `confirm` (the contract's
confirmation member). The REST door's `inputs` / `variables`, and
`params` / `data` / `fields`, are named as aliases in the refusal and
never accepted. `run_action`'s description names `resume_run` only where
it is registered.
- **Bridge** (`packages/runtime/src/domains/mcp.ts`): `buildMcpBridge`
implements `resumeRun` through the new `resumeActionRun`.
- **Shared answer table**
(`packages/runtime/src/domains/automation.ts`): the REST resume arm's
inline mapping of the engine result (six coded refusals plus `400
FLOW_FAILED` with its details) moved unchanged into the exported
`classifyResumeResult`. The REST arm and the MCP door both call it, and
both build their error through the same `deps.error`. The REST arm's
answers are byte-identical: the existing REST resume pins
(`http-dispatcher.test.ts`, `automation-resume-*.test.ts`) pass
unchanged.
- **Docs**: `content/docs/ai/actions-as-tools.mdx` (table row + a
"Completing a paused screen flow" section),
`content/docs/ai/connect-mcp.mdx` (tool count and table), the generated
SKILL.md (`skill-md.ts`) and `packages/mcp/README.md`.
## Measured before building (the order's mechanism assumptions)
1. **No resume verb in `packages/mcp/src`**: holds. At `2c1011b0` the
only `resume` hits were the stdin-resume prose in
`mcp-server-runtime.ts`.
2. **"Reuse the REST door's checks"**: measured, and they do not cover
what the ruling asks for. `POST /automation/:name/runs/:runId/resume`
checks (i) the anonymous floor, (ii) a closed body and value shapes, and
(iii) the engine's `resumeAuthority` gate on the suspended NODE (a
`screen` declares `'any'`; "an unclaimed pause is fail-closed" is about
node types that declare no authority, not about callers). It never asks
who is calling. It does not check run ownership, the action's AI
exposure or permissions, or whether the caller can read the record. The
engine also resumes under the identity stored in the run
(`resumeInternal` restores `run.context`), so the data nodes of a
`runAs: 'user'` flow run as the user who STARTED the run. So the MCP
verb reuses the door's engine-answer table (one table, identical codes),
and applies the admission `run_action` applies, plus an ownership check.
Details below.
3. **#16370's precedent**: `loadActionSubjectRecord` +
`refuseDeniedSubjectLoad` (`action-execution.ts`). The resume door calls
the same two functions on the run's subject record, so a record the
caller can no longer read is refused with `run_action`'s exact envelope.
4. **Is the flow name recoverable?** Yes, and it is not needed.
`getRun(runId).flowName` recovers it, and the REST resume arm never
reads `:name` (it hands only `parts[2]`, the run id, to `resume`). So
the verb takes only `runId`, which is what the paused envelope carries.
5. **Where `run_action` is registered**: one composition,
`wireBridgeTools`, called by `handleHttpRequest` (HTTP) and
`bridgeDataTools` (the long-lived stdio server). `resume_run` is
registered in the same function, so both transports have it. (The
production stdio host, `createStdioDataBridge`, carries no action seam,
so neither `run_action` nor `resume_run` is served there today. That is
unchanged.)
## Which paused runs become completable over MCP, and no others
`resume_run` continues a run only when ALL of these hold. Each check is
named with the refusal it answers:
| # | Check | Refusal |
|:--|:--|:--|
| 1 | The automation service implements `resume`, `getRun` and
`getSuspendedScreen` (without `getRun` ownership cannot be checked, so
this fails closed) | `501 NOT_IMPLEMENTED` |
| 2 | **The run is the caller's own and still paused**:
`getRun(runId).trigger.userId` equals the caller's `userId` | `404
RESOURCE_NOT_FOUND`, the same code, status and message for an unknown
id, a finished run and another user's run (existence non-disclosure, as
#16370 does for records) |
| 3 | A `type: 'flow'` action whose `target` is the run's flow, on the
run's object (the object-less key when the run carries no object),
admits the call through `run_action`'s own gates in `run_action`'s
order, with the same helpers: system-object guard, `ai.exposed`,
`requiredPermissions`, ADR-0126 activation, `ai.requiresConfirmation` |
`403 PERMISSION_DENIED` (system object, not AI-exposed, missing
capability, no flow action targets the flow); `409 ACTION_DISABLED`;
`428 ACTION_CONFIRMATION_REQUIRED` with `run_action`'s `details` |
| 4 | **The subject record is read again in the caller's scope**
(#16370's two functions) | `404 RECORD_NOT_FOUND` |
| 5 | The run is parked on a **screen** (`getSuspendedScreen` returns
one); a run waiting on a timer `wait`, an approval or anything else is
left alone | `409 RESOURCE_CONFLICT` |
| 6 | The engine accepts the submission; the signal is built one field
at a time (`{ variables: values }`), never spread (#3801) | the shared
table: `403` / `400` (`INVALID_SIGNAL`, `INVALID_SCREEN_INPUT`) / `404`
/ `503` / `409`, and `400 FLOW_FAILED` with `errorMessage` / `summary` /
`runId` / `repairable` / `status`, all identical to the REST door |
Every refusal in 1–5 is thrown before `resume()` is called, so a refused
call consumes nothing and the run stays parked. The confirmation gate
applies on resume because the flow's writes happen after the screen: a
run started with `confirm: true` needs `confirm: true` again to be
resumed. With several candidate actions, the first one that passes
admits the call. That is exactly the set of calls `run_action` could
admit for this flow. When none passes, the first candidate's refusal is
served.
The success value is `run_action`'s envelope, `{ ok, action, objectName,
recordId?, result }`. `result` is the engine's own answer: a run that
pauses on its next screen comes back paused with the next `screen`, so a
multi-screen wizard is walked one `resume_run` per screen.
**Not claimed.** This does not make every paused run completable over
MCP. Runs waiting on non-screen nodes, runs another user started, and
runs of flows no AI-exposed action targets stay refused. A run whose own
suspension carries no screen (for example a parent parked on a subflow
node where the screen is only on the child) is refused `409` unless
`getSuspendedScreen` answers for it. The REST resume door's behaviour is
unchanged.
## Pins — each one shown able to fail
`packages/runtime/src/mcp-resume-run.test.ts` (21 tests) drives the real
`buildMcpBridge` through `HttpDispatcher` against a stateful automation
double. The double keeps paused runs with their trigger identity and
screens, checks required screen fields, and records the task the flow
would create. The engine's own resume rules stay pinned on the real
engine in `@objectstack/service-automation`.
- **(a)** `run_action` without the inputs pauses. `resumeRun` with the
values completes the run, `resume` receives exactly `{ variables: values
}`, and the task lands once, for that lead, as that caller. A two-screen
wizard pauses again and a second call finishes it. A missing required
field is the engine's `400` and the run stays resumable.
- **(b)** another user's run (with record access, so only ownership is
under test), a record reassigned away from the caller, a missing
capability, a run whose only action is not AI-exposed, a run no flow
action targets, a disabled action, a confirmation-gated action without
`confirm`, and a non-screen pause. Each is asserted as `code` +
`status`, `resume` is never called, and the run is shown to be still
parked afterwards.
- **(c)** an unknown `runId` gives the identical envelope to another
user's run and to a finished run (only the id differs). A service
without `getRun` gives `501`.
- **(d)** door against door: for all six coded engine refusals and both
`FLOW_FAILED` shapes (stranded and plain), the MCP refusal's `{ code,
status, message, details }` equals the REST door's for the same engine
result.
`packages/mcp/src/mcp-resume-run-tool.test.ts` (8),
`mcp-stdio-tools.test.ts` (+1, and a control) and
`mcp-http-tools.unknown-argument-keys.test.ts` (the sweep now covers
twelve tools, plus an `inputs` → `values` case) pin the tool: registered
only with `resumeRun` and under `actions:execute`, a closed schema in
`tools/list` on HTTP and on stdio, exact forwarding of `{ values,
confirm }`, and a coded refusal that keeps its envelope. The SKILL.md
drift guard's full bridge now carries `resumeRun`, so the skill must
document the tool.
**Mutation legs.** Each leg was run from committed source with
`scripts/ablation-replace.mjs` in WRAP mode: the anchor must hit once,
the write is verified on disk, and the restore is proven by blob hash
plus an empty `git diff HEAD`, with a trap on EXIT/INT/TERM. The mutated
subjects are reached through relative source imports, so no `dist/`
rebuild leg is owed.
| Leg | Mutation | Result |
|:--|:--|:--|
| M1 | drop the ownership condition | 2 red: another user's run;
unknown-vs-foreign envelope equality |
| M2 | drop the record re-read refusal | 1 red: the reassigned record |
| M3 | drop the permission gate | 1 red: the missing capability |
| M4 | drop the exposure gate | 1 red: the console-only action |
| M5 | disable the confirmation gate | 1 red: the confirmation case |
| M6 | disable the screen-only check | 1 red: the non-screen pause |
| M7 | MCP bypasses the shared table's status | 9 red: all 8
door-against-door rows and the engine-400 control |
| M8 | match candidates by any flow | 5 red: capability, exposure,
no-action, disabled, confirmation |
| M9 | never register the tool | 9 red (the tool file and the stdio
listing) |
| M10b | drop `confirm` forwarding in the tool | 1 red: exact forwarding
|
The first try at M10 did nothing: the tool refused the write because its
replacement text was already present in the file, so no test ran. M10b
redid it with a unique anchor.
## Verification (head `0b6b2d78`)
- `@objectstack/runtime` local project: **274 files, 3837 passed, 1
skipped**. Repo project: 2 files, 69 passed.
- `@objectstack/mcp`: **32 files, 344 passed**.
- `@objectstack/dogfood` (it drives MCP over HTTP,
`showcase-mcp-http-identity`): **137 files passed, 1 skipped; 1103 tests
passed, 3 skipped**, after building its dependency closure.
- `pnpm --filter @objectstack/runtime run typecheck` and the same for
`@objectstack/mcp`: exit 0, including `check:test-typecheck`, which
compiles the new test files.
- Gates re-derived on the real diff with `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (15 paths vs merge base `2c1011b01`): **89
derived, 89 run, all exit 0**. Reconciled with `--ran`: "89 run, 0
NOT-MEASURED (a DERIVED zero)". Three gates first answered exit 3 =
PREREQUISITE NOT MET (`check:skill-examples`,
`check:dual-build-cjs-loads`, `check:type-check-debt`, all of which read
built output). After a full `turbo run build` of `./packages/*`, all
three measured green. Also run beyond the derivation, all exit 0: the
five roster gates whose rosters sit in these paths
(`check-changeset-fixed`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`,
`check:route-ledger-census`) and `check:published-readme-exports`.
- Lint: `pnpm lint` (`eslint . --no-inline-config`) exit 0 over the
whole repo at `0b6b2d78`, not narrowed. A narrowed run came first (the
11 changed TS files, `--format json`: 0 errors, 0 warnings) and the full
run supersedes it.
## Acceptance notes
- `run_action` answers its exposure and permission refusals as plain
text tool errors. `resume_run` answers the same reasons coded
(`PERMISSION_DENIED` / `403`, the code and status REST `/actions` uses
for the permission gate), because a new door should be machine-readable.
The two tools therefore spell one reason two ways. Aligning `run_action`
is outside this card.
- `packages/mcp/README.md` and the runtime bridge's test file sit
outside the claim's declared file surface (`packages/mcp/src/**`,
`domains/mcp.ts`, `domains/automation.ts`, the MCP docs pages, the
changeset). The README is the published package's own list of its tools
and scopes, and the test file pins the bridge. Both are named here and
in the report.
- The SKILL.md surface guard's "full bridge" still has no `aggregate`,
so `aggregate_records` is outside what the guard sees and the SKILL.md
does not list it. This predates this PR.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent e169acb commit 08b213e
15 files changed
Lines changed: 1319 additions & 99 deletions
File tree
- .changeset
- content/docs/ai
- packages
- mcp
- src
- runtime/src
- domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
14 | | - | |
15 | | - | |
| 14 | + | |
| 15 | + | |
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
| |||
50 | 50 | | |
51 | 51 | | |
52 | 52 | | |
| 53 | + | |
53 | 54 | | |
54 | 55 | | |
55 | 56 | | |
| |||
161 | 162 | | |
162 | 163 | | |
163 | 164 | | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
164 | 212 | | |
165 | 213 | | |
166 | 214 | | |
| |||
212 | 260 | | |
213 | 261 | | |
214 | 262 | | |
| 263 | + | |
| 264 | + | |
215 | 265 | | |
216 | 266 | | |
217 | 267 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
170 | 170 | | |
171 | 171 | | |
172 | 172 | | |
173 | | - | |
| 173 | + | |
174 | 174 | | |
175 | 175 | | |
176 | 176 | | |
| |||
180 | 180 | | |
181 | 181 | | |
182 | 182 | | |
| 183 | + | |
183 | 184 | | |
184 | 185 | | |
185 | 186 | | |
| |||
296 | 297 | | |
297 | 298 | | |
298 | 299 | | |
299 | | - | |
| 300 | + | |
300 | 301 | | |
301 | 302 | | |
302 | 303 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
| 125 | + | |
125 | 126 | | |
126 | 127 | | |
127 | | - | |
128 | | - | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
129 | 131 | | |
130 | 132 | | |
131 | 133 | | |
132 | | - | |
| 134 | + | |
133 | 135 | | |
134 | 136 | | |
135 | 137 | | |
| |||
141 | 143 | | |
142 | 144 | | |
143 | 145 | | |
144 | | - | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
145 | 150 | | |
146 | 151 | | |
147 | 152 | | |
| |||
252 | 257 | | |
253 | 258 | | |
254 | 259 | | |
255 | | - | |
| 260 | + | |
256 | 261 | | |
257 | 262 | | |
258 | 263 | | |
| |||
0 commit comments