Skip to content

Commit 7d7fee7

Browse files
os-zhuangclaude
andauthored
feat(mcp): native business-action execution (list_actions / run_action) (#2307)
* feat(mcp): native business-action execution (list_actions / run_action) Make the open-source MCP server (`@objectstack/mcp`) natively expose business action execution, so a self-host / Community-Edition runtime (open framework + @objectstack/mcp, no cloud studio) can *operate* an app over MCP — run its business actions/flows — not just CRUD records. Action execution previously rode on `@objectstack/service-ai`'s tool registry, which the MCP server only ever optionally duck-typed. With the in-UI `ask` agent moved out to the closed cloud package, that registry is empty in a headless CE runtime, so BYO-AI via MCP could read/write rows but never trigger business logic. Action execution is an open *mechanism*, so it now lives in the open framework. @objectstack/mcp: - `registerActionTools()` registers two native tools alongside the object-CRUD tools, via a `McpActionBridge` seam (mirrors `registerObjectTools` exactly): - `list_actions` — enumerate invokable business actions the caller may run. - `run_action` — invoke an action by name with { recordId, params }. - Wired into `handleHttpRequest` by capability (only when the runtime bridge can resolve the action mechanism — graceful degradation, like record resources needing a dataEngine). No dependency on `@objectstack/service-ai`. @objectstack/runtime: - `buildMcpBridge` now resolves + dispatches actions through the framework's own mechanism — `IDataEngine.executeAction` (script/body) / automation flow runner (flow) — bound to the caller's ExecutionContext: the same permission + RLS path the REST `/actions/...` route uses. `list_actions` is permission- and visibility-filtered; `sys_*`-object actions are held back fail-closed. - The ADR-0066 D4 `requiredPermissions` capability gate is single-sourced (`actionPermissionError`) and enforced for both the REST route and run_action. Tests: - mcp: `mcp-action-tools.test.ts` — tool shapes, delegation, capability gating, fail-closed sys_* guard, tool-error surfacing. - runtime: `http-dispatcher.test.ts` — real `buildMcpBridge` listActions filtering, run_action executeAction dispatch (via the action's target), ADR-0066 D4 gate. - examples/app-todo: `mcp-actions.test.ts` — CE E2E booting the real engine (no service-ai) and driving the MCP runtime over JSON-RPC: list + execute a real action end-to-end, permission-enforced. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(runtime): cover MCP run_action flow dispatch via automation runner Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7e223a8 commit 7d7fee7

11 files changed

Lines changed: 1193 additions & 29 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/mcp': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
Native MCP business-action execution (`list_actions` / `run_action`)
7+
8+
The open-source MCP server now natively exposes the app's **business actions**, so a self-host / Community-Edition runtime (open framework + `@objectstack/mcp`, no cloud studio) can *operate* an app over MCP — not just CRUD records. Previously action execution rode on `@objectstack/service-ai`'s tool registry, which is empty in a headless CE runtime after the in-UI `ask` agent moved to the cloud package.
9+
10+
- **`@objectstack/mcp`**: new `registerActionTools()` registering two native tools alongside the object-CRUD tools, plus a `McpActionBridge` seam (`McpActionSummary`, `RegisterActionToolsOptions`):
11+
- `list_actions` — enumerate the invokable business actions the caller may run (permission- + visibility-filtered, system-object actions held back fail-closed).
12+
- `run_action` — invoke an action by name with `recordId` / `params`.
13+
- Wired into `handleHttpRequest` by capability: only registered when the runtime bridge can resolve the action mechanism (graceful degradation). No dependency on `@objectstack/service-ai`.
14+
- **`@objectstack/runtime`**: the principal-bound MCP bridge (`buildMcpBridge`) now resolves + dispatches actions through the framework's own mechanism — `IDataEngine.executeAction` (script/body) / automation flow runner (flow) — bound to the caller's `ExecutionContext`, the same permission + RLS path the REST `/actions/...` route uses. The ADR-0066 D4 `requiredPermissions` capability gate is now single-sourced and enforced for both surfaces.

