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/25139-mcp-actions-merged-object-view.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 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

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

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

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.

- **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.
- **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.
- **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.
- **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.
- **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.
24 changes: 20 additions & 4 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2601,16 +2601,21 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
envId?: string;
ec: any;
getMeta: () => any;
// [#25139] The objects whose `actions[]` the name is resolved against —
// the MCP bridge passes the listing `list_objects` serves (the
// protocol's merged read). Left out, the metadata service's
// `listObjects()` is read, as before.
readObjects?: () => Promise<readonly any[]>;
callData: (action: string, params: any, dataDriver?: any, scopeId?: string, ec?: ExecutionContext) => Promise<any>;
},
): Promise<any> {
const { driver, envId, ec, getMeta, callData } = wiring;
const { driver, envId, ec, getMeta, readObjects, 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);
const resolved = await resolveActionByName(deps, meta, name, input?.objectName, await readObjects?.());
if (!resolved) {
throw new Error(
input?.objectName
Expand Down Expand Up @@ -2822,13 +2827,16 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
* optionally scoped to a single object. Returns the action plus its owning
* 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.
*/
export async function resolveActionByName(deps: ActionExecutionDeps,
meta: any,
name: string,
objectName?: string,
objects?: readonly any[],
): Promise<{ action: any; objectName: string; obj: any } | null> {
const decls = await collectActionDeclarations(deps, meta);
const decls = await collectActionDeclarations(deps, meta, objects);
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 @@ -2860,11 +2868,19 @@ export async function resolveActionByName(deps: ActionExecutionDeps,
* declaration is the one that matches what actually runs. All MCP gating
* (`ai.exposed`, ADR-0066 D4, headless-invokability) applies downstream of
* this collection, unchanged.
*
* [#25139] `objects` are the definitions shape 1 reads; left out, the metadata
* service's `listObjects()`. The MCP bridge passes the protocol's merged
* 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.
*/
export async function collectActionDeclarations(deps: ActionExecutionDeps,
meta: any,
objects?: readonly any[],
): Promise<Array<{ action: any; objectName: string; obj: any }>> {
const objs: any[] = (await meta?.listObjects?.()) ?? [];
const objs: readonly any[] = objects ?? (await meta?.listObjects?.()) ?? [];
const objByName = new Map<string, any>();
for (const obj of objs) {
if (typeof obj?.name === 'string') objByName.set(obj.name, obj);
Expand Down
Loading
Loading