diff --git a/.changeset/mcp-stdio-principal-admission.md b/.changeset/mcp-stdio-principal-admission.md new file mode 100644 index 0000000000..e653f556b7 --- /dev/null +++ b/.changeset/mcp-stdio-principal-admission.md @@ -0,0 +1,26 @@ +--- +'@objectstack/mcp': minor +--- + +feat(mcp)!: stdio transport requires an API-key principal — fail-closed, no unscoped bridge (ADR-0101, #3246) + +The long-lived MCP **stdio** transport no longer reads data unscoped. It now runs +under an env-supplied identity, closing the platform's last identity-less +execution surface (the `mcp-stdio-authority` conformance row graduates +`experimental` → `enforced`). + +- `OS_MCP_STDIO_API_KEY=osk_...` supplies the stdio identity, resolved through + the SAME `@objectstack/core` verify + authorization chain as the HTTP/REST + surfaces; the `record_by_id` resource reads via `ql.find(obj, { where:{id}, + context })`, so RLS/FLS/tenant apply exactly as on REST `/data`. Re-resolved + per read, so a revoked/expired key stops working on a live session. +- **Fail-closed** — enabling stdio auto-start (`OS_MCP_STDIO_ENABLED=true` / + `autoStart`) without a resolvable key throws and refuses to start. There is no + unscoped fallback and deliberately no `system` bypass; full authority is a key + minted on a platform-admin or dedicated service identity. + +**BREAKING (stdio auto-start only):** previously `OS_MCP_STDIO_ENABLED=true` +(or the plugin `autoStart` option) started stdio with full, unscoped authority +and no credential. It now requires `OS_MCP_STDIO_API_KEY`; without it, boot +fails closed. The default-on HTTP surface and any deployment that never enables +stdio auto-start are unaffected. diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index cdb001936a..923e35fde6 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -226,15 +226,16 @@ The **HTTP surface** and the long-lived **stdio transport** are separate switche see the note below): ```bash -os start # HTTP MCP served at /api/v1/mcp by default -OS_MCP_SERVER_ENABLED=false os start # opt out of the HTTP MCP surface -OS_MCP_STDIO_ENABLED=true os start # additionally auto-start the local stdio transport +os start # HTTP MCP served at /api/v1/mcp by default +OS_MCP_SERVER_ENABLED=false os start # opt out of the HTTP MCP surface +OS_MCP_STDIO_ENABLED=true OS_MCP_STDIO_API_KEY=osk_... os start # start the local stdio transport as that key's identity ``` | Variable | Type | Default | Description | |:---|:---|:---|:---| | `OS_MCP_SERVER_ENABLED` | boolean | `true` | The MCP **HTTP** surface (`/api/v1/mcp`) is a core capability and defaults **on**. Set `false` to disable it (endpoint 404s, the Connect-an-Agent page disappears). | -| `OS_MCP_STDIO_ENABLED` | boolean | `false` | Auto-start the long-lived **stdio** transport at boot. Opt-in and **stricter** than the HTTP surface: stdio bridges the raw services with no per-request principal, so it is unscoped — safe only as a single-operator local tool. Leave off unless a local client spawns the process directly. | +| `OS_MCP_STDIO_ENABLED` | boolean | `false` | Auto-start the long-lived **stdio** transport at boot. Opt-in and **stricter** than the HTTP surface. **Requires `OS_MCP_STDIO_API_KEY`** — stdio runs as that key's identity with RLS/FLS/tenant applied; if the key is missing or invalid, boot **fails closed** (stdio refuses to start). See ADR-0101. | +| `OS_MCP_STDIO_API_KEY` | string | — | The `osk_...` API key the **stdio** transport runs as. Resolved through the same verify chain as the HTTP/REST surfaces, so reads are scoped to that identity's permissions. Mint one from **Setup → Connect an Agent** (or `POST /api/v1/keys`). For full authority, mint a key on a platform-admin or dedicated **service** identity — there is deliberately no `system`/unscoped bypass. | | `OS_MCP_SERVER_NAME` | string | `objectstack` | Server name advertised to MCP clients. | | `OS_MCP_SERVER_TRANSPORT` | enum | `stdio` | `stdio` \| `http`. Use `http` (Streamable HTTP) for a remote client; `stdio` for a local one. | diff --git a/docs/adr/0101-mcp-stdio-principal-admission.md b/docs/adr/0101-mcp-stdio-principal-admission.md index d2103d2d06..2c23849fb7 100644 --- a/docs/adr/0101-mcp-stdio-principal-admission.md +++ b/docs/adr/0101-mcp-stdio-principal-admission.md @@ -1,6 +1,6 @@ # ADR-0101: MCP stdio Principal Admission — env-supplied API-key identity, fail-closed, no system bypass -**Status**: Proposed (2026-07-19) +**Status**: Accepted (2026-07-19) — v1 landed with this acceptance (#3246): key→EC threading, fail-closed start, no `system` bypass; `mcp-stdio-authority` graduated `experimental` → `enforced` **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0096](./0096-execution-surface-identity-admission.md) (execution-surface identity admission — this ADR closes its last unadmitted transport), [ADR-0024](./0024-mcp-connectors.md) §4 (trust model), [ADR-0036](./0036-app-as-rest-api-and-mcp-server.md) (the app *as* an MCP server — the surface being admitted), [ADR-0099](./0099-posture-adjudicated-tiering-and-external-rung.md) (posture rides the `ExecutionContext` this ADR threads) **Composes with**: framework#3055 (consume-side declarative stdio **spawn** policy — the sibling trust decision: #3055 gates *who may start* a local MCP process from metadata; this ADR gates *as whom* our own stdio server reads data), [ADR-0097](./0097-declarative-connector-instances.md) diff --git a/packages/mcp/src/__tests__/mcp-server-runtime.test.ts b/packages/mcp/src/__tests__/mcp-server-runtime.test.ts index f3d6e2f41f..202dde3221 100644 --- a/packages/mcp/src/__tests__/mcp-server-runtime.test.ts +++ b/packages/mcp/src/__tests__/mcp-server-runtime.test.ts @@ -104,25 +104,6 @@ function createMockMetadataService() { }; } -function createMockDataEngine() { - const records: Record> = { - 'account:abc123': { id: 'abc123', name: 'Acme Corp', status: 'active' }, - 'contact:xyz789': { id: 'xyz789', first_name: 'John', last_name: 'Doe' }, - }; - - return { - find: vi.fn(async () => []), - findOne: vi.fn(async (objectName: string, options: any) => { - const recordId = options?.where?.id; - return records[`${objectName}:${recordId}`] ?? null; - }), - insert: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - count: vi.fn(async () => 0), - aggregate: vi.fn(async () => []), - }; -} function createMockLogger() { return { @@ -224,10 +205,10 @@ describe('MCPServerRuntime', () => { expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 3 resource endpoints'); }); - it('should bridge record resources when DataEngine is available', () => { + it('should bridge record resources when a principal-bound reader is provided', () => { const metadataService = createMockMetadataService(); - const dataEngine = createMockDataEngine(); - runtime.bridgeResources(metadataService as any, dataEngine as any); + const getRecord = async () => null; // principal-bound reader (ADR-0101) + runtime.bridgeResources(metadataService as any, getRecord); // Should register: object_list, object_schema, record_by_id, metadata_types (4 resources) expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 4 resource endpoints'); diff --git a/packages/mcp/src/__tests__/plugin.test.ts b/packages/mcp/src/__tests__/plugin.test.ts index c38e0b67ff..f5f97c070c 100644 --- a/packages/mcp/src/__tests__/plugin.test.ts +++ b/packages/mcp/src/__tests__/plugin.test.ts @@ -182,20 +182,6 @@ describe('MCPServerPlugin', () => { ); }); - it('should handle missing data engine gracefully', async () => { - const aiService = createMockAIService(); - const metadataService = createMockMetadataService(); - const ctx = createMockPluginContext({ ai: aiService, metadata: metadataService }); - - const plugin = new MCPServerPlugin(); - await plugin.init(ctx as any); - await plugin.start(ctx as any); - - expect(ctx.logger.debug).toHaveBeenCalledWith( - '[MCP] Data engine not available, skipping record resources', - ); - }); - it('should not auto-start when MCP_SERVER_ENABLED is not set', async () => { const ctx = createMockPluginContext(); @@ -219,6 +205,52 @@ describe('MCPServerPlugin', () => { }); }); + describe('stdio principal admission — fail-closed (ADR-0101)', () => { + beforeEach(() => { + delete process.env.OS_MCP_STDIO_ENABLED; + delete process.env.OS_MCP_STDIO_API_KEY; + }); + + it('refuses to start stdio when enabled without OS_MCP_STDIO_API_KEY', async () => { + const ctx = createMockPluginContext({ + metadata: createMockMetadataService(), + objectql: createMockDataEngine(), + }); + const plugin = new MCPServerPlugin({ autoStart: true }); + await plugin.init(ctx as any); + await expect(plugin.start(ctx as any)).rejects.toThrow(/OS_MCP_STDIO_API_KEY/); + }); + + it('refuses to start stdio when the objectql service is unavailable', async () => { + process.env.OS_MCP_STDIO_API_KEY = 'osk_test'; + const ctx = createMockPluginContext({ metadata: createMockMetadataService() }); // no objectql + const plugin = new MCPServerPlugin({ autoStart: true }); + await plugin.init(ctx as any); + await expect(plugin.start(ctx as any)).rejects.toThrow(/objectql/); + }); + + it('refuses to start stdio when the key does not resolve to an identity', async () => { + process.env.OS_MCP_STDIO_API_KEY = 'osk_unknown'; + // find() returns no rows → no sys_api_key match → no principal → no userId. + const ql = { find: vi.fn(async () => []) }; + const ctx = createMockPluginContext({ metadata: createMockMetadataService(), objectql: ql }); + const plugin = new MCPServerPlugin({ autoStart: true }); + await plugin.init(ctx as any); + await expect(plugin.start(ctx as any)).rejects.toThrow(/did not resolve to a valid identity/); + }); + + it('does NOT require a key when stdio is not enabled (HTTP surface only)', async () => { + // No autoStart, no OS_MCP_STDIO_ENABLED → shouldStart false → no key needed. + const ctx = createMockPluginContext({ metadata: createMockMetadataService() }); + const plugin = new MCPServerPlugin(); + await plugin.init(ctx as any); + await expect(plugin.start(ctx as any)).resolves.toBeUndefined(); + expect(ctx.logger.info).toHaveBeenCalledWith( + expect.stringContaining('[MCP] Transport not auto-started'), + ); + }); + }); + describe('destroy', () => { it('should clean up on destroy', async () => { const ctx = createMockPluginContext(); diff --git a/packages/mcp/src/mcp-server-runtime.ts b/packages/mcp/src/mcp-server-runtime.ts index e8da5dad5d..56de9f541d 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -3,7 +3,7 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; -import type { Logger, IMetadataService, IDataEngine, AIToolDefinition } from '@objectstack/spec/contracts'; +import type { Logger, IMetadataService, AIToolDefinition } from '@objectstack/spec/contracts'; import type { Agent } from '@objectstack/spec/ai'; import type { ToolRegistry, ToolExecutionResult } from './types.js'; import { registerObjectTools, registerActionTools } from './mcp-http-tools.js'; @@ -241,7 +241,10 @@ export class MCPServerRuntime { * - `objectstack://objects/{objectName}/records/{recordId}` — Get a specific record * - `objectstack://metadata/types` — List all metadata types */ - bridgeResources(metadataService: IMetadataService, dataEngine?: IDataEngine): void { + bridgeResources( + metadataService: IMetadataService, + getRecord?: (objectName: string, recordId: string) => Promise | null>, + ): void { const logger = this.config.logger; let resourceCount = 0; @@ -320,12 +323,19 @@ export class MCPServerRuntime { resourceCount++; // ── Template resource: Record by ID ── - if (dataEngine) { + // The ONE resource that reads ROW data, so it MUST run under a principal + // (ADR-0101): the caller supplies a principal-bound reader that applies + // RLS/FLS/tenant (e.g. `ql.find(obj, { where: { id }, context })`). Without + // one, the resource is NOT registered — there is deliberately no unscoped + // fallback. The long-lived stdio server reaches this only after the plugin + // resolved `OS_MCP_STDIO_API_KEY` to an identity; the reader re-resolves per + // call, so a revoked/expired key stops working on the next read. + if (getRecord) { this.mcpServer.registerResource( 'record_by_id', new ResourceTemplate('objectstack://objects/{objectName}/records/{recordId}', { list: undefined }), { - description: 'Get a specific record by ID from a data object', + description: 'Get a specific record by ID from a data object (under the caller\'s permissions and row-level security)', mimeType: 'application/json', }, async (_uri, variables) => { @@ -333,9 +343,7 @@ export class MCPServerRuntime { const recordId = String(variables.recordId); try { - const record = await dataEngine.findOne(objectName, { - where: { id: recordId }, - }); + const record = await getRecord(objectName, recordId); if (!record) { return { diff --git a/packages/mcp/src/plugin.ts b/packages/mcp/src/plugin.ts index 40361ece0b..112da1c5b6 100644 --- a/packages/mcp/src/plugin.ts +++ b/packages/mcp/src/plugin.ts @@ -1,13 +1,46 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; +import { resolveAuthzContext } from '@objectstack/core'; import { readEnvWithDeprecation, isMcpServerEnabled, resolveMcpStdioAutoStart } from '@objectstack/types'; -import type { IAIService, IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { IAIService, IMetadataService } from '@objectstack/spec/contracts'; import { MCPServerRuntime } from './mcp-server-runtime.js'; import type { MCPServerRuntimeConfig } from './mcp-server-runtime.js'; import type { ToolRegistry } from './types.js'; import { CONNECT_AGENT_UI_BUNDLE } from './connect-ui.js'; +/** + * Resolve `OS_MCP_STDIO_API_KEY` into an {@link ExecutionContext} through the + * SAME `@objectstack/core` verify + authorization chain the HTTP and REST + * surfaces use (`resolveApiKeyPrincipal` → `resolveAuthzContext`), so a stdio + * read is scoped exactly like the same identity over REST (RLS / FLS / tenant). + * + * Fail-closed: returns `undefined` for an unknown / revoked / expired / + * owner-less key (no `userId` resolved). Re-run per read, so revocation of a + * key takes effect on the next call of a live stdio session (ADR-0101 D1). + */ +async function resolveStdioExecutionContext( + ql: { find: (object: string, opts: unknown) => Promise }, + apiKey: string, +): Promise { + const authz = await resolveAuthzContext({ ql, headers: { 'x-api-key': apiKey } }); + if (!authz.userId) return undefined; + const ec: ExecutionContext = { + positions: authz.positions, + permissions: authz.permissions, + systemPermissions: authz.systemPermissions, + isSystem: false, + principalKind: 'human', + userId: authz.userId, + }; + if (authz.tenantId) ec.tenantId = authz.tenantId; + if (authz.email) ec.email = authz.email; + if (authz.posture) ec.posture = authz.posture; + (ec as unknown as { org_user_ids?: string[] }).org_user_ids = authz.org_user_ids; + return ec; +} + /** * Configuration options for the MCPServerPlugin. */ @@ -101,33 +134,18 @@ export class MCPServerPlugin implements Plugin { ctx.logger.debug('[MCP] AI service not available, skipping tool bridging'); } - // ── Bridge resources from MetadataService & DataEngine ── + // ── Metadata service for the resource bridge ── let metadataService: IMetadataService | undefined; - let dataEngine: IDataEngine | undefined; - try { metadataService = ctx.getService('metadata'); } catch { ctx.logger.debug('[MCP] Metadata service not available, skipping resource bridging'); } - try { - dataEngine = ctx.getService('data'); - } catch { - ctx.logger.debug('[MCP] Data engine not available, skipping record resources'); - } - - if (metadataService) { - this.runtime.bridgeResources(metadataService, dataEngine); - this.runtime.bridgePrompts(metadataService); - } - - // ── Auto-start if configured ── + // ── stdio auto-start decision (opt-in, its OWN switch) ── // Deliberately stricter than the HTTP-surface default (`isMcpServerEnabled`, - // default-on): start() attaches a long-lived transport — for stdio that - // means claiming the process's stdin/stdout AND bridging the RAW services - // with no per-request principal (unscoped — see the mcp-stdio-authority - // conformance row) — so it stays opt-in via a SEPARATE switch + // default-on): start() attaches a long-lived transport claiming the + // process's stdin/stdout, so it stays opt-in via a SEPARATE switch // (`OS_MCP_STDIO_ENABLED` / the `autoStart` option), never the HTTP var. // The HTTP surface does not depend on this: the runtime dispatcher serves // `/api/v1/mcp` per-request regardless. @@ -138,6 +156,69 @@ export class MCPServerPlugin implements Plugin { '[MCP] Starting the stdio transport via OS_MCP_SERVER_ENABLED=true is DEPRECATED — that var now only gates the default-on HTTP surface. Use OS_MCP_STDIO_ENABLED=true (or the plugin `autoStart` option) for the long-lived stdio transport.', ); } + + // ── Principal-bound record reader for the stdio transport (ADR-0101) ── + // The long-lived stdio server reads ROW data only under an env-supplied + // API-key identity, resolved through the same @objectstack/core chain as the + // HTTP/REST surfaces (RLS/FLS/tenant apply). FAIL-CLOSED: stdio auto-start + // without a resolvable key REFUSES to start — no unscoped fallback, and no + // `system`/identity-skipping bypass. Full authority = an admin/service key. + let getRecord: + | ((objectName: string, recordId: string) => Promise | null>) + | undefined; + if (shouldStart) { + const apiKey = readEnvWithDeprecation('OS_MCP_STDIO_API_KEY', [], { silent: true }); + let ql: { find: (object: string, opts: unknown) => Promise } | undefined; + try { + ql = ctx.getService('objectql'); + } catch { + ql = undefined; + } + if (!apiKey) { + throw new Error( + '[MCP] The stdio transport is enabled (OS_MCP_STDIO_ENABLED / autoStart) but OS_MCP_STDIO_API_KEY is not set. ' + + 'stdio must run under a real identity — mint an API key (Setup → Connect an Agent, or POST /api/v1/keys) and set ' + + 'OS_MCP_STDIO_API_KEY=osk_.... Refusing to start an unscoped stdio server (ADR-0101).', + ); + } + if (!ql || typeof ql.find !== 'function') { + throw new Error( + '[MCP] The stdio transport requires the objectql data service to resolve its principal, but it is not available. ' + + 'Refusing to start (ADR-0101).', + ); + } + // Validate the key up-front (fail-closed) before attaching the transport. + const initial = await resolveStdioExecutionContext(ql, apiKey); + if (!initial) { + throw new Error( + '[MCP] OS_MCP_STDIO_API_KEY did not resolve to a valid identity (unknown / revoked / expired / owner-less). ' + + 'Refusing to start stdio (ADR-0101).', + ); + } + const scopedQl = ql; + // Re-resolve per call so a revoked/expired key stops working on the next read. + getRecord = async (objectName, recordId) => { + const ec = await resolveStdioExecutionContext(scopedQl, apiKey); + if (!ec) throw new Error('MCP stdio identity is no longer valid (key revoked or expired)'); + const res = (await scopedQl.find(objectName, { + where: { id: recordId }, + limit: 1, + context: ec, + })) as unknown; + const rows = res && (res as { value?: unknown }).value ? (res as { value: unknown }).value : res; + const row = Array.isArray(rows) ? rows[0] : rows; + return (row ?? null) as Record | null; + }; + ctx.logger.info( + `[MCP] stdio transport principal-bound to OS_MCP_STDIO_API_KEY identity ${initial.userId} (RLS/FLS/tenant applied)`, + ); + } + + if (metadataService) { + this.runtime.bridgeResources(metadataService, getRecord); + this.runtime.bridgePrompts(metadataService); + } + if (shouldStart) { await this.runtime.start(); ctx.logger.info('[MCP] Server started automatically'); diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 8586556022..b949961721 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -100,9 +100,10 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ covers: ['mcp:http-dispatcher.ts:handleMcp', 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)'], proof: 'showcase-mcp-http-identity.dogfood.test.ts', note: 'The per-request principal-bound tool server is isolated from the long-lived UNSCOPED stdio server (see mcp-stdio-authority). HIGH-RISK, proven end-to-end (#3167 PR-B): the proof boots the real showcase + security + MCP plugin and drives POST /api/v1/mcp — an anonymous tools/call is 401 before any tool runs, and a member\'s query_records over the owner-private showcase_private_note returns ONLY their own rows (if the tool ran unscoped/system — the stdio posture — the other owner\'s rows would leak). Dropping the buildMcpBridge(context) threading (or building an unscoped/system bridge for HTTP) makes the context-threaded key STALE → red CI; a new sibling MCP data handler appears as an UNCLASSIFIED surface until a row covers it. Dispatcher-level unit coverage: http-dispatcher.mcp.test.ts (401, EC-to-bridge) + http-dispatcher.mcp-oauth.test.ts (scope 403).' }, - { id: 'mcp-stdio-authority', summary: 'MCP stdio transport runs UNSCOPED — the long-lived server bridges the raw metadata service + data engine with no per-request principal (opt-in: autoStart / OS_MCP_STDIO_ENABLED=true)', state: 'experimental', - covers: ['mcp:plugin.ts:bridgeResources(unscoped-stdio)'], - note: 'Surface posture: process-authority, opt-in. Unlike the HTTP path (mcp-http-identity), MCPServerPlugin.start() bridges resources/tools onto the long-lived this.mcpServer from the RAW metadata service + data engine (bridgeResources(metadataService, dataEngine)) — there is no ExecutionContext, so a stdio-attached client reads metadata + records with full, unscoped authority (no RLS/FLS/tenant). This is safe ONLY as a single-operator LOCAL tool: the operator who can attach stdio already owns the process and its dev database, so the transport grants nothing they lack. It is deliberately NOT default: the shouldStart gate in plugin.ts (resolveMcpStdioAutoStart) keeps stdio opt-in behind its OWN switch (OS_MCP_STDIO_ENABLED / autoStart), stricter than and decoupled from the default-on HTTP surface (#3167: the legacy OS_MCP_SERVER_ENABLED=true trigger still starts stdio for one release but now warns — it used to overload the HTTP var into silently attaching this unscoped transport). ADMISSION REQUIREMENT before stdio is ever promoted to default-on OR served in a multi-user / hosted context: thread a principal (a configured service identity, or a per-session ExecutionContext) into the long-lived bridge, mirroring the HTTP path\'s buildMcpBridge. Changing the raw-service bridging makes the unscoped-stdio key STALE → forces re-classification in CI.' }, + { id: 'mcp-stdio-authority', summary: 'MCP stdio transport admits an env-supplied API-key principal — RLS/FLS/tenant applied to record reads, fail-closed on a missing/invalid key, no `system` bypass (opt-in: autoStart / OS_MCP_STDIO_ENABLED=true + OS_MCP_STDIO_API_KEY)', state: 'enforced', + enforcement: 'mcp/plugin.ts start() — when stdio auto-start is requested it resolves OS_MCP_STDIO_API_KEY through the SAME @objectstack/core chain as HTTP/REST (resolveStdioExecutionContext → resolveAuthzContext → resolveApiKeyPrincipal), builds the caller ExecutionContext, and threads it (re-resolved per call) into the record-reader passed to mcp-server-runtime.ts bridgeResources; record_by_id reads via ql.find(obj, { where:{id}, context }) so RLS/FLS/tenant apply exactly as on REST /data. FAIL-CLOSED: no key / no objectql / an unknown|revoked|expired|owner-less key throws and refuses to start stdio — there is no unscoped fallback and deliberately no OS_MCP_STDIO_IDENTITY=system bypass (full authority = a minted admin/service key; see ADR-0101).', + covers: ['mcp:plugin.ts:stdio-principal-bound'], + note: 'ADR-0101. Unlike the raw pre-ADR-0101 bridge (which fed the long-lived server the RAW metadata service + data engine with no ExecutionContext), record_by_id now reads only under a principal resolved from OS_MCP_STDIO_API_KEY, re-resolved per call so a revoked/expired key stops working live. Unit-proven in @objectstack/mcp plugin.test.ts (fail-closed: no key / no objectql / unresolvable key each refuse to start) + mcp-server-runtime.test.ts (record_by_id registered only with a principal-bound reader); the scoped read shares the exact ql.find({context}) enforcement path proven end-to-end by mcp-http-identity (showcase-mcp-http-identity.dogfood.test.ts) and the RLS fixtures. Dropping the principal binding (the resolveStdioExecutionContext threading) makes the stdio-principal-bound key STALE → red CI. NOT high-risk: driving a real stdio transport in-process is impractical, but the reader path is the same RLS-applying engine call the HTTP e2e already exercises.' }, { id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced', enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' }, { id: 'readonly-static-write', summary: 'static `readonly: true` stripped from non-system UPDATE (#2948 / #3003) AND INSERT (#3043) payloads — neither a direct PATCH nor a direct POST can forge approval/status/amount columns the UI never renders', state: 'enforced', diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 84ff361df3..7a3920490b 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -131,14 +131,15 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray re: /this\.buildMcpBridge\(context\)/g, key: () => 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)', }, - // (3) The stdio transport's UNSCOPED data bridge: the long-lived server is fed - // the raw metadata service + data engine with no principal. Wrapping these in - // a principal-bound bridge (the admission fix) changes this line → the - // unscoped-stdio key goes STALE → forces re-classifying mcp-stdio-authority. + // (3) The stdio transport's PRINCIPAL binding (ADR-0101): the long-lived + // server reads record data only under an OS_MCP_STDIO_API_KEY identity, + // resolved via resolveStdioExecutionContext and threaded into the record + // reader. Dropping that resolution (reverting to a raw/unscoped bridge) makes + // this key vanish → the mcp-stdio-authority row goes STALE → red CI. { file: 'packages/mcp/src/plugin.ts', - re: /bridgeResources\(metadataService, dataEngine\)/g, - key: () => 'mcp:plugin.ts:bridgeResources(unscoped-stdio)', + re: /resolveStdioExecutionContext\s*\(/g, + key: () => 'mcp:plugin.ts:stdio-principal-bound', }, ]; @@ -254,9 +255,9 @@ describe('#2567 — anonymous-deny surface ratchet bites', () => { expect(problems.some((p) => /STALE covers/.test(p) && p.includes(threaded))).toBe(true); }); - it('(g) the stdio unscoped-bridge posture is pinned; changing it goes STALE (#3167)', () => { - const stdio = 'mcp:plugin.ts:bridgeResources(unscoped-stdio)'; - // Baseline sanity: the long-lived server bridges the raw services today. + it('(g) the stdio principal binding is pinned; dropping it goes STALE (ADR-0101)', () => { + const stdio = 'mcp:plugin.ts:stdio-principal-bound'; + // Baseline sanity: the stdio path resolves an API-key principal today. expect(discoverAnonymousDenySurfaces().has(stdio)).toBe(true); const problems = checkLedger( AUTHZ_CONFORMANCE,