examples/app-todo/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@
2323
"test:hitl": "tsx test/ai-hitl.test.ts",
2424
"test:hitl:llm": "tsx test/ai-hitl-llm.test.ts",
2525
"test:llm": "tsx test/ai-llm.test.ts",
26-
"test:knowledge:llm": "tsx test/ai-knowledge-llm.test.ts"
26+
"test:knowledge:llm": "tsx test/ai-knowledge-llm.test.ts",
27+
"test:mcp": "tsx test/mcp-actions.test.ts"
2728
},
2829
"dependencies": {
2930
"@objectstack/client": "workspace:*",
3031
"@objectstack/driver-sqlite-wasm": "workspace:^",
32+
"@objectstack/mcp": "workspace:*",
3133
"@objectstack/metadata": "workspace:*",
3234
"@objectstack/objectql": "workspace:^",
3335
"@objectstack/knowledge-memory": "workspace:*",
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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+
// Run via: `pnpm --filter @objectstack/example-todo test:mcp`
17+
18+
import { ObjectKernel, DriverPlugin, AppPlugin } from '@objectstack/runtime';
19+
import { HttpDispatcher } from '@objectstack/runtime';
20+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
21+
import { ObjectQLPlugin } from '@objectstack/objectql';
22+
import { MCPServerRuntime } from '@objectstack/mcp';
23+
import TodoApp from '../objectstack.config';
24+
import { registerTaskActionHandlers } from '../src/actions/register-handlers';
25+
26+
let failures = 0;
27+
function check(cond: boolean, msg: string): void {
28+
if (cond) {
29+
console.log(` ✓ ${msg}`);
30+
} else {
31+
failures++;
32+
console.error(` ✗ ${msg}`);
33+
}
34+
}
35+
36+
/** Build a JSON-RPC request the MCP Streamable-HTTP transport accepts. */
37+
function mcpRequest(body: unknown): Request {
38+
return new Request('http://localhost/api/v1/mcp', {
39+
method: 'POST',
40+
headers: {
41+
'content-type': 'application/json',
42+
accept: 'application/json, text/event-stream',
43+
},
44+
body: JSON.stringify(body),
45+
});
46+
}
47+
48+
(async () => {
49+
console.log('🔌 ObjectStack MCP Action E2E — list + execute business actions (CE, no service-ai)');
50+
console.log('────────────────────────────────────────────────────────────────────────────────');
51+
52+
process.env.OS_MULTI_ORG_ENABLED = 'false';
53+
54+
// ── Boot the real open-framework runtime ──────────────────────────
55+
const kernel = new ObjectKernel();
56+
await kernel.use(new ObjectQLPlugin());
57+
await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' })));
58+
await kernel.use(new AppPlugin(TodoApp));
59+
await kernel.bootstrap();
60+
61+
// app-todo wires handlers via a named `onEnable` export that the default
62+
// AppPlugin import misses — register them directly against the engine.
63+
const engine: any = await (kernel as any).getServiceAsync('data');
64+
if (!engine) throw new Error('data engine not available');
65+
registerTaskActionHandlers(engine);
66+
67+
// defineStack() merges actions[] into their object by `objectName`; this is
68+
// exactly what a real metadata service returns. Surface it to the dispatcher.
69+
const mergedObjects: any[] = (TodoApp.objects ?? []) as any[];
70+
const todo = mergedObjects.find((o) => o.name === 'todo_task');
71+
check(Array.isArray(todo?.actions) && todo.actions.length > 0, `todo_task has ${todo?.actions?.length ?? 0} declarative actions`);
72+
73+
// A metadata service backed by the app's own merged stack. In a full
74+
// deployment MetadataPlugin returns these same objects; the action mechanism
75+
// under test (resolve → gate → executeAction → handler → driver) is 100% real.
76+
const makeMetadata = (objects: any[]) => ({
77+
listObjects: async () => objects,
78+
getObject: async (n: string) => objects.find((o) => o.name === n),
79+
});
80+
81+
// Build a principal-bound MCP action bridge for a given user + metadata view.
82+
const bridgeFor = (executionContext: any, objects: any[] = mergedObjects) => {
83+
const metadata = makeMetadata(objects);
84+
const fakeKernel: any = {
85+
context: {
86+
getService: (n: string) =>
87+
n === 'objectql' || n === 'data' ? engine : n === 'metadata' ? metadata : null,
88+
},
89+
};
90+
const dispatcher = new HttpDispatcher(fakeKernel);
91+
return (dispatcher as any).buildMcpBridge({ executionContext, environmentId: undefined });
92+
};
93+
94+
const runtime = new MCPServerRuntime({ name: 'objectstack', version: '1.0.0' });
95+
const callMcp = async (bridge: any, body: unknown) => {
96+
const res = await runtime.handleHttpRequest(mcpRequest(body), { bridge, parsedBody: body });
97+
return res.json();
98+
};
99+
const toolsCall = (id: number, name: string, args: Record<string, unknown>) => ({
100+
jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args },
101+
});
102+
103+
// The acting user — an authenticated, non-system principal.
104+
const user = { userId: 'user_1', roles: [], permissions: [], systemPermissions: [] };
105+
const bridge = bridgeFor(user);
106+
107+
// ── Step 1 — the MCP server advertises the action tools ───────────
108+
console.log('\n📋 Step 1 — tools/list advertises list_actions + run_action');
109+
const list = await callMcp(bridge, { jsonrpc: '2.0', id: 1, method: 'tools/list' });
110+
const toolNames: string[] = list.result.tools.map((t: any) => t.name);
111+
check(toolNames.includes('list_actions'), 'list_actions tool is exposed');
112+
check(toolNames.includes('run_action'), 'run_action tool is exposed');
113+
check(toolNames.includes('query_records'), 'object tools remain exposed (query_records)');
114+
115+
// ── Step 2 — list_actions enumerates the app's business actions ───
116+
console.log('\n📋 Step 2 — list_actions enumerates invokable business actions');
117+
const listed = await callMcp(bridge, toolsCall(2, 'list_actions', {}));
118+
const actions = JSON.parse(listed.result.content[0].text).actions as any[];
119+
const actionNames = actions.map((a) => a.name);
120+
console.log(` → ${actionNames.length} actions: ${actionNames.join(', ')}`);
121+
check(actionNames.includes('complete_task'), 'complete_task (script) is listed');
122+
check(!actionNames.includes('defer_task'), 'defer_task (modal/UI-only) is NOT listed');
123+
const complete = actions.find((a) => a.name === 'complete_task');
124+
check(complete?.requiresRecord === true, 'complete_task is flagged requiresRecord');
125+
const del = actions.find((a) => a.name === 'delete_completed');
126+
check(del?.requiresConfirmation === true, 'delete_completed (danger) flagged requiresConfirmation');
127+
128+
// ── Step 3 — run_action EXECUTES real business logic ──────────────
129+
console.log('\n⚡ Step 3 — run_action executes complete_task against a real record');
130+
const seeded: any = await engine.insert('todo_task', { subject: 'Ship MCP actions', status: 'not_started', priority: 'high' });
131+
const taskId = seeded?.id ?? seeded?.record?.id;
132+
check(Boolean(taskId), `seeded a task (${taskId})`);
133+
const ran = await callMcp(bridge, toolsCall(3, 'run_action', { actionName: 'complete_task', recordId: taskId }));
134+
check(ran.result?.isError !== true, 'run_action returned success (no tool error)');
135+
const payload = ran.result?.isError ? {} : JSON.parse(ran.result.content[0].text);
136+
check(payload.ok === true, 'run_action payload reports ok:true');
137+
const after: any[] = await engine.find('todo_task', { where: { id: taskId } });
138+
check(after?.[0]?.status === 'completed', `the task status is now '${after?.[0]?.status}' (handler ran)`);
139+
140+
// ── Step 4 — fail-closed on system-object actions ─────────────────
141+
console.log('\n🔒 Step 4 — system-object actions are blocked fail-closed');
142+
const sysRun = await callMcp(bridge, toolsCall(4, 'run_action', { actionName: 'rotate', objectName: 'sys_api_key' }));
143+
check(sysRun.result?.isError === true, 'run_action on a sys_* object is a tool error');
144+
check(/system object/i.test(sysRun.result?.content?.[0]?.text ?? ''), 'error names the system-object guard');
145+
146+
// ── Step 5 — capability gate (ADR-0066 D4) end-to-end ─────────────
147+
console.log('\n🔒 Step 5 — requiredPermissions gate denies, then allows');
148+
// Same app, but clone_task now declares a capability requirement.
149+
const gatedObjects = mergedObjects.map((o) =>
150+
o.name !== 'todo_task'
151+
? o
152+
: { ...o, actions: o.actions.map((a: any) => (a.name === 'clone_task' ? { ...a, requiredPermissions: ['todo_admin'] } : a)) },
153+
);
154+
const denyBridge = bridgeFor({ userId: 'user_2', roles: [], permissions: [], systemPermissions: [] }, gatedObjects);
155+
const denied = await callMcp(denyBridge, toolsCall(5, 'run_action', { actionName: 'clone_task', recordId: taskId }));
156+
check(denied.result?.isError === true, 'run_action denied when the caller lacks the capability');
157+
check(/requires capability/i.test(denied.result?.content?.[0]?.text ?? ''), 'denial cites the missing capability');
158+
// …and list_actions hides what the caller may not run.
159+
const denyList = JSON.parse((await callMcp(denyBridge, toolsCall(6, 'list_actions', {}))).result.content[0].text).actions as any[];
160+
check(!denyList.some((a) => a.name === 'clone_task'), 'list_actions hides the gated action from a non-holder');
161+
162+
const allowBridge = bridgeFor({ userId: 'admin_1', roles: [], permissions: [], systemPermissions: ['todo_admin'] }, gatedObjects);
163+
const allowed = await callMcp(allowBridge, toolsCall(7, 'run_action', { actionName: 'clone_task', recordId: taskId }));
164+
check(allowed.result?.isError !== true, 'run_action allowed for a holder of the capability');
165+
const allowList = JSON.parse((await callMcp(allowBridge, toolsCall(8, 'list_actions', {}))).result.content[0].text).actions as any[];
166+
check(allowList.some((a) => a.name === 'clone_task'), 'list_actions reveals the gated action to a holder');
167+
168+
console.log('\n────────────────────────────────────────────────────────────────────────────────');
169+
if (failures > 0) {
170+
console.error(`❌ MCP action E2E FAILED — ${failures} check(s) failed`);
171+
process.exit(1);
172+
}
173+
console.log('✅ MCP action E2E PASSED — CE runtime lists + executes business actions, permission-enforced');
174+
process.exit(0);
175+
})().catch((err) => {
176+
console.error('❌ MCP action E2E threw:', err);
177+
process.exit(1);
178+
});

