Skip to content
18 changes: 18 additions & 0 deletions .changeset/27681-mcp-system-object-action-exposure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/runtime': patch
'@objectstack/mcp': patch
---

fix(mcp): an action on a `sys_*` object whose author exposed it to AI is listed and run over MCP, behind every gate the action already has

Clause-②: yes (widening: MCP `list_actions` / `run_action` / `resume_run` now admit a `sys_*` action that declares `ai.exposed: true`; `McpActionSummary` gains the optional `aiExposed`)
Metadata-protocol: none
Ruling-ref: #27681 ruling `6513869839` letter B

MCP refused every action on a `sys_*` object by its object name, ahead of the action's own AI opt-in, so no declaration could open one, while `POST /api/v1/actions/:object/:action` served the same action to the same caller. Now the name refusal yields to the action's own `ai: { exposed: true, description }` (ADR-0011), and to nothing else:

- **Where.** `run_action`, `list_actions` and `resume_run`, in the runtime bridge and in the `@objectstack/mcp` tool layer. An agent that lists sees exactly the system-object actions it may run.
- **What still runs behind the flag, unchanged.** The ADR-0066 D4 capability gate, the ADR-0126 §8 activation switch, the declared param contract, the `ai.requiresConfirmation` confirmation (428 `ACTION_CONFIRMATION_REQUIRED` without `confirm: true`), the subject load under the caller's row-level security, and for a `type: 'api'` action the target endpoint's own authorization.
- **No deployment switch.** `allowSystemObjects` keeps its meaning (the tool layer's filter) and opens nothing in the runtime: a `sys_*` action without the declaration stays refused there.
- **Nothing changes on day one.** No platform `sys_*` action declares an `ai` block, so every one of them stays hidden and refused until its declaration lands.
- **`McpActionSummary.aiExposed`** (optional) is how a bridge attests the author's opt-in to the tool layer, which never sees the declaration. A bridge that does not set it keeps every `sys_*` action hidden and refused, as before. The runtime bridge sets it on every action it lists.
11 changes: 8 additions & 3 deletions content/docs/ai/actions-as-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ bound to the caller's principal (the API key acts as the user):

| Tool | What it does |
|:---|:---|
| `list_actions` | Enumerates the business actions that are **AI-exposed** (`ai.exposed: true`) **and** the caller is permitted to run — name, target object, description, whether it needs a `recordId`, whether it is destructive, and its declared params. |
| `list_actions` | Enumerates the business actions that are **AI-exposed** (`ai.exposed: true`) **and** the caller is permitted to run — name, target object, description, whether it needs a `recordId`, whether it is destructive, its declared params, and `aiExposed: true` (the author's opt-in, attested by the bridge). |
| `run_action` | Invokes an action by name with `{ recordId, params }`. Invocation is gated (author opt-in + capabilities); the action body then runs the app's registered logic as trusted code. |
| `resume_run` | Continues a flow run that `run_action` answered with `status: "paused"` and a `screen`, by submitting that screen's field values as `{ runId, values }`. Only the user who started the run can resume it, behind the same gates as `run_action`. See [Completing a paused screen flow](#completing-a-paused-screen-flow). |

Expand Down Expand Up @@ -101,8 +101,13 @@ data-layer backstop, is the boundary (#2849).

The bridge walks every registered object's `actions[]` and offers only actions
that are **AI-exposed** (`ai.exposed: true`), have a headless dispatch path,
**and** that the caller is permitted to run. System objects (`sys_*`) are held
back fail-closed.
**and** that the caller is permitted to run. An action on a system object
(`sys_*`) is offered only on its own `ai.exposed: true` — the object's name is
not a second opt-in to clear, and no deployment switch opens one that is not
flagged — and then runs behind every gate below, as on any other object (#27681).
The stored-metadata tables (`sys_metadata`, `sys_metadata_history`) are the one
exception: their actions stay refused even when flagged; read and change those
through the metadata API.

| `action.type` | Dispatch path | Available where |
|:---|:---|:---|
Expand Down
11 changes: 10 additions & 1 deletion content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ can reach is the **default exposure policy (v1)**:
whose `target` is on another origin — the browser calls that origin
directly, so it is not exposed. See [Actions as Tools](/docs/ai/actions-as-tools)
and [Actions](/docs/ui/actions).
- **An action on a `sys_*` object follows the same opt-in**, decided action by
action: it is listed and runs only when the action itself declares
`ai.exposed: true`, and then every gate behind the flag still runs (its
`requiredPermissions`, the installation's action switch, its param contract,
`ai.requiresConfirmation`, and the target's own authorization). The object
being `sys_*` opens nothing, and no deployment switch opens an unflagged one.
The stored-metadata tables (`sys_metadata`, `sys_metadata_history`) stay
refused even when flagged. Each action `list_actions` returns carries
`aiExposed: true`, the server's attestation of that opt-in.

<Callout type="info">
This is the default policy, not a ceiling. A finer, **metadata-declared** exposure
Expand Down Expand Up @@ -397,7 +406,7 @@ skill and a guided `/objectstack:connect` command.
| stdio won't start / boot fails closed | `OS_MCP_STDIO_ENABLED=true` but `OS_MCP_STDIO_API_KEY` is missing, unknown, revoked, or expired — fail-closed by design (ADR-0101). Set a valid `osk_` key; there is no unscoped or `system` fallback |
| `401` on every call | Anonymous or invalid credentials. Interactive clients: complete the browser login (the `WWW-Authenticate` header advertises the OAuth metadata). Headless: check the `osk_` key and header spelling |
| `403 insufficient_scope` | The OAuth token lacks the scope for that tool family (e.g. writes without `data:write`) — reconnect and grant the scope |
| An action is missing from `list_actions` | `ai.exposed` is not `true`, `ai.description` is shorter than 40 characters, the type isn't headless-callable here (`script` with a body/handler, `flow` with an automation service, and `api` with a same-origin `target` are — `url`, `modal`, `form`, and an `api` action whose `target` is on another origin never appear), it targets a `sys_*` object (held back fail-closed for every type, api included — #27681 tracks that gap), or the caller fails its `requiredPermissions` |
| An action is missing from `list_actions` | `ai.exposed` is not `true`, `ai.description` is shorter than 40 characters, the type isn't headless-callable here (`script` with a body/handler, `flow` with an automation service, and `api` with a same-origin `target` are — `url`, `modal`, `form`, and an `api` action whose `target` is on another origin never appear), it targets `sys_metadata` / `sys_metadata_history` (refused even when flagged — use the metadata API), or the caller fails its `requiredPermissions` |
| Reads return few rows / writes denied | Working as designed — the caller's permissions and RLS apply. Verify with the same user in the Console |

## Related
Expand Down
133 changes: 133 additions & 0 deletions packages/mcp/src/mcp-action-system-object-exposure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#27681] The action tools' `sys_*` filter yields PER ACTION to the author's
* AI opt-in, which the bridge attests as `aiExposed` (ADR-0011).
*
* Maintainer ruling 6513869839 (letter B): a `sys_*` action is reachable over
* MCP only when the action itself declares `ai: { exposed: true, description }`.
* This package never sees a declaration, so before the ruling its fail-closed
* filter (`allowSystemObjects`, default false) hid and refused EVERY action on
* a `sys_*` object by name. It now lets one through exactly when the bridge's
* listing attests the opt-in — at `list_actions` AND at `run_action`, so an
* agent that lists sees exactly what it may run — and stays closed for a bridge
* that attests nothing. `allowSystemObjects` keeps its meaning and gains no
* reach (ruling parameter 1).
*
* Driven through `MCPServerRuntime.handleHttpRequest` over JSON-RPC, the path
* an external client hits. The bridge is a double; the runtime half (the
* bridge that reads the declaration, and the gates behind the flag) is pinned
* in `packages/runtime/src/mcp-system-object-action-exposure.test.ts`.
*/

import { describe, it, expect } from 'vitest';

import { MCPServerRuntime } from './mcp-server-runtime.js';
import type { McpDataBridge, McpActionBridge, McpActionSummary } from './mcp-http-tools.js';

const SYS = 'sys_user';

/** The bridge attests the author's opt-in on this one. */
const EXPOSED: McpActionSummary = { name: 'ban_user', objectName: SYS, type: 'api', requiresRecord: true, aiExposed: true };
/** A `sys_*` entry the bridge listed WITHOUT attesting the opt-in. */
const UNATTESTED: McpActionSummary = { name: 'unban_user', objectName: SYS, type: 'api', requiresRecord: true };
/** Control: an ordinary object's action, untouched by this change. */
const APP: McpActionSummary = { name: 'complete_task', objectName: 'todo_task', type: 'script', requiresRecord: true };

function makeBridge(listing: McpActionSummary[]): McpDataBridge & McpActionBridge & { runs: string[] } {
const runs: string[] = [];
return {
runs,
async listObjects() { return []; },
async describeObject() { return null; },
async query() { return { records: [] }; },
async get() { return null; },
async create() { return {}; },
async update() { return {}; },
async remove() { return {}; },
async listActions() { return listing; },
async runAction(name: string) {
runs.push(name);
return { ok: true, action: name };
},
};
}

const toolsCall = (id: number, name: string, args: Record<string, unknown>) => ({
jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args },
});

async function call(bridge: ReturnType<typeof makeBridge>, body: unknown, allowSystemObjects?: boolean) {
const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' });
const req = new Request('http://localhost/api/v1/mcp', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify(body),
});
const res = await runtime.handleHttpRequest(req, {
bridge,
parsedBody: body,
...(allowSystemObjects !== undefined ? { toolOptions: { allowSystemObjects } } : {}),
});
return (await res.json()) as any;
}

const listedNames = async (bridge: ReturnType<typeof makeBridge>, allowSystemObjects?: boolean) => {
const json = await call(bridge, toolsCall(1, 'list_actions', {}), allowSystemObjects);
expect(json.result.isError, JSON.stringify(json.result)).toBeFalsy();
return (JSON.parse(json.result.content[0].text).actions as McpActionSummary[]).map((a) => a.name);
};

describe('[#27681] list_actions: a sys_* action is listed exactly when the bridge attests its opt-in', () => {
it('lists the attested sys_* action and hides the unattested one', async () => {
const names = await listedNames(makeBridge([EXPOSED, UNATTESTED, APP]));
expect(names).toEqual(['ban_user', 'complete_task']);
});

it('a bridge that attests nothing keeps every sys_* action hidden, as before', async () => {
const { aiExposed: _dropped, ...unattested } = EXPOSED;
expect(await listedNames(makeBridge([unattested, APP]))).toEqual(['complete_task']);
});

it('`allowSystemObjects: true` keeps its meaning: the tool layer lists every entry the bridge serves', async () => {
expect(await listedNames(makeBridge([EXPOSED, UNATTESTED, APP]), true)).toEqual(['ban_user', 'unban_user', 'complete_task']);
});
});

describe('[#27681] run_action: a sys_* action runs only where list_actions lists it', () => {
it('REFUSES the unattested sys_* action before the bridge runs anything', async () => {
const bridge = makeBridge([EXPOSED, UNATTESTED]);
const json = await call(bridge, toolsCall(2, 'run_action', { actionName: 'unban_user', objectName: SYS, recordId: 'u1' }));
expect(json.result.isError).toBe(true);
expect(String(json.result.content[0].text)).toMatch(/system object "sys_user" is not exposed via MCP/);
expect(bridge.runs).toEqual([]);
});

it('REFUSES a sys_* action the bridge does not list at all (not exposed, or not permitted for this caller)', async () => {
const bridge = makeBridge([APP]);
const json = await call(bridge, toolsCall(3, 'run_action', { actionName: 'ban_user', objectName: SYS, recordId: 'u1' }));
expect(json.result.isError).toBe(true);
expect(bridge.runs).toEqual([]);
});

it('a same-named attested action on ANOTHER object does not admit this one', async () => {
const bridge = makeBridge([{ ...EXPOSED, objectName: 'sys_member' }]);
const json = await call(bridge, toolsCall(4, 'run_action', { actionName: 'ban_user', objectName: SYS, recordId: 'u1' }));
expect(json.result.isError).toBe(true);
expect(bridge.runs).toEqual([]);
});

it('ADMITS the attested sys_* action and hands it to the bridge, whose gates decide the rest', async () => {
const bridge = makeBridge([EXPOSED, UNATTESTED]);
const json = await call(bridge, toolsCall(5, 'run_action', { actionName: 'ban_user', objectName: SYS, recordId: 'u1' }));
expect(json.result.isError, JSON.stringify(json.result)).toBeFalsy();
expect(bridge.runs).toEqual(['ban_user']);
});

it('CONTROL — an ordinary object\'s action is not screened against the listing', async () => {
const bridge = makeBridge([]);
const json = await call(bridge, toolsCall(6, 'run_action', { actionName: 'complete_task', objectName: 'todo_task', recordId: 't1' }));
expect(json.result.isError).toBeFalsy();
expect(bridge.runs).toEqual(['complete_task']);
});
});
60 changes: 52 additions & 8 deletions packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@
*
* SECURITY (zero-tolerance):
* - System objects (`sys_*`) are NOT exposed by default — fail-closed guard on
* every tool that takes an object name, independent of the bridge.
* every tool that takes an object name, independent of the bridge. The
* action tools yield PER ACTION: a `sys_*` action whose author declared
* `ai.exposed: true` (the bridge attests it as `aiExposed`) is listed and
* run like any other exposed action (#27681, ADR-0011) — no other key and
* no deployment switch opens one.
* - The bridge is bound to the caller's principal; tools cannot widen it.
* - Errors are returned as tool errors (text), never thrown across the wire,
* and never include secrets.
Expand Down Expand Up @@ -223,6 +227,15 @@ export interface McpActionSummary {
requiresRecord?: boolean;
/** True when the action is destructive / an author flagged it for confirmation. */
requiresConfirmation?: boolean;
/**
* True when the action's author opted it into the AI surface
* (`ai: { exposed: true, description }`, ADR-0011), as the bridge read it off
* the declaration. With `allowSystemObjects` off, an action on a `sys_*`
* object is listed and run ONLY when this is `true` (#27681): the tool layer
* never sees the declaration, so a bridge that does not attest it keeps
* every system-object action hidden and refused, exactly as before.
*/
aiExposed?: boolean;
/** Declared input parameters. */
params?: McpActionParamSummary[];
}
Expand Down Expand Up @@ -277,7 +290,13 @@ export interface McpActionBridge {
}

export interface RegisterActionToolsOptions {
/** Expose actions on `sys_*` system objects too. Default false (fail-closed). */
/**
* Expose actions on `sys_*` system objects too. Default false (fail-closed):
* a `sys_*` action is then listed and run only when the bridge attests its
* author's AI opt-in ({@link McpActionSummary.aiExposed}). This option opens
* the TOOL layer's filter only; the runtime bridge still requires the
* per-action opt-in for every `sys_*` action (#27681).
*/
allowSystemObjects?: boolean;
/**
* OAuth 2.1 scopes granted to the caller's access token (#2698) — same
Expand All @@ -294,6 +313,24 @@ function isSystemObject(name: string): boolean {
return /^sys_/i.test(name);
}

/**
* [#27681] Whether the action tools may list a summary with `allowSystemObjects`
* off: any action not on a `sys_*` object, and a `sys_*` one only when the
* bridge attests its author's `ai.exposed` opt-in. ONE predicate for
* `list_actions` and `run_action`, so the two cannot disagree about a system
* object's action.
*/
function isListableAction(action: McpActionSummary): boolean {
return !action.objectName || !isSystemObject(action.objectName) || action.aiExposed === true;
}

function systemObjectActionRefusal(actionName: string, objectName: string): string {
return (
`Action "${actionName}" on system object "${objectName}" is not exposed via MCP: a system object's action is ` +
'reachable only when its author exposed it to AI (ai.exposed) and list_actions lists it for you'
);
}

function textResult(value: unknown) {
return { content: [{ type: 'text' as const, text: jsonText(value) }] };
}
Expand Down Expand Up @@ -1225,9 +1262,7 @@ export function registerActionTools(
async () => {
try {
const actions = await bridge.listActions();
const visible = allowSystem
? actions
: actions.filter((a) => !a.objectName || !isSystemObject(a.objectName));
const visible = allowSystem ? actions : actions.filter(isListableAction);
return textResult({ actions: visible, totalCount: visible.length });
} catch (err) {
return errorResultWithCode(err);
Expand Down Expand Up @@ -1322,10 +1357,19 @@ export function registerActionTools(
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 {
// [#27681] A `sys_*` action runs only where `list_actions` would list
// it: the bridge's own listing is what attests the author's
// `ai.exposed` (see `isListableAction`). A name with no `objectName`
// is resolved by the bridge, whose runtime applies the same per-action
// rule to the object it resolves.
if (objectName && !allowSystem && isSystemObject(objectName)) {
const listed = await bridge.listActions();
const admitted = listed.some(
(a) => a.name === actionName && a.objectName === objectName && isListableAction(a),
);
if (!admitted) return errorResult(systemObjectActionRefusal(actionName, objectName));
}
const result = await bridge.runAction(actionName, {
objectName,
recordId,
Expand Down
Loading
Loading