Skip to content

Commit ae2b2bc

Browse files
committed
fix(runtime)!: the /mcp bridge reads an object's actions from the listing list_objects serves
list_actions, run_action and resume_run collected object-embedded actions from the metadata service's listObjects(). On a kernel with an environmentId that service holds no copy of an object a package registers during the boot, so its actions were neither listed nor resolved while list_objects listed the object and REST's /actions door ran them. The bridge now hands the three tools the object definitions list_objects lists (the protocol's merged listing, the metadata service only on a host with none). collectActionDeclarations and resolveActionByName take that listing as an optional input; invokeBusinessAction and resumeActionRun take a reader for it. A disabled package's actions are omitted and not found, as describe_object answers its objects. Claude-Session: https://claude.ai/code/session_01TgbP2QERdKmh147ohacHbK Co-authored-by: Claude <noreply@anthropic.com>
1 parent bda53bb commit ae2b2bc

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)