Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/15705-mcp-resume-run.md
Original file line number Diff line number Diff line change
@@ -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 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 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.
54 changes: 52 additions & 2 deletions content/docs/ai/actions-as-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|:---|:---|
Expand All @@ -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
Expand Down Expand Up @@ -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 }"}]}}
Expand Down
15 changes: 10 additions & 5 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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,
Expand Down Expand Up @@ -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`,
Expand Down
Loading
Loading