Skip to content

Commit fb7501b

Browse files
fix(runtime)!: the /mcp bridge's list_actions, run_action and resume_run read an object's actions from the listing list_objects serves, so a package object's actions are listed and run (#25291)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu4BM6tVgxhg4qFQsbuRHiy9)_ Fixes #25139 Clause-②: no (narrowing) Metadata-protocol: none The HTTP MCP bridge's three business-action tools, `list_actions`, `run_action` and `resume_run`, now collect the actions declared on objects from the object definitions `list_objects` lists: the protocol's merged listing, the one `GET /api/v1/meta/object` serves (#25120). They read the `metadata` service's `listObjects()` until now, so on a kernel with an `environmentId` they saw none of the actions on an object a package registers during the boot, while `list_objects` listed the object and REST's `/actions` door ran the actions. The listing is read once per call through the same function `list_objects` uses, not copied. ## The defect, measured on base `cc8d886d4` `collectActionDeclarations` (`packages/runtime/src/action-execution.ts`) built its object-embedded rows from `meta.listObjects()`, and `list_actions`, `run_action` (through `resolveActionByName`) and `resume_run` all read it. The `metadata` service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`createStandaloneStack`: `os dev` as `env_local`, `os serve`, `os start`; and the cloud single-environment runtime) nothing copies an object a package registers in the `SchemaRegistry` during the boot. Real kernels (`ObjectKernel` + `MetadataPlugin` + `ObjectQLPlugin` over a better-sqlite3 `SqlDriver`), the bridge the real `/mcp` entry hands the MCP service (`HttpDispatcher.handleMcp`), and REST's door driven through the same dispatcher (`POST /actions/OBJECT/NAME`). The fixture package: an object with a declarative update exposed to AI (`mark_done`), a script action exposed to AI whose handler is registered (`ping`), a declarative update not exposed (`hidden_done`), and a top-level action bound to the object (`bound_done`); and an `apiEnabled: false` object with a declarative update exposed to AI (`gated_done`). | shape, when the package registered | `list_actions` | `run_action mark_done` on a record | `run_action ping` | REST `POST /actions/qa_act_open/mark_done` | REST `ping` | | :-- | :-- | :-- | :-- | :-- | :-- | | `env_local`, during the boot | **none of the four** | **`Action 'mark_done' not found on object 'qa_act_open'`** | **`Action 'ping' not found on object 'qa_act_open'`** | `200`, the record updated | `200`, `{ pong: true }` | | `env_local`, installed after the boot (`source: 'marketplace'`) | all four | the record updated | `{ pong: true }` | `200`, updated | `200` | | no `environmentId`, during the boot | all four | the record updated | `{ pong: true }` | `200`, updated | `200` | | no `environmentId`, installed after the boot | all four | the record updated | `{ pong: true }` | `200`, updated | `200` | The MCP columns of the first row are the pins' main leg below, verbatim. The REST columns and the disabled-package reading come from a scratch probe over the same rig and fixture (object prefix `qa_pkg`), not committed. Without `objectName`, `run_action mark_done` answered `Action 'mark_done' not found` on the first row and resolved on the other three. The bound top-level action rides inside the object's `actions` on every shape (in the merged listing and in the copy alike), so it followed the same split. **A disabled package (`registry.disablePackage`).** On the shape without an `environmentId` the boot copied the objects into the `metadata` service, actions and all, and the copy outlives the disable: `list_actions` listed all four and `run_action ping` ran the handler (`{ pong: true }`), while `list_objects` and `describe_object` omit the objects since #25120 and `GET /api/v1/meta/object` omits them. On `env_local` there is no copy, so the bridge already listed none and answered "not found". ## What changed - **`packages/runtime/src/domains/mcp.ts`.** `buildMcpBridge` gains `readServedObjects()`: the merged listing (`readMergedObjects`, unchanged from #25120), or the `metadata` service's `listObjects()` on a host with no merged read. `list_objects` now lists through it (its answer does not change), and `list_actions`, `run_action` and `resume_run` collect object-embedded actions from it. `resumeActionRun`'s wiring gains an optional `readObjects`. - **`packages/runtime/src/action-execution.ts`.** `collectActionDeclarations(deps, meta, objects?)` and `resolveActionByName(deps, meta, name, objectName?, objects?)` take the object listing as an optional last input; left out, they read `meta.listObjects()` as before, so REST's activation-ambiguity check, the one other caller, is unchanged. `invokeBusinessAction`'s wiring gains an optional `readObjects`. Standalone `action` rows are still read from `meta.loadMany('action')`, and an object-embedded declaration still wins a clash. None of these functions is published by `index.ts`. - **`.changeset/25139-mcp-actions-merged-object-view.md`.** A `patch` for `@objectstack/runtime`, with the BREAKING banner and the ADR-0087 disposition `not-required (no-migration-prescription)`; both declaration lines are in the body. ## Contract Every input whose answer changes, old to new, on `POST /api/v1/mcp`: 1. **`list_actions` on a kernel with an `environmentId`, for an action declared on an object a package or plugin registered during the boot** (inside the object, or a top-level action bound to it). Old: not listed. New: listed, behind the same filters (headless-invokable, a registered handler for a script action, `ai.exposed`, `requiredPermissions`). 2. **`run_action` for such an action.** Old: `Action 'NAME' not found on object 'OBJECT'`, or `Action 'NAME' not found` without `objectName`. New: resolved and dispatched behind the same gates as on every other shape, as REST's `/actions` door runs it. 3. **`resume_run` of a run started by such a flow action.** Old: refused `403` (no flow action it could see targets that flow). New: admitted through `run_action`'s gates. Pinned with doubles, not on a real automation engine. 4. **The three tools on a disabled package's actions, where the `metadata` service still holds a copy of its objects** (the narrowing; every kernel without an `environmentId`, and a marketplace install on any kernel). Old: listed; `run_action` ran a script action's handler, and refused a declarative update at the record load (`RECORD_NOT_FOUND`). New: omitted from `list_actions`; `run_action` answers `Action 'NAME' not found on object 'OBJECT'`; `resume_run` refuses their runs `403`. As `describe_object` answers their objects "not found". 5. **An object whose served definition differs from the `metadata` service's copy.** Old: its actions came from the copy. New: from the definition REST serves. 6. **The three tools while the protocol's merged listing throws.** Old: the `metadata` service's listing. New: the protocol's error reaches the caller, as `list_objects` lets it since #25120; nothing falls back. Pinned with a throwing double, not on a real store outage. Unchanged: actions on objects that already had a copy are listed and run as before. Standalone `action` rows, a host with no `protocol` service or one without the merged listing (it reads the `metadata` service), every gate after resolution, and REST's `/actions` door, which still resolves through `ql.getSchema` and the registry. The published surface does not move: `@objectstack/runtime`'s `index.ts` exports none of the four functions, and each gains only an optional trailing input. The parity target the card names is met: on every row of the table above, `run_action` now answers what REST's door answers the same request (pinned: the declarative update, an absent record, the script action). ## Pins - **`packages/runtime/src/domains/mcp-package-object-discovery.test.ts`, 36 new cases on real kernels, in #25120's rig (106 in the file).** For each of the four rows above: `list_actions` lists exactly `gated_done`, `bound_done`, `mark_done` and `ping` on the package's two objects, and not `hidden_done`; `run_action mark_done` updates a seeded record (read back through `get`), and REST's door updates another, `200`; without `objectName` on an absent record both answer `RECORD_NOT_FOUND` / `404`; `bound_done` updates a record; `ping` answers `{ pong: true }` through the handler, as REST does; `hidden_done` is resolved and refused at the exposure gate; and a name nothing declares is still `Action 'qa_nowhere' not found on object 'qa_act_open'`. For a disabled package on both shapes: `describe_object` answers null (precondition), `list_actions` lists none of the package's actions, and `run_action` answers "not found" for `mark_done`, `ping` and `bound_done`, scoped and unscoped; on the shape without an `environmentId` a precondition shows the copy still holds the actions. The rig's `bridgeFor` takes an optional caller (default unchanged), so these cases carry a `tenantId` and can seed records. - **`packages/runtime/src/mcp-resume-run.test.ts`, 3 new cases, doubles (25 in the file).** With a merged listing that serves the object and a `metadata` service that lists nothing: `run_action` starts the screen flow and `resume_run` completes it, the `metadata` service's `listObjects` never asked; `list_actions` lists the object's three exposed and permitted flow actions. With a merged listing that throws: `list_actions`, `run_action` and `resume_run` fail with its error, the `metadata` service is not asked, and the engine's `resume` is not called. The harness's `merged` option is additive; every existing case runs as before. **Red before, green after.** Each leg's direction was predicted before it ran; all six runs read both pin files (131 cases), on `c5e95a9d` over `cc8d886d4`. The final head is that commit cherry-picked onto the newer `origin/main` (no `packages/runtime` file moved between the two bases) with one change in the pin file: a precondition's local is typed instead of `any`, which lint flagged. Both pin files were re-run on the final head (below). Mutations were landed and restored by `scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed; restored blob equals HEAD, `git diff HEAD` empty). These files import the runtime's source, so no `dist/` is involved. | leg | mutation | failed | which | | :-- | :-- | :-- | :-- | | main | the base tree's `action-execution.ts` and `domains/mcp.ts`, with the new pins | 13 | the six `env_local` during-boot cases, the four no-`environmentId` disabled-package cases, the three `resume_run` cases | | listactions | `list_actions` collects from the `metadata` service | 4 | the two real-kernel `list_actions` cases (`env_local` during the boot; disabled without an `environmentId`), the unit listing case, the throwing case | | invoke | `run_action` resolves against the `metadata` service | 10 | the five `env_local` during-boot `run_action` cases, the three disabled-package `run_action` cases, the unit resume case (its `run_action` start), the throwing case | | resume | `resume_run` admits against the `metadata` service | 2 | the unit resume case (`403`), the throwing case | | collect | `collectActionDeclarations` ignores the listing it is given | 12 | every real-kernel case the main leg failed, the unit resume and listing cases | | fix | `c5e95a9d` | 0 | 131 passed | ## Local verification On `ae2b2bc5ff`, the final commit, over `origin/main` `bda53bb0ba`, unless a line says otherwise. Every heavy run went through the verify lock, and each line cites its `VERDICT`. - `pnpm turbo run build --filter='@objectstack/dogfood^...' --concurrency=2` (the runtime's closure included): `VERDICT command-exit 0`, 66 of 66 tasks. - Both pin files (`npx vitest run src/domains/mcp-package-object-discovery.test.ts src/mcp-resume-run.test.ts` in `packages/runtime`): `VERDICT command-exit 0`, 131 passed. - `@objectstack/runtime` unit suite (`pnpm --filter @objectstack/runtime test`): `VERDICT command-exit 0`; Test Files 435 passed (435); Tests 6924 passed, 19 skipped (6943). - `pnpm --filter @objectstack/runtime typecheck`: `VERDICT command-exit 0` (`tsc --noEmit`, and `check:test-typecheck` OK). - `pnpm lint` (`eslint . --no-inline-config`): `VERDICT command-exit 0`. The first lint of the changed files, on `c5e95a9d`, answered one error (a service-lookup result erased to `any` in a pin's precondition), fixed before the final head. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 68 families derived; each run, every one `exit 0` (a first pass on `efcb85dc` over `cc8d886d4`, the same five files, also answered 68 of 68 `exit 0`). - `--ran` reconciliation: `68 derived, 68 run, 0 NOT-MEASURED`, exit 0. - `node scripts/check-changeset-no-major.mjs --base origin/main --event PAYLOAD`, with PAYLOAD a `pull_request` event carrying this title and body: exit 0 (`✓ LEVEL AXIS`: `no (narrowing)`, `Metadata-protocol: none`, a non-protocol change at `patch`). - `node scripts/check-closing-keyword-parity.mjs --body` on this body: exit 2, its "closing declarations found" answer: `Fixes #25139` and nothing else, bound by the duplicate-fix guard's and the part-of guard's parsers. - The full dogfood battery, once, on the final head (`pnpm --filter @objectstack/dogfood test`, no shard, `NODE_OPTIONS=--max-old-space-size=8192`): `VERDICT command-exit 0`; Test Files 289 passed, 3 skipped (292); Tests 2491 passed, 18 skipped (2509); no failure. Owed by the `!` and the `(narrowing)` arm; the PR's Dogfood shards skip on pull requests. ## Acceptance notes - **Found while measuring, not changed here: REST's `/actions` door runs a disabled package's script action.** `POST /actions/OBJECT/ping` answered `200 { pong: true }` for a disabled package's object on both shapes (its declarative update is refused at the record load), while the data plane refuses the object `404 OBJECT_PACKAGE_DISABLED`. After this change the MCP door answers "not found" there; the REST door is a different door and a different card. - **Found while measuring, not changed here: a package's object-less action is invisible to MCP on every shape.** A top-level action with no `objectName` (a `global` action) is in the registry's `action` items and REST's `POST /actions/global/NAME` runs it, but `collectActionDeclarations` reads standalone rows only from `meta.loadMany('action')`, which holds the environment-authored rows alone, so `list_actions` never lists it and `run_action` answers `Action 'NAME' not found` on both shapes. Not this card's shape defect. - **REST's activation-ambiguity check still collects from the `metadata` service.** `refuseAmbiguousActionActivation` (`domains/actions.ts`) is the one other caller of `collectActionDeclarations`; it passes no listing, so its reading on an `environmentId` kernel is unchanged. Not MCP, and not measured here. - **Each call reads the whole merged listing once**, as `list_objects` and `describe_object` do since #25120. - **Branch name.** This PR's branch is `claude/project-thread-dkk3hd`, the name the session was given, not `claude/issue-25139-…`; the Duplicate Fix Guard may say so. It carried #25268 (#25245) until that merged, and was restarted from `origin/main` for this card. --- _Generated by [Claude Code](https://claude.ai/code/session_01TgbP2QERdKmh147ohacHbK)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01TgbP2QERdKmh147ohacHbK Co-authored-by: Claude <noreply@anthropic.com>
1 parent f47f7f2 commit fb7501b

