feat(mcp): resume_run completes a paused screen flow — the caller's own run, behind run_action's gates - #19985
Conversation
…ehind run_action's gates WIP: runtime bridge + shared resume refusal table + MCP tool registration. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
…ports Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
…nd a changeset Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
📓 Docs Drift CheckThis PR changes 2 package(s): 29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 33 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 48cb002c712df0220ebde24346a742b154c718d9 && git checkout 48cb002c712df0220ebde24346a742b154c718d9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2c1011b01bc071c545f72f2761647b8d9ab56375 0b6b2d782f7465679ddf8e634c895a1b74c2ec20 && git checkout -B drift-repro 2c1011b01bc071c545f72f2761647b8d9ab56375 && git merge --no-ff 0b6b2d782f7465679ddf8e634c895a1b74c2ec20
node scripts/docs-audit/affected-docs.mjs --json 2c1011b01bc071c545f72f2761647b8d9ab56375
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Isolated at-tier reviewer adopted by the director seat, summon #29, on the maintainer's instruction 「帮我处理并跟进到合并」 · fed only card #15705, ruling 5793364219, the PR body, the dev's flags and the code · CI on this head: 35 runs, 33 success / 2 skipped Generated by Claude Code |
Fixes #15705
Clause-②: yes
What this adds
run_actionon a flow action whose flow stops on ascreennode answersstatus: "paused"with arunIdand thescreento 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 toolresume_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_actionspublishing flow inputs) are not touched either. With this PR the card's last open expectation is delivered, so it closes the card.packages/mcp/src/mcp-http-tools.ts):resume_runis registered inregisterActionTools, directly afterrun_action, when the bridge implements the new OPTIONALMcpActionBridge.resumeRun. It sits in the sameactions:executeOAuth family. Both transports register tools through the onewireBridgeToolscomposition, so it exists whereverrun_actiondoes. Its input schema is closed (strictToolInput, the existing unknown-key convention):runId(required),values(a record) andconfirm(the contract's confirmation member). The REST door'sinputs/variables, andparams/data/fields, are named as aliases in the refusal and never accepted.run_action's description namesresume_runonly where it is registered.packages/runtime/src/domains/mcp.ts):buildMcpBridgeimplementsresumeRunthrough the newresumeActionRun.packages/runtime/src/domains/automation.ts): the REST resume arm's inline mapping of the engine result (six coded refusals plus400 FLOW_FAILEDwith its details) moved unchanged into the exportedclassifyResumeResult. The REST arm and the MCP door both call it, and both build their error through the samedeps.error. The REST arm's answers are byte-identical: the existing REST resume pins (http-dispatcher.test.ts,automation-resume-*.test.ts) pass unchanged.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) andpackages/mcp/README.md.Measured before building (the order's mechanism assumptions)
packages/mcp/src: holds. At2c1011b0the onlyresumehits were the stdin-resume prose inmcp-server-runtime.ts.POST /automation/:name/runs/:runId/resumechecks (i) the anonymous floor, (ii) a closed body and value shapes, and (iii) the engine'sresumeAuthoritygate on the suspended NODE (ascreendeclares'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 (resumeInternalrestoresrun.context), so the data nodes of arunAs: '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 admissionrun_actionapplies, plus an ownership check. Details below.run_actionon a flow action answersok: trueand starts the run on a row the caller cannot read (or that does not exist) — the flow door still turns a denied load into an implicit grant #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 withrun_action's exact envelope.getRun(runId).flowNamerecovers it, and the REST resume arm never reads:name(it hands onlyparts[2], the run id, toresume). So the verb takes onlyrunId, which is what the paused envelope carries.run_actionis registered: one composition,wireBridgeTools, called byhandleHttpRequest(HTTP) andbridgeDataTools(the long-lived stdio server).resume_runis registered in the same function, so both transports have it. (The production stdio host,createStdioDataBridge, carries no action seam, so neitherrun_actionnorresume_runis served there today. That is unchanged.)Which paused runs become completable over MCP, and no others
resume_runcontinues a run only when ALL of these hold. Each check is named with the refusal it answers:resume,getRunandgetSuspendedScreen(withoutgetRunownership cannot be checked, so this fails closed)501 NOT_IMPLEMENTEDgetRun(runId).trigger.userIdequals the caller'suserId404 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)type: 'flow'action whosetargetis the run's flow, on the run's object (the object-less key when the run carries no object), admits the call throughrun_action's own gates inrun_action's order, with the same helpers: system-object guard,ai.exposed,requiredPermissions, ADR-0126 activation,ai.requiresConfirmation403 PERMISSION_DENIED(system object, not AI-exposed, missing capability, no flow action targets the flow);409 ACTION_DISABLED;428 ACTION_CONFIRMATION_REQUIREDwithrun_action'sdetails404 RECORD_NOT_FOUNDgetSuspendedScreenreturns one); a run waiting on a timerwait, an approval or anything else is left alone409 RESOURCE_CONFLICT{ variables: values }), never spread (#3801)403/400(INVALID_SIGNAL,INVALID_SCREEN_INPUT) /404/503/409, and400 FLOW_FAILEDwitherrorMessage/summary/runId/repairable/status, all identical to the REST doorEvery 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 withconfirm: trueneedsconfirm: trueagain to be resumed. With several candidate actions, the first one that passes admits the call. That is exactly the set of callsrun_actioncould 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 }.resultis the engine's own answer: a run that pauses on its next screen comes back paused with the nextscreen, so a multi-screen wizard is walked oneresume_runper 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
409unlessgetSuspendedScreenanswers 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 realbuildMcpBridgethroughHttpDispatcheragainst 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.run_actionwithout the inputs pauses.resumeRunwith the values completes the run,resumereceives 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's400and the run stays resumable.confirm, and a non-screen pause. Each is asserted ascode+status,resumeis never called, and the run is shown to be still parked afterwards.runIdgives the identical envelope to another user's run and to a finished run (only the id differs). A service withoutgetRungives501.FLOW_FAILEDshapes (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) andmcp-http-tools.unknown-argument-keys.test.ts(the sweep now covers twelve tools, plus aninputs→valuescase) pin the tool: registered only withresumeRunand underactions:execute, a closed schema intools/liston 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 carriesresumeRun, so the skill must document the tool.Mutation legs. Each leg was run from committed source with
scripts/ablation-replace.mjsin WRAP mode: the anchor must hit once, the write is verified on disk, and the restore is proven by blob hash plus an emptygit diff HEAD, with a trap on EXIT/INT/TERM. The mutated subjects are reached through relative source imports, so nodist/rebuild leg is owed.confirmforwarding in the toolThe 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/runtimelocal 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 typecheckand the same for@objectstack/mcp: exit 0, includingcheck:test-typecheck, which compiles the new test files.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(15 paths vs merge base2c1011b01): 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 fullturbo run buildof./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) andcheck:published-readme-exports.pnpm lint(eslint . --no-inline-config) exit 0 over the whole repo at0b6b2d78, 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_actionanswers its exposure and permission refusals as plain text tool errors.resume_runanswers the same reasons coded (PERMISSION_DENIED/403, the code and status REST/actionsuses for the permission gate), because a new door should be machine-readable. The two tools therefore spell one reason two ways. Aligningrun_actionis outside this card.packages/mcp/README.mdand 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.aggregate, soaggregate_recordsis outside what the guard sees and the SKILL.md does not list it. This predates this PR.Generated by Claude Code