Skip to content

Commit 08b213e

Browse files
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

‎.changeset/15705-mcp-resume-run.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/mcp': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
feat(mcp): `resume_run` continues a flow run that paused on a screen, behind the same gates as `run_action` (#15705)
7+
8+
Clause-②: yes
9+
10+
**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.
11+
12+
**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:
13+
14+
- **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.
15+
- **`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.
16+
- **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.
17+
- **Screen pauses only.** A run waiting on anything else (a timer `wait`, an approval) is refused `409 RESOURCE_CONFLICT` and left as it is.
18+
19+
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.
20+
21+
**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.

‎content/docs/ai/actions-as-tools.mdx‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ Any business `Action` you already have — a `script` action or a Flow — can b
1111
reached by an LLM as a callable tool. On the **open edition** this happens
1212
through [`@objectstack/mcp`](/docs/ai): your own AI (Claude, Cursor, any MCP
1313
client, or a local model) connects over the Model Context Protocol, and the
14-
server exposes two business-action tools — `list_actions` and `run_action` —
15-
bound to the caller's principal. The agent invokes actions the same way the
14+
server exposes three business-action tools — `list_actions`, `run_action` and
15+
`resume_run` — bound to the caller's principal. The agent invokes actions the same way the
1616
Console toolbar does — but only actions the author explicitly exposed to AI, and
1717
only ones the caller is permitted to run. No cloud service and no ObjectOS
1818
runtime are required.
@@ -50,6 +50,7 @@ bound to the caller's principal (the API key acts as the user):
5050
|:---|:---|
5151
| `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. |
5252
| `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. |
53+
| `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). |
5354

5455
`run_action` resolves the action and dispatches it through the framework's own
5556
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:
161162
// → invoke-gated as the caller; case_triage is a flow, so it honours runAs. Returns the flow result.
162163
```
163164

165+
### Completing a paused screen flow
166+
167+
A flow action whose flow opens on a `screen` node stops there when the call did
168+
not already answer the screen. `run_action` then answers with the run paused and
169+
the form it is waiting for:
170+
171+
```jsonc
172+
// tools/call → run_action
173+
{ "actionName": "schedule_followup", "recordId": "lead_42" }
174+
// → { "ok": true, "action": "schedule_followup", "objectName": "crm_lead", "recordId": "lead_42",
175+
// "result": { "success": true, "status": "paused", "runId": "run_…",
176+
// "screen": { "nodeId": "screen_1", "fields": [ { "name": "subject", "required": true }, … ] } } }
177+
```
178+
179+
`resume_run` submits the screen's values, keyed by the names in `screen.fields`,
180+
and the run continues from that screen:
181+
182+
```jsonc
183+
// tools/call → resume_run
184+
{ "runId": "run_…", "values": { "subject": "Call back", "dueDate": "2026-10-01" } }
185+
// → the same envelope as run_action: the run completed, or it paused again on its
186+
// next screen, which you answer with another resume_run.
187+
```
188+
189+
The call is admitted only where `run_action` would admit starting the same flow
190+
on the same record for this caller:
191+
192+
- **Only your own run.** The user whose call started the run is the only one
193+
who can resume it. Another user's run id, a finished run and an unknown id all
194+
answer the same `404`, so a run id says nothing about runs you did not start.
195+
- **The same gates as `run_action`.** A flow action exposed to AI
196+
(`ai.exposed`) must target the run's flow on the run's object, and the caller
197+
must pass its `requiredPermissions` and its activation switch. An action
198+
flagged `ai.requiresConfirmation` needs `confirm: true` on the resume as well,
199+
because the flow's writes happen after the screen.
200+
- **The record is read again as you.** A subject record you can no longer read
201+
is refused `RECORD_NOT_FOUND` (`404`), as `run_action` refuses it.
202+
- **Screens only.** A run waiting on anything other than a screen (a timer
203+
`wait`, an approval) is refused `409` and left as it is.
204+
205+
A submission that does not satisfy the screen (a required field missing, a
206+
field the screen does not declare) is refused `400` and the run stays paused,
207+
so you can correct the values and call `resume_run` again. Every refusal leaves
208+
the run parked. The engine's answers carry the same code and status the REST
209+
resume route (`POST /api/v1/automation/:name/runs/:runId/resume`) gives for the
210+
same result.
211+
164212
## Human-in-the-loop approval
165213

166214
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:
212260
`run_action` refuses anything the user cannot invoke.
213261
4. The subject record (for record-context actions) is loaded under the caller's
214262
RLS, so an action over a record the user cannot see reads as not-found.
263+
`resume_run` applies steps 2–4 again before a paused run continues, and only
264+
the user who started the run can resume it.
215265
5. The action body then executes with the app's full data authority (flows honour
216266
`runAs`), and the dispatch is audit-logged against the real user.
217267

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ decision.
170170

171171
## What the agent gets
172172

173-
Eleven tools, generated from your metadata:
173+
Twelve tools, generated from your metadata:
174174

175175
| Tool | What it does |
176176
|:---|:---|
@@ -180,6 +180,7 @@ Eleven tools, generated from your metadata:
180180
| `aggregate_records` | Grouped aggregation (registered when the active driver supports it) |
181181
| `create_record` / `update_record` / `delete_record` | Write data |
182182
| `list_actions` / `run_action` | Discover and invoke your business actions by name |
183+
| `resume_run` | Finish a flow action that paused on a screen, by submitting the screen's values |
183184

184185
The tools are a fixed ~10-tool **spine** with the object name as a *parameter*
185186
(`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":
296297
# → {"result":{"serverInfo":{"name":"objectstack",...},"capabilities":{"tools":{...},"prompts":{}}},...}
297298

