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
20 changes: 20 additions & 0 deletions .changeset/25297-mcp-standalone-actions-merged-listing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/runtime': patch
---

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

Clause-②: no (narrowing)
Metadata-protocol: none

<!-- 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). -->

**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.

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.

- **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`.
- **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.
- **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.
- **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.
- **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.
46 changes: 36 additions & 10 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2606,16 +2606,23 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
// protocol's merged read). Left out, the metadata service's
// `listObjects()` is read, as before.
readObjects?: () => Promise<readonly any[]>;
// [#25297] The standalone `action` items the name is resolved against
// — the MCP bridge passes the protocol's merged `action` listing.
// Left out, or answering `undefined` (a host with no merged read), the
// metadata service's `loadMany('action')` is read, as before.
readActions?: () => Promise<readonly any[] | undefined>;
callData: (action: string, params: any, dataDriver?: any, scopeId?: string, ec?: ExecutionContext) => Promise<any>;
},
): Promise<any> {
const { driver, envId, ec, getMeta, readObjects, callData } = wiring;
const { driver, envId, ec, getMeta, readObjects, readActions, callData } = wiring;
const meta: any = await getMeta();
const params = input?.params && typeof input.params === 'object' ? input.params : {};
const recordId = typeof input?.recordId === 'string' && input.recordId.length > 0 ? input.recordId : undefined;

// Resolve the action def by declarative name (optionally scoped).
const resolved = await resolveActionByName(deps, meta, name, input?.objectName, await readObjects?.());
const resolved = await resolveActionByName(
deps, meta, name, input?.objectName, await readObjects?.(), await readActions?.(),
);
if (!resolved) {
throw new Error(
input?.objectName
Expand Down Expand Up @@ -2828,15 +2835,17 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
* object name, or `null`. Throws when the name is ambiguous across objects
* and no `objectName` was supplied (so `run_action` can ask for one).
*
* `objects` is handed to {@link collectActionDeclarations} unchanged.
* `objects` and `actions` are handed to {@link collectActionDeclarations}
* unchanged.
*/
export async function resolveActionByName(deps: ActionExecutionDeps,
meta: any,
name: string,
objectName?: string,
objects?: readonly any[],
actions?: readonly any[],
): Promise<{ action: any; objectName: string; obj: any } | null> {
const decls = await collectActionDeclarations(deps, meta, objects);
const decls = await collectActionDeclarations(deps, meta, objects, actions);
if (objectName) {
const hit = decls.find((d) => d.objectName === objectName && d.action?.name === name);
return hit ? { action: hit.action, objectName, obj: hit.obj } : null;
Expand Down Expand Up @@ -2874,11 +2883,24 @@ export async function resolveActionByName(deps: ActionExecutionDeps,
* listing, the one `list_objects` serves: on a kernel with an `environmentId`
* the metadata service holds no copy of an object a package registered during
* the boot, so its actions were neither listed nor resolved while REST's
* `/actions` door ran them. Shape 2 still reads the metadata service.
* `/actions` door ran them.
*
* [#25297] `actions` are the items shape 2 reads; left out, the metadata
* service's `loadMany('action')`, whose failure answers none. The MCP bridge
* passes the protocol's merged `action` listing, the one
* `GET /api/v1/meta/action` serves. `loadMany` reads the service's loaders
* alone: they hold the compiled artifact's actions, but not an action a
* package registers through the `manifest` service (during the boot or by a
* marketplace install) nor an environment-authored `sys_metadata` row, which
* live in the registry and the overlay the merged read reads. So a package's
* object-less action, and an authored one, were never listed nor resolved
* while REST's `POST /actions/global/NAME` ran them; and an artifact
* package's action outlived the package's disable there.
*/
export async function collectActionDeclarations(deps: ActionExecutionDeps,
meta: any,
objects?: readonly any[],
actions?: readonly any[],
): Promise<Array<{ action: any; objectName: string; obj: any }>> {
const objs: readonly any[] = objects ?? (await meta?.listObjects?.()) ?? [];
const objByName = new Map<string, any>();
Expand All @@ -2896,11 +2918,15 @@ export async function collectActionDeclarations(deps: ActionExecutionDeps,
out.push({ action, objectName, obj });
}
}
let standalone: any[] = [];
try {
standalone = (await meta?.loadMany?.('action')) ?? [];
} catch {
standalone = []; // no standalone-item source on this metadata service
let standalone: readonly any[] = [];
if (actions) {
standalone = actions;
} else {
try {
standalone = (await meta?.loadMany?.('action')) ?? [];
} catch {
standalone = []; // no standalone-item source on this metadata service
}
}
for (const action of standalone) {
if (!action || typeof action.name !== 'string') continue;
Expand Down
Loading
Loading