From 85d2d21128ebd0593890e3a54208ecf72d36cdc8 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 25 Jun 2026 00:54:36 +0800 Subject: [PATCH 1/2] feat(mcp): native business-action execution (list_actions / run_action) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/mcp-native-action-tools.md | 14 + examples/app-todo/package.json | 4 +- examples/app-todo/test/mcp-actions.test.ts | 178 ++++++++++ packages/mcp/README.md | 30 ++ packages/mcp/src/index.ts | 6 +- packages/mcp/src/mcp-action-tools.test.ts | 246 +++++++++++++ packages/mcp/src/mcp-http-tools.ts | 156 +++++++++ packages/mcp/src/mcp-server-runtime.ts | 33 +- packages/runtime/src/http-dispatcher.test.ts | 148 ++++++++ packages/runtime/src/http-dispatcher.ts | 345 ++++++++++++++++++- pnpm-lock.yaml | 3 + 11 files changed, 1134 insertions(+), 29 deletions(-) create mode 100644 .changeset/mcp-native-action-tools.md create mode 100644 examples/app-todo/test/mcp-actions.test.ts create mode 100644 packages/mcp/src/mcp-action-tools.test.ts diff --git a/.changeset/mcp-native-action-tools.md b/.changeset/mcp-native-action-tools.md new file mode 100644 index 0000000000..095f7fa712 --- /dev/null +++ b/.changeset/mcp-native-action-tools.md @@ -0,0 +1,14 @@ +--- +'@objectstack/mcp': minor +'@objectstack/runtime': minor +--- + +Native MCP business-action execution (`list_actions` / `run_action`) + +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. + +- **`@objectstack/mcp`**: new `registerActionTools()` registering two native tools alongside the object-CRUD tools, plus a `McpActionBridge` seam (`McpActionSummary`, `RegisterActionToolsOptions`): + - `list_actions` — enumerate the invokable business actions the caller may run (permission- + visibility-filtered, system-object actions held back fail-closed). + - `run_action` — invoke an action by name with `recordId` / `params`. + - Wired into `handleHttpRequest` by capability: only registered when the runtime bridge can resolve the action mechanism (graceful degradation). No dependency on `@objectstack/service-ai`. +- **`@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. diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index 7a3c4e8c50..b7734b519a 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -23,11 +23,13 @@ "test:hitl": "tsx test/ai-hitl.test.ts", "test:hitl:llm": "tsx test/ai-hitl-llm.test.ts", "test:llm": "tsx test/ai-llm.test.ts", - "test:knowledge:llm": "tsx test/ai-knowledge-llm.test.ts" + "test:knowledge:llm": "tsx test/ai-knowledge-llm.test.ts", + "test:mcp": "tsx test/mcp-actions.test.ts" }, "dependencies": { "@objectstack/client": "workspace:*", "@objectstack/driver-sqlite-wasm": "workspace:^", + "@objectstack/mcp": "workspace:*", "@objectstack/metadata": "workspace:*", "@objectstack/objectql": "workspace:^", "@objectstack/knowledge-memory": "workspace:*", diff --git a/examples/app-todo/test/mcp-actions.test.ts b/examples/app-todo/test/mcp-actions.test.ts new file mode 100644 index 0000000000..dea4affbdf --- /dev/null +++ b/examples/app-todo/test/mcp-actions.test.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// MCP business-action E2E — Community-Edition path. +// +// Proves that a self-host runtime composing ONLY the open framework +// (@objectstack/runtime + objectql + a driver + the seeded app) and +// @objectstack/mcp — NO @objectstack/service-ai, NO cloud studio — exposes MCP +// tools that LIST and EXECUTE the app's business actions, permission-enforced. +// +// We boot the real ObjectQL engine, register app-todo's real action handlers, +// then drive the real MCPServerRuntime over JSON-RPC (the same code path an +// external MCP client hits) through the runtime's principal-bound action +// bridge. `run_action` flows through engine.executeAction → the registered +// handler → the real driver, exactly as in production. +// +// Run via: `pnpm --filter @objectstack/example-todo test:mcp` + +import { ObjectKernel, DriverPlugin, AppPlugin } from '@objectstack/runtime'; +import { HttpDispatcher } from '@objectstack/runtime'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { MCPServerRuntime } from '@objectstack/mcp'; +import TodoApp from '../objectstack.config'; +import { registerTaskActionHandlers } from '../src/actions/register-handlers'; + +let failures = 0; +function check(cond: boolean, msg: string): void { + if (cond) { + console.log(` ✓ ${msg}`); + } else { + failures++; + console.error(` ✗ ${msg}`); + } +} + +/** Build a JSON-RPC request the MCP Streamable-HTTP transport accepts. */ +function mcpRequest(body: unknown): Request { + return new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + body: JSON.stringify(body), + }); +} + +(async () => { + console.log('🔌 ObjectStack MCP Action E2E — list + execute business actions (CE, no service-ai)'); + console.log('────────────────────────────────────────────────────────────────────────────────'); + + process.env.OS_MULTI_ORG_ENABLED = 'false'; + + // ── Boot the real open-framework runtime ────────────────────────── + const kernel = new ObjectKernel(); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' }))); + await kernel.use(new AppPlugin(TodoApp)); + await kernel.bootstrap(); + + // app-todo wires handlers via a named `onEnable` export that the default + // AppPlugin import misses — register them directly against the engine. + const engine: any = await (kernel as any).getServiceAsync('data'); + if (!engine) throw new Error('data engine not available'); + registerTaskActionHandlers(engine); + + // defineStack() merges actions[] into their object by `objectName`; this is + // exactly what a real metadata service returns. Surface it to the dispatcher. + const mergedObjects: any[] = (TodoApp.objects ?? []) as any[]; + const todo = mergedObjects.find((o) => o.name === 'todo_task'); + check(Array.isArray(todo?.actions) && todo.actions.length > 0, `todo_task has ${todo?.actions?.length ?? 0} declarative actions`); + + // A metadata service backed by the app's own merged stack. In a full + // deployment MetadataPlugin returns these same objects; the action mechanism + // under test (resolve → gate → executeAction → handler → driver) is 100% real. + const makeMetadata = (objects: any[]) => ({ + listObjects: async () => objects, + getObject: async (n: string) => objects.find((o) => o.name === n), + }); + + // Build a principal-bound MCP action bridge for a given user + metadata view. + const bridgeFor = (executionContext: any, objects: any[] = mergedObjects) => { + const metadata = makeMetadata(objects); + const fakeKernel: any = { + context: { + getService: (n: string) => + n === 'objectql' || n === 'data' ? engine : n === 'metadata' ? metadata : null, + }, + }; + const dispatcher = new HttpDispatcher(fakeKernel); + return (dispatcher as any).buildMcpBridge({ executionContext, environmentId: undefined }); + }; + + const runtime = new MCPServerRuntime({ name: 'objectstack', version: '1.0.0' }); + const callMcp = async (bridge: any, body: unknown) => { + const res = await runtime.handleHttpRequest(mcpRequest(body), { bridge, parsedBody: body }); + return res.json(); + }; + const toolsCall = (id: number, name: string, args: Record) => ({ + jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args }, + }); + + // The acting user — an authenticated, non-system principal. + const user = { userId: 'user_1', roles: [], permissions: [], systemPermissions: [] }; + const bridge = bridgeFor(user); + + // ── Step 1 — the MCP server advertises the action tools ─────────── + console.log('\n📋 Step 1 — tools/list advertises list_actions + run_action'); + const list = await callMcp(bridge, { jsonrpc: '2.0', id: 1, method: 'tools/list' }); + const toolNames: string[] = list.result.tools.map((t: any) => t.name); + check(toolNames.includes('list_actions'), 'list_actions tool is exposed'); + check(toolNames.includes('run_action'), 'run_action tool is exposed'); + check(toolNames.includes('query_records'), 'object tools remain exposed (query_records)'); + + // ── Step 2 — list_actions enumerates the app's business actions ─── + console.log('\n📋 Step 2 — list_actions enumerates invokable business actions'); + const listed = await callMcp(bridge, toolsCall(2, 'list_actions', {})); + const actions = JSON.parse(listed.result.content[0].text).actions as any[]; + const actionNames = actions.map((a) => a.name); + console.log(` → ${actionNames.length} actions: ${actionNames.join(', ')}`); + check(actionNames.includes('complete_task'), 'complete_task (script) is listed'); + check(!actionNames.includes('defer_task'), 'defer_task (modal/UI-only) is NOT listed'); + const complete = actions.find((a) => a.name === 'complete_task'); + check(complete?.requiresRecord === true, 'complete_task is flagged requiresRecord'); + const del = actions.find((a) => a.name === 'delete_completed'); + check(del?.requiresConfirmation === true, 'delete_completed (danger) flagged requiresConfirmation'); + + // ── Step 3 — run_action EXECUTES real business logic ────────────── + console.log('\n⚡ Step 3 — run_action executes complete_task against a real record'); + const seeded: any = await engine.insert('todo_task', { subject: 'Ship MCP actions', status: 'not_started', priority: 'high' }); + const taskId = seeded?.id ?? seeded?.record?.id; + check(Boolean(taskId), `seeded a task (${taskId})`); + const ran = await callMcp(bridge, toolsCall(3, 'run_action', { actionName: 'complete_task', recordId: taskId })); + check(ran.result?.isError !== true, 'run_action returned success (no tool error)'); + const payload = ran.result?.isError ? {} : JSON.parse(ran.result.content[0].text); + check(payload.ok === true, 'run_action payload reports ok:true'); + const after: any[] = await engine.find('todo_task', { where: { id: taskId } }); + check(after?.[0]?.status === 'completed', `the task status is now '${after?.[0]?.status}' (handler ran)`); + + // ── Step 4 — fail-closed on system-object actions ───────────────── + console.log('\n🔒 Step 4 — system-object actions are blocked fail-closed'); + const sysRun = await callMcp(bridge, toolsCall(4, 'run_action', { actionName: 'rotate', objectName: 'sys_api_key' })); + check(sysRun.result?.isError === true, 'run_action on a sys_* object is a tool error'); + check(/system object/i.test(sysRun.result?.content?.[0]?.text ?? ''), 'error names the system-object guard'); + + // ── Step 5 — capability gate (ADR-0066 D4) end-to-end ───────────── + console.log('\n🔒 Step 5 — requiredPermissions gate denies, then allows'); + // Same app, but clone_task now declares a capability requirement. + const gatedObjects = mergedObjects.map((o) => + o.name !== 'todo_task' + ? o + : { ...o, actions: o.actions.map((a: any) => (a.name === 'clone_task' ? { ...a, requiredPermissions: ['todo_admin'] } : a)) }, + ); + const denyBridge = bridgeFor({ userId: 'user_2', roles: [], permissions: [], systemPermissions: [] }, gatedObjects); + const denied = await callMcp(denyBridge, toolsCall(5, 'run_action', { actionName: 'clone_task', recordId: taskId })); + check(denied.result?.isError === true, 'run_action denied when the caller lacks the capability'); + check(/requires capability/i.test(denied.result?.content?.[0]?.text ?? ''), 'denial cites the missing capability'); + // …and list_actions hides what the caller may not run. + const denyList = JSON.parse((await callMcp(denyBridge, toolsCall(6, 'list_actions', {}))).result.content[0].text).actions as any[]; + check(!denyList.some((a) => a.name === 'clone_task'), 'list_actions hides the gated action from a non-holder'); + + const allowBridge = bridgeFor({ userId: 'admin_1', roles: [], permissions: [], systemPermissions: ['todo_admin'] }, gatedObjects); + const allowed = await callMcp(allowBridge, toolsCall(7, 'run_action', { actionName: 'clone_task', recordId: taskId })); + check(allowed.result?.isError !== true, 'run_action allowed for a holder of the capability'); + const allowList = JSON.parse((await callMcp(allowBridge, toolsCall(8, 'list_actions', {}))).result.content[0].text).actions as any[]; + check(allowList.some((a) => a.name === 'clone_task'), 'list_actions reveals the gated action to a holder'); + + console.log('\n────────────────────────────────────────────────────────────────────────────────'); + if (failures > 0) { + console.error(`❌ MCP action E2E FAILED — ${failures} check(s) failed`); + process.exit(1); + } + console.log('✅ MCP action E2E PASSED — CE runtime lists + executes business actions, permission-enforced'); + process.exit(0); +})().catch((err) => { + console.error('❌ MCP action E2E threw:', err); + process.exit(1); +}); diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 63606dc6de..c7eaa5b1e7 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -93,6 +93,36 @@ ObjectStack automatically exposes these operations as MCP tools: 'objectstack_listFields' // List object fields ``` +### Native Tools (Streamable HTTP) + +Over the network-reachable Streamable HTTP transport, the server self-registers +a native tool set bound to the **caller's principal** (the API key acts as the +user, with full row-level security + permission enforcement). No +`@objectstack/service-ai` and no cloud studio are required — these are part of +the open framework. + +```typescript +// Object data (RLS-enforced as the caller) +'list_objects' // List objects (system sys_* objects hidden by default) +'describe_object' // Object schema: fields + features +'query_records' // Filter / sort / paginate +'get_record' // Fetch one by id +'create_record' / 'update_record' / 'delete_record' + +// Business actions — operate the app, not just its rows +'list_actions' // Invokable business actions the caller may run +'run_action' // Invoke an action by name with { recordId, params } +``` + +`list_actions` enumerates each object's headless-invokable actions (script / +flow), filtered to what the caller is permitted to run — declared +`requiredPermissions` (ADR-0066 D4) are enforced and `sys_*`-object actions are +held back fail-closed. `run_action` resolves the action by name and dispatches +it through the framework's own action mechanism (`engine.executeAction` / +automation flow runner) as the caller, so a BYO-AI MCP client (Claude Code, +Cursor, …) can trigger real business logic — e.g. "complete this task", +"convert this lead" — exactly as the UI would, under the same guardrails. + ### Custom Tools Register custom tools that AI models can call: diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index f1a456db5b..9042dd2485 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -14,11 +14,15 @@ export { MCPServerPlugin } from './plugin.js'; export type { MCPServerPluginOptions } from './plugin.js'; export { MCPServerRuntime } from './mcp-server-runtime.js'; export type { MCPServerRuntimeConfig } from './mcp-server-runtime.js'; -export { registerObjectTools } from './mcp-http-tools.js'; +export { registerObjectTools, registerActionTools } from './mcp-http-tools.js'; export type { McpDataBridge, McpObjectSummary, RegisterObjectToolsOptions, + McpActionBridge, + McpActionSummary, + McpActionParamSummary, + RegisterActionToolsOptions, } from './mcp-http-tools.js'; export { renderSkillMarkdown, diff --git a/packages/mcp/src/mcp-action-tools.test.ts b/packages/mcp/src/mcp-action-tools.test.ts new file mode 100644 index 0000000000..ec834d11f9 --- /dev/null +++ b/packages/mcp/src/mcp-action-tools.test.ts @@ -0,0 +1,246 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { MCPServerRuntime } from './mcp-server-runtime.js'; +import type { McpDataBridge, McpActionBridge } from './mcp-http-tools.js'; + +/** + * A combined data+action bridge that records calls, so tests can assert that + * the action tools delegate (principal-bound) to the runtime bridge — the same + * shape `buildMcpBridge` produces. Object methods are stubbed minimally so the + * object tools still register alongside the action tools. + */ +function makeBridge(): McpDataBridge & McpActionBridge & { calls: any[] } { + const calls: any[] = []; + return { + calls, + // ── object surface (minimal) ── + async listObjects() { + return [{ name: 'todo_task', label: 'Task', fieldCount: 3 }]; + }, + async describeObject() { + return null; + }, + async query() { + return { records: [] }; + }, + async get() { + return null; + }, + async create() { + return {}; + }, + async update() { + return {}; + }, + async remove() { + return {}; + }, + // ── action surface ── + async listActions() { + calls.push(['listActions']); + return [ + { + name: 'complete_task', + objectName: 'todo_task', + label: 'Mark Complete', + description: 'Mark a todo task as complete.', + type: 'script', + requiresRecord: true, + requiresConfirmation: false, + params: [], + }, + { + name: 'delete_completed', + objectName: 'todo_task', + label: 'Delete Completed', + description: 'Delete every completed task.', + type: 'script', + requiresRecord: false, + requiresConfirmation: true, + }, + { + // An action on a system object — must be hidden by default. + name: 'rotate_key', + objectName: 'sys_api_key', + label: 'Rotate', + type: 'script', + requiresRecord: true, + requiresConfirmation: true, + }, + ]; + }, + async runAction(name: string, input: any) { + calls.push(['runAction', name, input]); + if (name === 'forbidden') { + throw new Error("Action 'forbidden' requires capability [manage_platform] — caller is missing [manage_platform]"); + } + return { + ok: true, + action: name, + objectName: input?.objectName ?? 'todo_task', + ...(input?.recordId ? { recordId: input.recordId } : {}), + result: { status: 'completed' }, + }; + }, + }; +} + +/** An object-only bridge (no action methods) — the pre-existing CRUD shape. */ +function makeObjectOnlyBridge(): McpDataBridge { + return { + async listObjects() { + return [{ name: 'todo_task', label: 'Task', fieldCount: 3 }]; + }, + async describeObject() { + return null; + }, + async query() { + return {}; + }, + async get() { + return null; + }, + async create() { + return {}; + }, + async update() { + return {}; + }, + async remove() { + return {}; + }, + }; +} + +function mcpRequest(body: unknown): Request { + return new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + body: JSON.stringify(body), + }); +} + +async function call(runtime: MCPServerRuntime, body: unknown, bridge?: any) { + const res = await runtime.handleHttpRequest(mcpRequest(body), { bridge, parsedBody: body }); + const json = res.status === 202 ? null : await res.json(); + return { status: res.status, json }; +} + +const toolsCall = (id: number, name: string, args: Record) => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name, arguments: args }, +}); + +describe('MCP action tools (list_actions / run_action)', () => { + let runtime: MCPServerRuntime; + let bridge: ReturnType; + + beforeEach(() => { + runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + bridge = makeBridge(); + }); + + it('registers list_actions and run_action alongside the object tools', async () => { + const { json } = await call(runtime, { jsonrpc: '2.0', id: 1, method: 'tools/list' }, bridge); + const names = json.result.tools.map((t: any) => t.name); + expect(names).toContain('list_actions'); + expect(names).toContain('run_action'); + // Object tools remain present and unchanged. + expect(names).toContain('query_records'); + expect(names).toContain('delete_record'); + }); + + it('does NOT register action tools when the bridge has no action methods', async () => { + const { json } = await call( + runtime, + { jsonrpc: '2.0', id: 1, method: 'tools/list' }, + makeObjectOnlyBridge(), + ); + const names = json.result.tools.map((t: any) => t.name); + expect(names).not.toContain('list_actions'); + expect(names).not.toContain('run_action'); + // …but the object tools are still there (graceful degradation). + expect(names).toContain('query_records'); + }); + + it('marks run_action destructive and list_actions read-only', async () => { + const { json } = await call(runtime, { jsonrpc: '2.0', id: 1, method: 'tools/list' }, bridge); + const byName = Object.fromEntries(json.result.tools.map((t: any) => [t.name, t])); + expect(byName.run_action.annotations.destructiveHint).toBe(true); + expect(byName.run_action.annotations.readOnlyHint).toBe(false); + expect(byName.list_actions.annotations.readOnlyHint).toBe(true); + }); + + it('list_actions returns the caller-visible actions and hides system-object actions', async () => { + const { json } = await call(runtime, toolsCall(2, 'list_actions', {}), bridge); + const payload = JSON.parse(json.result.content[0].text); + const names = payload.actions.map((a: any) => a.name); + expect(names).toEqual(['complete_task', 'delete_completed']); // rotate_key (sys_api_key) hidden + expect(payload.totalCount).toBe(2); + expect(bridge.calls).toContainEqual(['listActions']); + }); + + it('run_action delegates to the bridge with name, recordId and params', async () => { + const { json } = await call( + runtime, + toolsCall(3, 'run_action', { actionName: 'complete_task', recordId: 't1', params: { note: 'done' } }), + bridge, + ); + expect(json.result.isError).toBeFalsy(); + const runCall = bridge.calls.find((c) => c[0] === 'runAction'); + expect(runCall[1]).toBe('complete_task'); + expect(runCall[2]).toEqual({ objectName: undefined, recordId: 't1', params: { note: 'done' } }); + const payload = JSON.parse(json.result.content[0].text); + expect(payload.ok).toBe(true); + expect(payload.result).toEqual({ status: 'completed' }); + }); + + it('run_action requires an actionName', async () => { + const { json } = await call(runtime, toolsCall(4, 'run_action', { actionName: '' }), bridge); + expect(json.result.isError).toBe(true); + expect(json.result.content[0].text).toMatch(/actionName is required/i); + expect(bridge.calls.find((c) => c[0] === 'runAction')).toBeUndefined(); + }); + + it('run_action rejects system-object actions fail-closed (never reaches the bridge)', async () => { + const { json } = await call( + runtime, + toolsCall(5, 'run_action', { actionName: 'rotate_key', objectName: 'sys_api_key', recordId: 'k1' }), + bridge, + ); + expect(json.result.isError).toBe(true); + expect(json.result.content[0].text).toMatch(/system object/i); + expect(bridge.calls.find((c) => c[0] === 'runAction')).toBeUndefined(); + }); + + it('surfaces a permission denial from the bridge as a tool error, not a thrown wire error', async () => { + const { status, json } = await call( + runtime, + toolsCall(6, 'run_action', { actionName: 'forbidden', recordId: 't1' }), + bridge, + ); + expect(status).toBe(200); + expect(json.result.isError).toBe(true); + expect(json.result.content[0].text).toMatch(/requires capability/i); + }); + + it('surfaces list_actions bridge errors as tool errors', async () => { + const failing = { + ...bridge, + async listActions() { + throw new Error('metadata unavailable'); + }, + }; + const { status, json } = await call(runtime, toolsCall(7, 'list_actions', {}), failing); + expect(status).toBe(200); + expect(json.result.isError).toBe(true); + expect(json.result.content[0].text).toContain('metadata unavailable'); + }); +}); diff --git a/packages/mcp/src/mcp-http-tools.ts b/packages/mcp/src/mcp-http-tools.ts index b300906e6a..6be75875b7 100644 --- a/packages/mcp/src/mcp-http-tools.ts +++ b/packages/mcp/src/mcp-http-tools.ts @@ -60,6 +60,74 @@ export interface RegisterObjectToolsOptions { maxQueryLimit?: number; } +/** One declared input parameter of a business action, LLM-facing. */ +export interface McpActionParamSummary { + name: string; + type?: 'string' | 'number' | 'boolean' | 'array'; + required?: boolean; + description?: string; + /** Allowed values, when the param (or its backing field) is an enum. */ + enum?: string[]; +} + +/** + * A business action the caller may invoke, as surfaced by `list_actions`. + * Mirrors {@link McpObjectSummary} for the action surface: enough for an agent + * to decide whether and how to call `run_action`, nothing engine-internal. + */ +export interface McpActionSummary { + /** Declarative action name — the identifier passed to `run_action`. */ + name: string; + /** The object the action operates on (omitted for object-less actions). */ + objectName?: string; + /** Human label. */ + label?: string; + /** What the action does (the authored `ai.description` when present). */ + description?: string; + /** Dispatch kind: `script` | `flow` | `api`. */ + type?: string; + /** True when the action acts on a row and so needs a `recordId`. */ + requiresRecord?: boolean; + /** True when the action is destructive / an author flagged it for confirmation. */ + requiresConfirmation?: boolean; + /** Declared input parameters. */ + params?: McpActionParamSummary[]; +} + +/** + * Action-execution seam for the HTTP MCP tools. Implemented by the runtime so + * resolution + dispatch flows through the framework's own action mechanism + * (`IDataEngine.executeAction` / automation flow runner) bound to the caller's + * ExecutionContext — the SAME permission + RLS path the REST `/actions/...` + * endpoint uses. Every method runs AS the authenticated principal. + * + * Deliberately decoupled from {@link McpDataBridge}: a runtime that cannot + * resolve the action mechanism simply does not implement this, and the action + * tools are then not registered (graceful degradation — see + * `registerActionTools`). Bridging here is direct framework-contract access; it + * does NOT depend on `@objectstack/service-ai`. + */ +export interface McpActionBridge { + /** Actions the caller may run, already filtered by permission + visibility. */ + listActions(): Promise; + /** + * Invoke an action by its declarative name. Resolves the action (optionally + * scoped by `objectName` to disambiguate), enforces its `requiredPermissions` + * as the caller, loads the subject record under RLS when row-context, then + * dispatches through the framework action runner. Throws on + * denial / not-found / handler failure so the tool surfaces a tool-error. + */ + runAction( + name: string, + input: { objectName?: string; recordId?: string; params?: Record }, + ): Promise; +} + +export interface RegisterActionToolsOptions { + /** Expose actions on `sys_*` system objects too. Default false (fail-closed). */ + allowSystemObjects?: boolean; +} + const DEFAULT_MAX_LIMIT = 50; /** A `sys_`-prefixed object is a system table — off-limits to external agents. */ @@ -273,6 +341,94 @@ export function registerObjectTools( ); } +/** + * Register the business-action tool set (`list_actions`, `run_action`) on a + * fresh per-request {@link McpServer}. This is the action analogue of + * {@link registerObjectTools}: it owns the tool *shape* and delegates all + * resolution + dispatch + security to `bridge`, which the runtime binds to the + * caller's principal. + * + * Symmetry with the object tools is deliberate — like `list_objects`/CRUD, the + * action surface exposes the *mechanism* and relies on the bridge's + * permission + RLS enforcement (not a separate AI opt-in flag) for safety, + * with `sys_*`-scoped actions held back fail-closed by default. + */ +export function registerActionTools( + server: McpServer, + bridge: McpActionBridge, + options: RegisterActionToolsOptions = {}, +): void { + const allowSystem = options.allowSystemObjects === true; + + server.registerTool( + 'list_actions', + { + description: + 'List the business actions you can invoke in this app (e.g. "complete task", "convert lead"). ' + + 'Returns each action\'s name, the object it operates on, a description, whether it needs a record id, ' + + 'whether it is destructive, and its input parameters. Only actions the caller is permitted to run are returned. ' + + 'Use run_action to invoke one.', + inputSchema: {}, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + }, + async () => { + try { + const actions = await bridge.listActions(); + const visible = allowSystem + ? actions + : actions.filter((a) => !a.objectName || !isSystemObject(a.objectName)); + return textResult({ actions: visible, totalCount: visible.length }); + } catch (err) { + return errorResult(messageOf(err)); + } + }, + ); + + server.registerTool( + 'run_action', + { + description: + 'Invoke a business action by name (see list_actions). Runs the app\'s registered business logic ' + + 'under the caller\'s permissions and row-level security — this can mutate data or trigger flows. ' + + 'Supply recordId for actions that operate on a specific record, and params for any declared inputs.', + inputSchema: { + actionName: z.string().describe('The action name from list_actions, e.g. "complete_task"'), + objectName: z + .string() + .optional() + .describe('The object the action belongs to. Optional; required only to disambiguate a name shared by multiple objects.'), + recordId: z + .string() + .optional() + .describe('The id of the record to act on (for record-scoped actions).'), + params: z + .record(z.string(), z.unknown()) + .optional() + .describe('Input parameters declared by the action.'), + }, + // Actions execute app-defined business logic with side effects (writes, + // flows, outbound calls), so we mark the tool destructive + open-world: + // MCP clients should confirm before invoking. Per-action destructiveness + // is further surfaced via `requiresConfirmation` in list_actions. + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, + }, + async ({ actionName, objectName, recordId, params }) => { + if (!actionName || typeof actionName !== 'string') { + return errorResult('actionName is required'); + } + if (objectName && !allowSystem && isSystemObject(objectName)) { + return errorResult(`Object "${objectName}" is a system object and its actions are not exposed via MCP`); + } + try { + const result = await bridge.runAction(actionName, { objectName, recordId, params }); + return textResult(result); + } catch (err) { + return errorResult(messageOf(err)); + } + }, + ); +} + function messageOf(err: unknown): string { if (err instanceof Error) return err.message; if (err && typeof err === 'object' && 'message' in err) return String((err as any).message); diff --git a/packages/mcp/src/mcp-server-runtime.ts b/packages/mcp/src/mcp-server-runtime.ts index 4f493dcf11..4964ccbb0b 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -6,8 +6,13 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/ import type { Logger, IMetadataService, IDataEngine, AIToolDefinition } from '@objectstack/spec/contracts'; import type { Agent } from '@objectstack/spec/ai'; import type { ToolRegistry, ToolExecutionResult } from './types.js'; -import { registerObjectTools } from './mcp-http-tools.js'; -import type { McpDataBridge, RegisterObjectToolsOptions } from './mcp-http-tools.js'; +import { registerObjectTools, registerActionTools } from './mcp-http-tools.js'; +import type { + McpDataBridge, + McpActionBridge, + RegisterObjectToolsOptions, + RegisterActionToolsOptions, +} from './mcp-http-tools.js'; import { z } from 'zod'; /** @@ -507,27 +512,29 @@ export class MCPServerRuntime { * Stateless by design: a fresh {@link McpServer} + transport is built per * request (the SDK-recommended pattern for stateless HTTP — it avoids any * cross-request session/request-id collision and keeps each call isolated). - * The tool set is the object-CRUD bridge, bound to the **caller's principal** + * The tool set is the object-CRUD bridge plus — when the bridge can resolve + * the framework's action mechanism — the business-action tools + * (`list_actions` / `run_action`), all bound to the **caller's principal** * via `bridge`; the runtime wires that bridge to the existing permission + * RLS path, so an external agent can never exceed the key's authority. * - * Only the object-CRUD tools are exposed here — the internal AI/authoring + * Only these native tools are exposed here — the internal AI/authoring * toolRegistry (which can mutate metadata) is deliberately NOT bridged onto * the external surface. * * @param request The inbound Web `Request` (headers/method/url). - * @param opts.bridge Principal-bound data accessor (required to expose tools). + * @param opts.bridge Principal-bound data (+ optional action) accessor (required to expose tools). * @param opts.parsedBody Pre-parsed JSON-RPC body (the dispatcher already read it). * @param opts.authInfo Optional auth info forwarded to message handlers. - * @param opts.toolOptions Object-tool exposure options (system objects, limits). + * @param opts.toolOptions Tool exposure options (system objects, query limits). */ async handleHttpRequest( request: Request, opts: { - bridge?: McpDataBridge; + bridge?: McpDataBridge & Partial; parsedBody?: unknown; authInfo?: unknown; - toolOptions?: RegisterObjectToolsOptions; + toolOptions?: RegisterObjectToolsOptions & RegisterActionToolsOptions; } = {}, ): Promise { // Fresh, isolated server per request (stateless). @@ -543,6 +550,16 @@ export class MCPServerRuntime { if (opts.bridge) { registerObjectTools(server, opts.bridge, opts.toolOptions); + // The action surface is wired by capability: only when the runtime's + // bridge can resolve + dispatch the framework's actions. A host with no + // action mechanism keeps serving object tools unchanged (graceful + // degradation, mirroring how record resources need a dataEngine). + if ( + typeof opts.bridge.listActions === 'function' && + typeof opts.bridge.runAction === 'function' + ) { + registerActionTools(server, opts.bridge as McpActionBridge, opts.toolOptions); + } } const transport = new WebStandardStreamableHTTPServerTransport({ diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index e9b4693850..bf628d64bc 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -1973,3 +1973,151 @@ describe('HttpDispatcher — ADR-0066 D4 action requiredPermissions gate', () => expect(executeAction).not.toHaveBeenCalled(); }); }); + +describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', () => { + // A `todo_task` object with declarative actions, mirroring examples/app-todo: + // - complete_task: script bound to the `completeTask` handler (row-context) + // - issue_license: script gated behind a capability (ADR-0066 D4) + // - defer_task: modal (UI-only, no headless dispatch) + const completeAction = { + name: 'complete_task', + label: 'Mark Complete', + objectName: 'todo_task', + type: 'script', + target: 'completeTask', // handler key differs from the declarative name + locations: ['record_header', 'list_item'], + ai: { exposed: true, description: 'Mark a todo task as complete.' }, + }; + const gatedAction = { + name: 'issue_license', + label: 'Issue', + objectName: 'todo_task', + type: 'script', + target: 'issueLicense', + requiredPermissions: ['manage_platform_settings'], + }; + const modalAction = { + name: 'defer_task', + label: 'Defer', + objectName: 'todo_task', + type: 'modal', + target: 'defer_modal', + }; + const todoObject = { + name: 'todo_task', + label: 'Task', + fields: { subject: { type: 'text', label: 'Subject' }, status: { type: 'select', label: 'Status' } }, + actions: [completeAction, gatedAction, modalAction], + }; + // A system object carrying an action — must be hidden + fail-closed. + const sysObject = { + name: 'sys_api_key', + label: 'API Key', + actions: [{ name: 'rotate', type: 'script', target: 'rotate', objectName: 'sys_api_key' }], + }; + + const makeBridge = (execCtx: any) => { + const store: Record = { t1: { id: 't1', subject: 'A', status: 'open' } }; + const executeAction = vi.fn(async (obj: string, key: string, ctx: any) => { + if (key === 'completeTask') { + const id = ctx?.record?.id ?? ctx?.params?.recordId; + if (store[id]) store[id].status = 'completed'; + return { updated: id }; + } + if (key === 'issueLicense') return { issued: true }; + throw new Error(`Action '${key}' on object '${obj}' not found`); + }); + const ql: any = { + executeAction, + registry: { getObject: (n: string) => (n === 'todo_task' ? todoObject : null) }, + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + find: vi.fn(async (_o: string, opts: any) => { + const id = opts?.where?.id; + return id && store[id] ? [store[id]] : []; + }), + }; + const metadata: any = { + listObjects: vi.fn(async () => [todoObject, sysObject]), + getObject: vi.fn(async (n: string) => + n === 'todo_task' ? todoObject : n === 'sys_api_key' ? sysObject : undefined, + ), + }; + const kernel: any = { + context: { + getService: (n: string) => (n === 'objectql' ? ql : n === 'metadata' ? metadata : null), + }, + }; + const dispatcher = new HttpDispatcher(kernel); + const ctx: any = { request: {}, environmentId: 'platform', executionContext: execCtx }; + const bridge = (dispatcher as any).buildMcpBridge(ctx); + return { bridge, executeAction, store }; + }; + + it('list_actions returns only invokable, permitted, non-system actions', async () => { + const { bridge } = makeBridge({ userId: 'u1', systemPermissions: [] }); + const names = (await bridge.listActions()).map((a: any) => a.name); + expect(names).toContain('complete_task'); // script + permitted + expect(names).not.toContain('issue_license'); // gated, caller lacks the capability + expect(names).not.toContain('defer_task'); // modal = UI-only, no headless path + expect(names).not.toContain('rotate'); // sys_api_key → hidden fail-closed + }); + + it('list_actions surfaces record-context + summary metadata', async () => { + const { bridge } = makeBridge({ userId: 'u1', systemPermissions: [] }); + const complete = (await bridge.listActions()).find((a: any) => a.name === 'complete_task'); + expect(complete).toMatchObject({ objectName: 'todo_task', type: 'script', requiresRecord: true }); + expect(complete.description).toMatch(/complete/i); + }); + + it('list_actions reveals a gated action once the caller holds the capability', async () => { + const { bridge } = makeBridge({ userId: 'u1', systemPermissions: ['manage_platform_settings'] }); + const names = (await bridge.listActions()).map((a: any) => a.name); + expect(names).toContain('issue_license'); + }); + + it('run_action dispatches a script action via executeAction using its target handler key', async () => { + const { bridge, executeAction, store } = makeBridge({ userId: 'u1', systemPermissions: [] }); + const res = await bridge.runAction('complete_task', { recordId: 't1' }); + expect(res.ok).toBe(true); + expect(executeAction).toHaveBeenCalledWith( + 'todo_task', + 'completeTask', // the action's target, NOT its declarative name + expect.objectContaining({ + record: expect.objectContaining({ id: 't1' }), + params: expect.objectContaining({ recordId: 't1', objectName: 'todo_task' }), + }), + ); + expect(store.t1.status).toBe('completed'); // the handler actually ran + }); + + it('run_action enforces the ADR-0066 D4 capability gate (throws, never dispatches)', async () => { + const { bridge, executeAction } = makeBridge({ userId: 'u1', systemPermissions: [] }); + await expect(bridge.runAction('issue_license', {})).rejects.toThrow(/requires capability/i); + expect(executeAction).not.toHaveBeenCalled(); + }); + + it('run_action allows a gated action for a holder of the capability', async () => { + const { bridge, executeAction } = makeBridge({ userId: 'u1', systemPermissions: ['manage_platform_settings'] }); + const res = await bridge.runAction('issue_license', {}); + expect(res.ok).toBe(true); + expect(executeAction).toHaveBeenCalledWith('todo_task', 'issueLicense', expect.anything()); + }); + + it('run_action blocks system-object actions fail-closed (even for a system context)', async () => { + const { bridge, executeAction } = makeBridge({ isSystem: true }); + await expect(bridge.runAction('rotate', { objectName: 'sys_api_key' })).rejects.toThrow(/system object/i); + expect(executeAction).not.toHaveBeenCalled(); + }); + + it('run_action rejects an unknown action name', async () => { + const { bridge } = makeBridge({ userId: 'u1', systemPermissions: [] }); + await expect(bridge.runAction('nope', {})).rejects.toThrow(/not found/i); + }); + + it('run_action refuses a UI-only (modal) action', async () => { + const { bridge } = makeBridge({ userId: 'u1', systemPermissions: [] }); + await expect(bridge.runAction('defer_task', { recordId: 't1' })).rejects.toThrow(/cannot be invoked/i); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 43c0f37076..d7b2443cd8 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -29,6 +29,11 @@ function randomUUID(): string { }); } +/** A `sys_`-prefixed object is a system table — off-limits to external MCP agents. */ +function isSystemObjectName(name: string): boolean { + return /^sys_/i.test(name); +} + export interface HttpProtocolContext { request: any; response?: any; @@ -534,9 +539,327 @@ export class HttpDispatcher { await callData('update', { object, id, data }, driver, envId, ec), remove: async (object: string, id: string) => await callData('delete', { object, id }, driver, envId, ec), + + // ── Business-action surface (McpActionBridge) ────────────── + // Resolution + dispatch flow through the framework's own action + // mechanism (engine.executeAction / automation flow runner) bound to + // THIS request's ExecutionContext — the same permission + RLS path + // the REST `/actions/...` route uses. No `@objectstack/service-ai`. + listActions: async () => { + const meta: any = await getMeta(); + const objs: any[] = (await meta?.listObjects?.()) ?? []; + const hasAutomation = Boolean( + await this.resolveService('automation', envId).catch(() => null), + ); + const out: any[] = []; + for (const obj of objs) { + const objectName: string | undefined = obj?.name; + if (!objectName || isSystemObjectName(objectName)) continue; // fail-closed on sys_* + const actions: any[] = Array.isArray(obj?.actions) ? obj.actions : []; + for (const action of actions) { + if (!action || typeof action.name !== 'string') continue; + if (!this.isHeadlessInvokableAction(action, hasAutomation)) continue; + // Hide actions the caller is not permitted to run. + if (this.actionPermissionError(action, ec)) continue; + out.push(this.summarizeAction(action, obj, objectName)); + } + } + return out; + }, + runAction: async ( + name: string, + input: { objectName?: string; recordId?: string; params?: Record }, + ) => this.invokeBusinessAction(name, input ?? {}, { driver, envId, ec, getMeta, callData }), + }; + } + + // ── MCP action bridge helpers ────────────────────────────────────── + + /** + * [ADR-0066 D4] Shared capability gate for an action invocation. Returns a + * human-readable error string when the caller's `systemPermissions` don't + * cover the action's declared `requiredPermissions`, or `null` when allowed. + * System/engine self-invocation (`isSystem`) bypasses; an action without + * `requiredPermissions` is ungated. Single-sourced so the REST `/actions/...` + * route and the MCP `run_action` bridge enforce the SAME declaration. + */ + private actionPermissionError(actionDef: any, ec: any, objectName?: string): string | null { + const required: string[] = Array.isArray(actionDef?.requiredPermissions) + ? actionDef.requiredPermissions + : []; + if (required.length === 0) return null; + if (ec?.isSystem) return null; + const held = new Set(ec?.systemPermissions ?? []); + const missing = required.filter((perm) => !held.has(perm)); + if (missing.length === 0) return null; + const on = objectName ? ` on '${objectName}'` : ''; + return ( + `Action '${actionDef?.name ?? 'unknown'}'${on} requires capability ` + + `[${required.join(', ')}] — caller is missing [${missing.join(', ')}]` + ); + } + + /** + * Whether an action has a headless invocation path (so MCP can run it). + * Mirrors the supported-type set of the (now cloud-side) action-tools + * bridge: `script` needs a handler binding (`target`) or an inline `body`; + * `flow` needs a `target` and an automation service. UI-only types + * (`url`, `modal`, `form`) and `api` have no server dispatch here. + */ + private isHeadlessInvokableAction(action: any, hasAutomation: boolean): boolean { + const type: string = action?.type ?? 'script'; + if (type === 'script') return Boolean(action?.target || action?.body); + if (type === 'flow') return Boolean(action?.target) && hasAutomation; + return false; + } + + /** True when an action is destructive by author signal/heuristic (HITL hint). */ + private actionLooksDestructive(action: any): boolean { + if (action?.ai?.requiresConfirmation !== undefined) return Boolean(action.ai.requiresConfirmation); + return Boolean(action?.confirmText || action?.mode === 'delete' || action?.variant === 'danger'); + } + + /** Project an action's declarative metadata into a lean MCP summary. */ + private summarizeAction(action: any, obj: any, objectName: string): any { + const requiresRecord = + Array.isArray(action?.locations) && + action.locations.some( + (l: string) => + l === 'list_item' || l === 'record_header' || l === 'record_more' || l === 'record_related', + ); + const description = + (typeof action?.ai?.description === 'string' ? action.ai.description : undefined) ?? + (typeof action?.label === 'string' ? action.label : undefined); + const params = this.summarizeActionParams(action, obj); + return { + name: action.name, + objectName, + ...(typeof action?.label === 'string' ? { label: action.label } : {}), + ...(description ? { description } : {}), + type: action?.type ?? 'script', + requiresRecord: Boolean(requiresRecord), + requiresConfirmation: this.actionLooksDestructive(action), + ...(params.length > 0 ? { params } : {}), + }; + } + + /** Map an ObjectStack field type to a JSON-Schema primitive (conservative). */ + private jsonTypeOf(t: string | undefined): 'string' | 'number' | 'boolean' | 'array' { + switch (t) { + case 'number': case 'currency': case 'percent': case 'rating': case 'slider': case 'autonumber': + return 'number'; + case 'boolean': case 'toggle': + return 'boolean'; + case 'multiselect': case 'checkboxes': case 'tags': + return 'array'; + default: + return 'string'; + } + } + + /** Resolve an action's params into LLM-facing summaries (field-backed types resolved). */ + private summarizeActionParams(action: any, obj: any): any[] { + const fields: Record = obj?.fields ?? {}; + const out: any[] = []; + for (const p of (Array.isArray(action?.params) ? action.params : [])) { + const fieldRef: string | undefined = p?.field; + const field = fieldRef ? fields[fieldRef] : undefined; + const name: string | undefined = p?.name ?? fieldRef; + if (!name) continue; + const type = this.jsonTypeOf(p?.type ?? field?.type); + const label = typeof p?.label === 'string' ? p.label : field?.label; + const help = p?.helpText ?? field?.description; + const description = [label, help].filter(Boolean).join(' — ') || undefined; + const optionSource = p?.options ?? field?.options; + const enumVals = Array.isArray(optionSource) + ? optionSource + .map((o: any) => (typeof o === 'string' ? o : o?.value)) + .filter((v: any): v is string => typeof v === 'string') + : []; + out.push({ + name, + type, + required: Boolean(p?.required ?? field?.required ?? false), + ...(description ? { description } : {}), + ...(enumVals.length > 0 ? { enum: enumVals } : {}), + }); + } + return out; + } + + /** Slim engine facade matching the ActionContext.engine shape handlers expect. */ + private buildActionEngineFacade(ql: any): any { + return { + async insert(object: string, data: Record): Promise<{ id: string }> { + const res = await ql.insert(object, data); + const id = (res && (res as any).id) ?? (data as any).id; + return { id }; + }, + async update(object: string, id: string, data: Record): Promise { + await ql.update(object, data, { where: { id } }); + }, + // Tolerant of both the single-id and array conventions handler suites + // use (CRM handlers pass one id; todo handlers pass an id array). + async delete(object: string, idOrIds: string | string[]): Promise { + const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + for (const id of ids) { + if (id != null) await ql.delete(object, { where: { id } }); + } + }, + async find(object: string, query: Record): Promise>> { + const opts = query && Object.keys(query).length ? { where: query } : undefined; + const rows = await ql.find(object, opts as any); + return Array.isArray(rows) ? rows : ((rows as any)?.value ?? []); + }, }; } + /** + * Resolve + invoke a business action by its declarative name for the MCP + * `run_action` tool. Enforces the ADR-0066 D4 capability gate and RLS as the + * caller, loads the subject record under RLS for row-context actions, and + * dispatches through the framework's `engine.executeAction` (script/body) or + * automation flow runner (flow). Throws on denial / not-found / handler + * failure so the tool surfaces a clean tool-error. No service-ai dependency. + */ + private async invokeBusinessAction( + name: string, + input: { objectName?: string; recordId?: string; params?: Record }, + wiring: { + driver: any; + envId?: string; + ec: any; + getMeta: () => any; + callData: (action: string, params: any, dataDriver?: any, scopeId?: string, ec?: ExecutionContext) => Promise; + }, + ): Promise { + const { driver, envId, ec, getMeta, 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 this.resolveActionByName(meta, name, input?.objectName); + if (!resolved) { + throw new Error( + input?.objectName + ? `Action '${name}' not found on object '${input.objectName}'` + : `Action '${name}' not found`, + ); + } + const { action, objectName } = resolved; + + // Fail-closed on system-object actions (mirrors the object-tool guard). + if (isSystemObjectName(objectName)) { + throw new Error(`Action '${name}' is on a system object and is not exposed via MCP`); + } + const hasAutomation = Boolean(await this.resolveService('automation', envId).catch(() => null)); + if (!this.isHeadlessInvokableAction(action, hasAutomation)) { + throw new Error( + `Action '${name}' (type='${action?.type ?? 'script'}') cannot be invoked via MCP`, + ); + } + + // ADR-0066 D4 capability gate — same declaration the REST route enforces. + const gateError = this.actionPermissionError(action, ec, objectName); + if (gateError) throw new Error(gateError); + + // Load the subject record under RLS when row-context (engages the same + // permission path as get_record — an unseen record reads as not-found). + let record: Record = {}; + if (recordId && objectName !== 'global') { + try { + const got: any = await callData('get', { object: objectName, id: recordId }, driver, envId, ec); + if (got?.record) record = got.record; + } catch { + /* new-record / record-less actions pass an empty record */ + } + } + if (record && (record as any).id == null && recordId) (record as any).id = recordId; + + const user = ec?.userId + ? { id: ec.userId, name: ec.userName ?? ec.userDisplayName ?? ec.userId } + : { id: 'system', name: 'system' }; + + // ── flow dispatch ── + if (action.type === 'flow') { + const automation: any = await this.resolveService('automation', envId).catch(() => null); + if (!automation || typeof automation.execute !== 'function') { + throw new Error(`Action '${name}' is a flow but no automation service is available`); + } + const result: any = await automation.execute(action.target, { + triggerData: { record, params, user, action: action.name }, + }); + if (result && typeof result === 'object' && 'success' in result && result.success === false) { + throw new Error(`Flow '${action.target}' failed: ${result.error ?? 'unknown error'}`); + } + return { ok: true, action: action.name, objectName, ...(recordId ? { recordId } : {}), result: result ?? null }; + } + + // ── script/body dispatch via the engine's executeAction ── + const ql: any = await this.getObjectQLService(envId); + if (!ql || typeof ql.executeAction !== 'function') { + throw new Error('Data engine not available for action dispatch'); + } + const actionContext: any = { + record, + user, + engine: this.buildActionEngineFacade(ql), + params: { ...params, recordId, objectName }, + }; + // Handler key: body-based actions register under `name` (AppPlugin); + // target-bound script actions register under `target` (user code). + // Probe both, then the wildcard object, distinguishing the engine's + // "action not registered" miss from a genuine handler error. + const primary = action.body ? action.name : (action.target || action.name); + const candidates = [primary, action.target, action.name].filter( + (k: unknown, i: number, a: unknown[]): k is string => typeof k === 'string' && a.indexOf(k) === i, + ); + const notRegistered = (err: any) => /Action '.+' on object '.+' not found/i.test(String(err?.message ?? err)); + for (const obj of [objectName, '*']) { + for (const key of candidates) { + try { + const result = await ql.executeAction(obj, key, actionContext); + return { ok: true, action: action.name, objectName, ...(recordId ? { recordId } : {}), result: result ?? null }; + } catch (err: any) { + if (!notRegistered(err)) throw err; // real handler failure → surface + } + } + } + throw new Error(`No handler registered for action '${name}' on '${objectName}'`); + } + + /** + * Find an action's declarative definition by name across object metadata, + * 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). + */ + private async resolveActionByName( + meta: any, + name: string, + objectName?: string, + ): Promise<{ action: any; objectName: string } | null> { + if (objectName) { + const def: any = await meta?.getObject?.(objectName); + const action = Array.isArray(def?.actions) ? def.actions.find((a: any) => a?.name === name) : undefined; + return action ? { action, objectName } : null; + } + const objs: any[] = (await meta?.listObjects?.()) ?? []; + const matches: Array<{ action: any; objectName: string }> = []; + for (const obj of objs) { + if (!obj?.name) continue; + const action = Array.isArray(obj?.actions) ? obj.actions.find((a: any) => a?.name === name) : undefined; + if (action) matches.push({ action, objectName: obj.name }); + } + if (matches.length === 0) return null; + if (matches.length > 1) { + const where = matches.map((m) => m.objectName).join(', '); + throw new Error(`Action '${name}' exists on multiple objects (${where}); pass objectName to disambiguate`); + } + return matches[0]; + } + /** * Generate a `sys_api_key` and return the raw secret EXACTLY ONCE * (`POST /keys`). This is the only mint path — the raw key is never stored @@ -2722,25 +3045,9 @@ export class HttpDispatcher { const actionDef: any = Array.isArray(actionSchema?.actions) ? actionSchema.actions.find((a: any) => a?.name === actionName) : undefined; - const required: string[] = Array.isArray(actionDef?.requiredPermissions) - ? actionDef.requiredPermissions - : []; - if (required.length > 0) { - const execCtx: any = _context?.executionContext; - if (!execCtx?.isSystem) { - const held = new Set(execCtx?.systemPermissions ?? []); - const missing = required.filter((perm) => !held.has(perm)); - if (missing.length > 0) { - return { - handled: true, - response: this.error( - `Action '${actionName}' on '${objectName}' requires capability ` + - `[${required.join(', ')}] — caller is missing [${missing.join(', ')}]`, - 403, - ), - }; - } - } + const gateError = this.actionPermissionError(actionDef, _context?.executionContext, objectName); + if (gateError) { + return { handled: true, response: this.error(gateError, 403) }; } } catch { /* schema unresolved → no declared gate to enforce (handler-only action) */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f59df3c491..f883c53379 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: '@objectstack/knowledge-memory': specifier: workspace:* version: link:../../packages/plugins/knowledge-memory + '@objectstack/mcp': + specifier: workspace:* + version: link:../../packages/mcp '@objectstack/metadata': specifier: workspace:* version: link:../../packages/metadata From 96567d8f88d0c076fa8d16cc95cab3e7acbb16e7 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 25 Jun 2026 00:59:37 +0800 Subject: [PATCH 2/2] test(runtime): cover MCP run_action flow dispatch via automation runner Co-Authored-By: Claude Opus 4.8 --- packages/runtime/src/http-dispatcher.test.ts | 59 ++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index bf628d64bc..73517f64a1 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2120,4 +2120,63 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', () const { bridge } = makeBridge({ userId: 'u1', systemPermissions: [] }); await expect(bridge.runAction('defer_task', { recordId: 't1' })).rejects.toThrow(/cannot be invoked/i); }); + + // ── flow dispatch (type:'flow' → automation flow runner) ── + const flowAction = { + name: 'escalate_ticket', + label: 'Escalate', + objectName: 'todo_task', + type: 'flow', + target: 'escalation_flow', + locations: ['record_header'], + }; + const makeFlowBridge = (execCtx: any, automation: any) => { + const flowObject = { ...{ name: 'todo_task', label: 'Task', fields: {} }, actions: [flowAction] }; + const ql: any = { + executeAction: vi.fn(), + registry: { getObject: () => flowObject }, + find: vi.fn(async () => []), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + const metadata: any = { + listObjects: vi.fn(async () => [flowObject]), + getObject: vi.fn(async () => flowObject), + }; + const kernel: any = { + context: { + getService: (n: string) => + n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : n === 'automation' ? automation : null, + }, + }; + const dispatcher = new HttpDispatcher(kernel); + const ctx: any = { request: {}, environmentId: 'platform', executionContext: execCtx }; + return { bridge: (dispatcher as any).buildMcpBridge(ctx), ql }; + }; + + it('list_actions includes a flow action only when an automation service is present', async () => { + const withAuto = makeFlowBridge({ userId: 'u1', systemPermissions: [] }, { execute: vi.fn() }); + expect((await withAuto.bridge.listActions()).map((a: any) => a.name)).toContain('escalate_ticket'); + const noAuto = makeFlowBridge({ userId: 'u1', systemPermissions: [] }, null); + expect((await noAuto.bridge.listActions()).map((a: any) => a.name)).not.toContain('escalate_ticket'); + }); + + it('run_action dispatches a flow action through the automation flow runner', async () => { + const execute = vi.fn(async () => ({ success: true, output: { escalated: true } })); + const { bridge, ql } = makeFlowBridge({ userId: 'u1', systemPermissions: [] }, { execute }); + const res = await bridge.runAction('escalate_ticket', { recordId: 't1', params: { reason: 'sla' } }); + expect(res.ok).toBe(true); + expect(ql.executeAction).not.toHaveBeenCalled(); // flow path, not executeAction + expect(execute).toHaveBeenCalledWith( + 'escalation_flow', + expect.objectContaining({ triggerData: expect.objectContaining({ action: 'escalate_ticket', params: { reason: 'sla' } }) }), + ); + }); + + it('run_action surfaces a flow failure as an error', async () => { + const execute = vi.fn(async () => ({ success: false, error: 'boom' })); + const { bridge } = makeFlowBridge({ userId: 'u1', systemPermissions: [] }, { execute }); + await expect(bridge.runAction('escalate_ticket', {})).rejects.toThrow(/boom/i); + }); });