Skip to content

Commit e1dac19

Browse files
fix(mcp, runtime)!: MCP describe_object answers the same field-masked object schema the /meta door answers (ADR-0106) (#31042)
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc Clause-②: no (narrowing) — the MCP object-schema reads now serve a caller only the fields the `/meta` object door 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 it does 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. Metadata-protocol: none Skills-docs: no move — `skills/**` teaches no field set for `describe_object`; `content/docs/ai/connect-mcp.mdx` states that schema discovery is covered by field-level security. ## 维护者速读 - **改了什么**:MCP 的 `describe_object`、`list_objects` 的 `fieldCount`、`validate_expression` 的可用字段,以及 stdio 的 `objectstack://objects` 资源,现在与 `/meta` 对象门走同一套 ADR-0106 字段级遮蔽,同一调用者看到同一组字段。 - **为什么改**:ADR-0106 D1 与 MCP 文档都承诺字段级安全在每个平面一致生效;本 PR 让 MCP 的对象结构读取兑现这一承诺。 - **风险与回滚**:对带 `readable: false` 字段的调用者是收窄(标题带 `!`);字段可见性无法评估、或遮蔽后对象无字段时,按 `/meta` 的方式拒绝(`FIELD_VISIBILITY_UNRESOLVED` / 503)。无限制调用者、D4 豁免者与无 security 服务的部署逐字节不变。回滚即 revert 本 PR。 - **先看**:`packages/runtime/src/domains/mcp.ts`(`servedToCaller`)、`packages/mcp/src/stdio-data-bridge.ts`(`callerObjectView`)、`packages/mcp/src/plugin.ts`(stdio 资源视图接线)。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run src/domains/mcp-object-schema-mask.test.ts`,预期 `Tests 17 passed (17)`。 - **需要你做的**:无,席位落地。 ## What changes - **HTTP bridge** (`packages/runtime/src/domains/mcp.ts`). Every object definition the bridge serves to a caller (`describeObject`, `listObjects`' `fieldCount`, `optionFieldsOf`) now passes the `/meta` door's own masker: `resolveObjectMasker` / `maskObjectSchema`, newly exported from `packages/runtime/src/domains/meta.ts` for this purpose. A refused projection throws a coded error (`FIELD_VISIBILITY_UNRESOLVED`, 503) that the tools render as their usual `{ error: { code, message, status } }`. The action collection and exposure gates keep their unmasked reads. - **stdio bridge** (`packages/mcp/src/stdio-data-bridge.ts`). A new `callerObjectView` wraps the object view with `resolveObjectSchemaMaskPosture` → `relateObjectSchemaMaskPosture` → `applyObjectSchemaMask` (`@objectstack/metadata-core`), asking the host's `security` service about the API-key principal, resolved on each read. `listObjects`, `describeObject` and `optionFieldsOf` read through it. The query verb's sort check keeps the unmasked view. - **stdio resources** (`packages/mcp/src/plugin.ts`, `packages/mcp/src/mcp-server-runtime.ts`). When the plugin binds a stdio principal, the `objectstack://objects` resources read the same masked view. A coded refusal from the view is answered as `{ error, code, status }`, and any other throw still propagates. - **Dependency.** `@objectstack/mcp` now depends on `@objectstack/metadata-core` at runtime; it was a dev dependency. `pnpm-lock.yaml` moves the one entry. - **Docs.** `content/docs/ai/connect-mcp.mdx` adds one sentence to the security model saying that schema discovery is covered by it. - **Changeset.** `.changeset/mcp-describe-object-field-mask.md` (patch, `@objectstack/mcp` and `@objectstack/runtime`). ## Tests - `packages/runtime/src/domains/mcp-object-schema-mask.test.ts` (17). The first half runs the shared `OBJECT_SCHEMA_MASK_CASES` table over the HTTP bridge. The second half runs the real stack (ObjectQL + SQL driver + `SecurityPlugin` + the metadata protocol): it covers a member with one field denied, an unrestricted control, a D4 author, and a member denied every field, and compares `describe_object` with the `/meta/object/:name` item field set by field set. - `packages/mcp/src/stdio-data-bridge.object-schema-mask.test.ts` (16). It runs the same case table over the stdio `describe_object` / `list_objects` / `validate_expression` behind the real tool registration, and an `MCPServerPlugin` composition that covers both tools and both resources. - `packages/qa/dogfood/test/showcase-mcp-describe-object-field-mask.dogfood.test.ts` (3). On the showcase stack with real keys, the denied field is absent from REST `/meta`, from HTTP and stdio `describe_object` and from the stdio resource, all with one field set. A member denied every field is refused 503 `FIELD_VISIBILITY_UNRESOLVED` on both transports. The unrestricted control is answered byte for byte. - **Ablations.** Each was committed first, then mutated through `scripts/ablation-replace.mjs`, rebuilt, checked with `ablation-dist-preflight` and restored to the HEAD blob. Removing the HTTP mask call turns 7 runtime cases and 2 dogfood cases red. Removing the stdio projection turns 5 mcp cases and 2 dogfood cases red. Both restore legs are green. ## Acceptance notes - Field-level masking only: lacking object read is not turned into a whole-object refusal. - The runtime dispatcher's own `/meta` object door refuses the same unresolvable projection with code `SERVICE_UNAVAILABLE` (503), while the REST `/meta` door and these MCP tools use `FIELD_VISIBILITY_UNRESOLVED`. That door is not changed here. - The stdio object reads now resolve the API-key principal on each call, as the data verbs already do. ## Verification <details> <summary>Package suites, typecheck, dogfood</summary> - `pnpm --filter @objectstack/mcp test`: Test Files 58 passed, Tests 706 passed - `pnpm --filter @objectstack/runtime test`: Test Files 551 passed, Tests 8833 passed, 19 skipped - `pnpm --filter @objectstack/metadata-core test`: Test Files 24 passed, Tests 928 passed - `turbo run typecheck` over mcp, runtime, metadata-core and their direct consumers (example-todo, cli, dogfood, http-conformance, verify, example-crm, example-showcase, hono, client, cloud-connection, plugin-dev): 85 tasks; dogfood re-run green after its last test-typing commit - Full dogfood battery under the verify lock (`pnpm --filter @objectstack/dogfood test`): Test Files 429 passed, 3 skipped; Tests 3563 passed, 41 skipped; lock VERDICT command-exit 0 </details> <details> <summary>Repo gates</summary> - `node scripts/pm/dispatch-gates.mjs --ran`: 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN - `node scripts/check-nul-bytes.mjs`: exit 0 - `node scripts/pm/check-governed-merges.mjs --branch origin/claude/sec-hardening-r13`: NOT governed, 1240 changed lines </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 115a43a commit e1dac19

14 files changed

Lines changed: 1233 additions & 25 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/mcp': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
fix(mcp, runtime)!: MCP describe_object answers the same field-masked object schema the /meta door answers (ADR-0106)
8+
9+
Clause-②: no (narrowing) — the MCP object-schema reads now serve a caller only the fields the `/meta` object door 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 it does 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.
10+
Metadata-protocol: none
11+
Skills-docs: no move — `skills/**` teaches no field set for `describe_object`; `content/docs/ai/connect-mcp.mdx` states that schema discovery is covered by field-level security.
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema, no export and no stored shape changes: the MCP tools answer a field-restricted caller the field set the metadata door already serves them, with a code and status already on the wire. There is nothing for a migration to rewrite and no tombstone. -->
14+
15+
**BREAKING** for a field-restricted caller of the MCP object-schema reads, on both transports (`/api/v1/mcp` and the stdio server).
16+
17+
- **Now.** `describe_object`, `list_objects`' `fieldCount`, the fields `validate_expression` puts in scope (and its `label()` option-field check) and, on the stdio server, the `objectstack://objects` resources serve each object through the ADR-0106 posture and projection the `/meta` doors run (`resolveObjectSchemaMaskPosture` → `relateObjectSchemaMaskPosture` → `applyObjectSchemaMask`, `@objectstack/metadata-core`), for the same caller: a field the caller cannot read is not listed, and the D4 exemptions, the D6 tiers and the D8 switch (`OS_ALLOW_UNMASKED_OBJECT_METADATA`) are the shared posture's. The HTTP bridge asks the `/meta` door's own masker (`resolveObjectMasker` / `maskObjectSchema`, `@objectstack/runtime`); the stdio bridge asks the host's `security` service about its API-key principal.
18+
- **Refusal.** Where the caller's field visibility cannot be evaluated, or the projection would leave the object with no field, the read is refused as the `/meta` door refuses it: `FIELD_VISIBILITY_UNRESOLVED` / 503, rendered as the tools' coded error (`{ error: { code, message, status } }`) and, on a stdio object resource, as `{ error, code, status }`. One object that cannot be projected refuses `list_objects`, as the `/meta` object list does.
19+
- **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. The data verbs, their gates and `list_actions` / `run_action` are untouched. The stdio object reads now resolve the API-key principal on each call, as the data verbs already do, so a revoked key is refused there too.
20+
- **`@objectstack/mcp`** now depends on `@objectstack/metadata-core` at runtime (it was a dev dependency).
21+
- **`@objectstack/spec`**: the error-code ledger lists `FIELD_VISIBILITY_UNRESOLVED` under `@objectstack/runtime` and `@objectstack/mcp` as well, its two new emitters. The code itself was already registered.

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,8 +361,12 @@ drive this MCP server. Same word, different layer.
361361
- **Every call runs as the caller.** The MCP bridge resolves the same
362362
`ExecutionContext` as a REST request, so RBAC, row-level security, and
363363
field-level security apply to the agent exactly as they do to a person in
364-
the Console. Sparse results or denied writes usually mean governance is
365-
*working*, not that the connection is broken.
364+
the Console. Schema discovery is included: `describe_object`,
365+
`list_objects`' `fieldCount` and the fields `validate_expression` puts in
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
368+
writes usually mean governance is *working*, not that the connection is
369+
broken.
366370
- **OAuth scopes narrow the toolset.** Tokens carry `data:read`,
367371
`data:write`, and `actions:execute` scopes — tools outside the granted
368372
scopes are not even registered for that session. API-key and session

‎packages/mcp/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@
3030
"@modelcontextprotocol/sdk": "^1.30.1",
3131
"@objectstack/core": "workspace:*",
3232
"@objectstack/formula": "workspace:*",
33+
"@objectstack/metadata-core": "workspace:*",
3334
"@objectstack/spec": "workspace:*",
3435
"@objectstack/types": "workspace:*",
3536
"zod": "^4.6.6"
3637
},
3738
"devDependencies": {
3839
"@objectstack/lint": "workspace:*",
39-
"@objectstack/metadata-core": "workspace:*",
4040
"@types/node": "^26.6.5",
4141
"typescript": "^6.0.3",
4242
"vitest": "^4.1.11"

‎packages/mcp/src/mcp-server-runtime.ts‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -877,6 +877,23 @@ export function setRuntimeObjectView(runtime: MCPServerRuntime, objects: McpObje
877877
runtimeObjectViews.set(runtime, objects);
878878
}
879879

880+
/**
881+
* [ADR-0106] A coded refusal an object view raised (a schema whose field
882+
* visibility could not be evaluated, refused by the stdio host's caller view),
883+
* in the `{ error, code, status }` vocabulary the object resources answer a
884+
* miss and an outage with; `undefined` for a throw with no code. `details` is
885+
* withheld, as on the data tools.
886+
*/
887+
function objectResourceRefusal(err: unknown): { error: string; code: string; status?: number } | undefined {
888+
const envelope = thrownRefusalEnvelope(err);
889+
if (!envelope) return undefined;
890+
return {
891+
error: envelope.message,
892+
code: envelope.code,
893+
...(envelope.status !== undefined ? { status: envelope.status } : {}),
894+
};
895+
}
896+
880897
/**
881898
* [#6055] Resolve the `objectstack://objects/{objectName}` answer for one call.
882899
*
@@ -906,7 +923,18 @@ export async function buildObjectSchemaResource(
906923

907924
// [#25245] The object from `objects` ({@link McpObjectView}); the verdict
908925
// on a miss is still the metadata service's.
909-
const objectDef = await objects.getObject(objectName);
926+
// [ADR-0106] A coded refusal from the view (the stdio host's caller view
927+
// refuses a schema whose field visibility it could not evaluate) answers
928+
// in this resource's `{ error, code, status }` vocabulary
929+
// ({@link objectResourceRefusal}); an uncoded throw travels, as before.
930+
let objectDef: unknown;
931+
try {
932+
objectDef = await objects.getObject(objectName);
933+
} catch (err) {
934+
const refusal = objectResourceRefusal(err);
935+
if (refusal) return body(refusal);
936+
throw err;
937+
}
910938

911939
if (!objectDef) {
912940
const { degraded, errors } = await diagnoseEmptyRead(metadataService, 'object', objectName);
@@ -997,7 +1025,17 @@ export async function buildObjectListResource(
9971025
): Promise<ObjectSchemaResourceResult> {
9981026
// [#25245] The items from `view` ({@link McpObjectView}); the verdict below
9991027
// is still the metadata service's.
1000-
const objects = await view.listObjects();
1028+
// [ADR-0106] …and a coded refusal from it answers as on the schema resource.
1029+
let objects: unknown[];
1030+
try {
1031+
objects = await view.listObjects();
1032+
} catch (err) {
1033+
const refusal = objectResourceRefusal(err);
1034+
if (!refusal) throw err;
1035+
return {
1036+
contents: [{ uri: 'objectstack://objects', mimeType: 'application/json', text: JSON.stringify(refusal) }],
1037+
};
1038+
}
10011039
const summary = ((objects ?? []) as ObjectDef[]).map(o => ({
10021040
name: o.name,
10031041
label: o.label ?? o.name,

‎packages/mcp/src/plugin.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ import { MCPServerRuntime, setRuntimeObjectView } from './mcp-server-runtime.js'
3636
import { PACKAGE_VERSION, DEFAULT_SERVER_VERSION } from './package-version.js';
3737
import type { MCPServerRuntimeConfig, McpMergedMetadataRead } from './mcp-server-runtime.js';
3838
import type { ToolRegistry } from './types.js';
39+
import type { ObjectSchemaMaskSecuritySurface } from '@objectstack/metadata-core';
3940
import {
41+
callerObjectView,
4042
createStdioDataBridge,
4143
enforceApiExposure,
4244
GATED_ACTIONS,
@@ -458,6 +460,21 @@ export class MCPServerPlugin implements Plugin {
458460
return undefined;
459461
}
460462
};
463+
// [ADR-0106] The `security` service the object-schema mask asks for the
464+
// stdio principal's readable fields — resolved at each use, as the
465+
// protocol is, and `undefined` on a host that registers none (no
466+
// field-level posture there, so the schema is served as declared).
467+
const securityReads = (): ObjectSchemaMaskSecuritySurface | undefined => {
468+
try {
469+
return ctx.getService<ObjectSchemaMaskSecuritySurface>('security') ?? undefined;
470+
} catch {
471+
return undefined;
472+
}
473+
};
474+
// [ADR-0106 D1/D5] The stdio principal, once this transport has one: the
475+
// object resources below serve their schemas to it through the same mask
476+
// `list_objects` / `describe_object` do.
477+
let schemaPrincipal: (() => Promise<ExecutionContext>) | undefined;
461478

462479
// ── stdio auto-start decision (opt-in, its OWN switch) ──
463480
// Deliberately stricter than the HTTP-surface default (`isMcpServerEnabled`,
@@ -729,6 +746,7 @@ export class MCPServerPlugin implements Plugin {
729746
metadataService,
730747
protocol: protocolReads,
731748
resolvePrincipal,
749+
security: securityReads,
732750
keyedDigest: storedHashDigest,
733751
});
734752
} else {
@@ -791,6 +809,7 @@ export class MCPServerPlugin implements Plugin {
791809
storedHashDigest(),
792810
);
793811
};
812+
schemaPrincipal = resolvePrincipal;
794813
ctx.logger.info(
795814
`[MCP] stdio transport principal-bound to OS_MCP_STDIO_API_KEY identity ${initial.userId} (RLS/FLS/tenant applied)`,
796815
);
@@ -801,7 +820,17 @@ export class MCPServerPlugin implements Plugin {
801820
// `describe_object` answer from (`stdioObjectView`), never the metadata
802821
// service alone: on a kernel with an `environmentId` that service holds
803822
// no copy of an object a package registered during the boot.
804-
setRuntimeObjectView(this.runtime, stdioObjectView({ metadataService, protocol: protocolReads }));
823+
// [ADR-0106 D1/D5] …served to the stdio principal through the
824+
// object-schema mask (`callerObjectView`), the view the tools read, so
825+
// a resource never names a field the tools withhold. With no principal
826+
// (this process starts no stdio transport) the view is the bare one.
827+
const objectView = stdioObjectView({ metadataService, protocol: protocolReads });
828+
setRuntimeObjectView(
829+
this.runtime,
830+
schemaPrincipal
831+
? callerObjectView(objectView, { resolvePrincipal: schemaPrincipal, security: securityReads })
832+
: objectView,
833+
);
805834
this.runtime.bridgeResources(metadataService, getRecord);
806835
// Awaited: the prompt bridge reads `skill` metadata to project each
807836
// skill's instructions onto an MCP prompt (#3905), so the surface must be

0 commit comments

Comments
 (0)