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

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.
127 changes: 127 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2718,4 +2718,131 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', ()
const { bridge } = makeFlowBridge({ userId: 'u1', systemPermissions: [] }, { execute });
await expect(bridge.runAction('escalate_ticket', {})).rejects.toThrow(/boom/i);
});

// ── standalone authored `action` rows (#3010) ──
// Studio-authored standalone `action` metadata items execute since #2608
// (`resyncAuthoredActions` registers their body under the declarative name),
// but the bridge used to read declarations only from `object.actions`, so
// they were invisible to list_actions and unresolvable by run_action.
const standaloneScoped = {
name: 'archive_task',
label: 'Archive',
objectName: 'todo_task',
type: 'script',
body: { language: 'js', source: 'ctx.record.archived = true;' },
locations: ['record_header'],
params: [{ name: 'reason', type: 'text', required: true }],
ai: { exposed: true, description: 'Archive a completed todo task.' },
};
const standaloneGlobal = {
name: 'nightly_cleanup',
label: 'Nightly Cleanup',
type: 'script',
body: { language: 'js', source: 'return 1;' },
ai: { exposed: true, description: 'Purge stale drafts.' },
};
const standaloneUnexposed = {
name: 'raw_reindex',
objectName: 'todo_task',
type: 'script',
body: { language: 'js', source: 'return 1;' },
};
const standaloneOnSysObject = {
name: 'rotate_all',
objectName: 'sys_api_key',
type: 'script',
body: { language: 'js', source: 'return 1;' },
ai: { exposed: true },
};
// Same key as the embedded `complete_task` declaration — the embedded one wins.
const standaloneShadowing = {
name: 'complete_task',
objectName: 'todo_task',
type: 'script',
body: { language: 'js', source: 'return 1;' },
ai: { exposed: true, description: 'SHADOW — must not surface.' },
};

const makeStandaloneBridge = (execCtx: any, standaloneRows: any[]) => {
const executeAction = vi.fn(async (obj: string, key: string) => {
if (obj === 'todo_task' && key === 'archive_task') return { archived: true };
if (obj === 'global' && key === 'nightly_cleanup') return { purged: 3 };
if (key === 'completeTask') return { updated: true };
throw new Error(`Action '${key}' on object '${obj}' not found`);
});
const ql: any = {
executeAction,
registry: { getObject: (n: string) => (n === 'todo_task' ? todoObject : null) },
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
find: vi.fn(async () => []),
};
const metadata: any = {
listObjects: vi.fn(async () => [todoObject, sysObject]),
getObject: vi.fn(async (n: string) => (n === 'todo_task' ? todoObject : undefined)),
loadMany: vi.fn(async (type: string) => (type === 'action' ? standaloneRows : [])),
};
const kernel: any = {
context: { getService: (n: string) => (n === 'objectql' ? ql : n === 'metadata' ? metadata : null) },
};
const dispatcher = new HttpDispatcher(kernel);
const ctx: any = { request: {}, environmentId: 'platform', executionContext: execCtx };
return { bridge: (dispatcher as any).buildMcpBridge(ctx), executeAction, metadata };
};

it('list_actions surfaces standalone authored rows — object-scoped and global — under the engine-key object name', async () => {
const { bridge } = makeStandaloneBridge({ userId: 'u1', systemPermissions: [] }, [
standaloneScoped, standaloneGlobal, standaloneUnexposed, standaloneOnSysObject,
]);
const actions = await bridge.listActions();
const archive = actions.find((a: any) => a.name === 'archive_task');
expect(archive).toMatchObject({ objectName: 'todo_task', type: 'script' });
expect(archive.params).toEqual([expect.objectContaining({ name: 'reason', required: true })]);
expect(actions.find((a: any) => a.name === 'nightly_cleanup')).toMatchObject({ objectName: 'global' });
const names = actions.map((a: any) => a.name);
expect(names).not.toContain('raw_reindex'); // ai.exposed absent → hidden (#2849)
expect(names).not.toContain('rotate_all'); // sys_* owner → hidden fail-closed
});

