From ae2b2bc5ff2b3256968339d75810dc1fceeb22e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 19:13:42 +0000 Subject: [PATCH] fix(runtime)!: the /mcp bridge reads an object's actions from the listing list_objects serves list_actions, run_action and resume_run collected object-embedded actions from the metadata service's listObjects(). On a kernel with an environmentId that service holds no copy of an object a package registers during the boot, so its actions were neither listed nor resolved while list_objects listed the object and REST's /actions door ran them. The bridge now hands the three tools the object definitions list_objects lists (the protocol's merged listing, the metadata service only on a host with none). collectActionDeclarations and resolveActionByName take that listing as an optional input; invokeBusinessAction and resumeActionRun take a reader for it. A disabled package's actions are omitted and not found, as describe_object answers its objects. Claude-Session: https://claude.ai/code/session_01TgbP2QERdKmh147ohacHbK Co-authored-by: Claude --- .../25139-mcp-actions-merged-object-view.md | 20 ++ packages/runtime/src/action-execution.ts | 24 +- .../mcp-package-object-discovery.test.ts | 213 +++++++++++++++++- packages/runtime/src/domains/mcp.ts | 36 ++- packages/runtime/src/mcp-resume-run.test.ts | 58 ++++- 5 files changed, 329 insertions(+), 22 deletions(-) create mode 100644 .changeset/25139-mcp-actions-merged-object-view.md diff --git a/.changeset/25139-mcp-actions-merged-object-view.md b/.changeset/25139-mcp-actions-merged-object-view.md new file mode 100644 index 00000000000..378d087509f --- /dev/null +++ b/.changeset/25139-mcp-actions-merged-object-view.md @@ -0,0 +1,20 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime)!: the `/mcp` bridge's `list_actions`, `run_action` and `resume_run` read an object's actions from the listing `list_objects` serves, so a package object's actions are listed and run on every kernel, and a disabled package's are omitted as its objects are + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** (`list_actions`, `run_action` and `resume_run` stop serving a disabled package's 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 actions declared on objects from the `metadata` service's `listObjects()`. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, and the cloud single-environment runtime) nothing copies the objects a package registers during the boot. So `list_actions` listed none of such a package's actions and `run_action` answered `Action 'NAME' not found on object 'OBJECT'`, while `list_objects` listed the object since #25120 and REST's `POST /api/v1/actions/OBJECT/NAME` ran the action. + +- **Before.** On that kernel, an action declared on an object a package registered during the boot (inside the object, or as a top-level action bound to it) was missing from `list_actions`; `run_action` answered `Action 'NAME' not found on object 'OBJECT'` (`Action 'NAME' not found` without `objectName`); and `resume_run` refused a run of such a flow action `403`, because no flow action it could see targets that flow. +- **Now.** The three tools collect object-embedded actions from the object definitions `list_objects` lists: the protocol's merged listing, the one `GET /api/v1/meta/object` serves. 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`), and runs as REST's `/actions` door runs it. +- **Also changed.** A disabled package's actions, where the `metadata` service still held a copy of its objects (every kernel without an `environmentId`, and a marketplace install on any kernel), were listed and run: a script action's body ran, a declarative update was refused at the record load. They are now omitted from `list_actions`, `run_action` answers `Action 'NAME' not found on object 'OBJECT'` for them, and `resume_run` refuses their runs `403`, as `describe_object` answers their objects "not found". When the protocol's merged listing throws, the three tools fail with that error, as `list_objects` does, instead of reading the `metadata` service's listing. +- **Unchanged.** Actions on objects that already had a copy are listed and run as before. Standalone `action` rows are still read from the `metadata` service (`loadMany('action')`), and an object-embedded declaration still wins over one with the same object and name. A host with no `protocol` service, or one without the merged listing, keeps reading the `metadata` service. 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 objects 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 7ed931ec871..c76dc58d6b5 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -2601,16 +2601,21 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, envId?: string; ec: any; getMeta: () => any; + // [#25139] The objects whose `actions[]` the name is resolved against — + // the MCP bridge passes the listing `list_objects` serves (the + // protocol's merged read). Left out, the metadata service's + // `listObjects()` is read, as before. + readObjects?: () => Promise; callData: (action: string, params: any, dataDriver?: any, scopeId?: string, ec?: ExecutionContext) => Promise; }, ): Promise { - const { driver, envId, ec, getMeta, callData } = wiring; + const { driver, envId, ec, getMeta, readObjects, 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); + const resolved = await resolveActionByName(deps, meta, name, input?.objectName, await readObjects?.()); if (!resolved) { throw new Error( input?.objectName @@ -2822,13 +2827,16 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, * optionally scoped to a single object. Returns the action plus its owning * 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. */ export async function resolveActionByName(deps: ActionExecutionDeps, meta: any, name: string, objectName?: string, + objects?: readonly any[], ): Promise<{ action: any; objectName: string; obj: any } | null> { - const decls = await collectActionDeclarations(deps, meta); + const decls = await collectActionDeclarations(deps, meta, objects); if (objectName) { const hit = decls.find((d) => d.objectName === objectName && d.action?.name === name); return hit ? { action: hit.action, objectName, obj: hit.obj } : null; @@ -2860,11 +2868,19 @@ export async function resolveActionByName(deps: ActionExecutionDeps, * 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. + * + * [#25139] `objects` are the definitions shape 1 reads; left out, the metadata + * service's `listObjects()`. The MCP bridge passes the protocol's merged + * 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. */ export async function collectActionDeclarations(deps: ActionExecutionDeps, meta: any, + objects?: readonly any[], ): Promise> { - const objs: any[] = (await meta?.listObjects?.()) ?? []; + const objs: readonly any[] = objects ?? (await meta?.listObjects?.()) ?? []; const objByName = new Map(); for (const obj of objs) { if (typeof obj?.name === 'string') objByName.set(obj.name, obj); 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 e9af88e88cf..1574322dcf7 100644 --- a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts +++ b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts @@ -203,7 +203,7 @@ function recordingMcp() { return mcp; } -function requestContext(environmentId?: string): any { +function requestContext(environmentId?: string, caller: Record = CALLER): any { return { request: new Request('http://localhost/api/v1/mcp', { method: 'POST', @@ -212,13 +212,13 @@ function requestContext(environmentId?: string): any { }), response: {}, environmentId, - executionContext: { ...CALLER }, + executionContext: { ...caller }, }; } -/** The bridge the real `/mcp` entry builds for `CALLER` and hands the MCP service. */ -async function bridgeFor(dispatcher: HttpDispatcher, mcp: any, environmentId?: string): Promise { - const res = await dispatcher.handleMcp({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, requestContext(environmentId)); +/** The bridge the real `/mcp` entry builds for `caller` (`CALLER` unless named) and hands the MCP service. */ +async function bridgeFor(dispatcher: HttpDispatcher, mcp: any, environmentId?: string, caller: Record = CALLER): Promise { + const res = await dispatcher.handleMcp({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, requestContext(environmentId, caller)); expect(res.response?.status, 'precondition: the /mcp entry served the request').toBe(200); return mcp.lastOpts.bridge; } @@ -547,3 +547,206 @@ describe('[#25120] two environments behind one host: each bridge reads its own e }); } }); + +/** + * [#25139] `list_actions`, `run_action` and `resume_run` collect the actions + * declared on an object from the listing `list_objects` serves, the protocol's + * merged read, and no longer from `metadata.listObjects()`. + * + * Measured on this rig at the base tree, with the package below registered + * during the boot on the `env_local` shape: `list_actions` listed none of its + * actions and `run_action` answered `Action 'mark_done' not found on object + * 'qa_act_open'`, while REST's `POST /api/v1/actions/qa_act_open/mark_done` + * ran the same update. Every other shape listed and ran them. A disabled + * package's actions are omitted and "not found", as `describe_object` answers + * its objects, on both shapes; on the one without an `environmentId` the + * metadata service still holds the copy, and the bridge listed and ran them + * from it. + * + * The package: on its exposed object, a declarative update exposed to AI, a + * script action exposed to AI whose handler is registered after the boot, a + * declarative update NOT exposed, and a top-level action bound to the object + * (the registry carries it inside the object's `actions`); on its + * `apiEnabled: false` object, a declarative update exposed to AI. + */ +const TENANT_CALLER = { ...CALLER, tenantId: 'org_qa' }; + +function actionPackageOf(prefix: string) { + const pkg = packageOf(prefix); + const update = (name: string, exposed: boolean, extra: Record = {}) => ({ + name, + label: name, + type: 'script', + operation: 'update', + patch: { name: 'done' }, + ...(exposed ? { ai: { exposed: true, description: `${name} for the test` } } : {}), + ...extra, + }); + return { + ...pkg, + id: `com.qa.${prefix}`, + listed: [`${pkg.gated}:gated_done`, `${pkg.open}:bound_done`, `${pkg.open}:mark_done`, `${pkg.open}:ping`], + artifact: () => { + const base: any = pkg.artifact(); + base.objects[0].actions = [ + update('mark_done', true), + { name: 'ping', label: 'Ping', type: 'script', target: 'qa_ping', ai: { exposed: true, description: 'Ping' } }, + update('hidden_done', false), + ]; + base.objects[1].actions = [update('gated_done', true)]; + base.actions = [update('bound_done', true, { objectName: pkg.open })]; + return base; + }, + }; +} + +/** The `objectName:name` pairs `list_actions` lists on the package's two objects. */ +async function listedPackageActions(bridge: any, pkg: ReturnType): Promise { + return ((await bridge.listActions()) as any[]) + .filter((a) => a?.objectName === pkg.open || a?.objectName === pkg.gated) + .map((a) => `${a.objectName}:${a.name}`) + .sort(); +} + +/** What REST's `POST /actions/:object/:action` answers `TENANT_CALLER`, through the runtime dispatcher. */ +async function restActionAnswer(kernel: ObjectKernel, object: string, action: string, body: Record) { + const dispatcher = new HttpDispatcher(kernel as any, undefined, { enforceProjectMembership: false }); + (dispatcher as any).timedResolveExecutionContext = async () => ({ ...TENANT_CALLER }); + const path = `/actions/${object}/${action}`; + const raw = new Request(`http://localhost/api/v1${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const out: any = await dispatcher.dispatch('POST', path, body, {}, { request: raw } as any, '/api/v1'); + return { status: out.response?.status, code: codeOf(out.response?.body), data: out.response?.body?.data }; +} + +/** `run_action`'s answer: its result, or the refusal's code, status and message. */ +async function runOutcome(call: () => Promise): Promise<{ result?: any; code?: unknown; status?: unknown; message?: string }> { + try { + return { result: (await call())?.result }; + } catch (e: any) { + return { code: e?.code, status: e?.status, message: String(e?.message) }; + } +} + +for (const { shape, environmentId } of SHAPES) { + for (const { label, when } of REGISTRATIONS) { + describe(`[#25139] the /mcp bridge and a package object's actions — ${shape}, ${label}`, () => { + const pkg = actionPackageOf('qa_act'); + const mcp = recordingMcp(); + let booted: Booted | undefined; + let dispatcher: HttpDispatcher; + + beforeAll(async () => { + booted = await bootKernel(environmentId, when, pkg, mcp); + booted.engine.registerAction(pkg.open, 'qa_ping', async () => ({ pong: true })); + 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); + const seed = async (): Promise => { + const created: any = await (await bridge()).create(pkg.open, { name: 'seed' }); + const id = created?.id ?? created?.record?.id; + expect(typeof id, 'precondition: a record of the package object was created').toBe('string'); + return id; + }; + + it('list_actions lists each action the package exposed to AI on its objects, the bound top-level one included, and not the unexposed one', async () => { + expect(await listedPackageActions(await bridge(), pkg)).toEqual(pkg.listed); + }); + + it('run_action runs the declarative update on a record of the package object, as REST /actions runs it', async () => { + const viaMcp = await seed(); + const ran = await runOutcome(async () => (await bridge()).runAction('mark_done', { objectName: pkg.open, recordId: viaMcp })); + expect(ran.message).toBeUndefined(); + expect(ran.result?.record?.name).toBe('done'); + expect((await (await bridge()).get(pkg.open, viaMcp))?.name).toBe('done'); + + const viaRest = await seed(); + const rest = await restActionAnswer(booted!.kernel, pkg.open, 'mark_done', { recordId: viaRest }); + expect(rest.status, 'the parity target: REST runs it').toBe(200); + expect(rest.data?.record?.name).toBe('done'); + }); + + it('run_action resolves the name without objectName, and refuses an absent record RECORD_NOT_FOUND, as REST does', async () => { + const ran = await runOutcome(async () => (await bridge()).runAction('mark_done', { recordId: 'r_absent' })); + const rest = await restActionAnswer(booted!.kernel, pkg.open, 'mark_done', { recordId: 'r_absent' }); + expect({ code: ran.code, status: ran.status }).toEqual({ code: 'RECORD_NOT_FOUND', status: 404 }); + expect({ code: rest.code, status: rest.status }).toEqual({ code: 'RECORD_NOT_FOUND', status: 404 }); + }); + + it('run_action runs the top-level action bound to the package object', async () => { + const id = await seed(); + const ran = await runOutcome(async () => (await bridge()).runAction('bound_done', { objectName: pkg.open, recordId: id })); + expect(ran.message).toBeUndefined(); + expect(ran.result?.record?.name).toBe('done'); + }); + + it('run_action runs the package\'s script action through its registered handler, as REST does', async () => { + const ran = await runOutcome(async () => (await bridge()).runAction('ping', { objectName: pkg.open })); + expect(ran).toEqual({ result: { pong: true } }); + const rest = await restActionAnswer(booted!.kernel, pkg.open, 'ping', {}); + expect({ status: rest.status, data: rest.data }).toEqual({ status: 200, data: { pong: true } }); + }); + + it('run_action resolves the action the package did not expose to AI, and refuses it at the exposure gate', async () => { + const ran = await runOutcome(async () => (await bridge()).runAction('hidden_done', { objectName: pkg.open, recordId: 'r_absent' })); + expect(ran.message).toMatch(/^Action 'hidden_done' on 'qa_act_open' is not exposed to AI/); + }); + + it('CONTROL: a name the package does not declare is still not found', async () => { + const ran = await runOutcome(async () => (await bridge()).runAction('qa_nowhere', { objectName: pkg.open })); + expect(ran.message).toBe(`Action 'qa_nowhere' not found on object '${pkg.open}'`); + }); + }); + } +} + +for (const { shape, environmentId } of SHAPES) { + describe(`[#25139] a DISABLED package's actions — ${shape}: neither listed nor run, as its objects are neither listed nor described`, () => { + const pkg = actionPackageOf('qa_act_off'); + const mcp = recordingMcp(); + let booted: Booted | undefined; + let dispatcher: HttpDispatcher; + + beforeAll(async () => { + booted = await bootKernel(environmentId, 'boot', pkg, mcp); + booted.engine.registerAction(pkg.open, 'qa_ping', async () => ({ pong: true })); + expect((booted.engine as any).registry.disablePackage(pkg.id), 'precondition: disabled').toBeTruthy(); + if (environmentId === undefined) { + // The copy the boot bridge made outlives the disable, actions and all. + const copy = await booted.kernel.getService('metadata').getObject(pkg.open) as + { actions?: Array<{ name?: unknown }> } | undefined; + expect( + (copy?.actions ?? []).map((a) => a?.name), + 'precondition: the metadata service still holds the object\'s actions', + ).toEqual(expect.arrayContaining(['mark_done', 'ping', 'bound_done'])); + } + dispatcher = new HttpDispatcher(booted.kernel as any, undefined, { enforceProjectMembership: false }); + }, 60_000); + afterAll(async () => { + await shutdown(booted); + }, 60_000); + + it('list_actions lists none of them', async () => { + const bridge = await bridgeFor(dispatcher, mcp, undefined, TENANT_CALLER); + expect(await bridge.describeObject(pkg.open), 'precondition: describe_object answers the object "not found"').toBeNull(); + expect(await listedPackageActions(bridge, pkg)).toEqual([]); + }); + + for (const name of ['mark_done', 'ping', 'bound_done'] as const) { + it(`run_action answers ${name} "not found", scoped and unscoped`, async () => { + const bridge = await bridgeFor(dispatcher, mcp, undefined, TENANT_CALLER); + expect(await runOutcome(() => bridge.runAction(name, { objectName: pkg.open, recordId: 'r_any' }))) + .toEqual({ code: undefined, status: undefined, message: `Action '${name}' not found on object '${pkg.open}'` }); + expect(await runOutcome(() => bridge.runAction(name, { recordId: 'r_any' }))) + .toEqual({ code: undefined, status: undefined, message: `Action '${name}' not found` }); + }); + } + }); +} diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts index 05785f4c68a..194b94b7b42 100644 --- a/packages/runtime/src/domains/mcp.ts +++ b/packages/runtime/src/domains/mcp.ts @@ -599,12 +599,22 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon const readMergedObjects = async (): Promise => (await readMergedItems(await getProtocol(), 'object')) as any[] | undefined; + /** + * [#25139] The object definitions `list_objects` lists: the merged listing, + * or the metadata service's on a host with none. `list_actions`, + * `run_action` and `resume_run` collect object-embedded actions from the + * same read, so an action is listed and resolved on every object an agent + * is listed, and on none the listing omits (a disabled package's). + */ + const readServedObjects = async (): Promise => { + const merged = await readMergedObjects(); + if (merged !== undefined) return merged; + const meta: any = await getMeta(); + return ((await meta?.listObjects?.()) ?? []) as any[]; + }; + const listObjectSummaries = async (): Promise => { - let objs: any[] | undefined = await readMergedObjects(); - if (objs === undefined) { - const meta: any = await getMeta(); - objs = ((await meta?.listObjects?.()) ?? []) as any[]; - } + const objs = await readServedObjects(); return objs.map((o) => ({ name: o.name, label: o.label ?? o.name, @@ -800,7 +810,7 @@ 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)) { + for (const { action, objectName, obj } of await actionExec.collectActionDeclarations(deps, meta, await readServedObjects())) { if (!objectName || isSystemObjectName(objectName)) continue; // fail-closed on sys_* if (!actionExec.isHeadlessInvokableAction(deps, action, hasAutomation)) continue; // The script arm — every action `invokeBusinessAction` sends to @@ -840,14 +850,18 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon runAction: async ( name: string, input: { objectName?: string; recordId?: string; params?: Record } & AIActionConfirmation, - ) => actionExec.invokeBusinessAction(deps, context, name, input ?? {}, { driver, envId, ec, getMeta, callData }), + ) => actionExec.invokeBusinessAction(deps, context, name, input ?? {}, { + driver, envId, ec, getMeta, readObjects: readServedObjects, callData, + }), // [#15705] `resume_run`: continue the caller's own paused screen run. // Admitted by the same gates as `run_action`; see // {@link resumeActionRun}. resumeRun: async ( runId: string, input: { values?: Record } & AIActionConfirmation, - ) => resumeActionRun(deps, context, runId, input ?? {}, { driver, envId, ec, getMeta, callData }), + ) => resumeActionRun(deps, context, runId, input ?? {}, { + driver, envId, ec, getMeta, readObjects: readServedObjects, callData, + }), }; } @@ -975,10 +989,12 @@ export async function resumeActionRun( envId?: string; ec: any; getMeta: () => Promise; + /** [#25139] As `invokeBusinessAction`'s: the objects whose `actions[]` admit the run. */ + readObjects?: () => Promise; callData: (action: string, params: any, dataDriver?: any, scopeId?: string, ec?: any) => Promise; }, ): Promise { - const { driver, envId, ec, getMeta, callData } = wiring; + const { driver, envId, ec, getMeta, readObjects, callData } = wiring; if (typeof runId !== 'string' || runId === '') { throw resumeDoorError(deps, 'runId is required', 400); } @@ -1015,7 +1031,7 @@ 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)).filter(({ action, objectName }) => + const candidates = (await actionExec.collectActionDeclarations(deps, meta, await readObjects?.())).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 3c0aaaad81a..603106dd2d4 100644 --- a/packages/runtime/src/mcp-resume-run.test.ts +++ b/packages/runtime/src/mcp-resume-run.test.ts @@ -194,8 +194,15 @@ function makeEngine() { type Engine = ReturnType; -/** Lead rows with an owner; the data double answers a caller only its own rows (RLS). */ -function makeHarness(opts: { actions?: any[]; engine?: Engine } = {}) { +/** + * Lead rows with an owner; the data double answers a caller only its own rows (RLS). + * + * [#25139] `merged` adds a `protocol` whose merged listing (`getMetaItems`) + * 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. + */ +function makeHarness(opts: { actions?: any[]; engine?: Engine; merged?: 'listing' | 'throws' } = {}) { const engine = opts.engine ?? makeEngine(); const leads: Array<{ id: string; owner: string; name: string }> = [ { id: 'lead_1', owner: 'u1', name: 'Ada' }, @@ -223,10 +230,17 @@ function makeHarness(opts: { actions?: any[]; engine?: Engine } = {}) { isActionEnabled: (name: string) => !disabled.has(name), }; const metadata: any = { - listObjects: vi.fn(async () => [object]), + listObjects: vi.fn(async () => (opts.merged === 'listing' ? [] : [object])), getObject: vi.fn(async () => object), }; 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'); + return { type: request.type, items: request.type === 'object' ? [object] : [] }; + }), + }; + if (opts.merged) services.protocol = protocol; const resolve = (name: string) => services[name] ?? null; const kernel: any = { getService: resolve, @@ -242,6 +256,8 @@ function makeHarness(opts: { actions?: any[]; engine?: Engine } = {}) { return { engine, leads, + metadata, + protocol, dispatcher, ctxFor, disable: (name: string) => { disabled = new Set([...disabled, name]); }, @@ -530,3 +546,39 @@ describe('MCP resumeRun — (e) the refusal a flow node met crosses the bridge a expect(thrown?.message).toBe("Node 'mk' failed: create_record(crm_task) failed: boom"); }); }); + +describe('[#25139] MCP resumeRun — the flow action that admits the run is read from the listing list_objects serves', () => { + const VALUES = { subject: 'Call back' }; + + it('where the metadata service holds no copy of the object, run_action starts the flow and resumeRun completes it from the merged listing', async () => { + const h = makeHarness({ merged: 'listing' }); + const runId = await startPaused(h); + const resumed = await h.bridgeFor('u1').resumeRun(runId, { values: VALUES }); + + expect(resumed).toMatchObject({ ok: true, action: SCHEDULE.name, objectName: OBJECT, recordId: 'lead_1' }); + expect(h.engine.tasks).toHaveLength(1); + expect(h.protocol.getMetaItems).toHaveBeenCalledWith({ type: 'object' }); + expect(h.metadata.listObjects).not.toHaveBeenCalled(); + }); + + it('list_actions lists the object\'s flow actions from the same listing', async () => { + const h = makeHarness({ merged: 'listing' }); + const names = ((await h.bridgeFor('u1').listActions()) as any[]).map((a) => a.name).sort(); + + // Exposed to AI and permitted: GATED needs a capability u1 lacks, CONSOLE_ONLY is not exposed. + expect(names).toEqual([CONFIRMED.name, WIZARD.name, SCHEDULE.name].sort()); + expect(h.metadata.listObjects).not.toHaveBeenCalled(); + }); + + it('a merged 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: 'throws' }); + const bridge = h.bridgeFor('u1'); + const started = await h.engine.execute(SCHEDULE.target, { userId: 'u1', object: OBJECT, record: { id: 'lead_1' } }); + + await expect(bridge.listActions()).rejects.toThrow('merged listing unavailable'); + await expect(bridge.runAction(SCHEDULE.name, { recordId: 'lead_1' })).rejects.toThrow('merged listing unavailable'); + await expect(bridge.resumeRun(started.runId, { values: VALUES })).rejects.toThrow('merged listing unavailable'); + expect(h.metadata.listObjects).not.toHaveBeenCalled(); + expect(h.engine.resume).not.toHaveBeenCalled(); + }); +});