Skip to content

Commit 24bc259

Browse 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>
1 parent 8cf1fd0 commit 24bc259

4 files changed

Lines changed: 247 additions & 3 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
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`.

‎content/docs/ai/connect-mcp.mdx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,11 @@ drive this MCP server. Same word, different layer.
364364
the Console. Schema discovery is included: `describe_object`,
365365
`list_objects`' `fieldCount` and the fields `validate_expression` puts in
366366
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
368372
writes usually mean governance is *working*, not that the connection is
369373
broken.
370374
- **OAuth scopes narrow the toolset.** Tokens carry `data:read`,
@@ -489,7 +493,7 @@ skill and a guided `/objectstack:connect` command.
489493
| 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 |
490494
| `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 |
491495
| `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 |
493497
| Reads return few rows / writes denied | Working as designed — the caller's permissions and RLS apply. Verify with the same user in the Console |
494498

495499
## Related

‎packages/runtime/src/domains/mcp-object-schema-mask.test.ts‎

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,3 +311,203 @@ describe('[ADR-0106] HTTP MCP bridge and the /metadata object door agree — the
311311
expect({ code: options?.code, status: options?.status }).toEqual(REFUSED);
312312
});
313313
});
314+
315+
describe('[ADR-0106] HTTP MCP list_actions summarises an action against the object as the caller is served it', () => {
316+
const OBJ = 'mcp_fls_project';
317+
const OTHER = 'mcp_fls_vendor';
318+
const DENIED = 'budget';
319+
const DENIED_OTHER = 'rate';
320+
const DENIED_LABEL = 'Approved budget';
321+
const DENIED_HELP = 'Spend ceiling the board signed off';
322+
let engine: ObjectQL;
323+
let services: Record<string, any>;
324+
325+
const ai = (description: string) => ({ exposed: true, description });
326+
/** Binds a field the member cannot read. */
327+
const SET_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). */
329+
const QUOTE_RATE = {
330+
name: 'quote_rate', label: 'Quote rate', operation: 'update',
331+
params: [{ field: DENIED_OTHER, objectOverride: OTHER }], ai: ai('Quote the day rate of a vendor for this project.'),
332+
};
333+
/**
334+
* Binds the OTHER object's field of the same name as the denied one, which
335+
* the member can read: listed, and summarised against no field of this
336+
* object (the served one carries no `budget`).
337+
*/
338+
const VENDOR_BUDGET = {
339+
name: 'quote_vendor_budget', label: 'Quote vendor budget', operation: 'update',
340+
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+
const RENAME = { 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+
const CLOSE = { name: 'close_project', label: 'Close', operation: 'update', patch: { stage: 'closed' }, ai: ai('Close this project once the work on it is finished.') };
346+
const ALL_ACTIONS = [CLOSE.name, QUOTE_RATE.name, VENDOR_BUDGET.name, RENAME.name, SET_BUDGET.name];
347+
const MEMBER_ACTIONS = [CLOSE.name, VENDOR_BUDGET.name, RENAME.name];
348+
const READABLE_ACTIONS = [CLOSE.name, RENAME.name];
349+
350+
const PERSONAS = {
351+
/** Denied `budget` here and `rate` on the other object. */
352+
member: { userId: 'usr_member', positions: [] as string[], permissions: ['mcp_act_member'], systemPermissions: [] as string[] },
353+
/** Denied nothing: the control. */
354+
staff: { userId: 'usr_staff', positions: [] as string[], permissions: ['mcp_act_staff'], systemPermissions: [] as string[] },
355+
/** Denied every field of the project object. */
356+
walled: { userId: 'usr_walled', positions: [] as string[], permissions: ['mcp_act_walled'], systemPermissions: [] as string[] },
357+
} as const;
358+
359+
beforeAll(async () => {
360+
engine = new ObjectQL();
361+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any);
362+
const storeObjects = [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject] as any[];
363+
await driver.initObjects(storeObjects);
364+
engine.registerDriver(driver as any, true);
365+
await engine.init();
366+
for (const o of storeObjects) engine.registry.registerObject(o, '@objectstack/platform-objects');
367+
engine.registerApp({
368+
id: 'com.objectstack.qa.mcp-list-actions-field-mask',
369+
name: 'MCP list_actions field mask',
370+
version: '1.0.0',
371+
type: 'plugin',
372+
scope: 'system',
373+
objects: [
374+
{
375+
name: OBJ,
376+
label: 'Project',
377+
sharingModel: 'public_read_write',
378+
fields: {
379+
title: { name: 'title', type: 'text', label: 'Title', required: true },
380+
stage: {
381+
name: 'stage', type: 'select', label: 'Stage',
382+
options: [{ value: 'open', label: 'Open' }, { value: 'closed', label: 'Closed' }],
383+
},
384+
[DENIED]: { name: DENIED, type: 'currency', label: DENIED_LABEL, description: DENIED_HELP },
385+
},
386+
actions: [SET_BUDGET, QUOTE_RATE, VENDOR_BUDGET, RENAME, CLOSE],
387+
},
388+
{
389+
name: OTHER,
390+
label: 'Vendor',
391+
sharingModel: 'public_read_write',
392+
fields: {
393+
name: { name: 'name', type: 'text', label: 'Name' },
394+
[DENIED_OTHER]: { name: DENIED_OTHER, type: 'currency', label: 'Day rate' },
395+
[DENIED]: { name: DENIED, type: 'currency', label: 'Vendor budget' },
396+
},
397+
},
398+
],
399+
} as never);
400+
await engine.syncSchemas();
401+
const all = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true };
402+
const deny = { readable: false, editable: false };
403+
const sets = [
404+
PermissionSetSchema.parse({
405+
name: 'mcp_act_member', label: 'Member', objects: { '*': all },
406+
fields: { [`${OBJ}.${DENIED}`]: deny, [`${OTHER}.${DENIED_OTHER}`]: deny },
407+
}),
408+
PermissionSetSchema.parse({ name: 'mcp_act_staff', label: 'Staff', objects: { '*': all } }),
409+
PermissionSetSchema.parse({
410+
name: 'mcp_act_walled', label: 'Walled', objects: { '*': all },
411+
fields: Object.fromEntries(Object.keys((engine.getSchema(OBJ) as any).fields).map((f) => [`${OBJ}.${f}`, deny])),
412+
}),
413+
];
414+
const pluginServices: Record<string, unknown> = {
415+
manifest: { register: vi.fn() },
416+
objectql: engine,
417+
metadata: {
418+
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
419+
list: async () => sets,
420+
},
421+
};
422+
const ctx = {
423+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
424+
registerService: vi.fn((name: string, service: unknown) => { pluginServices[name] = service; }),
425+
getService: (name: string) => {
426+
if (!(name in pluginServices)) throw new Error(`service not registered: ${name}`);
427+
return pluginServices[name];
428+
},
429+
};
430+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'mcp_act_staff' });
431+
await plugin.init(ctx as never);
432+
await plugin.start(ctx as never);
433+
expect(pluginServices.security, 'precondition: the security plugin registered its service').toBeDefined();
434+
vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined);
435+
services = {
436+
protocol: new ObjectStackProtocolImplementation(engine as any),
437+
security: pluginServices.security,
438+
objectql: engine,
439+
};
440+
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
441+
}, 60_000);
442+
443+
afterAll(async () => {
444+
vi.restoreAllMocks();
445+
try { await engine?.destroy(); } catch { /* noop */ }
446+
});
447+
448+
/** One persona's `list_actions` answer on the HTTP bridge, and the actions the `/metadata` object door serves them. */
449+
async function listBoth(persona: Record<string, unknown>, over: Record<string, any> = services) {
450+
const { bridge, dispatcher } = await bridgeFor(kernelOver(over), persona);
451+
const listed: any[] = await bridge.listActions();
452+
const meta = await dispatcher.handleMetadata(`/object/${OBJ}`, {
453+
request: {}, environmentId: undefined, executionContext: { ...persona },
454+
} as any, 'GET');
455+
const metaActions = ((meta.response?.body?.data?.item?.actions ?? []) as any[]).map((a) => a.name).sort();
456+
return { listed, metaActions, metaStatus: meta.response?.status };
457+
}
458+
const namesOf = (listed: any[]) => listed.filter((a) => a.objectName === OBJ).map((a) => a.name).sort();
459+
const byName = (listed: any[], name: string) => listed.find((a) => a.objectName === OBJ && a.name === name);
460+
461+
it('precondition: every fixture action is listed to an unrestricted caller', async () => {
462+
const { listed, metaActions } = await listBoth(PERSONAS.staff);
463+
expect(namesOf(listed)).toEqual(ALL_ACTIONS);
464+
expect(metaActions).toEqual(ALL_ACTIONS);
465+
});
466+
467+
it('a member who cannot read the bound field is not listed the action, nor shown the field — as the /metadata door serves them', async () => {
468+
const { listed, metaActions, metaStatus } = await listBoth(PERSONAS.member);
469+
expect(metaStatus).toBe(200);
470+
expect(metaActions, 'the /metadata object door withholds both').toEqual(MEMBER_ACTIONS);
471+
expect(namesOf(listed), 'list_actions lists what the /metadata door serves').toEqual(metaActions);
472+
const text = JSON.stringify(listed.filter((a) => a.name !== VENDOR_BUDGET.name));
473+
for (const hidden of [DENIED, DENIED_OTHER, DENIED_LABEL, DENIED_HELP, 'Day rate']) {
474+
expect(text.includes(hidden), `'${hidden}' must not reach this caller`).toBe(false);
475+
}
476+
});
477+
478+
it('a param bound to another object\'s readable field is summarised against no field of this object the member cannot read', async () => {
479+
const { listed } = await listBoth(PERSONAS.member);
480+
const entry = byName(listed, VENDOR_BUDGET.name);
481+
expect(entry.params).toEqual([{ name: DENIED, type: 'string', required: false }]);
482+
expect(JSON.stringify(entry).includes(DENIED_LABEL)).toBe(false);
483+
expect(JSON.stringify(entry).includes(DENIED_HELP)).toBe(false);
484+
});
485+
486+
it('a member denied every field of a listed action\'s object is refused, as list_objects refuses them', async () => {
487+
const { bridge } = await bridgeFor(kernelOver(services), PERSONAS.walled);
488+
const refused = await rejectionOf(() => bridge.listActions());
489+
expect({ code: refused?.code, status: refused?.status }).toEqual(REFUSED);
490+
const listed = await rejectionOf(() => bridge.listObjects());
491+
expect({ code: listed?.code, status: listed?.status }).toEqual(REFUSED);
492+
});
493+
494+
it('CONTROL — an unrestricted member is shown the full summary, byte-identical to a deployment with no security service', async () => {
495+
const { listed } = await listBoth(PERSONAS.staff);
496+
expect(byName(listed, SET_BUDGET.name).params).toEqual([
497+
{ name: DENIED, type: 'number', required: false, description: `${DENIED_LABEL} — ${DENIED_HELP}` },
498+
]);
499+
expect(byName(listed, QUOTE_RATE.name).params?.map((p: any) => p.name)).toEqual([DENIED_OTHER]);
500+
const unsecured = await listBoth(PERSONAS.staff, { ...services, security: undefined });
501+
expect(JSON.stringify(listed)).toBe(JSON.stringify(unsecured.listed));
502+
});
503+
504+
it('an action binding a readable field, or none, is summarised to the member exactly as to the control', async () => {
505+
const member = await listBoth(PERSONAS.member);
506+
const staff = await listBoth(PERSONAS.staff);
507+
for (const name of READABLE_ACTIONS) {
508+
expect(byName(member.listed, name), name).toEqual(byName(staff.listed, name));
509+
}
510+
expect(byName(member.listed, RENAME.name).params).toEqual([{ name: 'title', type: 'string', required: false, description: 'Title' }]);
511+
expect(byName(member.listed, CLOSE.name)).not.toHaveProperty('params');
512+
});
513+
});

‎packages/runtime/src/domains/mcp.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,24 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon
697697
return masked.document;
698698
};
699699

700+
/**
701+
* [ADR-0106 D1] The object one action is summarised against, as THIS
702+
* caller is served it — or `undefined` when the `/meta` object door's
703+
* projection withholds the action from this caller. The action is
704+
* projected as its object's only `actions` entry ({@link servedToCaller}),
705+
* the position where that projection drops an action reading a field the
706+
* caller cannot read: a param's `field` (one under `objectOverride` judged
707+
* on the object it names), or any other position of the action. So one
708+
* rule decides on both doors, and the object handed back is the projected
709+
* one, which carries no field the caller cannot read. An action with no
710+
* object definition carries no field of one, and is answered as declared.
711+
*/
712+
const servedActionObject = async (action: any, obj: any): Promise<{ obj: any } | undefined> => {
713+
if (!obj || typeof obj !== 'object') return { obj };
714+
const served = await servedToCaller({ ...obj, actions: [action] });
715+
return Array.isArray(served?.actions) && served.actions.length === 1 ? { obj: served } : undefined;
716+
};
717+
700718
/** {@link readServedObject}, as this caller is served it ({@link servedToCaller}). */
701719
const readObjectForCaller = async (name: string): Promise<any> => {
702720
const def: any = await readServedObject(name);
@@ -964,8 +982,12 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon
964982
// that credential (a `sys_api_key` write under an API key),
965983
// marked with that gate's own code and sentence.
966984
const refusal = actionExec.apiForwardCredentialRefusal(context, action);
985+
// [ADR-0106 D1] Summarised against the object as this caller is
986+
// served it, and not listed where `/meta` withholds it.
987+
const served = await servedActionObject(action, obj);
988+
if (!served) continue;
967989
out.push({
968-
...actionExec.summarizeAction(deps, action, obj, objectName, flow ?? undefined),
990+
...actionExec.summarizeAction(deps, action, served.obj, objectName, flow ?? undefined),
969991
...(refusal ? { unavailable: { code: refusal.code, reason: refusal.message } } : {}),
970992
});
971993
}

0 commit comments

Comments
 (0)