From ee2cd6e78bbe061d844f8b50ffd9d324d0fb27bb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:48:34 +0000 Subject: [PATCH] fix(runtime)!: the /mcp bridge reads standalone actions from the merged action listing, so a package's object-less action and an authored one are listed and run list_actions, run_action and resume_run collected the action items no object embeds from metadata.loadMany('action'), which reads the metadata service's loaders alone: the compiled artifact's actions, but not an action a package registers through the manifest service nor an environment-authored row. They now collect them from the protocol's merged action listing, the read GET /api/v1/meta/action serves, through the same readMergedItems the object listing uses; a host with no merged read still reads loadMany. A disabled artifact package's action, which the loader copy kept listing, is omitted. Claude-Session: https://claude.ai/code/session_01TgbP2QERdKmh147ohacHbK Co-authored-by: Claude --- ...7-mcp-standalone-actions-merged-listing.md | 20 ++ packages/runtime/src/action-execution.ts | 46 ++- .../mcp-package-object-discovery.test.ts | 285 ++++++++++++++++++ packages/runtime/src/domains/mcp.ts | 35 ++- packages/runtime/src/mcp-resume-run.test.ts | 59 +++- 5 files changed, 426 insertions(+), 19 deletions(-) create mode 100644 .changeset/25297-mcp-standalone-actions-merged-listing.md diff --git a/.changeset/25297-mcp-standalone-actions-merged-listing.md b/.changeset/25297-mcp-standalone-actions-merged-listing.md new file mode 100644 index 0000000000..50de164aec --- /dev/null +++ b/.changeset/25297-mcp-standalone-actions-merged-listing.md @@ -0,0 +1,20 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime)!: the `/mcp` bridge's `list_actions`, `run_action` and `resume_run` read the actions no object embeds from the merged `action` listing, so a package's object-less action and an environment-authored one are listed and run, and a disabled package's is omitted + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** (`list_actions`, `run_action` and `resume_run` stop serving a disabled compiled-artifact package's object-less actions), shipped as `patch`: it changes which reads the MCP bridge answers, not what a metadata author can write. + +The HTTP MCP bridge (`POST /api/v1/mcp`) collected the `action` items that no object embeds, the object-less (`global`) actions among them, from the `metadata` service's `loadMany('action')`. That call reads the service's loaders alone. They hold the compiled artifact's actions, but not an action a package registers through the `manifest` service (a capability plugin during the boot, or a marketplace install) and not an environment-authored action saved through `PUT /api/v1/meta/action/NAME`. Those live in the registry and in `sys_metadata`, which `GET /api/v1/meta/action` reads, and REST's `POST /api/v1/actions/global/NAME` ran them. + +- **Before.** On every kernel, such an action was missing from `list_actions`, `run_action` answered `Action 'NAME' not found` (`Action 'NAME' not found on object 'global'` when scoped), and `resume_run` refused a run of such a flow action `403`. +- **Now.** The three tools collect those items from the protocol's merged `action` listing, the one `GET /api/v1/meta/action` serves, through the same read the object listing uses. 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`, a registered handler for a script action), and runs as REST's `/actions` door runs it. +- **Also changed.** A compiled-artifact package's object-less action stayed in the loader after the package was disabled, so it was still listed and run. It is now omitted from `list_actions`, and `run_action` answers `Action 'NAME' not found` for it, as the package's objects are omitted since #25120. When the merged `action` listing throws, the three tools fail with that error instead of reading the `metadata` service. +- **Unchanged.** The compiled artifact's actions are listed and run as before. An object-embedded declaration still wins over a standalone one with the same object and name. A host with no `protocol` service, or one without the merged listing, keeps reading `loadMany('action')`. REST's `/actions` door is untouched. +- **What to do.** Nothing. An agent that ran a disabled package's action through MCP was running an action whose package the rest of the platform no longer serves: re-enable the package to restore it. diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index c76dc58d6b..60a09184e2 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -2606,16 +2606,23 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, // protocol's merged read). Left out, the metadata service's // `listObjects()` is read, as before. readObjects?: () => Promise; + // [#25297] The standalone `action` items the name is resolved against + // — the MCP bridge passes the protocol's merged `action` listing. + // Left out, or answering `undefined` (a host with no merged read), the + // metadata service's `loadMany('action')` is read, as before. + readActions?: () => Promise; callData: (action: string, params: any, dataDriver?: any, scopeId?: string, ec?: ExecutionContext) => Promise; }, ): Promise { - const { driver, envId, ec, getMeta, readObjects, callData } = wiring; + const { driver, envId, ec, getMeta, readObjects, readActions, callData } = wiring; const meta: any = await getMeta(); const params = input?.params && typeof input.params === 'object' ? input.params : {}; const recordId = typeof input?.recordId === 'string' && input.recordId.length > 0 ? input.recordId : undefined; // Resolve the action def by declarative name (optionally scoped). - const resolved = await resolveActionByName(deps, meta, name, input?.objectName, await readObjects?.()); + const resolved = await resolveActionByName( + deps, meta, name, input?.objectName, await readObjects?.(), await readActions?.(), + ); if (!resolved) { throw new Error( input?.objectName @@ -2828,15 +2835,17 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, * object name, or `null`. Throws when the name is ambiguous across objects * and no `objectName` was supplied (so `run_action` can ask for one). * - * `objects` is handed to {@link collectActionDeclarations} unchanged. + * `objects` and `actions` are handed to {@link collectActionDeclarations} + * unchanged. */ export async function resolveActionByName(deps: ActionExecutionDeps, meta: any, name: string, objectName?: string, objects?: readonly any[], + actions?: readonly any[], ): Promise<{ action: any; objectName: string; obj: any } | null> { - const decls = await collectActionDeclarations(deps, meta, objects); + const decls = await collectActionDeclarations(deps, meta, objects, actions); if (objectName) { const hit = decls.find((d) => d.objectName === objectName && d.action?.name === name); return hit ? { action: hit.action, objectName, obj: hit.obj } : null; @@ -2874,11 +2883,24 @@ export async function resolveActionByName(deps: ActionExecutionDeps, * listing, the one `list_objects` serves: on a kernel with an `environmentId` * the metadata service holds no copy of an object a package registered during * the boot, so its actions were neither listed nor resolved while REST's - * `/actions` door ran them. Shape 2 still reads the metadata service. + * `/actions` door ran them. + * + * [#25297] `actions` are the items shape 2 reads; left out, the metadata + * service's `loadMany('action')`, whose failure answers none. The MCP bridge + * passes the protocol's merged `action` listing, the one + * `GET /api/v1/meta/action` serves. `loadMany` reads the service's loaders + * alone: they hold the compiled artifact's actions, but not an action a + * package registers through the `manifest` service (during the boot or by a + * marketplace install) nor an environment-authored `sys_metadata` row, which + * live in the registry and the overlay the merged read reads. So a package's + * object-less action, and an authored one, were never listed nor resolved + * while REST's `POST /actions/global/NAME` ran them; and an artifact + * package's action outlived the package's disable there. */ export async function collectActionDeclarations(deps: ActionExecutionDeps, meta: any, objects?: readonly any[], + actions?: readonly any[], ): Promise> { const objs: readonly any[] = objects ?? (await meta?.listObjects?.()) ?? []; const objByName = new Map(); @@ -2896,11 +2918,15 @@ export async function collectActionDeclarations(deps: ActionExecutionDeps, out.push({ action, objectName, obj }); } } - let standalone: any[] = []; - try { - standalone = (await meta?.loadMany?.('action')) ?? []; - } catch { - standalone = []; // no standalone-item source on this metadata service + let standalone: readonly any[] = []; + if (actions) { + standalone = actions; + } else { + 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; diff --git a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts index 1574322dcf..8fad856161 100644 --- a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts +++ b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts @@ -95,6 +95,8 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { RestServer } from '@objectstack/rest'; import { resolveThrownHttpError } from '@objectstack/types'; import { HttpDispatcher } from '../http-dispatcher.js'; +import { AppPlugin } from '../app-plugin.js'; +import { loadArtifactBundle } from '../load-artifact-bundle.js'; const CALLER = { userId: 'u_mcp', isSystem: false, positions: [], permissions: [], systemPermissions: [] }; @@ -750,3 +752,286 @@ for (const { shape, environmentId } of SHAPES) { } }); } + +/** + * [#25297] `list_actions`, `run_action` and `resume_run` collect the standalone + * `action` items, the ones no object embeds, from the protocol's merged + * `action` listing (`GET /api/v1/meta/action`'s read), and no longer from + * `metadata.loadMany('action')`. + * + * `loadMany` reads the metadata service's loaders alone. Measured on this rig + * at the base tree, on both shapes: + * + * | the object-less action, and where it came from | `loadMany('action')` | `list_actions` | `run_action` | REST `POST /actions/global/NAME` | + * | :-- | :-- | :-- | :-- | :-- | + * | a package's, registered through the `manifest` service (during the boot, or a marketplace install) | absent | not listed | `Action 'NAME' not found` | `200`, the handler's answer | + * | an environment-authored row (`saveMetaItem`) | absent | not listed | `Action 'NAME' not found` | `200` | + * | the same, bound to the package's object (the merged object listing does not fold it into the object's `actions`) | absent | not listed | `… not found on object 'OBJECT'` | `200` | + * | the compiled artifact's, its `packages[]` included (the artifact door and `AppPlugin`, as `createStandaloneStack` composes them) | held | listed | run | `200` | + * | the same, its package disabled | still held | **listed** | **run** | `200` | + * + * The last row is the narrowing: the merged listing omits a disabled + * package's items, so the bridge now omits them, as it omits the package's + * objects since #25120. REST's door still runs that action; that is REST's + * door, not this one. + */ +const GLOBAL = 'global'; +const AUTHORED = 'qa_authored_ping'; +const AUTHORED_HANDLER = 'qa_authored_handler'; +const AUTHORED_BOUND = 'qa_authored_bound'; + +/** An object-less action `target`ing `handler`, exposed to AI unless `exposed` is false. */ +function objectLessAction(name: string, handler: string, exposed = true) { + return { + name, + label: name, + type: 'script', + target: handler, + ...(exposed ? { ai: { exposed: true, description: `${name}: an object-less action, for this test only` } } : {}), + }; +} + +/** The package of two objects (`packageOf`) with two top-level actions that name no object. */ +function globalActionPackageOf(prefix: string) { + const pkg = packageOf(prefix); + const names = { ping: `${prefix}_ping`, hidden: `${prefix}_hidden`, handler: `${prefix}_handler` }; + return { + ...pkg, + ...names, + id: `com.qa.${prefix}`, + artifact: () => ({ + ...pkg.artifact(), + actions: [objectLessAction(names.ping, names.handler), objectLessAction(names.hidden, names.handler, false)], + }), + }; +} + +/** The object-less actions `list_actions` lists, by name, the platform's own left out. */ +async function listedGlobalActions(bridge: any): Promise { + return ((await bridge.listActions()) as any[]) + .filter((a) => a?.objectName === GLOBAL && /^qa_/.test(String(a?.name))) + .map((a) => String(a.name)) + .sort(); +} + +/** + * Save the environment-authored object-less action through the protocol, as + * `PUT /api/v1/meta/action/NAME` does; with `boundTo`, also one bound to that + * object, whose handler is `AUTHORED_HANDLER` on it. + */ +async function saveAuthoredAction(kernel: ObjectKernel, boundTo?: string): Promise { + const protocol: any = kernel.getService('protocol'); + await protocol.saveMetaItem({ type: 'action', name: AUTHORED, item: objectLessAction(AUTHORED, AUTHORED_HANDLER) }); + if (boundTo) { + await protocol.saveMetaItem({ + type: 'action', + name: AUTHORED_BOUND, + item: { ...objectLessAction(AUTHORED_BOUND, AUTHORED_HANDLER), objectName: boundTo }, + }); + } +} + +/** The standalone `action` items' names the metadata service's loaders hold. */ +async function loaderActionNames(kernel: ObjectKernel): Promise { + const meta = kernel.getService('metadata') as unknown as { loadMany(type: string): Promise> }; + return (await meta.loadMany('action')).map((a) => String(a?.name)); +} + +for (const { shape, environmentId } of SHAPES) { + for (const { label, when } of REGISTRATIONS) { + describe(`[#25297] a package's object-less action, and an environment-authored one — ${shape}, ${label}`, () => { + const pkg = globalActionPackageOf('qa_glb'); + const mcp = recordingMcp(); + let booted: Booted | undefined; + let dispatcher: HttpDispatcher; + + beforeAll(async () => { + booted = await bootKernel(environmentId, when, pkg, mcp); + booted.engine.registerAction(GLOBAL, pkg.handler, async () => ({ pong: 'package' })); + booted.engine.registerAction(GLOBAL, AUTHORED_HANDLER, async () => ({ pong: 'authored' })); + booted.engine.registerAction(pkg.open, AUTHORED_HANDLER, async () => ({ pong: 'authored, bound' })); + await saveAuthoredAction(booted.kernel, pkg.open); + dispatcher = new HttpDispatcher(booted.kernel as any, undefined, { enforceProjectMembership: false }); + }, 60_000); + afterAll(async () => { + await shutdown(booted); + }, 60_000); + + const bridge = () => bridgeFor(dispatcher, mcp, undefined, TENANT_CALLER); + + it('list_actions lists the package\'s object-less action exposed to AI and the environment-authored one, and not the unexposed one', async () => { + expect(await listedGlobalActions(await bridge())).toEqual([AUTHORED, pkg.ping].sort()); + }); + + it('run_action runs the package\'s object-less action through its handler, unscoped and scoped to global, as REST does', async () => { + expect(await runOutcome(async () => (await bridge()).runAction(pkg.ping, {}))).toEqual({ result: { pong: 'package' } }); + expect(await runOutcome(async () => (await bridge()).runAction(pkg.ping, { objectName: GLOBAL }))) + .toEqual({ result: { pong: 'package' } }); + const rest = await restActionAnswer(booted!.kernel, GLOBAL, pkg.ping, {}); + expect({ status: rest.status, data: rest.data }, 'the parity target: REST runs it').toEqual({ status: 200, data: { pong: 'package' } }); + }); + + it('run_action runs the environment-authored object-less action, as REST does', async () => { + expect(await runOutcome(async () => (await bridge()).runAction(AUTHORED, {}))).toEqual({ result: { pong: 'authored' } }); + const rest = await restActionAnswer(booted!.kernel, GLOBAL, AUTHORED, {}); + expect({ status: rest.status, data: rest.data }).toEqual({ status: 200, data: { pong: 'authored' } }); + }); + + it('list_actions lists, and run_action runs, an environment-authored action bound to the package\'s object, as REST does', async () => { + const listed = ((await (await bridge()).listActions()) as any[]).map((a) => `${a?.objectName}:${a?.name}`); + expect(listed).toContain(`${pkg.open}:${AUTHORED_BOUND}`); + expect(await runOutcome(async () => (await bridge()).runAction(AUTHORED_BOUND, { objectName: pkg.open }))) + .toEqual({ result: { pong: 'authored, bound' } }); + const rest = await restActionAnswer(booted!.kernel, pkg.open, AUTHORED_BOUND, {}); + expect({ status: rest.status, data: rest.data }).toEqual({ status: 200, data: { pong: 'authored, bound' } }); + }); + + it('run_action resolves the object-less action the package did not expose to AI, and refuses it at the exposure gate', async () => { + const ran = await runOutcome(async () => (await bridge()).runAction(pkg.hidden, {})); + expect(ran.message).toMatch(new RegExp(`^Action '${pkg.hidden}' on 'global' is not exposed to AI`)); + }); + + it('CONTROL: an object-less name nothing declares is still not found', async () => { + const ran = await runOutcome(async () => (await bridge()).runAction('qa_nowhere', {})); + expect(ran.message).toBe('Action \'qa_nowhere\' not found'); + }); + }); + } +} + +for (const { shape, environmentId } of SHAPES) { + describe(`[#25297] a DISABLED package's object-less action — ${shape}: neither listed nor run, the environment-authored one still both`, () => { + const pkg = globalActionPackageOf('qa_glb_off'); + const mcp = recordingMcp(); + let booted: Booted | undefined; + let dispatcher: HttpDispatcher; + + beforeAll(async () => { + booted = await bootKernel(environmentId, 'boot', pkg, mcp); + booted.engine.registerAction(GLOBAL, pkg.handler, async () => ({ pong: 'package' })); + booted.engine.registerAction(GLOBAL, AUTHORED_HANDLER, async () => ({ pong: 'authored' })); + await saveAuthoredAction(booted.kernel); + expect((booted.engine as any).registry.disablePackage(pkg.id), 'precondition: disabled').toBeTruthy(); + dispatcher = new HttpDispatcher(booted.kernel as any, undefined, { enforceProjectMembership: false }); + }, 60_000); + afterAll(async () => { + await shutdown(booted); + }, 60_000); + + it('list_actions lists the environment-authored action and none of the package\'s', async () => { + const bridge = await bridgeFor(dispatcher, mcp, undefined, TENANT_CALLER); + expect(await listedGlobalActions(bridge)).toEqual([AUTHORED]); + }); + + it('run_action answers the package\'s action "not found", scoped and unscoped, and runs the environment-authored one', async () => { + const bridge = await bridgeFor(dispatcher, mcp, undefined, TENANT_CALLER); + expect(await runOutcome(() => bridge.runAction(pkg.ping, {}))) + .toEqual({ code: undefined, status: undefined, message: `Action '${pkg.ping}' not found` }); + expect(await runOutcome(() => bridge.runAction(pkg.ping, { objectName: GLOBAL }))) + .toEqual({ code: undefined, status: undefined, message: `Action '${pkg.ping}' not found on object 'global'` }); + expect(await runOutcome(() => bridge.runAction(AUTHORED, {}))).toEqual({ result: { pong: 'authored' } }); + }); + }); +} + +/** The compiled artifact: the project's own object-less action, and a `packages[]` entry carrying another. */ +const ART = { app: 'com.qa.qa-art', pkg: 'com.qa.qa-art-pkg', ping: 'qa_art_ping', pkgPing: 'qa_art_pkg_ping', handler: 'qa_art_handler' } as const; +const artifactWithGlobalActions = () => ({ + manifest: { id: ART.app, name: 'qa art', version: '0.0.1', type: 'app' }, + objects: [{ name: 'qa_art_obj', label: 'Art', fields: { name: { name: 'name', type: 'text', label: 'Name' } } }], + actions: [objectLessAction(ART.ping, ART.handler)], + views: [], apps: [], flows: [], requires: [], + packages: [{ + manifest: { + id: ART.pkg, name: 'qa art package', version: '0.0.1', type: 'app', + objects: [{ name: 'qa_art_pkg_obj', label: 'Art package', fields: { name: { name: 'name', type: 'text', label: 'Name' } } }], + actions: [objectLessAction(ART.pkgPing, ART.handler)], + }, + }], +}); + +/** + * A kernel booted from a compiled artifact the way `createStandaloneStack` + * composes it: the artifact door (`MetadataPlugin`'s `artifactSource`) and an + * `AppPlugin` over the same bundle. + */ +async function bootArtifactKernel(environmentId: string | undefined, mcp: unknown): Promise { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + const root = mkdtempSync(join(tmpdir(), 'os-mcp-artifact-actions-')); + const engine = new ObjectQL(); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }) as any, true); + const artifact = join(root, 'objectstack.json'); + writeFileSync(artifact, JSON.stringify(artifactWithGlobalActions())); + await kernel.use(new MetadataPlugin({ + watch: false, artifactWatch: false, ...(environmentId ? { environmentId } : {}), + artifactSource: { mode: 'local-file', path: artifact }, rootDir: root, + } as any)); + await kernel.use(new ObjectQLPlugin({ ql: engine, ...(environmentId ? { environmentId, hydrateMetadataFromDb: true } : {}) })); + const bundle = await loadArtifactBundle(artifact, { tag: '[#25297 rig]', unwrapEnvelope: true }); + await kernel.use(new AppPlugin(bundle, undefined, { skipSeedData: true, securityMetadataRegistrar: 'artifact-door' })); + await kernel.use({ + name: 'qa.mcp-host', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: PluginContext) => { + ctx.registerService('mcp', mcp); + }, + } as Plugin); + await kernel.bootstrap(); + await engine.syncSchemas(); + engine.registerAction(GLOBAL, ART.handler, async () => ({ pong: 'artifact' })); + return { kernel, engine, root }; +} + +for (const { shape, environmentId } of SHAPES) { + for (const disabled of [false, true]) { + describe(`[#25297] the compiled artifact's object-less actions — ${shape}${disabled ? ', its package disabled' : ''}`, () => { + const mcp = recordingMcp(); + let booted: Booted | undefined; + let dispatcher: HttpDispatcher; + + beforeAll(async () => { + booted = await bootArtifactKernel(environmentId, mcp); + if (disabled) { + expect((booted.engine as any).registry.disablePackage(ART.pkg), 'precondition: disabled').toBeTruthy(); + } + dispatcher = new HttpDispatcher(booted.kernel as any, undefined, { enforceProjectMembership: false }); + }, 60_000); + afterAll(async () => { + await shutdown(booted); + }, 60_000); + + const bridge = () => bridgeFor(dispatcher, mcp, undefined, TENANT_CALLER); + + it(`precondition: the metadata service's loaders hold both${disabled ? ', the disabled package\'s too' : ''}`, async () => { + expect((await loaderActionNames(booted!.kernel)).sort()).toEqual([ART.pkgPing, ART.ping].sort()); + }); + + if (!disabled) { + it('CONTROL: list_actions lists both, and run_action runs both, as REST does', async () => { + expect(await listedGlobalActions(await bridge())).toEqual([ART.pkgPing, ART.ping].sort()); + for (const name of [ART.ping, ART.pkgPing]) { + expect(await runOutcome(async () => (await bridge()).runAction(name, {}))).toEqual({ result: { pong: 'artifact' } }); + const rest = await restActionAnswer(booted!.kernel, GLOBAL, name, {}); + expect({ status: rest.status, data: rest.data }).toEqual({ status: 200, data: { pong: 'artifact' } }); + } + }); + } else { + it('list_actions lists the project\'s own action and not the disabled package\'s', async () => { + expect(await listedGlobalActions(await bridge())).toEqual([ART.ping]); + }); + + it('run_action answers the disabled package\'s action "not found", scoped and unscoped, and still runs the project\'s own', async () => { + expect(await runOutcome(async () => (await bridge()).runAction(ART.pkgPing, {}))) + .toEqual({ code: undefined, status: undefined, message: `Action '${ART.pkgPing}' not found` }); + expect(await runOutcome(async () => (await bridge()).runAction(ART.pkgPing, { objectName: GLOBAL }))) + .toEqual({ code: undefined, status: undefined, message: `Action '${ART.pkgPing}' not found on object 'global'` }); + expect(await runOutcome(async () => (await bridge()).runAction(ART.ping, {}))).toEqual({ result: { pong: 'artifact' } }); + }); + } + }); + } +} diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts index 194b94b7b4..ef3fc0905c 100644 --- a/packages/runtime/src/domains/mcp.ts +++ b/packages/runtime/src/domains/mcp.ts @@ -444,8 +444,8 @@ async function readMergedSkillRows( * reads it — or `undefined` when this host has no merged read to offer. * * The one place this file unwraps `getMetaItems`, shared by the skill read - * above and the object reads in {@link buildMcpBridge}, so the three cannot - * disagree about which answers they accept. Its three outcomes are the ones + * above and the object and action reads in {@link buildMcpBridge}, so they + * cannot disagree about which answers they accept. Its three outcomes are the ones * {@link readMergedSkillRows} documents: `undefined` is STRUCTURAL absence (no * protocol in the slot, or one without the list verb), and the caller answers * from the metadata service exactly as before; a throw travels to the caller, @@ -453,7 +453,7 @@ async function readMergedSkillRows( */ async function readMergedItems( protocol: McpMergedMetadataRead | undefined | null, - type: 'skill' | 'object', + type: 'skill' | 'object' | 'action', ): Promise { if (!protocol || typeof protocol.getMetaItems !== 'function') return undefined; const answer = await protocol.getMetaItems({ type }); @@ -613,6 +613,19 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon return ((await meta?.listObjects?.()) ?? []) as any[]; }; + /** + * [#25297] The standalone `action` items `list_actions`, `run_action` and + * `resume_run` collect beside the objects' embedded ones: the merged + * listing, the read `GET /api/v1/meta/action` serves. The metadata + * service's `loadMany('action')` holds the compiled artifact's actions + * alone, so a package's object-less action and an environment-authored + * one were never listed, and a disabled artifact package's still was. + * `undefined` = no merged read on this host, and the collection reads the + * metadata service, exactly as before. + */ + const readServedActions = async (): Promise => + (await readMergedItems(await getProtocol(), 'action')) as any[] | undefined; + const listObjectSummaries = async (): Promise => { const objs = await readServedObjects(); return objs.map((o) => ({ @@ -810,7 +823,10 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon await deps.getObjectQL(context, envId).catch(() => undefined), ); const out: any[] = []; - for (const { action, objectName, obj } of await actionExec.collectActionDeclarations(deps, meta, await readServedObjects())) { + const declarations = await actionExec.collectActionDeclarations( + deps, meta, await readServedObjects(), await readServedActions(), + ); + for (const { action, objectName, obj } of declarations) { if (!objectName || isSystemObjectName(objectName)) continue; // fail-closed on sys_* if (!actionExec.isHeadlessInvokableAction(deps, action, hasAutomation)) continue; // The script arm — every action `invokeBusinessAction` sends to @@ -851,7 +867,7 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon name: string, input: { objectName?: string; recordId?: string; params?: Record } & AIActionConfirmation, ) => actionExec.invokeBusinessAction(deps, context, name, input ?? {}, { - driver, envId, ec, getMeta, readObjects: readServedObjects, callData, + driver, envId, ec, getMeta, readObjects: readServedObjects, readActions: readServedActions, callData, }), // [#15705] `resume_run`: continue the caller's own paused screen run. // Admitted by the same gates as `run_action`; see @@ -860,7 +876,7 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon runId: string, input: { values?: Record } & AIActionConfirmation, ) => resumeActionRun(deps, context, runId, input ?? {}, { - driver, envId, ec, getMeta, readObjects: readServedObjects, callData, + driver, envId, ec, getMeta, readObjects: readServedObjects, readActions: readServedActions, callData, }), }; } @@ -991,10 +1007,12 @@ export async function resumeActionRun( getMeta: () => Promise; /** [#25139] As `invokeBusinessAction`'s: the objects whose `actions[]` admit the run. */ readObjects?: () => Promise; + /** [#25297] As `invokeBusinessAction`'s: the standalone `action` items that admit the run. */ + readActions?: () => Promise; callData: (action: string, params: any, dataDriver?: any, scopeId?: string, ec?: any) => Promise; }, ): Promise { - const { driver, envId, ec, getMeta, readObjects, callData } = wiring; + const { driver, envId, ec, getMeta, readObjects, readActions, callData } = wiring; if (typeof runId !== 'string' || runId === '') { throw resumeDoorError(deps, 'runId is required', 400); } @@ -1031,7 +1049,8 @@ export async function resumeActionRun( const flowName: unknown = run.flowName; const runObject = typeof trigger?.object === 'string' && trigger.object !== '' ? trigger.object : undefined; const meta: any = await getMeta(); - const candidates = (await actionExec.collectActionDeclarations(deps, meta, await readObjects?.())).filter(({ action, objectName }) => + const declarations = await actionExec.collectActionDeclarations(deps, meta, await readObjects?.(), await readActions?.()); + const candidates = declarations.filter(({ action, objectName }) => action?.type === 'flow' && !actionExec.isDeclarativeUpdateAction(action) && typeof action?.target === 'string' diff --git a/packages/runtime/src/mcp-resume-run.test.ts b/packages/runtime/src/mcp-resume-run.test.ts index 603106dd2d..53d6c99ab6 100644 --- a/packages/runtime/src/mcp-resume-run.test.ts +++ b/packages/runtime/src/mcp-resume-run.test.ts @@ -108,6 +108,7 @@ const FLOW_SCREENS: Record = { close_lead: [SCREEN_1], merge_lead: [SCREEN_1], orphan_flow: [SCREEN_1], + send_digest: [SCREEN_1], }; interface Run { @@ -201,8 +202,15 @@ type Engine = ReturnType; * serves the object, and empties the metadata service's `listObjects()`, the * way an `environmentId` kernel holds no copy of a package's object; `'throws'` * makes that listing fail while the metadata service still lists the object. + * + * [#25297] `standalone` are the `action` items no object embeds: the merged + * listing serves them, and the metadata service's `loadMany('action')` holds + * them only where there is no merged listing. `'action-throws'` makes the + * merged `action` listing alone fail, the object listing still served. */ -function makeHarness(opts: { actions?: any[]; engine?: Engine; merged?: 'listing' | 'throws' } = {}) { +function makeHarness( + opts: { actions?: any[]; standalone?: any[]; engine?: Engine; merged?: 'listing' | 'throws' | 'action-throws' } = {}, +) { const engine = opts.engine ?? makeEngine(); const leads: Array<{ id: string; owner: string; name: string }> = [ { id: 'lead_1', owner: 'u1', name: 'Ada' }, @@ -232,11 +240,16 @@ function makeHarness(opts: { actions?: any[]; engine?: Engine; merged?: 'listing const metadata: any = { listObjects: vi.fn(async () => (opts.merged === 'listing' ? [] : [object])), getObject: vi.fn(async () => object), + loadMany: vi.fn(async () => (opts.merged ? [] : opts.standalone ?? [])), }; const services: Record = { objectql: ql, data: ql, metadata, automation: engine }; const protocol = { getMetaItems: vi.fn(async (request: { type: string }) => { if (opts.merged === 'throws') throw new Error('merged listing unavailable'); + if (request.type === 'action') { + if (opts.merged === 'action-throws') throw new Error('merged action listing unavailable'); + return { type: request.type, items: opts.standalone ?? [] }; + } return { type: request.type, items: request.type === 'object' ? [object] : [] }; }), }; @@ -582,3 +595,47 @@ describe('[#25139] MCP resumeRun — the flow action that admits the run is read expect(h.engine.resume).not.toHaveBeenCalled(); }); }); + +describe('[#25297] MCP resumeRun — an object-less flow action is read from the merged action listing', () => { + /** A flow action that names no object, as a package's top-level action ships it. */ + const DIGEST = { + name: 'send_digest', + label: 'Send digest', + type: 'flow', + target: 'send_digest', + ai: { exposed: true, description: 'Send the daily digest; no record involved.' }, + }; + const VALUES = { subject: 'Daily digest' }; + + it('where the metadata service\'s loaders hold none, run_action starts it and resumeRun completes it from the merged listing', async () => { + const h = makeHarness({ merged: 'listing', standalone: [DIGEST] }); + const started = await h.bridgeFor('u1').runAction(DIGEST.name, {}); + expect(started.result.status).toBe('paused'); + const resumed = await h.bridgeFor('u1').resumeRun(started.result.runId, { values: VALUES }); + + expect(resumed).toMatchObject({ ok: true, action: DIGEST.name }); + expect(h.engine.tasks).toHaveLength(1); + expect(h.protocol.getMetaItems).toHaveBeenCalledWith({ type: 'action' }); + expect(h.metadata.loadMany).not.toHaveBeenCalled(); + }); + + it('list_actions lists it from the same listing', async () => { + const h = makeHarness({ merged: 'listing', standalone: [DIGEST] }); + const names = ((await h.bridgeFor('u1').listActions()) as any[]).map((a) => a.name); + + expect(names).toContain(DIGEST.name); + expect(h.metadata.loadMany).not.toHaveBeenCalled(); + }); + + it('a merged action listing that throws fails list_actions, run_action and resumeRun with its error, and the metadata service is not asked', async () => { + const h = makeHarness({ merged: 'action-throws', standalone: [DIGEST] }); + const bridge = h.bridgeFor('u1'); + const started = await h.engine.execute(DIGEST.target, { userId: 'u1' }); + + await expect(bridge.listActions()).rejects.toThrow('merged action listing unavailable'); + await expect(bridge.runAction(DIGEST.name, {})).rejects.toThrow('merged action listing unavailable'); + await expect(bridge.resumeRun(started.runId, { values: VALUES })).rejects.toThrow('merged action listing unavailable'); + expect(h.metadata.loadMany).not.toHaveBeenCalled(); + expect(h.engine.resume).not.toHaveBeenCalled(); + }); +});