it('list_actions dedupes a standalone row that shadows an object-embedded declaration (embedded wins)', async () => {
const { bridge } = makeStandaloneBridge({ userId: 'u1', systemPermissions: [] }, [standaloneShadowing]);
const matches = (await bridge.listActions()).filter((a: any) => a.name === 'complete_task');
expect(matches).toHaveLength(1);
expect(matches[0].description).not.toMatch(/SHADOW/);
});

it('run_action dispatches a standalone body action under its declarative name key', async () => {
const { bridge, executeAction } = makeStandaloneBridge({ userId: 'u1', systemPermissions: [] }, [standaloneScoped]);
const res = await bridge.runAction('archive_task', { params: { reason: 'done' } });
expect(res.ok).toBe(true);
expect(executeAction).toHaveBeenCalledWith(
'todo_task',
'archive_task', // body-based → registered under the declarative name, not a target
expect.objectContaining({ params: expect.objectContaining({ reason: 'done' }) }),
);
});

it('run_action dispatches a standalone GLOBAL action under the global wildcard key', async () => {
const { bridge, executeAction } = makeStandaloneBridge({ userId: 'u1', systemPermissions: [] }, [standaloneGlobal]);
const res = await bridge.runAction('nightly_cleanup', {});
expect(res.ok).toBe(true);
expect(executeAction).toHaveBeenCalledWith('global', 'nightly_cleanup', expect.anything());
});

it('run_action refuses an unexposed standalone row and never dispatches', async () => {
const { bridge, executeAction } = makeStandaloneBridge({ userId: 'u1', systemPermissions: [] }, [standaloneUnexposed]);
await expect(bridge.runAction('raw_reindex', {})).rejects.toThrow(/not exposed to AI/i);
expect(executeAction).not.toHaveBeenCalled();
});

it('run_action blocks a standalone row owned by a system object', async () => {
const { bridge, executeAction } = makeStandaloneBridge({ userId: 'u1', systemPermissions: [] }, [standaloneOnSysObject]);
await expect(bridge.runAction('rotate_all', {})).rejects.toThrow(/system object/i);
expect(executeAction).not.toHaveBeenCalled();
});

