Skip to content

Commit 882372d

Browse files
fix(runtime)!: the /mcp bridge's list_actions, run_action and resume_run read the actions no object embeds from the merged action listing, so a package's object-less action and an environment-authored one are listed and run (#25304)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu4BM6tVgxhg4qFQsbuRHiy9)_ Fixes #25297 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 `action` items that no object embeds (the object-less `global` actions among them) from the protocol's merged `action` listing, the one `GET /api/v1/meta/action` serves. They read the `metadata` service's `loadMany('action')` until now. That call reads the service's loaders alone, so it never held an action a package registers through the `manifest` service, nor an environment-authored one, while REST's `POST /api/v1/actions/global/NAME` (`OBJECT/NAME` for a bound one) ran both. The listing is read through the same `readMergedItems` the object listing uses since #25120 and #25139, not copied. ## The defect, measured on PR #25291's head `ae2b2bc5ff` `collectActionDeclarations` (`packages/runtime/src/action-execution.ts`) built its second shape, the standalone rows, from `meta.loadMany('action')`. `MetadataManager.loadMany` aggregates `loader.loadMany(type)` over the registered loaders and nothing else: not the in-memory `register()` registry and not `sys_metadata`. On these rigs, and in the in-repo compositions, the loaders are the filesystem loader and, on an artifact boot, the artifact door's in-memory loader. No `DatabaseLoader` is wired (an `environmentId` kernel skips it by design). 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 through the same dispatcher. Both shapes (`environmentId` `env_local` as `createStandaloneStack` composes it, and none) answered the same on every row: | the standalone action, and where it came from | `loadMany('action')` | `GET /meta/action` | `list_actions` | `run_action` | REST `POST /actions/global/NAME` (`OBJECT/NAME` when bound) | | :-- | :-- | :-- | :-- | :-- | :-- | | a package's, registered through the `manifest` service (a plugin during the boot, or a marketplace install) | absent | listed | **not listed** | **`Action 'NAME' not found`** | `200`, the handler's answer | | an environment-authored row (`protocol.saveMetaItem`, the `PUT /api/v1/meta/action/NAME` write) | absent | listed | **not listed** | **`Action 'NAME' not found`** | `200` | | the same, bound to a package object (the merged object listing does not fold an authored row into the object's `actions`) | absent | listed | **not listed** | **`… not found on object 'OBJECT'`** | `200` | | the compiled artifact's, its `packages[]` included (the artifact door and `AppPlugin` over one bundle) | held | listed | listed | run | `200` | | the same, its package disabled (`registry.disablePackage`) | still held | omitted | **listed** | **run** | `200` | | a `manifest`-registered package's, the package disabled | absent | omitted | not listed | `not found` | `200` | Scoped to `objectName: 'global'`, `run_action` answered `Action 'NAME' not found on object 'global'` on the first two rows. The MCP columns are what the pins read; the bold cells are what the main leg below fails on. The `loadMany` and `GET /meta/action` columns are the pins' preconditions and a scratch probe over the same rig, not committed. The last column of the last two rows is REST's door running a disabled package's action, the defect #25296 tracks. ## What changed - **`packages/runtime/src/domains/mcp.ts`.** `buildMcpBridge` gains `readServedActions()`: `readMergedItems(protocol, 'action')`, whose type union widens by `'action'`. `list_actions` hands it to `collectActionDeclarations`, and `run_action` and `resume_run` pass it as `readActions`. It answers `undefined` on a host with no merged read, and the collection then reads `loadMany('action')` as before. `resumeActionRun`'s wiring gains an optional `readActions`. - **`packages/runtime/src/action-execution.ts`.** `collectActionDeclarations(deps, meta, objects?, actions?)` and `resolveActionByName(deps, meta, name, objectName?, objects?, actions?)` take the standalone items as an optional last input; left out, they read `meta.loadMany('action')` as before (its failure still answers none), so REST's activation-ambiguity check, the one other caller, is unchanged. `invokeBusinessAction`'s wiring gains an optional `readActions`. An object-embedded declaration still wins over a standalone one with the same object and name, and every gate after collection is untouched. None of these functions is published by `index.ts`. - **`.changeset/25297-mcp-standalone-actions-merged-listing.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`, on both kernel shapes: 1. **`list_actions`, for an object-less action a package registered through the `manifest` service, or an environment-authored action (object-less, or bound to an object).** 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` (`… not found on object 'global'`, or on its object, when scoped). New: resolved and dispatched behind the same gates, as REST's `POST /actions/OBJECT/NAME` 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. **A package's top-level action bound to one of its objects.** Old: collected from the object's `actions`, where the registry carries it (#25139). New: collected there and from the merged `action` listing too; the object-embedded declaration wins the clash, so its answer does not change. 5. **The three tools on a disabled compiled-artifact package's object-less action** (the narrowing). Old: listed and run, from the artifact loader's copy. New: omitted from `list_actions`; `run_action` answers `Action 'NAME' not found` (`… on object 'global'` when scoped); `resume_run` refuses its runs `403` (derived from the empty candidate set, not pinned). As `GET /api/v1/meta/action` omits it and `list_objects` omits the package's objects. 6. **The three tools while the merged `action` listing throws.** Old: `loadMany('action')`, a failure of which answered none. New: the protocol's error reaches the caller, as a throwing merged object listing does since #25139; nothing falls back. Pinned with a throwing double, not on a real store outage. Unchanged: the compiled artifact's object-less actions are listed and run as before (pinned as a control, its `loadMany` copy shown as the precondition). Object-embedded actions, a host with no `protocol` service or one without the merged listing (it reads `loadMany('action')`), every gate after resolution, and REST's `/actions` door, which still resolves through `ql.getSchema`, the registry and `loadDiagnosed`. 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. ## Pins - **`packages/runtime/src/domains/mcp-package-object-discovery.test.ts`, 38 new cases on real kernels, in #25120's rig (144 in the file).** A package of two objects with two top-level object-less actions, `qa_glb_ping` (exposed to AI, a registered handler) and `qa_glb_hidden` (not exposed), and two environment-authored actions saved through the protocol after the boot, `qa_authored_ping` (object-less) and `qa_authored_bound` (bound to the package's object). For each of the four shape×registration rows: `list_actions` lists exactly `qa_authored_ping` and `qa_glb_ping` among the object-less actions; `run_action qa_glb_ping` answers the handler's result unscoped and scoped to `global`, and REST's door answers it `200`; `run_action qa_authored_ping` likewise, as REST does; `qa_authored_bound` is listed on its object and run there, as REST runs it; `qa_glb_hidden` is resolved and refused at the exposure gate; and a name nothing declares is still `Action 'qa_nowhere' not found`. For a disabled package on both shapes: `list_actions` lists `qa_authored_ping` alone, and `run_action` answers `qa_glb_ping` "not found", scoped and unscoped, while the authored one still runs. For a compiled artifact booted as `createStandaloneStack` boots one (the artifact door and an `AppPlugin` over the same bundle), on both shapes: a precondition shows the metadata service's loaders hold both object-less actions; as a control, `list_actions` lists and `run_action` runs both, as REST does; with the artifact's `packages[]` entry disabled, the loaders still hold its action (precondition), `list_actions` lists only the project's own, and `run_action` answers the package's "not found", scoped and unscoped, and still runs the project's own. - **`packages/runtime/src/mcp-resume-run.test.ts`, 3 new cases, doubles (28 in the file).** With a merged listing that serves an object-less flow action and a `metadata` service whose `loadMany` holds none: `run_action` starts the screen flow and `resume_run` completes it, `getMetaItems({ type: 'action' })` asked and `loadMany` never; `list_actions` lists it. With a merged `action` listing that throws (the object listing still served): `list_actions`, `run_action` and `resume_run` fail with its error, `loadMany` is not asked, and the engine's `resume` is not called. The harness's `standalone` option and `'action-throws'` arm are additive; every existing case runs as before. **Red before, green after.** Each leg's failing set was itemized before it ran; all six runs read both pin files (172 cases), on this PR's head `ee2cd6e7` over `origin/main` `fb7501bc6` (PR #25291's merge). A first pass on a local commit over PR #25291's unmerged head, before the bound-action case was added, answered the same legs with the same itemized sets (27, 10, 18, 2, 26, 0 of 168). Mutations were landed and restored by `scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed; restored blob equals HEAD, `git diff HEAD` empty); the main leg wrote the base blobs of the two files and restored them by checkout from 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 | 31 | on the four rigs every case but the control (20); the two disabled-package rigs (4, the authored action missing); the two disabled-artifact-package cases on each shape (4); the three unit cases | | listactions | `list_actions` hands no standalone items | 14 | every real-kernel case that reads `list_actions`, but the artifact control (12), the unit listing case, the throwing case | | invoke | `run_action` hands no standalone items | 22 | the four `run_action` cases on the four rigs (16), the disabled-package and disabled-artifact-package `run_action` cases (4), the unit resume case (its `run_action` start), the throwing case | | resume | `resume_run` hands no standalone items | 2 | the unit resume case (`403`), the throwing case | | collect | `collectActionDeclarations` ignores the items it is given | 30 | every case the main leg failed but the throwing one (the bridge still asks the throwing listing) | | fix | `ee2cd6e7` | 0 | 172 passed | ## Local verification On `ee2cd6e7`, this PR's head, over `origin/main` `fb7501bc6`. 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`, 172 passed. - `@objectstack/runtime` unit suite (`pnpm --filter @objectstack/runtime test`): `VERDICT command-exit 0`; Test Files 436 passed (436); Tests 6969 passed, 19 skipped (6988). - `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`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 68 families derived; each run, every one `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-adr-0087-registration.mjs --base origin/main` on this head: exit 0 (one declared-breaking changeset, `not-required (no-migration-prescription)`). - `node scripts/check-closing-keyword-parity.mjs --body` on this body: exit 2, its "closing declarations found" answer: the one `Fixes` line at the top of this body and nothing else, bound by the duplicate-fix guard's and the part-of guard's parsers. - The full dogfood battery (owed by the `!` and the `(narrowing)` arm; the PR's Dogfood shards skip on pull requests) runs once on this head after the PR opens, and its counts are posted on this PR as the `dogfood_battery` comment. ## Acceptance notes - **The card's environment-authored control was not green at the base.** The card assumed `loadMany('action')` holds the environment-authored rows. It does not on either shape: the row is in `sys_metadata`, which no loader of the metadata service reads here, so the authored action was invisible to MCP by the same cause, and this change makes it listed and run. The control that is green at the base, and stays green, is the compiled artifact's object-less action; the pins carry both. - **The narrowing is the artifact loader's copy.** A disabled artifact package's object-less action was listed and run through the copy the artifact door keeps, as #25139 found for a disabled package's objects. A `manifest`-registered package's was never there, so its disable already hid it from MCP. - **REST's door still runs a disabled package's object-less action** (`200` on the last two rows of the table). That is #25296's door, not this one. - **The served listing, not the execution one.** `run_action` resolves its declaration from `getMetaItems`, the served face that answers callers, as `list_actions` lists from it and as #25139 resolves object-embedded actions. `action` has no registered redactor, so the served body is the stored one today. `getMetaItemsForExecution` is documented as never to be called from an MCP door. - **Each call now reads the merged listing twice**, once for `object` and once for `action`. - **REST's activation-ambiguity check still collects from `loadMany('action')`.** `refuseAmbiguousActionActivation` (`domains/actions.ts`) passes no items, so its reading is unchanged. Not MCP, and not measured here. - **Branch name.** This PR's branch is `claude/project-thread-dkk3hd`, the name the session was given, not `claude/issue-25297-…`; the Duplicate Fix Guard may say so. It carried #25291 (#25139) 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 829cb77 commit 882372d

5 files changed

Lines changed: 426 additions & 19 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 the actions no object embeds from the merged `action` listing, so a package's object-less action and an environment-authored one are listed and run, and a disabled package's is omitted
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 where the HTTP MCP bridge reads the standalone `action` items from: the protocol's merged listing, the one `GET /api/v1/meta/action` already serves, instead of the metadata service's `loadMany('action')`. 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 compiled-artifact package's object-less 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 `action` items that no object embeds, the object-less (`global`) actions among them, from the `metadata` service's `loadMany('action')`. That call reads the service's loaders alone. They hold the compiled artifact's actions, but not an action a package registers through the `manifest` service (a capability plugin during the boot, or a marketplace install) and not an environment-authored action saved through `PUT /api/v1/meta/action/NAME`. Those live in the registry and in `sys_metadata`, which `GET /api/v1/meta/action` reads, and REST's `POST /api/v1/actions/global/NAME` ran them.
15+
16+
- **Before.** On every kernel, such an action was missing from `list_actions`, `run_action` answered `Action 'NAME' not found` (`Action 'NAME' not found on object 'global'` when scoped), and `resume_run` refused a run of such a flow action `403`.
17+
- **Now.** The three tools collect those items from the protocol's merged `action` listing, the one `GET /api/v1/meta/action` serves, through the same read the object listing uses. 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`, a registered handler for a script action), and runs as REST's `/actions` door runs it.
18+
- **Also changed.** A compiled-artifact package's object-less action stayed in the loader after the package was disabled, so it was still listed and run. It is now omitted from `list_actions`, and `run_action` answers `Action 'NAME' not found` for it, as the package's objects are omitted since #25120. When the merged `action` listing throws, the three tools fail with that error instead of reading the `metadata` service.
19+
- **Unchanged.** The compiled artifact's actions are listed and run as before. An object-embedded declaration still wins over a standalone one with the same object and name. A host with no `protocol` service, or one without the merged listing, keeps reading `loadMany('action')`. 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 package the rest of the platform no longer serves: re-enable the package to restore it.

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

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2606,16 +2606,23 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
26062606
// protocol's merged read). Left out, the metadata service's
26072607
// `listObjects()` is read, as before.
26082608
readObjects?: () => Promise<readonly any[]>;
2609+
// [#25297] The standalone `action` items the name is resolved against
2610+
// — the MCP bridge passes the protocol's merged `action` listing.
2611+
// Left out, or answering `undefined` (a host with no merged read), the
2612+
// metadata service's `loadMany('action')` is read, as before.
2613+
readActions?: () => Promise<readonly any[] | undefined>;
26092614
callData: (action: string, params: any, dataDriver?: any, scopeId?: string, ec?: ExecutionContext) => Promise<any>;
26102615
},
26112616
): Promise<any> {
2612-
const { driver, envId, ec, getMeta, readObjects, callData } = wiring;
2617+
const { driver, envId, ec, getMeta, readObjects, readActions, callData } = wiring;
26132618
const meta: any = await getMeta();
26142619
const params = input?.params && typeof input.params === 'object' ? input.params : {};
26152620
const recordId = typeof input?.recordId === 'string' && input.recordId.length > 0 ? input.recordId : undefined;
26162621

26172622
// Resolve the action def by declarative name (optionally scoped).
2618-
const resolved = await resolveActionByName(deps, meta, name, input?.objectName, await readObjects?.());
2623+
const resolved = await resolveActionByName(
2624+
deps, meta, name, input?.objectName, await readObjects?.(), await readActions?.(),
2625+
);
26192626
if (!resolved) {
26202627
throw new Error(
26212628
input?.objectName
@@ -2828,15 +2835,17 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
28282835
* object name, or `null`. Throws when the name is ambiguous across objects
28292836
* and no `objectName` was supplied (so `run_action` can ask for one).
28302837
*
2831-
* `objects` is handed to {@link collectActionDeclarations} unchanged.
2838+
* `objects` and `actions` are handed to {@link collectActionDeclarations}
2839+
* unchanged.
28322840
*/
28332841
export async function resolveActionByName(deps: ActionExecutionDeps,
28342842
meta: any,
28352843
name: string,
28362844
objectName?: string,
28372845
objects?: readonly any[],
2846+
actions?: readonly any[],
28382847
): Promise<{ action: any; objectName: string; obj: any } | null> {
2839-
const decls = await collectActionDeclarations(deps, meta, objects);
2848+
const decls = await collectActionDeclarations(deps, meta, objects, actions);
28402849
if (objectName) {
28412850
const hit = decls.find((d) => d.objectName === objectName && d.action?.name === name);
28422851
return hit ? { action: hit.action, objectName, obj: hit.obj } : null;
@@ -2874,11 +2883,24 @@ export async function resolveActionByName(deps: ActionExecutionDeps,
28742883
* listing, the one `list_objects` serves: on a kernel with an `environmentId`
28752884
* the metadata service holds no copy of an object a package registered during
28762885
* 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.
2886+
* `/actions` door ran them.
2887+
*
2888+
* [#25297] `actions` are the items shape 2 reads; left out, the metadata
2889+
* service's `loadMany('action')`, whose failure answers none. The MCP bridge
2890+
* passes the protocol's merged `action` listing, the one
2891+
* `GET /api/v1/meta/action` serves. `loadMany` reads the service's loaders
2892+
* alone: they hold the compiled artifact's actions, but not an action a
2893+
* package registers through the `manifest` service (during the boot or by a
2894+
* marketplace install) nor an environment-authored `sys_metadata` row, which
2895+
* live in the registry and the overlay the merged read reads. So a package's
2896+
* object-less action, and an authored one, were never listed nor resolved
2897+
* while REST's `POST /actions/global/NAME` ran them; and an artifact
2898+
* package's action outlived the package's disable there.
28782899
*/
28792900
export async function collectActionDeclarations(deps: ActionExecutionDeps,
28802901
meta: any,
28812902
objects?: readonly any[],
2903+
actions?: readonly any[],
28822904
): Promise<Array<{ action: any; objectName: string; obj: any }>> {
28832905
const objs: readonly any[] = objects ?? (await meta?.listObjects?.()) ?? [];
28842906
const objByName = new Map<string, any>();
@@ -2896,11 +2918,15 @@ export async function collectActionDeclarations(deps: ActionExecutionDeps,
28962918
out.push({ action, objectName, obj });
28972919
}
28982920
}
2899-
let standalone: any[] = [];
2900-
try {
2901-
standalone = (await meta?.loadMany?.('action')) ?? [];
2902-
} catch {
2903-
standalone = []; // no standalone-item source on this metadata service
2921+
let standalone: readonly any[] = [];
2922+
if (actions) {
2923+
standalone = actions;
2924+
} else {
2925+
try {
2926+
standalone = (await meta?.loadMany?.('action')) ?? [];
2927+
} catch {
2928+
standalone = []; // no standalone-item source on this metadata service
2929+
}
29042930
}
29052931
for (const action of standalone) {
29062932
if (!action || typeof action.name !== 'string') continue;

0 commit comments

Comments
 (0)