Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/mcp-native-action-tools.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion examples/app-todo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
178 changes: 178 additions & 0 deletions examples/app-todo/test/mcp-actions.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({
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);
});
30 changes: 30 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading