Skip to content

Commit ee2cd6e

Browse files
committed
fix(runtime)!: the /mcp bridge reads standalone actions from the merged action listing, so a package's object-less action and an authored one are listed and run
list_actions, run_action and resume_run collected the action items no object embeds from metadata.loadMany('action'), which reads the metadata service's loaders alone: the compiled artifact's actions, but not an action a package registers through the manifest service nor an environment-authored row. They now collect them from the protocol's merged action listing, the read GET /api/v1/meta/action serves, through the same readMergedItems the object listing uses; a host with no merged read still reads loadMany. A disabled artifact package's action, which the loader copy kept listing, is omitted. Claude-Session: https://claude.ai/code/session_01TgbP2QERdKmh147ohacHbK Co-authored-by: Claude <noreply@anthropic.com>
1 parent fb7501b commit ee2cd6e

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)