Skip to content

Commit 3ddd3d0

Browse files
fix(mcp)!: the MCP stdio engine-only reader joins the stored-metadata-body family (exit one) (#21228)
Part of #21207 Clause-②: no (narrowing) This PR delivers **exit one** of #21207 only. Exit two (the served content hash) stopped at the claim's stop clause before any code was written; the reasons are below, and #21207 remains open for it. ## What this does (exit one) The MCP stdio transport reads stored metadata rows through the engine only: the stdio bridge's query, get and aggregate verbs, and the record resource. The engine returns a stored metadata body as stored, so an administrator's key was served credential material in cleartext from the stored-row table and the version-history table. Both read paths now join the stored-metadata-body family: - **Serve the projection.** Rows of the two stored-metadata tables are served through the family's one projection (`redactStoredMetadataRow` in `@objectstack/spec/kernel`), the same projection the generic data door and every metadata read use. Stored credential material is withheld, a body that cannot be judged is omitted (fail-closed), and a credential-free body is served byte-identical. No second projection: this package does not depend on `@objectstack/metadata-protocol`, and the projection lives in spec. - **Refuse the evaluate shapes.** A group, filter, sort or aggregate member on the stored body column is refused with `INVALID_FIELD` / 400 before the engine runs, the data door's code and envelope (the analytics door precedent: a door-local refusal built on the spec primitives). - **The family pin.** `stored-metadata-body-family.pin.test.ts` gains two rows for this door (seam and refusal), owned by `@objectstack/mcp`. The new per-package pin classifies every stdio bridge member and enumerates every engine read call site in `packages/mcp/src` on the syntax tree, so a future reader on this transport fails its pin instead of joining silently. Files: `packages/mcp/src/stdio-data-bridge.ts`, `packages/mcp/src/plugin.ts` (record resource), the new `packages/mcp/src/stdio-data-bridge.stored-metadata-body.test.ts`, the family pin, and `.changeset/21207-mcp-stdio-stored-metadata-body.md` (`minor`, breaking narrowing under the launch-window convention, ADR-0087 `not-required (no-migration-prescription)`). ## Before / after (live boot, administrator vs member, classes and statuses only) Measured on a real boot (showcase, security, automation, SQLite), with real API keys resolved the way the stdio door resolves them. Before = `origin/main` at `097ef80270`; after = this branch with the rebuilt `@objectstack/mcp`. | Door on the MCP stdio transport | Body class | Administrator before | Administrator after | Member before | Member after | |---|---|---|---|---|---| | Record resource (resources/read), stored-row table | flow body with credential | served, credential in cleartext | served as projection, no credential | refused (engine `PERMISSION_DENIED`), no row | same | | Record resource and bridge get / query, both tables | datasource body with credential (historical stock) | served, credential in cleartext | served as projection, no credential | refused (`PERMISSION_DENIED`) | same | | Bridge get / query, both tables | flow body with credential | served, credential in cleartext | served as projection, no credential | refused (`PERMISSION_DENIED`) | same | | All of the above | credential-free body (control) | served | served byte-identical | refused | same | | Bridge query: filter on the body column | flow, datasource | answered a guess about the credential (match vs no match) | refused `INVALID_FIELD` / 400, engine not asked | refused `PERMISSION_DENIED` | refused `INVALID_FIELD` / 400 | | Bridge query: sort; bridge aggregate: group, filter | flow, datasource | served, body in the answer | refused `INVALID_FIELD` / 400 | refused `PERMISSION_DENIED` | refused `INVALID_FIELD` / 400 | | Bridge aggregate: member over the body | flow, datasource | refused by the engine (`INVALID_FIELD` / 400) | refused here first (`INVALID_FIELD` / 400) | refused | refused | | Tools `query_records` / `get_record` | any | refused by the tool layer's system-object guard | unchanged | same | same | Datasource bodies: the save door refuses a credential in a datasource body (measured `422`), so cleartext at rest is historical stock written before that refusal; it was measured on such rows. It is affected exactly as the flow body was, by construction and now by measurement. ## Exit two: stopped at the claim's stop clause (not built) Measured live, unchanged by this PR: the stored content hash served on this door and on the generic data door is an unkeyed sha256 over the whole stored body, credential included, and it equals the save receipt's version. The claim asked to list every consumer that compares these hashes and where the server-held key comes from, and to stop if any consumer compares across deployments or if the key needs a new secret. The key source answers that stop clause: - **No existing keyed primitive reaches the repository.** The crypto provider contract offers encrypt, decrypt, rotate and an audit digest; the contract allows that digest to be plain SHA-256, and the local provider's is unkeyed. The provider is attached to the engine after the runtime starts and is not exposed to the metadata repository, while metadata saves already run during boot. - So a keyed hash needs either a **new secret**, or a **new keyed method on the crypto provider contract** (a `packages/spec` contract widening that every provider must implement) together with a boot-ordering change. - Beyond the key, the change reaches outside the declared surface: the repository contract's canonical-hashing invariant and the shared contract suite (`packages/metadata-core`), the spec's description of the history checksum as SHA-256, the stored-row checksum column's length bound, and a second producer of the same columns in `packages/metadata`. - Cross-deployment comparison: none was found in this repository; the cloud repository was not measured. The full consumer list, the coexistence analysis for existing rows and parent links, and the options are in the os-dev report on #21207. ## Tests and verification (head `ae754f9e3a`) - `@objectstack/mcp`: `pnpm test` 33 files / 366 tests passed; `pnpm typecheck` exit 0 (the new test is in the test-layer program, no debt added). - `@objectstack/metadata-protocol`: `vitest run` 200 files passed, 3 skipped / 2973 tests passed, 19 skipped; `pnpm typecheck` exit 0. - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands` derived 61 commands at `ae754f9e3a`; all 61 were run, each exit 0, and `--ran` reconciled 61 derived / 61 run / 0 not measured. - `pnpm lint` (repo-wide, `eslint . --no-inline-config`): exit 0 at `ae754f9e3a`. - Ablations (fix committed first, mutation through `scripts/ablation-replace.mjs`, restore proven against `HEAD`): - A1, projection removed: 5 administrator no-credential pins red (query, get, unjudgeable body, body-only projection, record resource); 17 green, controls included. - A2, evaluate refusal removed: all 9 refusal pins red; 13 green, the scalar-column control included. - A3, an extra engine read site added behind a cast: the call-site enumeration red; 21 green. History, stated rather than hidden: the first A3 attempt was a no-op (the replacement kept the anchor, so the tool refused it and nothing ran); re-run with a different anchor, it stayed green against the then name-based scan, which is why that scan was replaced by the syntax-tree one. The reading above is against the final scan. - A4, an unclassified bridge member added: the member classification red; 21 green. Its first attempt was likewise a refused no-op, re-run with a different anchor. ## Acceptance notes - The record resource is the reachable public door for exit one on today's stdio composition. The `query_records` / `get_record` / `aggregate_records` tools refuse the stored-metadata tables through the tool layer's system-object guard (default on). The bridge itself is now safe regardless of that guard. - Pin half not met: exit one still serves the stored hash column as stored, so "no unkeyed hash" on this door waits on the exit-two decision. This PR does not narrow the column on one door alone, since the ruling chose a keyed hash over a narrowed one. - This door judges a dotted path into the body column by its head segment. The data door's refusal matches the exact column name; its behaviour on a dotted path was read from source, not measured. - For a member, the evaluate shapes now answer `INVALID_FIELD` / 400 before the engine's `PERMISSION_DENIED`. This is the data door's order; no row is served either way. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 62b90d7 commit 3ddd3d0

5 files changed

Lines changed: 805 additions & 9 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/mcp': minor
3+
---
4+
5+
fix(mcp)!: the MCP stdio transport serves a stored metadata body only as its type's read projection, and refuses to evaluate it
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) the MCP stdio transport's engine-only reader (its query, get and aggregate verbs and its record resource) now serves a stored metadata body through the stored-metadata-body family's one projection, and refuses a group, filter, sort or aggregate member on the stored body column — the posture the generic data door already takes. No authorable key, spelling, export or stored shape moves, and no stored row is read differently by any metadata consumer. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a served body projection or a refused query shape on this door (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
10+
11+
**BREAKING**: this narrows what one door serves and accepts for the two stored-metadata tables (the stored row and its version history). On the MCP stdio transport, a read now carries the stored body as its type's read projection instead of the stored bytes, and a call that would evaluate the stored body is refused. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
**What changes.**
14+
15+
- **Reads.** The stdio bridge's query and get verbs, and the record resource, serve the stored body through the same projection the generic data door and every metadata read serve: stored credential material is withheld, a body that cannot be judged is omitted, and a credential-free body is served unchanged.
16+
- **Evaluate shapes.** A group, filter, sort or aggregate member on the stored body column is refused with `400 INVALID_FIELD` before the engine runs — the data door's code and envelope.
17+
18+
**What stays answerable.** Every scalar column of the two tables is still served, filtered, sorted, grouped and counted; only the stored body column is affected, and every other object is unchanged. A member's read of these tables is refused as before.

‎packages/mcp/src/plugin.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ import type { IAIService, IDataEngine, IMetadataService } from '@objectstack/spe
2525
import { MCPServerRuntime } from './mcp-server-runtime.js';
2626
import type { MCPServerRuntimeConfig, McpMergedMetadataRead } from './mcp-server-runtime.js';
2727
import type { ToolRegistry } from './types.js';
28-
import { createStdioDataBridge, enforceApiExposure, GATED_ACTIONS } from './stdio-data-bridge.js';
28+
import {
29+
createStdioDataBridge,
30+
enforceApiExposure,
31+
GATED_ACTIONS,
32+
serveStoredMetadataRow,
33+
} from './stdio-data-bridge.js';
2934
import type { McpDataBridge } from './mcp-http-tools.js';
3035
import { CONNECT_AGENT_UI_BUNDLE } from './connect-ui.js';
3136

@@ -601,7 +606,12 @@ export class MCPServerPlugin implements Plugin {
601606
})) as unknown;
602607
const rows = res && (res as { value?: unknown }).value ? (res as { value: unknown }).value : res;
603608
const row = Array.isArray(rows) ? rows[0] : rows;
604-
return (row ?? null) as Record<string, unknown> | null;
609+
// [#21207] The stored-metadata-body family's one projection, applied by
610+
// the same helper `bridge.get` serves through: a `sys_metadata` /
611+
// `sys_metadata_history` row's body reaches this resource as its type's
612+
// read projection, never as the stored bytes — so the tool and the
613+
// resource cannot disagree about what a stored credential is.
614+
return serveStoredMetadataRow(objectName, (row ?? null) as Record<string, unknown> | null);
605615
};
606616
ctx.logger.info(
607617
`[MCP] stdio transport principal-bound to OS_MCP_STDIO_API_KEY identity ${initial.userId} (RLS/FLS/tenant applied)`,

0 commit comments

Comments
 (0)