Skip to content

Commit fae5dd0

Browse files
os-zhuangclaude
andauthored
fix(runtime): surface standalone authored action rows on the MCP action bridge (#3010) (#3020)
* fix(runtime): surface standalone authored action rows on the MCP action bridge (#3010) list_actions/run_action resolved declarations exclusively from object.actions, so a standalone authored `action` metadata row — executable since #2608 via resyncAuthoredActions — was invisible to list_actions and unresolvable by run_action, even with ai.exposed set. The bridge's declaration source is now collectActionDeclarations: object.actions unioned with standalone `action` items from metadata.loadMany('action'), keyed the same way the engine registers handlers (objectName field, legacy object field, else the 'global' wildcard) so the resolved declaration always matches the handler executeAction will find. On an objectName:name clash the object-embedded declaration wins, mirroring the execution layer's artifact-wins rule. All invoke-time gates (ai.exposed fail-closed, ADR-0066 D4 capability gate, headless-invokability, sys_* fail-closed) apply unchanged downstream of the collection. Closes #3010 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4uCgJCaSABHDz6UntuqEj * chore: changeset for MCP standalone authored-action surfacing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4uCgJCaSABHDz6UntuqEj --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 26fe609 commit fae5dd0

3 files changed

Lines changed: 215 additions & 27 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
Surface standalone authored `action` metadata rows on the MCP action bridge (#3010). `list_actions` and `run_action` now resolve declarations from `object.actions` unioned with standalone `action` items, keyed the same way the engine registers their handlers (`objectName` → legacy `object``'global'`), with object-embedded declarations winning on a key clash. Previously a Studio-authored standalone action executed via REST but was invisible and uninvokable on the MCP/AI surface, even with `ai.exposed: true`. All invoke-time gates (`ai.exposed` fail-closed, ADR-0066 D4 capability gate, sys_* fail-closed) are unchanged.

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2718,4 +2718,131 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', ()
27182718
const { bridge } = makeFlowBridge({ userId: 'u1', systemPermissions: [] }, { execute });
27192719
await expect(bridge.runAction('escalate_ticket', {})).rejects.toThrow(/boom/i);
27202720
});
2721+
2722+
// ── standalone authored `action` rows (#3010) ──
2723+
// Studio-authored standalone `action` metadata items execute since #2608
2724+
// (`resyncAuthoredActions` registers their body under the declarative name),
2725+
// but the bridge used to read declarations only from `object.actions`, so
2726+
// they were invisible to list_actions and unresolvable by run_action.
2727+
const standaloneScoped = {
2728+
name: 'archive_task',
2729+
label: 'Archive',
2730+
objectName: 'todo_task',
2731+
type: 'script',
2732+
body: { language: 'js', source: 'ctx.record.archived = true;' },
2733+
locations: ['record_header'],
2734+
params: [{ name: 'reason', type: 'text', required: true }],
2735+
ai: { exposed: true, description: 'Archive a completed todo task.' },
2736+
};
2737+
const standaloneGlobal = {
2738+
name: 'nightly_cleanup',
2739+
label: 'Nightly Cleanup',
2740+
type: 'script',
2741+
body: { language: 'js', source: 'return 1;' },
2742+
ai: { exposed: true, description: 'Purge stale drafts.' },
2743+
};
2744+
const standaloneUnexposed = {
2745+
name: 'raw_reindex',
2746+
objectName: 'todo_task',
2747+
type: 'script',
2748+
body: { language: 'js', source: 'return 1;' },
2749+
};
2750+
const standaloneOnSysObject = {
2751+
name: 'rotate_all',
2752+
objectName: 'sys_api_key',
2753+
type: 'script',
2754+
body: { language: 'js', source: 'return 1;' },
2755+
ai: { exposed: true },
2756+
};
2757+
// Same key as the embedded `complete_task` declaration — the embedded one wins.
2758+
const standaloneShadowing = {
2759+
name: 'complete_task',
2760+
objectName: 'todo_task',
2761+
type: 'script',
2762+
body: { language: 'js', source: 'return 1;' },
2763+
ai: { exposed: true, description: 'SHADOW — must not surface.' },
2764+
};
2765+
2766+
const makeStandaloneBridge = (execCtx: any, standaloneRows: any[]) => {
2767+
const executeAction = vi.fn(async (obj: string, key: string) => {
2768+
if (obj === 'todo_task' && key === 'archive_task') return { archived: true };
2769+
if (obj === 'global' && key === 'nightly_cleanup') return { purged: 3 };
2770+
if (key === 'completeTask') return { updated: true };
2771+
throw new Error(`Action '${key}' on object '${obj}' not found`);
2772+
});
2773+
const ql: any = {
2774+
executeAction,
2775+
registry: { getObject: (n: string) => (n === 'todo_task' ? todoObject : null) },
2776+
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
2777+
find: vi.fn(async () => []),
2778+
};
2779+
const metadata: any = {
2780+
listObjects: vi.fn(async () => [todoObject, sysObject]),
2781+
getObject: vi.fn(async (n: string) => (n === 'todo_task' ? todoObject : undefined)),
2782+
loadMany: vi.fn(async (type: string) => (type === 'action' ? standaloneRows : [])),
2783+
};
2784+
const kernel: any = {
2785+
context: { getService: (n: string) => (n === 'objectql' ? ql : n === 'metadata' ? metadata : null) },
2786+
};
2787+
const dispatcher = new HttpDispatcher(kernel);
2788+
const ctx: any = { request: {}, environmentId: 'platform', executionContext: execCtx };
2789+
return { bridge: (dispatcher as any).buildMcpBridge(ctx), executeAction, metadata };
2790+
};
2791+
2792+
it('list_actions surfaces standalone authored rows — object-scoped and global — under the engine-key object name', async () => {
2793+
const { bridge } = makeStandaloneBridge({ userId: 'u1', systemPermissions: [] }, [
2794+
standaloneScoped, standaloneGlobal, standaloneUnexposed, standaloneOnSysObject,
2795+
]);
2796+
const actions = await bridge.listActions();
2797+
const archive = actions.find((a: any) => a.name === 'archive_task');
2798+
expect(archive).toMatchObject({ objectName: 'todo_task', type: 'script' });
2799+
expect(archive.params).toEqual([expect.objectContaining({ name: 'reason', required: true })]);
2800+
expect(actions.find((a: any) => a.name === 'nightly_cleanup')).toMatchObject({ objectName: 'global' });
2801+
const names = actions.map((a: any) => a.name);
2802+
expect(names).not.toContain('raw_reindex'); // ai.exposed absent → hidden (#2849)
2803+
expect(names).not.toContain('rotate_all'); // sys_* owner → hidden fail-closed
2804+
});
2805+
2806+
it('list_actions dedupes a standalone row that shadows an object-embedded declaration (embedded wins)', async () => {
2807+
const { bridge } = makeStandaloneBridge({ userId: 'u1', systemPermissions: [] }, [standaloneShadowing]);
2808+
const matches = (await bridge.listActions()).filter((a: any) => a.name === 'complete_task');
2809+
expect(matches).toHaveLength(1);
2810+
expect(matches[0].description).not.toMatch(/SHADOW/);
2811+
});
2812+
2813+
it('run_action dispatches a standalone body action under its declarative name key', async () => {
2814+
const { bridge, executeAction } = makeStandaloneBridge({ userId: 'u1', systemPermissions: [] }, [standaloneScoped]);
2815+
const res = await bridge.runAction('archive_task', { params: { reason: 'done' } });
2816+
expect(res.ok).toBe(true);
2817+
expect(executeAction).toHaveBeenCalledWith(
2818+
'todo_task',
2819+
'archive_task', // body-based → registered under the declarative name, not a target
2820+
expect.objectContaining({ params: expect.objectContaining({ reason: 'done' }) }),
2821+
);
2822+
});
2823+
2824+
it('run_action dispatches a standalone GLOBAL action under the global wildcard key', async () => {
2825+
const { bridge, executeAction } = makeStandaloneBridge({ userId: 'u1', systemPermissions: [] }, [standaloneGlobal]);
2826+
const res = await bridge.runAction('nightly_cleanup', {});
2827+
expect(res.ok).toBe(true);
2828+
expect(executeAction).toHaveBeenCalledWith('global', 'nightly_cleanup', expect.anything());
2829+
});
2830+
2831+
it('run_action refuses an unexposed standalone row and never dispatches', async () => {
2832+
const { bridge, executeAction } = makeStandaloneBridge({ userId: 'u1', systemPermissions: [] }, [standaloneUnexposed]);
2833+
await expect(bridge.runAction('raw_reindex', {})).rejects.toThrow(/not exposed to AI/i);
2834+
expect(executeAction).not.toHaveBeenCalled();
2835+
});
2836+
2837+
it('run_action blocks a standalone row owned by a system object', async () => {
2838+
const { bridge, executeAction } = makeStandaloneBridge({ userId: 'u1', systemPermissions: [] }, [standaloneOnSysObject]);
2839+
await expect(bridge.runAction('rotate_all', {})).rejects.toThrow(/system object/i);
2840+
expect(executeAction).not.toHaveBeenCalled();
2841+
});
2842+
2843+
it('the bridge tolerates a metadata service without loadMany (standalone source absent)', async () => {
2844+
const { bridge } = makeBridge({ userId: 'u1', systemPermissions: [] }); // makeBridge's metadata mock has no loadMany
2845+
const names = (await bridge.listActions()).map((a: any) => a.name);
2846+
expect(names).toContain('complete_task');
2847+
});
27212848
});

