Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
77b7a8a
feat(mcp): the read tools admit a system object that declares enable.…
claude Nov 13, 2026
a5d8f6a
test: pin the read admission in runtime and approvals; changeset
claude Nov 13, 2026
5b97a10
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 13, 2026
55a470c
docs(mcp): a system object that declares enable.aiReadable is open to…
claude Nov 13, 2026
5d0be8a
test(mcp): classify aiReadExposed in the bridge-member tripwires
claude Nov 13, 2026
a332db4
test(runtime): pay the metadata load at collection in the read-admiss…
claude Nov 13, 2026
1b1d909
feat(approvals): members read their approval requests; the read gate …
claude Nov 13, 2026
5b91c36
test(dogfood): an approver's agent lists and decides the approvals wa…
claude Nov 13, 2026
c12b783
docs(mcp): members read their approval requests; waiting-on-me covers…
claude Nov 13, 2026
7fd55e6
test(approvals): type the waiting-on-me rig's query options
claude Nov 13, 2026
31a83f8
test: re-ground the suites whose premise the member baseline read moved
claude Nov 13, 2026
1239e11
fix(audit): narrow the approval request's activity rows (GHSA-2985-5f…
claude Nov 13, 2026
dbd7515
fix(approvals): no discussion thread on a request; restate what the m…
claude Nov 13, 2026
8e4da80
fix(approvals): keep the tracker id out of the warning text
claude Nov 13, 2026
1322726
fix(audit): harden the activity and audit copies of approval requests…
claude Nov 13, 2026
a12e7c9
fix(audit): judge a pinned query over an approval request's copies ag…
claude Nov 13, 2026
33368c3
fix(audit): state the copy-column rule in the guard's words, and run …
claude Nov 13, 2026
b6acbd9
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 13, 2026
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
18 changes: 18 additions & 0 deletions .changeset/mcp-read-tools-ai-readable-system-object.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/mcp': patch
'@objectstack/runtime': patch
'@objectstack/plugin-approvals': patch
'@objectstack/plugin-security': patch
'@objectstack/plugin-audit': patch
---

Clause-②: yes (widening)
Metadata-protocol: none

The MCP read tools (`list_objects`, `describe_object`, `query_records`, `get_record`, `aggregate_records`) now admit a system object whose author declared `enable.aiReadable: true`, asked through a new optional bridge member `aiReadExposed` that both bridges answer with `isAiReadExposed`. Every gate after admission is unchanged (the caller's permissions, row-level security, the object's own read gates, field masking), and every write tool still refuses every `sys_*` object. `sys_approval_request` declares the key; `query_records` names the "waiting on you" filter shape.

The member baseline `member_default` now names `sys_approval_request` read-only, so every signed-in member holds a data-door read on it; which rows is still the approvals request read gate (the submitter, a current approver, a past actor), and no write opens. That gate now answers a conjunctive `pending_approvers` `$contains` of the caller's own id with the approvals service's acting-address fold, so the console's My Pending view and an agent's "waiting on me" query find a slot addressed by email or an unstaffed position, not only one holding the user id. A `pending_approvers` predicate naming another id, or under `$or`, is read as written.

`@objectstack/plugin-audit`: the activity and audit copies of an approval request are hardened (GHSA-2985-5f64-8wgh). New copies no longer carry the request's subject snapshot, and `migrateSideCopies` / `migrateArchivedSideCopies` rewrite the copies at rest. Both ledgers' read redactions drop it from an older copy whether or not the rewrite has run.

**Who can query those copies narrows.** A non-system query on `sys_activity` or `sys_audit_log` that names `sys_approval_request` by equality on `object_name` and names a column holding the copy (`metadata`; `old_value`, `new_value`), as a free-text search does, is now judged against every registered object, as a query naming no object already is. A reader withheld any field of any object, an organization administrator included, is refused it (`403`) where they were answered before. The same query pinned to another object, or naming only other columns, is judged as before. This does not depend on the rewrite having run.
12 changes: 11 additions & 1 deletion content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,17 @@ usable on an app with hundreds of objects. Which objects and actions that spine
can reach is the **default exposure policy (v1)**:

