Skip to content

Commit 30f23fe

Browse files
test(example-todo): boot the MCP action drive with its Default Organization and run it in the example suite (#31230)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XCt3ZdY2VYJxnxbVoW2sm2m)_ Fixes #30928 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`examples/app-todo` 的 MCP 业务动作端到端脚本改成普通 vitest 测试 `test/mcp-actions.test.ts`,随该示例自己的 `pnpm test` 在 CI 里跑;启动时挂上 plugin-auth 的身份对象(`createIdentityObjectsPlugin()`),于是装机在种子加载前就有默认组织。原 `test:mcp` 脚本删除。 - 为什么改:原脚本在 `main` 上第 3 步失败(「Organization is required」):它的内核不注册组织对象,种子和系统写入都被拒;且没有任何流水线跑它。卡片要求「修好启动,再接入一条会跑的流水线或证明已有覆盖后删除」:实测没有重复覆盖,选接入。 - 风险与回滚:只动私有示例包、一处测试注释和锁文件里一条 workspace 依赖链接;不改平台代码、不发布。回滚即还原本 PR。 - 先看:① `examples/app-todo/test/mcp-actions.test.ts` 的 `beforeAll` 启动顺序与第一条用例(默认组织与种子);② `examples/app-todo/vitest.config.ts`(三个平台包按源码解析);③ `examples/app-todo/package.json`。 - 两分钟验证:`pnpm --filter @objectstack/example-todo exec vitest run test/mcp-actions.test.ts`,预期 9 条全绿。 - 需要你做的:无,席位落地。 ## What changes **The manual `test:mcp` script now boots with an organization and runs in a lane.** - **Boot.** On `main` (re-measured at `4c318214`) `pnpm --filter @objectstack/example-todo test:mcp` exits 1 at step 3: the app seed and the step-3 insert are refused with `Organization is required`. The kernel registered no organization object, so nothing created the Default Organization a `single` install holds before seeds load (ADR-0131 D3/D9). The suite now mounts `createIdentityObjectsPlugin()` from `@objectstack/plugin-auth`: plugin-auth's identity objects with the same Default Organization invariant `AuthPlugin.start()` keeps, and no authentication mounted. This is the reduced-kernel composition plugin-auth's `identity-objects-plugin.ts` header documents for an app's own test suite, and `AppPlugin` already orders it ahead of its seed. No platform package changes. - **Wired, not deleted.** `test/mcp-actions.e2e.ts` (a `tsx` script) becomes `test/mcp-actions.test.ts` (vitest; a pure rename commit first, then the conversion). The default vitest include picks it up, so it runs in the example's own `pnpm test`, which CI's Test Core shards run. The `test:mcp` package script is removed. Each step is one case with `expect` assertions in place of the script's `check()` counter; step state no longer leaks between cases (steps 5 and 6 seed their own task). - **Source aliases.** `examples/app-todo/vitest.config.ts` stops re-exporting the shared preset and declares anchored array-form aliases that resolve `@objectstack/mcp`, `@objectstack/runtime` and `@objectstack/plugin-auth` to their `src/index.ts`, because `pnpm check:test-source-alias` refused the new suite's three `dist/` imports (measured red, exit 1, on the first head). It keeps `disableConsoleIntercept: true` itself, as `examples/app-crm/vitest.config.ts` does, because `check:console-intercept-disarm` judges only a bare preset re-export as the preset. - **Same boot for `test/seed-check.ts`** (bounded in-place fix, same defect class, same file family): the second manual `tsx` script exited 1 on `main` with the same eight `Organization is required` seed refusals; with `createIdentityObjectsPlugin()` mounted it prints `Found Tasks: 8` and exits 0. No lane runs it, and the new first case of the suite pins the same seed rows. - **New pin.** A first case asserts the boot holds exactly one organization and that the app's own seed landed (`Learn ObjectStack`, `Build a cool app`): the card's failure, pinned. - `@objectstack/plugin-auth` is added as a `workspace:*` devDependency of `@objectstack/example-todo`, with its `pnpm-lock.yaml` importer link (3 lines, generated by `pnpm install`). - Path pointers to the old file name move with it: the example README (and its run command), `test/node-shim.d.ts`, `test/task-completion-trigger.test.ts`, and the one comment line in `packages/mcp/src/mcp-action-confirmation-member.test.ts` that names this file as where the confirmation gate's two halves run together (comment only; on the claim's amended surface). ## Tests - `pnpm --filter @objectstack/example-todo exec vitest run test/mcp-actions.test.ts`: **9 passed (9)**; the file line reads 906 ms (default reporter, file time). - Example suite in `examples/app-todo` on the final head (with the source aliases): Test Files 9 passed (9), Tests 249 passed (249), exit 0. `pnpm typecheck` (`tsc --noEmit`, includes `test/**`): exit 0. - `packages/mcp`: `vitest run src/mcp-action-confirmation-member.test.ts` 6 passed (6) (comment-only edit). - `tsx test/seed-check.ts`: exit 1 before (eight seed refusals), exit 0 after (`Found Tasks: 8`). - **Red-first** (one-time proof, committed head first; run on the first head and again on the final head with the source aliases, same reading): `node scripts/ablation-replace.mjs --delete` of the `createIdentityObjectsPlugin()` line landed (anchor 1 -> 0, blob `08e51a7e` -> `e95cbeaa`), the file then read **5 failed | 4 passed**: the new boot case (`Object 'sys_organization' not found`) and steps 3, 5, 6, 7 (`ValidationError: Organization is required`, the card's failure). Restored: blob == HEAD `08e51a7e`, `git diff HEAD` empty. ## Acceptance notes **Coverage measured before choosing (the card's "name which one").** The candidates that drive actions over MCP with more than a stubbed bridge: - `packages/mcp/src/mcp-action-confirmation-member.test.ts` and `mcp-action-tools.test.ts` drive the real `MCPServerRuntime` door, but over a bridge double; the first file's header names this script as the only place the door and the runtime gate run together against a real engine. - `packages/runtime/src/action-confirmation-gate.test.ts` pins the gate predicate by calling `invokeBusinessAction` directly, without the MCP door. - `packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts` drives `list_actions` / `run_action` through live MCP on a booted showcase, including a 428 refusal and the confirmed call, but only for platform `sys_user` actions of `type: 'api'` (a forward), and dogfood does not run in PR CI. `mcp-delete-record-confirmation.dogfood.test.ts` is the `delete_record` tool, not `run_action`. None drives an app's `type: 'script'` action with a registered handler, the `requiredPermissions` gate (ADR-0066 D4), or the `ai.exposed` gate on an app object through `MCPServerRuntime` → `HttpDispatcher.buildMcpBridge` → a real engine and driver. So no duplicate exists at a layer as close to the code, and the script is wired, not deleted. **Why this boot and not the example testkit.** `test/default-organization.testkit.ts` (`installDefaultOrganization`) registers the organization after the engine is up and writes the row through the driver. Measured: called after `kernel.bootstrap()` it is too late for `AppPlugin`'s seed (eight `Organization is required` seed refusals at boot), and step 3 is then refused with `SystemWriteOrganizationRequiredError` because the engine memoised the empty organization probe during the seed. The plugin creates the organization in `start()`, ahead of the seed, the way a real install does. `@objectstack/example-todo` is `private: true` and ships nothing; the only non-example file is a test-file comment in `packages/mcp` (not in that package's `files[]`), so `skip-changeset`. ## Verification Gate union derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the final head, each exit captured before any pipe, reconciled with `--ran`, on head `e2ebb1ea`: - **64 derived, 64 run, exit 0 each.** `--ran`: "64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED". - The first run of the union (head `59c1bd94`) read `pnpm check:test-source-alias` red (exit 1, the three new `dist/` imports); the source aliases are the fix, and the second run is green. - `node scripts/check-plugin-teardown-shape.mjs --self-test` needed its pinned fixture on this shallow clone (`git fetch --depth=1 origin 621a487`), then exit 0. - Gate record: one `COMMAND :: exit N` line per family, kept with the seat. Scoped lint: `pnpm exec eslint --no-inline-config examples/app-todo packages/mcp/src/mcp-action-confirmation-member.test.ts`, exit 0. CI families are read from this PR's check-runs, not re-run here. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5k9SPSJRVA5sTSKkU8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d3f3686 commit 30f23fe

10 files changed

Lines changed: 319 additions & 269 deletions

‎examples/app-todo/README.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ examples/app-todo/
4040
│ └── docs/ # 📚 Package docs (ADR-0046)
4141
├── test/
4242
│ ├── seed-check.ts # 🧪 Seed/boot verification script (tsx)
43-
│ └── mcp-actions.e2e.ts # 🤖 MCP business-action E2E (pnpm test:mcp)
43+
│ └── mcp-actions.test.ts # 🤖 MCP business-action E2E (runs in pnpm test)
4444
├── objectstack.config.ts # Application manifest
4545
└── README.md
4646
```
@@ -124,13 +124,17 @@ in-product chat lives in the cloud distribution — ADR-0063). This example
124124
ships a real end-to-end proof, **no API key required**:
125125

126126
```bash
127-
pnpm --filter @objectstack/example-todo test:mcp
127+
pnpm --filter @objectstack/example-todo exec vitest run test/mcp-actions.test.ts
128128
```
129129

130-
What it does (`test/mcp-actions.e2e.ts`):
130+
It is part of this example's `pnpm test`, so CI runs it. What it does
131+
(`test/mcp-actions.test.ts`):
131132

132133
1. Boots a self-host composition of ONLY the open framework —
133-
`@objectstack/runtime` + ObjectQL + a driver + this seeded app + `@objectstack/mcp`
134+
`@objectstack/runtime` + ObjectQL + a driver + this seeded app + `@objectstack/mcp`,
135+
plus plugin-auth's identity objects (`createIdentityObjectsPlugin()`, no
136+
authentication mounted), which give the install its Default Organization
137+
before the seed loads
134138
2. Drives the real `MCPServerRuntime` over JSON-RPC — the same code path an
135139
external MCP client (e.g. Claude) hits
136140
3. Lists the app's business actions as MCP tools, then executes one via

‎examples/app-todo/package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616
"build": "objectstack build",
1717
"validate": "objectstack validate",
1818
"typecheck": "tsc --noEmit",
19-
"test": "vitest run",
20-
"test:mcp": "tsx test/mcp-actions.e2e.ts"
19+
"test": "vitest run"
2120
},
2221
"dependencies": {
2322
"@objectstack/client": "workspace:*",
@@ -34,6 +33,7 @@
3433
"devDependencies": {
3534
"@objectstack/cli": "workspace:*",
3635
"@objectstack/core": "workspace:*",
36+
"@objectstack/plugin-auth": "workspace:*",
3737
"@objectstack/service-automation": "workspace:*",
3838
"@objectstack/trigger-record-change": "workspace:*",
3939
"tsx": "^4.23.15",

‎examples/app-todo/test/mcp-actions.e2e.ts‎

Lines changed: 0 additions & 258 deletions
This file was deleted.
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// MCP business-action E2E — Community-Edition path.
4+
//
5+
// Proves that a self-host runtime composing ONLY the open framework
6+
// (@objectstack/runtime + objectql + a driver + the seeded app) and
7+
// @objectstack/mcp — NO @objectstack/service-ai, NO cloud studio — exposes MCP
8+
// tools that LIST and EXECUTE the app's business actions, permission-enforced.
9+
//
10+
// We boot the real ObjectQL engine, register app-todo's real action handlers,
11+
// then drive the real MCPServerRuntime over JSON-RPC (the same code path an
12+
// external MCP client hits) through the runtime's principal-bound action
13+
// bridge. `run_action` flows through engine.executeAction → the registered
14+
// handler → the real driver, exactly as in production.
15+
//
16+
// ## The organization the boot holds (ADR-0131 D3 / D9)
17+
//
18+
// Every `todo_task` row carries a NOT NULL organization. A real `single`
19+
// install creates its Default Organization at boot, before the app's seed
20+
// loads, and every organization-less system write is stamped with it. That is
21+
// the job of the plugin that registers the organization object: `AuthPlugin`
22+
// on a server, `createIdentityObjectsPlugin()` on a reduced kernel like this
23+
// one — plugin-auth's identity objects and the same boot invariant, with no
24+
// authentication mounted. A kernel with neither registers no organization
25+
// object, so the seed and every system insert here were refused with
26+
// "Organization is required". The suite boots the way the platform documents a
27+
// reduced kernel, and pins that the app's own seed landed.
28+
//
29+
// It runs in this example's own `pnpm test`, so CI sees it.
30+
31+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
32+
import { ObjectKernel, DriverPlugin, AppPlugin } from '@objectstack/runtime';
33+
import { HttpDispatcher } from '@objectstack/runtime';
34+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
35+
import { ObjectQLPlugin } from '@objectstack/objectql';
36+
import { MCPServerRuntime } from '@objectstack/mcp';
37+
import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth';
38+
import TodoApp from '../objectstack.config.js';
39+
import { registerTaskActionHandlers } from '../src/actions/register-handlers.js';
40+
41+
const BOOT_TIMEOUT = 60_000;
42+
43+
/** Build a JSON-RPC request the MCP Streamable-HTTP transport accepts. */
44+
function mcpRequest(body: unknown): Request {
45+
return new Request('http://localhost/api/v1/mcp', {
46+
method: 'POST',
47+
headers: {
48+
'content-type': 'application/json',
49+
accept: 'application/json, text/event-stream',
50+
},
51+
body: JSON.stringify(body),
52+
});
53+
}
54+
55+
const toolsCall = (id: number, name: string, args: Record<string, unknown>) => ({
56+
jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args },
57+
});
58+
59+
/** The app's merged stack with one action of `todo_task` rewritten. */
60+
function withTaskAction(objects: any[], actionName: string, rewrite: (action: any) => any): any[] {
61+
return objects.map((o) =>
62+
o.name !== 'todo_task'
63+
? o
64+
: { ...o, actions: o.actions.map((a: any) => (a.name === actionName ? rewrite(a) : a)) },
65+
);
66+
}
67+
68+
describe('app-todo business actions over MCP (CE runtime, no service-ai)', () => {
69+
let kernel: ObjectKernel;
70+
let engine: any;
71+
let mergedObjects: any[];
72+
let bridgeFor: (executionContext: any, objects?: any[]) => any;
73+
const runtime = new MCPServerRuntime({ name: 'objectstack', version: '1.0.0' });
74+
75+
const callMcp = async (bridge: any, body: unknown): Promise<any> => {
76+
const res = await runtime.handleHttpRequest(mcpRequest(body), { bridge, parsedBody: body });
77+
return res.json();
78+
};
79+
const listActions = async (bridge: any, id: number): Promise<any[]> =>
80+
JSON.parse((await callMcp(bridge, toolsCall(id, 'list_actions', {}))).result.content[0].text).actions;
81+
const seedTask = async (subject: string): Promise<string> => {
82+
const seeded: any = await engine.insert('todo_task', { subject, status: 'not_started', priority: 'high' });
83+
const id = seeded?.id ?? seeded?.record?.id;
84+
expect(id, `seeded '${subject}'`).toBeTruthy();
85+
return id;
86+
};
87+
const statusOf = async (id: string): Promise<unknown> =>
88+
(await engine.find('todo_task', { where: { id } }))?.[0]?.status;
89+
90+
// The acting user — an authenticated, non-system principal.
91+
const user = { userId: 'user_1', positions: [], permissions: [], systemPermissions: [] };
92+
93+
beforeAll(async () => {
94+
// ── Boot the real open-framework runtime ──────────────────────────
95+
kernel = new ObjectKernel({ logger: { level: 'silent' } } as any);
96+
await kernel.use(new ObjectQLPlugin());
97+
await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' })));
98+
// The organization object and the Default Organization; see the header.
99+
await kernel.use(createIdentityObjectsPlugin());
100+
await kernel.use(new AppPlugin(TodoApp));
101+
await kernel.bootstrap();
102+
103+
// app-todo wires handlers via a named `onEnable` export that the default
104+
// AppPlugin import misses — register them directly against the engine.
105+
engine = await (kernel as any).getServiceAsync('data');
106+
if (!engine) throw new Error('data engine not available');
107+
registerTaskActionHandlers(engine);
108+
109+
// defineStack() merges actions[] into their object by `objectName`; this is
110+
// exactly what a real metadata service returns. Surface it to the dispatcher.
111+
mergedObjects = (TodoApp.objects ?? []) as any[];
112+
113+
// A metadata service backed by the app's own merged stack. In a full
114+
// deployment MetadataPlugin returns these same objects; the action mechanism
115+
// under test (resolve → gate → executeAction → handler → driver) is 100% real.
116+
const makeMetadata = (objects: any[]) => ({
117+
listObjects: async () => objects,
118+
getObject: async (n: string) => objects.find((o) => o.name === n),
119+
});
120+
121+
// Build a principal-bound MCP action bridge for a given user + metadata view.
122+
bridgeFor = (executionContext: any, objects: any[] = mergedObjects) => {
123+
const metadata = makeMetadata(objects);
124+
const fakeKernel: any = {
125+
context: {
126+
getService: (n: string) =>
127+
n === 'objectql' || n === 'data' ? engine : n === 'metadata' ? metadata : null,
128+
},
129+
};
130+
const dispatcher = new HttpDispatcher(fakeKernel);
131+
return (dispatcher as any).buildMcpBridge({ executionContext, environmentId: undefined });
132+
};
133+
}, BOOT_TIMEOUT);
134+
135+
afterAll(async () => {
136+
await kernel?.shutdown();
137+
});
138+
139+
it('boots with its Default Organization, so the app seed landed', async () => {
140+
const organizations: any[] = await engine.find('sys_organization', {}, { context: { isSystem: true } });
141+
expect(organizations.length).toBe(1);
142+
const subjects = ((await engine.find('todo_task', {})) as any[]).map((t) => t.subject);
143+
expect(subjects).toEqual(expect.arrayContaining(['Learn ObjectStack', 'Build a cool app']));
144+
});
145+
146+
it('declares its actions on todo_task', () => {
147+
const todo = mergedObjects.find((o) => o.name === 'todo_task');
148+
expect(Array.isArray(todo?.actions) && todo.actions.length > 0).toBe(true);
149+
});
150+
151+
// ── Step 1 — the MCP server advertises the action tools ───────────
152+
it('step 1: tools/list advertises list_actions + run_action', async () => {
153+
const list = await callMcp(bridgeFor(user), { jsonrpc: '2.0', id: 1, method: 'tools/list' });
154+
const toolNames: string[] = list.result.tools.map((t: any) => t.name);
155+
expect(toolNames).toContain('list_actions');
156+
expect(toolNames).toContain('run_action');
157+
// Object tools remain exposed alongside them.
158+
expect(toolNames).toContain('query_records');
159+
});
160+
161+
// ── Step 2 — list_actions enumerates the app's business actions ───
162+
it('step 2: list_actions enumerates invokable business actions', async () => {
163+
const actions = await listActions(bridgeFor(user), 2);
164+
const actionNames = actions.map((a) => a.name);
165+
expect(actionNames).toContain('todo_complete_task');
166+
// A modal/UI-only action is not invokable, so not listed.
167+
expect(actionNames).not.toContain('todo_defer_task');
168+
expect(actions.find((a) => a.name === 'todo_complete_task')?.requiresRecord).toBe(true);
169+
// `variant: 'danger'` is reported as requiresConfirmation in the listing.
170+
expect(actions.find((a) => a.name === 'todo_delete_completed')?.requiresConfirmation).toBe(true);
171+
});
172+
173+
// ── Step 3 — run_action EXECUTES real business logic ──────────────
174+
it('step 3: run_action executes todo_complete_task against a real record', async () => {
175+
const taskId = await seedTask('Ship MCP actions');
176+
const ran = await callMcp(bridgeFor(user), toolsCall(3, 'run_action', { actionName: 'todo_complete_task', recordId: taskId }));
177+
expect(ran.result?.isError, ran.result?.content?.[0]?.text).not.toBe(true);
178+
expect(JSON.parse(ran.result.content[0].text).ok).toBe(true);
179+
// The handler ran.
180+
expect(await statusOf(taskId)).toBe('completed');
181+
});
182+
183+
// ── Step 4 — fail-closed on system-object actions ─────────────────
184+
it('step 4: system-object actions are blocked fail-closed', async () => {
185+
const sysRun = await callMcp(bridgeFor(user), toolsCall(4, 'run_action', { actionName: 'rotate', objectName: 'sys_api_key' }));
186+
expect(sysRun.result?.isError).toBe(true);
187+
expect(sysRun.result?.content?.[0]?.text ?? '').toMatch(/system object/i);
188+
});
189+
190+
// ── Step 5 — capability gate (ADR-0066 D4) end-to-end ─────────────
191+
it('step 5: the requiredPermissions gate denies, then allows', async () => {
192+
const taskId = await seedTask('Clone me');
193+
// Same app, but todo_clone_task now declares a capability requirement.
194+
const gatedObjects = withTaskAction(mergedObjects, 'todo_clone_task', (a) => ({ ...a, requiredPermissions: ['todo_admin'] }));
195+
196+
const denyBridge = bridgeFor({ userId: 'user_2', positions: [], permissions: [], systemPermissions: [] }, gatedObjects);
197+
const denied = await callMcp(denyBridge, toolsCall(5, 'run_action', { actionName: 'todo_clone_task', recordId: taskId }));
198+
expect(denied.result?.isError).toBe(true);
199+
expect(denied.result?.content?.[0]?.text ?? '').toMatch(/requires capability/i);
200+
// …and list_actions hides what the caller may not run.
201+
expect((await listActions(denyBridge, 6)).some((a) => a.name === 'todo_clone_task')).toBe(false);
202+
203+
const allowBridge = bridgeFor({ userId: 'admin_1', positions: [], permissions: [], systemPermissions: ['todo_admin'] }, gatedObjects);
204+
const allowed = await callMcp(allowBridge, toolsCall(7, 'run_action', { actionName: 'todo_clone_task', recordId: taskId }));
205+
expect(allowed.result?.isError, allowed.result?.content?.[0]?.text).not.toBe(true);
206+
expect((await listActions(allowBridge, 8)).some((a) => a.name === 'todo_clone_task')).toBe(true);
207+
});
208+
209+
// ── Step 6 — AI-exposure gate (#2849) end-to-end ───────────────────
210+
it('step 6: an action without ai.exposed is hidden and uninvokable', async () => {
211+
const taskId = await seedTask('Not for AI');
212+
// Same app, but todo_clone_task no longer opts into the AI surface.
213+
const unexposedObjects = withTaskAction(mergedObjects, 'todo_clone_task', ({ ai: _ai, ...rest }: any) => rest);
214+
const unexposedBridge = bridgeFor({ userId: 'user_3', positions: [], permissions: [], systemPermissions: ['todo_admin'] }, unexposedObjects);
215+
expect((await listActions(unexposedBridge, 9)).some((a) => a.name === 'todo_clone_task')).toBe(false);
216+
const unexposedRun = await callMcp(unexposedBridge, toolsCall(10, 'run_action', { actionName: 'todo_clone_task', recordId: taskId }));
217+
expect(unexposedRun.result?.isError).toBe(true);
218+
expect(unexposedRun.result?.content?.[0]?.text ?? '').toMatch(/not exposed to AI/i);
219+
});
220+
221+
// ── Step 7 — the confirmation gate (#15942), BOTH directions ───────
222+
//
223+
// The one drive that spans the whole path. A unit test on either side is
224+
// blind to the other: `invokeBusinessAction` called directly never sees the
225+
// MCP door strip the member (the SDK's shape wrap drops unknown keys, and
226+
// the handler forwards a rebuilt object), and a door test with a stubbed
227+
// bridge never sees the runtime gate. Here the member travels from a real
228+
// JSON-RPC `tools/call`, through both of those layers, into the real gate —
229+
// and the record afterwards says whether anything ran.
230+
it('step 7: ai.requiresConfirmation is ENFORCED, and satisfiable', async () => {
231+
// Same app, but todo_complete_task now declares the author's gate.
232+
const gatedConfirmObjects = withTaskAction(mergedObjects, 'todo_complete_task', (a) => ({
233+
...a,
234+
ai: { ...(a.ai ?? {}), requiresConfirmation: true },
235+
}));
236+
const confirmBridge = bridgeFor(user, gatedConfirmObjects);
237+
238+
// The listing tells a client the gate is there (unchanged behaviour).
239+
const gatedList = await listActions(confirmBridge, 11);
240+
expect(gatedList.find((a) => a.name === 'todo_complete_task')?.requiresConfirmation).toBe(true);
241+
242+
const gatedId = await seedTask('Needs a human');
243+
244+
// 7a — WITHOUT the member: refused, with the declared code, and NOTHING ran.
245+
const refused = await callMcp(confirmBridge, toolsCall(12, 'run_action', { actionName: 'todo_complete_task', recordId: gatedId }));
246+
expect(refused.result?.isError).toBe(true);
247+
const refusedEnvelope = JSON.parse(refused.result?.content?.[0]?.text ?? '{}');
248+
expect(refusedEnvelope?.error?.code).toBe('ACTION_CONFIRMATION_REQUIRED');
249+
expect(refusedEnvelope?.error?.status).toBe(428);
250+
expect(refusedEnvelope?.error?.details?.actionName).toBe('todo_complete_task');
251+
expect(refusedEnvelope?.error?.details?.confirmationMember).toBe('confirm');
252+
expect(await statusOf(gatedId)).toBe('not_started');
253+
254+
// 7b — WITH the member: the identical call succeeds and the handler runs.
255+
// This is the leg that proves the member is not stripped: before the door
256+
// grew it, this call was refused exactly like 7a and the action was
257+
// permanently un-invokable.
258+
const confirmed = await callMcp(confirmBridge, toolsCall(13, 'run_action', { actionName: 'todo_complete_task', recordId: gatedId, confirm: true }));
259+
expect(confirmed.result?.isError, confirmed.result?.content?.[0]?.text).not.toBe(true);
260+
expect(await statusOf(gatedId)).toBe('completed');
261+
262+
// 7c — the heuristic must NOT gate: todo_delete_completed is `variant:'danger'`
263+
// and the listing calls it requiresConfirmation, but its author declared
264+
// nothing, so it stays invokable with no member (this change narrows a
265+
// published accept set; refusing here would be the regression).
266+
const undeclared = await callMcp(confirmBridge, toolsCall(14, 'run_action', { actionName: 'todo_delete_completed' }));
267+
expect(undeclared.result?.isError, undeclared.result?.content?.[0]?.text).not.toBe(true);
268+
});
269+
});

‎examples/app-todo/test/node-shim.d.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
// Minimal ambient surface for the node globals the standalone harnesses under
4-
// `test/` touch (`seed-check.ts`, `mcp-actions.e2e.ts` — both run under `tsx`).
4+
// `test/` touch (`seed-check.ts`, run under `tsx`).
55
// This app's tsconfig deliberately omits `@types/node`, matching
66
// examples/app-showcase; tsx/node provide the real `process` at runtime.
77

‎examples/app-todo/test/seed-check.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { ObjectKernel, DriverPlugin, AppPlugin } from '@objectstack/runtime';
33
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
44
import { ObjectQLPlugin } from '@objectstack/objectql';
5+
import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth';
56
import TodoApp from '../objectstack.config';
67

78
(async () => {
@@ -15,6 +16,11 @@ import TodoApp from '../objectstack.config';
1516
await kernel.use(new ObjectQLPlugin());
1617
await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' })));
1718

19+
// The organization object and the Default Organization a `single` install
20+
// holds before seeds load (ADR-0131 D3); without them every seed row is
21+
// refused with "Organization is required". See test/mcp-actions.test.ts.
22+
await kernel.use(createIdentityObjectsPlugin());
23+
1824
// Load App with Data
1925
await kernel.use(new AppPlugin(TodoApp));
2026

‎examples/app-todo/test/task-completion-trigger.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ afterEach(async () => {
5959

6060
/**
6161
* A real kernel with the app's real `todo_task` object: ObjectQL over an
62-
* in-process sqlite-wasm database (the same driver `test/mcp-actions.e2e.ts`
62+
* in-process sqlite-wasm database (the same driver `test/mcp-actions.test.ts`
6363
* boots), the automation engine, and the record-change trigger. No test double
6464
* anywhere in the chain — #6882 was precisely a chain that looked correct in
6565
* the metadata and bound to nothing at runtime.

0 commit comments

Comments
 (0)