packages/runtime/src/http-dispatcher.ts

Lines changed: 83 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -812,27 +812,21 @@ export class HttpDispatcher {
812812
// identity forwarded. No `@objectstack/service-ai`.
813813
listActions: async () => {
814814
const meta: any = await getMeta();
815-
const objs: any[] = (await meta?.listObjects?.()) ?? [];
816815
const hasAutomation = Boolean(
817816
await this.resolveService('automation', envId).catch(() => null),
818817
);
819818
const out: any[] = [];
820-
for (const obj of objs) {
821-
const objectName: string | undefined = obj?.name;
819+
for (const { action, objectName, obj } of await this.collectActionDeclarations(meta)) {
822820
if (!objectName || isSystemObjectName(objectName)) continue; // fail-closed on sys_*
823-
const actions: any[] = Array.isArray(obj?.actions) ? obj.actions : [];
824-
for (const action of actions) {
825-
if (!action || typeof action.name !== 'string') continue;
826-
if (!this.isHeadlessInvokableAction(action, hasAutomation)) continue;
827-
// [#2849 / ADR-0011] MCP is an AI surface: only actions the
828-
// author explicitly opted in via `ai.exposed` are listed.
829-
// Fail-closed — bodies run as trusted code (see
830-
// buildActionEngineFacade), so author opt-in is the boundary.
831-
if (this.actionAiExposureError(action)) continue;
832-
// Hide actions the caller is not permitted to run.
833-
if (this.actionPermissionError(action, ec)) continue;
834-
out.push(this.summarizeAction(action, obj, objectName));
835-
}
821+
if (!this.isHeadlessInvokableAction(action, hasAutomation)) continue;
822+
// [#2849 / ADR-0011] MCP is an AI surface: only actions the
823+
// author explicitly opted in via `ai.exposed` are listed.
824+
// Fail-closed — bodies run as trusted code (see
825+
// buildActionEngineFacade), so author opt-in is the boundary.
826+
if (this.actionAiExposureError(action)) continue;
827+
// Hide actions the caller is not permitted to run.
828+
if (this.actionPermissionError(action, ec)) continue;
829+
out.push(this.summarizeAction(action, obj, objectName));
836830
}
837831
return out;
838832
},
@@ -1176,24 +1170,86 @@ export class HttpDispatcher {
11761170
name: string,
11771171
objectName?: string,
11781172
): Promise<{ action: any; objectName: string } | null> {
1173+
const decls = await this.collectActionDeclarations(meta);
11791174
if (objectName) {
1180-
const def: any = await meta?.getObject?.(objectName);
1181-
const action = Array.isArray(def?.actions) ? def.actions.find((a: any) => a?.name === name) : undefined;
1182-
return action ? { action, objectName } : null;
1183-
}
1184-
const objs: any[] = (await meta?.listObjects?.()) ?? [];
1185-
const matches: Array<{ action: any; objectName: string }> = [];
1186-
for (const obj of objs) {
1187-
if (!obj?.name) continue;
1188-
const action = Array.isArray(obj?.actions) ? obj.actions.find((a: any) => a?.name === name) : undefined;
1189-
if (action) matches.push({ action, objectName: obj.name });
1175+
const hit = decls.find((d) => d.objectName === objectName && d.action?.name === name);
1176+
return hit ? { action: hit.action, objectName } : null;
11901177
}
1178+
const matches = decls.filter((d) => d.action?.name === name);
11911179
if (matches.length === 0) return null;
11921180
if (matches.length > 1) {
11931181
const where = matches.map((m) => m.objectName).join(', ');
11941182
throw new Error(`Action '${name}' exists on multiple objects (${where}); pass objectName to disambiguate`);
11951183
}
1196-
return matches[0];
1184+
return { action: matches[0].action, objectName: matches[0].objectName };
1185+
}
1186+
1187+
/**
1188+
* The MCP surface's single declaration source: every action declaration the
1189+
* bridge may list or invoke, as `{ action, objectName, obj }` rows.
1190+
*
1191+
* Two shapes feed it (#3010):
1192+
* 1. `object.actions` — bundle/artifact objects and authored object rows.
1193+
* 2. Standalone `action` metadata items — Studio-authored rows that the
1194+
* engine executes since #2608 (`resyncAuthoredActions`) but that never
1195+
* appear inside any object definition. Their owning object follows the
1196+
* same convention as the engine registration key (`objectName` field,
1197+
* legacy `object` field, else the `'global'` wildcard).
1198+
*
1199+
* On a key clash (`objectName:name`) the object-embedded declaration wins,
1200+
* mirroring the execution layer's artifact-wins rule — `resyncAuthoredActions`
1201+
* refuses to clobber an artifact-registered handler, so the embedded
1202+
* declaration is the one that matches what actually runs. All MCP gating
1203+
* (`ai.exposed`, ADR-0066 D4, headless-invokability) applies downstream of
1204+
* this collection, unchanged.
1205+
*/
1206+
private async collectActionDeclarations(
1207+
meta: any,
1208+
): Promise<Array<{ action: any; objectName: string; obj: any }>> {
1209+
const objs: any[] = (await meta?.listObjects?.()) ?? [];
1210+
const objByName = new Map<string, any>();
1211+
for (const obj of objs) {
1212+
if (typeof obj?.name === 'string') objByName.set(obj.name, obj);
1213+
}
1214+
const out: Array<{ action: any; objectName: string; obj: any }> = [];
1215+
const seen = new Set<string>();
1216+
for (const obj of objs) {
1217+
const objectName: string | undefined = obj?.name;
1218+
if (!objectName) continue;
1219+
for (const action of Array.isArray(obj?.actions) ? obj.actions : []) {
1220+
if (!action || typeof action.name !== 'string') continue;
1221+
seen.add(`${objectName}:${action.name}`);
1222+
out.push({ action, objectName, obj });
1223+
}
1224+
}
1225+
let standalone: any[] = [];
1226+
try {
1227+
standalone = (await meta?.loadMany?.('action')) ?? [];
1228+
} catch {
1229+
standalone = []; // no standalone-item source on this metadata service
1230+
}
1231+
for (const action of standalone) {
1232+
if (!action || typeof action.name !== 'string') continue;
1233+
const objectName = this.standaloneActionObjectName(action);
1234+
const key = `${objectName}:${action.name}`;
1235+
if (seen.has(key)) continue; // object-embedded declaration wins
1236+
seen.add(key);
1237+
out.push({ action, objectName, obj: objByName.get(objectName) });
1238+
}
1239+
return out;
1240+
}
1241+
1242+
/**
1243+
* Owning object of a standalone `action` item — must stay in lockstep with
1244+
* the ObjectQL plugin's `actionObjectKey` (the engine registration key), so
1245+
* the declaration the MCP surface resolves is the one whose handler
1246+
* `executeAction` will find: spec `objectName`, bundle-collector `object`,
1247+
* else the `'global'` wildcard.
1248+
*/
1249+
private standaloneActionObjectName(action: any): string {
1250+
if (typeof action?.objectName === 'string' && action.objectName.length > 0) return action.objectName;
1251+
if (typeof action?.object === 'string' && action.object.length > 0) return action.object;
1252+
return 'global';
11971253
}
11981254

11991255
/**

0 commit comments

Comments
 (0)