it('the bridge tolerates a metadata service without loadMany (standalone source absent)', async () => {
const { bridge } = makeBridge({ userId: 'u1', systemPermissions: [] }); // makeBridge's metadata mock has no loadMany
const names = (await bridge.listActions()).map((a: any) => a.name);
expect(names).toContain('complete_task');
});
});
110 changes: 83 additions & 27 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,27 +812,21 @@ export class HttpDispatcher {
// identity forwarded. No `@objectstack/service-ai`.
listActions: async () => {
const meta: any = await getMeta();
const objs: any[] = (await meta?.listObjects?.()) ?? [];
const hasAutomation = Boolean(
await this.resolveService('automation', envId).catch(() => null),
);
const out: any[] = [];
for (const obj of objs) {
const objectName: string | undefined = obj?.name;
for (const { action, objectName, obj } of await this.collectActionDeclarations(meta)) {
if (!objectName || isSystemObjectName(objectName)) continue; // fail-closed on sys_*
const actions: any[] = Array.isArray(obj?.actions) ? obj.actions : [];
for (const action of actions) {
if (!action || typeof action.name !== 'string') continue;
if (!this.isHeadlessInvokableAction(action, hasAutomation)) continue;
// [#2849 / ADR-0011] MCP is an AI surface: only actions the
// author explicitly opted in via `ai.exposed` are listed.
// Fail-closed — bodies run as trusted code (see
// buildActionEngineFacade), so author opt-in is the boundary.
if (this.actionAiExposureError(action)) continue;
// Hide actions the caller is not permitted to run.
if (this.actionPermissionError(action, ec)) continue;
out.push(this.summarizeAction(action, obj, objectName));
}
if (!this.isHeadlessInvokableAction(action, hasAutomation)) continue;
// [#2849 / ADR-0011] MCP is an AI surface: only actions the
// author explicitly opted in via `ai.exposed` are listed.
// Fail-closed — bodies run as trusted code (see
// buildActionEngineFacade), so author opt-in is the boundary.
if (this.actionAiExposureError(action)) continue;
// Hide actions the caller is not permitted to run.
if (this.actionPermissionError(action, ec)) continue;
out.push(this.summarizeAction(action, obj, objectName));
}
return out;
},
Expand Down Expand Up @@ -1176,24 +1170,86 @@ export class HttpDispatcher {
name: string,
objectName?: string,
): Promise<{ action: any; objectName: string } | null> {
const decls = await this.collectActionDeclarations(meta);
if (objectName) {
const def: any = await meta?.getObject?.(objectName);
const action = Array.isArray(def?.actions) ? def.actions.find((a: any) => a?.name === name) : undefined;
return action ? { action, objectName } : null;
}
const objs: any[] = (await meta?.listObjects?.()) ?? [];
const matches: Array<{ action: any; objectName: string }> = [];
for (const obj of objs) {
if (!obj?.name) continue;
const action = Array.isArray(obj?.actions) ? obj.actions.find((a: any) => a?.name === name) : undefined;
if (action) matches.push({ action, objectName: obj.name });
const hit = decls.find((d) => d.objectName === objectName && d.action?.name === name);
return hit ? { action: hit.action, objectName } : null;
}
const matches = decls.filter((d) => d.action?.name === name);
if (matches.length === 0) return null;
if (matches.length > 1) {
const where = matches.map((m) => m.objectName).join(', ');
throw new Error(`Action '${name}' exists on multiple objects (${where}); pass objectName to disambiguate`);
}
return matches[0];
return { action: matches[0].action, objectName: matches[0].objectName };
}

/**
* The MCP surface's single declaration source: every action declaration the
* bridge may list or invoke, as `{ action, objectName, obj }` rows.
*
* Two shapes feed it (#3010):
* 1. `object.actions` — bundle/artifact objects and authored object rows.
* 2. Standalone `action` metadata items — Studio-authored rows that the
* engine executes since #2608 (`resyncAuthoredActions`) but that never
* appear inside any object definition. Their owning object follows the
* same convention as the engine registration key (`objectName` field,
* legacy `object` field, else the `'global'` wildcard).
*
* On a key clash (`objectName:name`) the object-embedded declaration wins,
* mirroring the execution layer's artifact-wins rule — `resyncAuthoredActions`
* refuses to clobber an artifact-registered handler, so the embedded
* 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.
*/
private async collectActionDeclarations(
meta: any,
): Promise<Array<{ action: any; objectName: string; obj: any }>> {
const objs: any[] = (await meta?.listObjects?.()) ?? [];
const objByName = new Map<string, any>();
for (const obj of objs) {
if (typeof obj?.name === 'string') objByName.set(obj.name, obj);
}
const out: Array<{ action: any; objectName: string; obj: any }> = [];
const seen = new Set<string>();
for (const obj of objs) {
const objectName: string | undefined = obj?.name;
if (!objectName) continue;
for (const action of Array.isArray(obj?.actions) ? obj.actions : []) {
if (!action || typeof action.name !== 'string') continue;
seen.add(`${objectName}:${action.name}`);
out.push({ action, objectName, obj });
}
}
let standalone: any[] = [];
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;
const objectName = this.standaloneActionObjectName(action);
const key = `${objectName}:${action.name}`;
if (seen.has(key)) continue; // object-embedded declaration wins
seen.add(key);
out.push({ action, objectName, obj: objByName.get(objectName) });
}
return out;
}

/**
* Owning object of a standalone `action` item — must stay in lockstep with
* the ObjectQL plugin's `actionObjectKey` (the engine registration key), so
* the declaration the MCP surface resolves is the one whose handler
* `executeAction` will find: spec `objectName`, bundle-collector `object`,
* else the `'global'` wildcard.
*/
private standaloneActionObjectName(action: any): string {
if (typeof action?.objectName === 'string' && action.objectName.length > 0) return action.objectName;
if (typeof action?.object === 'string' && action.object.length > 0) return action.object;
return 'global';
}

/**
Expand Down