diff --git a/.changeset/mcp-standalone-authored-actions.md b/.changeset/mcp-standalone-authored-actions.md new file mode 100644 index 0000000000..cc11c5e4c0 --- /dev/null +++ b/.changeset/mcp-standalone-authored-actions.md @@ -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. diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index c8e39a9b18..3f13887832 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -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'); + }); }); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 2b683418c2..ad358ba126 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -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; }, @@ -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> { + const objs: any[] = (await meta?.listObjects?.()) ?? []; + const objByName = new Map(); + 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(); + 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'; } /**