You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 24bc259
Browse filesBrowse the repository at this point in the historyBrowse files
fix(runtime)!: MCP list_actions summarises each action against the object as the caller is served it (ADR-0106) (#31075)
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc
Clause-②: no (narrowing) — `list_actions` over `/api/v1/mcp` now answers
a caller only the actions, and the param details, that the `/meta`
object door's projection serves that caller, which ADR-0106 D1 ("a field
the caller cannot read does not exist for that caller, on any plane")
and the MCP security model already promise, so no stated contract is
narrowed; what narrows is the answer to a caller with a `readable:
false` field. Nothing is widened: no export, answer member, error code
or status is added.
Metadata-protocol: none
Skills-docs: no move — `skills/**` teaches no param summary for
`list_actions`; `content/docs/ai/connect-mcp.mdx` states the projection.
## 维护者速读
- **改了什么**:MCP `list_actions`(HTTP)对每个动作,按 `/meta` 对象门对该调用者的 ADR-0106
投影来判定与摘要:投影会隐去的动作(参数绑定了调用者不可读的字段,含 `objectOverride`
指向的他对象字段)不再列出;列出的动作,其参数摘要只读投影后的对象,不再带出不可读字段的名称、标签、说明或选项。
- **为什么改**:ADR-0106 D1 要求不可读字段在任何门都不存在;`/meta` 已隐去这类动作、`describe_object`
已按调用者投影,唯独 `list_actions` 仍从未投影的对象读字段元数据。
- **风险与回滚**:对带 `readable: false` 字段的调用者是收窄(标题带
`!`);某动作所属对象的字段可见性无法评估或投影后无字段时,`list_actions` 与 `list_objects`
一样拒绝(`FIELD_VISIBILITY_UNRESOLVED` / 503);点名 `sys_*` 对象的 `run_action`
预筛以列表为准,被隐去的动作在那里同样被拒。无限制调用者、D4 豁免者、无 security 服务的部署逐字节不变。回滚即 revert 本
PR。
- **先看**:`packages/runtime/src/domains/mcp.ts`(`servedActionObject` 及其在
`listActions`
的调用点)、`packages/runtime/src/domains/mcp-object-schema-mask.test.ts`(新增的最后一个
describe)。
- **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run
src/domains/mcp-object-schema-mask.test.ts`,预期 `Tests 23 passed (23)`。
- **需要你做的**:无,席位落地。
## What changes
- `packages/runtime/src/domains/mcp.ts` — the HTTP MCP bridge gains
`servedActionObject(action, obj)`: the action is projected as its
object's only `actions` entry through `servedToCaller`, the same `/meta`
masker and projection (`resolveObjectMasker` → `maskObjectSchema`)
`describe_object` uses. `listActions` skips an action that projection
withholds and summarises every kept one against the projected object.
That is the `/meta` object door's own rule (`maskDeniedFieldReferences`,
an action's `params` read by `actionParamReadsDenied`, `objectOverride`
judged on the object it names), not a second one.
- The action collection, the exposure and capability gates,
`capabilityRefusal`, `run_action` and `resume_run` keep reading
`readServedObjects()` whole, as before.
- `content/docs/ai/connect-mcp.mdx` — the security model states the
`list_actions` projection, and the troubleshooting row names it as a
reason an action is missing.
- Changeset: `@objectstack/runtime` patch, BREAKING banner and the
ADR-0087 `not-required` disposition.
## Tests
A new top-level `describe` in the shared harness
`packages/runtime/src/domains/mcp-object-schema-mask.test.ts`: the real
`SecurityPlugin` on a real `ObjectQL` over a real `SqlDriver`, the
protocol over that engine, the bridge `handleMcp` builds for each
persona over `/api/v1/mcp`, and the dispatcher's
`/metadata/object/:name` door beside it.
- A member whose set marks `budget` and the other object's `rate`
`readable: false` is listed neither `set_budget` (`params: [{ field:
'budget' }]`) nor `quote_rate` (`params: [{ field: 'rate',
objectOverride: … }]`), and the listing equals the action set the
`/metadata` door serves them; the field names, the label, the help text
and the other object's label appear nowhere in the answer.
- A param bound through `objectOverride` to the other object's readable
`budget` is listed, and its summary carries no label or help of this
object's denied `budget`.
- A member denied every field of the action's object is refused
`FIELD_VISIBILITY_UNRESOLVED` / 503, as `list_objects` refuses them.
- Controls: an unrestricted member sees the full `set_budget` summary
(`budget`, `number`, `Approved budget — Spend ceiling the board signed
off`) and the cross-object one, byte-identical to a deployment with no
security service; an action binding a readable field, and one binding
none, is summarised to the restricted member exactly as to the control.
Ablations, each through `scripts/ablation-replace.mjs` (anchor hit 1 →
0, blob changed, restored to the HEAD blob with `git diff HEAD` empty).
The suite imports the bridge from `src`, so no `dist` leg applies.
- Projection off (`servedActionObject` returns the object unprojected):
3 failed | 20 passed — the restricted listing, the cross-object summary
and the refusal go red; the precondition, both controls and the 17
earlier cases of the file stay green.
- Withholding kept, summary against the unprojected object: 1 failed |
21 passed, measured at `bb679937a9`, before the refusal case was added
(the refusal is raised by the projection itself, which this mutation
keeps) — only the cross-object summary pin goes red.
## Acceptance notes
- The stdio MCP bridge implements no `listActions`
(`packages/mcp/src/stdio-data-bridge.ts`), so `list_actions` is an
HTTP-only tool; nothing changes there.
- `run_action` and `resume_run` resolve and gate against the unprojected
declarations, unchanged. Running an action is judged by `ai.exposed`,
the capability gate, the installation switch, the param contract,
confirmation and the subject record under row-level security; field
readability is not one of those gates, and that is unchanged here. One
consequence of the listing: the `run_action` pre-screen for a call
naming a `sys_*` object admits only a listed action, so an action
withheld here is refused there with the system-object sentence.
- An action with no object definition (an object-less action) carries no
field of an object to project, and is listed as declared.
## Verification
<details><summary>Targeted suite</summary>
`pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2
src/domains/mcp-object-schema-mask.test.ts` → `Test Files 1 passed (1)`
· `Tests 23 passed (23)`.
</details>
<details><summary>@objectstack/runtime, whole package, at fee5571
(origin/main merged)</summary>
- `pnpm --filter @objectstack/runtime test` under the verify lock →
`Test Files 553 passed (553)` · `Tests 8875 passed | 19 skipped (8894)`
· `VERDICT command-exit 0`.
- `pnpm --filter @objectstack/runtime typecheck` → `VERDICT command-exit
0`; `check:test-typecheck: OK`, the test layer held to its shrink-only
debt ledger.
- Built first: `pnpm --filter '@objectstack/runtime...' build`, exit 0.
</details>
<details><summary>Derived gate families (scripts/pm/dispatch-gates.mjs
--commands, at fee5571)</summary>
90 derived, 90 run locally. 87 exit 0. Three refused on a prerequisite
(exit 3), so NOT MEASURED here, each run by CI on this PR:
- NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason: it reads
every package's built output and this worktree built only the runtime
closure.
- NOT MEASURED: `pnpm --filter @objectstack/spec run
check:skill-examples`, reason: `packages/client-react/dist` not built
here.
- NOT MEASURED: `pnpm check:type-check-debt`, reason: its re-measure
build did not get a turn on the shared verify lock (queue-timeout).
`--ran` reconciliation: 90 derived, 90 run, 0 UNRUN.
</details>
NOT MEASURED at open: the dogfood battery (`pnpm --filter
@objectstack/dogfood test`, title carries `!`), reason: queued behind
another run on the shared verify lock; its reading goes to the seat's
report.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(runtime)!: MCP list_actions summarises each action against the object as the caller is served it (ADR-0106)
6
+
7
+
Clause-②: no (narrowing) — `list_actions` over `/api/v1/mcp` now answers a caller only the actions, and the param details, that the `/meta` object door's projection serves that caller, which ADR-0106 D1 ("a field the caller cannot read does not exist for that caller, on any plane") and the MCP security model (field-level security applies to the agent exactly as to a person) already promise, so no stated contract is narrowed; what narrows is the answer to a caller with a `readable: false` field. Nothing is widened: no export, answer member, error code or status is added.
8
+
Metadata-protocol: none
9
+
Skills-docs: no move — `skills/**` teaches no param summary for `list_actions`; `content/docs/ai/connect-mcp.mdx` states the projection.
10
+
11
+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema, no export and no stored shape changes: list_actions answers a field-restricted caller the actions and param details the metadata object door already serves them. There is nothing for a migration to rewrite and no tombstone. -->
12
+
13
+
**BREAKING** for a field-restricted caller of MCP `list_actions` over HTTP (`/api/v1/mcp`).
14
+
15
+
-**Now.** Each action `list_actions` would list is projected through the `/meta` object door's ADR-0106 posture and projection for this caller, as its object's only action (`servedToCaller`, the projection `describe_object` uses). An action that projection withholds — a param whose `field` the caller cannot read, a param under `objectOverride` whose field the caller cannot read on the object it names, or any other position of the action that reads such a field — is not listed, as `GET /api/v1/meta/object/:name` does not serve it. A listed action's params are summarised against the projected object, so no label, help text, type or select options of a field the caller cannot read reach its summary.
16
+
-**Refusal.** Where the caller's field visibility on a listed action's object cannot be evaluated, or the projection would leave that object with no field, `list_actions` is refused as `list_objects` is: `FIELD_VISIBILITY_UNRESOLVED` / 503.
17
+
-**`run_action` naming a `sys_*` object.** That call's pre-screen admits only an action `list_actions` lists, so an action withheld above is refused there too, with the system-object sentence. No other `run_action` call consults the listing.
18
+
-**Unchanged.** A caller who may read every field, a D4-exempt caller and a deployment with no `security` service are answered byte for byte as before; so is every action whose params bind only readable fields, or none. The action collection and the run gates (`run_action`, `resume_run`) still read the declarations whole. The stdio server carries no `list_actions`.
Copy file name to clipboardExpand all lines: content/docs/ai/connect-mcp.mdx
+6-2Lines changed: 6 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -364,7 +364,11 @@ drive this MCP server. Same word, different layer.
364
364
the Console. Schema discovery is included: `describe_object`,
365
365
`list_objects`' `fieldCount` and the fields `validate_expression` puts in
366
366
scope name only the fields the caller can read, the same field set
367
-
`GET /api/v1/meta/object/:name` serves that caller. Sparse results or denied
367
+
`GET /api/v1/meta/object/:name` serves that caller. `list_actions` follows
368
+
the same projection: an action that object door withholds from the caller
369
+
(one whose param binds a field the caller cannot read) is not listed, and a
370
+
listed action's params carry no label, help text or options of such a
371
+
field. Sparse results or denied
368
372
writes usually mean governance is *working*, not that the connection is
369
373
broken.
370
374
-**OAuth scopes narrow the toolset.** Tokens carry `data:read`,
@@ -489,7 +493,7 @@ skill and a guided `/objectstack:connect` command.
489
493
| 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 |
490
494
|`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 |
491
495
|`403 insufficient_scope`| The OAuth token lacks the scope for that tool family (e.g. writes without `data:write`) — reconnect and grant the scope |
492
-
| 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`|
496
+
| 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`, or one of its params binds a field the caller cannot read|
493
497
| Reads return few rows / writes denied | Working as designed — the caller's permissions and RLS apply. Verify with the same user in the Console |
constSET_BUDGET={name: 'set_budget',label: 'Set budget',operation: 'update',params: [{field: DENIED}],ai: ai('Set the approved budget of this project to a new amount.')};
328
+
/** Binds a field of ANOTHER object the member cannot read (`objectOverride`, judged there). */
params: [{field: DENIED,objectOverride: OTHER}],ai: ai('Quote the budget a vendor offered for this project.'),
341
+
};
342
+
/** Binds a field the member CAN read: the control. */
343
+
constRENAME={name: 'rename_project',label: 'Rename',operation: 'update',params: [{field: 'title'}],ai: ai('Rename this project to the title the user gives.')};
344
+
/** Binds no field at all. */
345
+
constCLOSE={name: 'close_project',label: 'Close',operation: 'update',patch: {stage: 'closed'},ai: ai('Close this project once the work on it is finished.')};
0 commit comments