From 444d4af79cb99819aa747a46ab410a7c26ac3251 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 05:04:48 +0000 Subject: [PATCH 1/2] fix(runtime): surface standalone authored action rows on the MCP action bridge (#3010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01R4uCgJCaSABHDz6UntuqEj --- packages/runtime/src/http-dispatcher.test.ts | 127 +++++++++++++++++++ packages/runtime/src/http-dispatcher.ts | 110 ++++++++++++---- 2 files changed, 210 insertions(+), 27 deletions(-) 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'; } /** From 235f07e2ab7b6e01f92ba27740a321cb5c967405 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 05:06:50 +0000 Subject: [PATCH 2/2] chore: changeset for MCP standalone authored-action surfacing Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R4uCgJCaSABHDz6UntuqEj --- .changeset/mcp-standalone-authored-actions.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mcp-standalone-authored-actions.md 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.