5 files changed

Lines changed: 329 additions & 22 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime)!: the `/mcp` bridge's `list_actions`, `run_action` and `resume_run` read an object's actions from the listing `list_objects` serves, so a package object's actions are listed and run on every kernel, and a disabled package's are omitted as its objects are
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object or action definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which object definitions the HTTP MCP bridge collects object-embedded actions from: the protocol's merged listing, the one its `list_objects` / `describe_object` tools and `GET /api/v1/meta/object` already serve, instead of the metadata service's copy. The functions that change (`collectActionDeclarations`, `resolveActionByName`, `invokeBusinessAction`, `resumeActionRun`) are not published by `index.ts`, and each gains only an optional trailing input. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11+
12+
**BREAKING** (`list_actions`, `run_action` and `resume_run` stop serving a disabled package's actions), shipped as `patch`: it changes which reads the MCP bridge answers, not what a metadata author can write.
13+
14+
The HTTP MCP bridge (`POST /api/v1/mcp`) collected the actions declared on objects from the `metadata` service's `listObjects()`. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, and the cloud single-environment runtime) nothing copies the objects a package registers during the boot. So `list_actions` listed none of such a package's actions and `run_action` answered `Action 'NAME' not found on object 'OBJECT'`, while `list_objects` listed the object since #25120 and REST's `POST /api/v1/actions/OBJECT/NAME` ran the action.
15+
16+
- **Before.** On that kernel, an action declared on an object a package registered during the boot (inside the object, or as a top-level action bound to it) was missing from `list_actions`; `run_action` answered `Action 'NAME' not found on object 'OBJECT'` (`Action 'NAME' not found` without `objectName`); and `resume_run` refused a run of such a flow action `403`, because no flow action it could see targets that flow.
17+
- **Now.** The three tools collect object-embedded actions from the object definitions `list_objects` lists: the protocol's merged listing, the one `GET /api/v1/meta/object` serves. Such an action is listed, resolved and dispatched behind the same gates as before (`ai.exposed`, `requiredPermissions`, the activation switch, the declared params, `ai.requiresConfirmation`), and runs as REST's `/actions` door runs it.
18+
- **Also changed.** A disabled package's actions, where the `metadata` service still held a copy of its objects (every kernel without an `environmentId`, and a marketplace install on any kernel), were listed and run: a script action's body ran, a declarative update was refused at the record load. They are now omitted from `list_actions`, `run_action` answers `Action 'NAME' not found on object 'OBJECT'` for them, and `resume_run` refuses their runs `403`, as `describe_object` answers their objects "not found". When the protocol's merged listing throws, the three tools fail with that error, as `list_objects` does, instead of reading the `metadata` service's listing.
19+
- **Unchanged.** Actions on objects that already had a copy are listed and run as before. Standalone `action` rows are still read from the `metadata` service (`loadMany('action')`), and an object-embedded declaration still wins over one with the same object and name. A host with no `protocol` service, or one without the merged listing, keeps reading the `metadata` service. REST's `/actions` door is untouched.
20+
- **What to do.** Nothing. An agent that ran a disabled package's action through MCP was running an action whose objects the rest of the platform no longer serves: re-enable the package to restore it.

‎packages/runtime/src/action-execution.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2601,16 +2601,21 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
26012601
envId?: string;
26022602
ec: any;
26032603
getMeta: () => any;
2604+
// [#25139] The objects whose `actions[]` the name is resolved against —
2605+
// the MCP bridge passes the listing `list_objects` serves (the
2606+
// protocol's merged read). Left out, the metadata service's
2607+
// `listObjects()` is read, as before.
2608+
readObjects?: () => Promise<readonly any[]>;
26042609
callData: (action: string, params: any, dataDriver?: any, scopeId?: string, ec?: ExecutionContext) => Promise<any>;
26052610
},
26062611
): Promise<any> {
2607-
const { driver, envId, ec, getMeta, callData } = wiring;
2612+
const { driver, envId, ec, getMeta, readObjects, callData } = wiring;
26082613
const meta: any = await getMeta();
26092614
const params = input?.params && typeof input.params === 'object' ? input.params : {};
26102615
const recordId = typeof input?.recordId === 'string' && input.recordId.length > 0 ? input.recordId : undefined;
26112616

26122617
// Resolve the action def by declarative name (optionally scoped).
2613-
const resolved = await resolveActionByName(deps, meta, name, input?.objectName);
2618+
const resolved = await resolveActionByName(deps, meta, name, input?.objectName, await readObjects?.());
26142619
if (!resolved) {
26152620
throw new Error(
26162621
input?.objectName
@@ -2822,13 +2827,16 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
28222827
* optionally scoped to a single object. Returns the action plus its owning
28232828
* object name, or `null`. Throws when the name is ambiguous across objects
28242829
* and no `objectName` was supplied (so `run_action` can ask for one).
2830+
*
2831+
* `objects` is handed to {@link collectActionDeclarations} unchanged.
28252832
*/
28262833
export async function resolveActionByName(deps: ActionExecutionDeps,
28272834
meta: any,
28282835
name: string,
28292836
objectName?: string,
2837+
objects?: readonly any[],
28302838
): Promise<{ action: any; objectName: string; obj: any } | null> {
2831-
const decls = await collectActionDeclarations(deps, meta);
2839+
const decls = await collectActionDeclarations(deps, meta, objects);
28322840
if (objectName) {
28332841
const hit = decls.find((d) => d.objectName === objectName && d.action?.name === name);
28342842
return hit ? { action: hit.action, objectName, obj: hit.obj } : null;
@@ -2860,11 +2868,19 @@ export async function resolveActionByName(deps: ActionExecutionDeps,
28602868
* declaration is the one that matches what actually runs. All MCP gating
28612869
* (`ai.exposed`, ADR-0066 D4, headless-invokability) applies downstream of
28622870
* this collection, unchanged.
2871+
*
2872+
* [#25139] `objects` are the definitions shape 1 reads; left out, the metadata
2873+
* service's `listObjects()`. The MCP bridge passes the protocol's merged
2874+
* listing, the one `list_objects` serves: on a kernel with an `environmentId`
2875+
* the metadata service holds no copy of an object a package registered during
2876+
* the boot, so its actions were neither listed nor resolved while REST's
2877+
* `/actions` door ran them. Shape 2 still reads the metadata service.
28632878
*/
28642879
export async function collectActionDeclarations(deps: ActionExecutionDeps,
28652880
meta: any,
2881+
objects?: readonly any[],
28662882
): Promise<Array<{ action: any; objectName: string; obj: any }>> {
2867-
const objs: any[] = (await meta?.listObjects?.()) ?? [];
2883+
const objs: readonly any[] = objects ?? (await meta?.listObjects?.()) ?? [];
28682884
const objByName = new Map<string, any>();
28692885
for (const obj of objs) {
28702886
if (typeof obj?.name === 'string') objByName.set(obj.name, obj);

0 commit comments

Comments
 (0)