298299
call '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | grep -o '"name":"[a-z_]*"'
299-
# → the eleven tools, list_objects … run_action
300+
# → the twelve tools, list_objects … resume_run
300301

301302
call '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"query_records","arguments":{"objectName":"crm_opportunity","limit":3}}}'
302303
# → {"result":{"content":[{"type":"text","text":"{ \"object\": \"crm_opportunity\", \"records\": [ ... ], \"total\": 23, \"hasMore\": true }"}]}}

‎packages/mcp/README.md‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,16 @@ apply):
122122
// Business actions — operate the app, not just its rows
123123
'list_actions' // Invokable business actions the caller may run
124124
'run_action' // Invoke an action by name with { recordId, params }
125+
'resume_run' // Continue a run paused on a screen with { runId, values }
125126
```
126127

127-
`aggregate_records` is registered only when the bridge implements `aggregate`;
128-
a bridge without that seam serves the rest and advertises nothing it cannot do.
128+
`aggregate_records` is registered only when the bridge implements `aggregate`,
129+
and `resume_run` only when it implements `resumeRun`; a bridge without those
130+
seams serves the rest and advertises nothing it cannot do.
129131

130132
OAuth scopes narrow the families at consent time: `data:read` covers
131133
list/describe/query/aggregate/get, `data:write` covers create/update/delete, and
132-
`actions:execute` covers `list_actions` / `run_action`. A tool outside the grant
134+
`actions:execute` covers `list_actions` / `run_action` / `resume_run`. A tool outside the grant
133135
is **not registered at all**, so the SDK rejects it as an unknown tool — the
134136
grant doubles as dispatch-time enforcement.
135137

@@ -141,7 +143,10 @@ listed, declared `requiredPermissions` (ADR-0066 D4) are enforced, and
141143
action by name and dispatches it through the framework's own action mechanism
142144
(`engine.executeAction` / automation flow runner), so a BYO-AI MCP client
143145
(Claude Code, Cursor, …) can trigger real business logic — e.g. "complete this
144-
task", "convert this lead".
146+
task", "convert this lead". When a flow action stops on a screen, `run_action`
147+
answers `status: 'paused'` with a `runId` and the `screen`, and `resume_run`
148+
submits that screen's values to continue the run. The runtime's bridge admits
149+
it only for the caller's own run, behind the same gates as `run_action`.
145150

146151
> **Security model (#2849):** gating happens at *invoke* time (`ai.exposed` +
147152
> capability gate + record-context loads under the caller's RLS). Once invoked,
@@ -252,7 +257,7 @@ await runtime.start();
252257
`grantedScopes`. `McpDataBridge` is the data seam (`listObjects`,
253258
`describeObject`, `query`, `get`, `create`, `update`, `remove`, and the optional
254259
`aggregate` / `listObjectsDiagnosed`); `McpActionBridge` adds `listActions` and
255-
`runAction`; `McpSkillBridge` is a single `listSkills`.
260+
`runAction` (plus the optional `resumeRun`); `McpSkillBridge` is a single `listSkills`.
256261

257262
Also exported for hosts that render the skill surface themselves:
258263
`renderSkillMarkdown`, `listSkillPrompts`, `projectSkillPrompt`,

0 commit comments

Comments
 (0)