packages/mcp/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,36 @@ ObjectStack automatically exposes these operations as MCP tools:
9393
'objectstack_listFields' // List object fields
9494
```
9595

96+
### Native Tools (Streamable HTTP)
97+
98+
Over the network-reachable Streamable HTTP transport, the server self-registers
99+
a native tool set bound to the **caller's principal** (the API key acts as the
100+
user, with full row-level security + permission enforcement). No
101+
`@objectstack/service-ai` and no cloud studio are required — these are part of
102+
the open framework.
103+
104+
```typescript
105+
// Object data (RLS-enforced as the caller)
106+
'list_objects' // List objects (system sys_* objects hidden by default)
107+
'describe_object' // Object schema: fields + features
108+
'query_records' // Filter / sort / paginate
109+
'get_record' // Fetch one by id
110+
'create_record' / 'update_record' / 'delete_record'
111+
112+
// Business actions — operate the app, not just its rows
113+
'list_actions' // Invokable business actions the caller may run
114+
'run_action' // Invoke an action by name with { recordId, params }
115+
```
116+
117+
`list_actions` enumerates each object's headless-invokable actions (script /
118+
flow), filtered to what the caller is permitted to run — declared
119+
`requiredPermissions` (ADR-0066 D4) are enforced and `sys_*`-object actions are
120+
held back fail-closed. `run_action` resolves the action by name and dispatches
121+
it through the framework's own action mechanism (`engine.executeAction` /
122+
automation flow runner) as the caller, so a BYO-AI MCP client (Claude Code,
123+
Cursor, …) can trigger real business logic — e.g. "complete this task",
124+
"convert this lead" — exactly as the UI would, under the same guardrails.
125+
96126
### Custom Tools
97127

98128
Register custom tools that AI models can call:

packages/mcp/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,15 @@ export { MCPServerPlugin } from './plugin.js';
1414
export type { MCPServerPluginOptions } from './plugin.js';
1515
export { MCPServerRuntime } from './mcp-server-runtime.js';
1616
export type { MCPServerRuntimeConfig } from './mcp-server-runtime.js';
17-
export { registerObjectTools } from './mcp-http-tools.js';
17+
export { registerObjectTools, registerActionTools } from './mcp-http-tools.js';
1818
export type {
1919
McpDataBridge,
2020
McpObjectSummary,
2121
RegisterObjectToolsOptions,
22+
McpActionBridge,
23+
McpActionSummary,
24+
McpActionParamSummary,
25+
RegisterActionToolsOptions,
2226
} from './mcp-http-tools.js';
2327
export {
2428
renderSkillMarkdown,

0 commit comments

Comments
 (0)