- **Objects are exposed automatically** — except `sys_*` system objects, which
are blocked fail-closed.
are blocked fail-closed. A system object whose author declared
`enable.aiReadable: true` is open to the read tools only (`list_objects`,
`describe_object`, `query_records`, `get_record`, `aggregate_records`), read
as you under every gate a read already runs (your permissions, row-level
security, the object's own read rules, field masking); no write tool opens
it, and the stored-metadata tables stay refused. `sys_approval_request`
declares it: every signed-in member reads the requests they take part in
(submitted, waiting on them, or acted on) and no others, so an agent can
list the approvals waiting on you with `pending_approvers` contains
`{current_user_id}` — answered for a slot that names you by email, position
or team too, not only by user id.
- **Actions require the author's opt-in**: `ai: { exposed: true }` plus an
`ai.description` of at least 40 characters, and the action must be callable
without a UI. In the open framework that means three types — `script`
Expand Down
30 changes: 18 additions & 12 deletions content/docs/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -590,16 +590,22 @@ The same rule governs a decision's files: an attachment is exactly as readable
as the decision it hangs off, never more (`sys_approval_action` delegates that
question back to this service via `fileAccessDelegate`).

So do the two objects' definitions. The metadata API answers an object the
caller may not read as absent, and `sys_approval_request` and
`sys_approval_action` are closed to ordinary members on the data side, yet the
request page renders from both definitions. Both declare
`schemaReadDelegate: 'approvals'`, so `GET /api/v1/meta/object/sys_approval_request`
(and `…/sys_approval_action`, and the object list) serves them to a caller who
can see at least one request by the rule above, projected by that caller's
field-level security. A member who can see none is answered `404`, and a
member the data door refuses still reaches the rows only through
`/api/v1/approvals/*`.
Every signed-in member holds a read on `sys_approval_request` through the
member baseline (`member_default`), and the data door serves them exactly the
requests the rule above lets them see, with the same snapshot redaction. So
`GET /api/v1/data/sys_approval_request` (and an MCP agent's `query_records`)
lists a member's own requests, and a `pending_approvers` filter on
`{current_user_id}` finds the requests waiting on them, including a step
addressed to their email or position. No write opens on it.

The two objects' definitions follow. The metadata API answers an object the
caller may not read as absent; with the baseline read, every member is served
the `sys_approval_request` definition, projected by their field-level
security. `sys_approval_action` stays closed to ordinary members on the data
side, and both objects declare `schemaReadDelegate: 'approvals'`, so
`GET /api/v1/meta/object/sys_approval_action` (and the object list) serves it
to a caller who can see at least one request by the rule above. A member who
can see none is answered `404` for it.
</Callout>

<Callout type="info">
Expand Down Expand Up @@ -857,8 +863,8 @@ progress, the approvers it is waiting on, the merged decision timeline and a rem
button for the submitter. Every viewer who can read the record sees it, not only
approvers. A request opens on its record page through the approvals routes rather
than the data API, so an approver who is not an administrator sees the whole page,
decision actions included, although the data API stays closed to them on the
approval objects.
decision actions included, even where the data API does not serve them the
request row.

Each action's visibility is gated by a **server-computed per-viewer block** the
service attaches to every request it returns:
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ apply):

```typescript
// Object data
'list_objects' // List objects (system sys_* objects hidden by default)
'list_objects' // List objects (sys_* hidden unless declared enable.aiReadable)
'describe_object' // Object schema: fields + features
'validate_expression' // Check a CEL expression against a schema before authoring it
'query_records' // Filter / sort / paginate
Expand Down
75 changes: 68 additions & 7 deletions packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@
* 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.
* no deployment switch opens one. The READ tools (list, describe, query,
* get one, aggregate) yield PER OBJECT the same way: a `sys_*` object whose
* author declared `enable.aiReadable: true` (the bridge attests it through
* `isAiReadExposed`, {@link McpDataBridge.aiReadExposed}) is read like any
* other object, under every later gate (#32442). No write tool ever opens a
* `sys_*` object.
* - 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 @@ -140,6 +145,24 @@ export interface McpDataBridge {
*/
listObjectsDiagnosed?(): Promise<DiagnosedObjectListing<McpObjectSummary>>;
describeObject(name: string): Promise<unknown | null>;
/**
* [#32442, ruling A] Has the system object `name` opted its records into the
* AI READ tools? The bridge answers with `isAiReadExposed`
* (`@objectstack/spec/data`), the ONE reading of `enable.aiReadable`, over
* the object's whole served definition: the stored-metadata family answers
* `false` before the key is read, and only the boolean `true` opens.
*
* The read tools (`list_objects`, `describe_object`, `query_records`,
* `get_record`, `aggregate_records`) ask it only for a `sys_*` name their
* fail-closed guard would otherwise refuse. An attested object is then read
* exactly like a business object: AS the caller, under the caller's
* permissions, row-level security, the object's own read gates and field
* masking. It never opens a write: every write tool keeps refusing `sys_*`.
*
* OPTIONAL and fail-closed: a bridge that omits it, or whose answer throws,
* keeps every system object refused, exactly as before.
*/
aiReadExposed?(name: string): Promise<boolean>;
/**
* [#28715] The fields of `name` that declare options — the fields a
* formula's `label(record.<field>)` can answer on — or `null` when no such
Expand Down Expand Up @@ -471,6 +494,12 @@ function isSystemObject(name: string): boolean {
return /^sys_/i.test(name);
}

/** `items` kept where `keep` answers true, asked concurrently, in order. */
async function filterAsync<T>(items: readonly T[], keep: (item: T) => Promise<boolean>): Promise<T[]> {
const verdicts = await Promise.all(items.map(keep));
return items.filter((_, i) => verdicts[i]);
}

/**
* [#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
Expand Down Expand Up @@ -2187,6 +2216,30 @@ export function registerObjectTools(
}
return undefined;
};
/**
* [#32442] Does the bridge attest this system object's read opt-in
* ({@link McpDataBridge.aiReadExposed})? Fail-closed: no member, or a throw,
* is `false`.
*/
const attestsAiRead = async (objectName: string): Promise<boolean> => {
if (typeof bridge.aiReadExposed !== 'function') return false;
try {
return (await bridge.aiReadExposed(objectName)) === true;
} catch {
return false;
}
};
/**
* [#32442] The guard of the READ tools only (`describe_object`,
* `query_records`, `get_record`, `aggregate_records`): {@link guard}, except
* that a `sys_*` object the bridge attests as AI-readable is admitted. Every
* write tool keeps {@link guard}, so no declaration opens a system write.
*/
const readGuard = async (objectName: string): Promise<string | undefined> => {
const bad = guard(objectName);
if (bad === undefined || !isSystemObject(objectName)) return bad;
return (await attestsAiRead(objectName)) ? undefined : bad;
};

if (canRead) {
server.registerTool(
Expand Down Expand Up @@ -2225,9 +2278,11 @@ export function registerObjectTools(
const diagnosed = bridge.listObjectsDiagnosed
? await bridge.listObjectsDiagnosed()
: { objects: await bridge.listObjects(), degraded: false, errors: [] };
// [#32442] A system object is listed only where the bridge attests
// its read opt-in, the same admission `readGuard` makes.
const visible = allowSystem
? diagnosed.objects
: diagnosed.objects.filter((o) => !isSystemObject(o.name));
: await filterAsync(diagnosed.objects, async (o) => !isSystemObject(o.name) || await attestsAiRead(o.name));
if (!diagnosed.degraded) {
return textResult({ objects: visible, totalCount: visible.length });
}
Expand Down Expand Up @@ -2261,7 +2316,7 @@ export function registerObjectTools(
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
},
async ({ objectName }) => {
const bad = guard(objectName);
const bad = await readGuard(objectName);
if (bad) return errorResult(bad);
try {
const def = await bridge.describeObject(objectName);
Expand Down Expand Up @@ -2441,7 +2496,13 @@ export function registerObjectTools(
{
description:
'Query records from an object with optional filter, field selection, sorting and pagination. ' +
'Runs under the caller\'s permissions and row-level security.',
'Runs under the caller\'s permissions and row-level security. ' +
'A filter value "{current_user_id}" stands for you. ' +
// [#32442] The console's My Pending list, filter for filter: the
// participant read alone also serves requests the caller submitted
// or already decided, so an agent must not guess "mine" from it.
'For example, where the app runs approvals, the ones waiting on you: objectName "sys_approval_request", where ' +
'{"status":"pending","pending_approvers":{"$contains":"{current_user_id}"}}; decide one with run_action.',
inputSchema: strictToolInput(
{
surface: 'this query_records call',
Expand Down Expand Up @@ -2487,7 +2548,7 @@ export function registerObjectTools(
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
},
async ({ objectName, where, fields, limit, offset, orderBy }) => {
const bad = guard(objectName);
const bad = await readGuard(objectName);
if (bad) return errorResult(bad);
try {
const result = await bridge.query(objectName, {
Expand Down Expand Up @@ -2588,7 +2649,7 @@ export function registerObjectTools(
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
},
async ({ objectName, aggregations, groupBy, where, timezone }) => {
const bad = guard(objectName);
const bad = await readGuard(objectName);
if (bad) return errorResult(bad);
try {
const rows = (await aggregateFn(objectName, {
Expand Down Expand Up @@ -2626,7 +2687,7 @@ export function registerObjectTools(
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
},
async ({ objectName, recordId }) => {
const bad = guard(objectName);
const bad = await readGuard(objectName);
if (bad) return errorResult(bad);
try {
const record = await bridge.get(objectName, recordId);
Expand Down
Loading
Loading