Skip to content

Commit 6dff1e2

Browse files
committed
feat(mcp)!: stdio transport requires an API-key principal — fail-closed, no unscoped bridge (ADR-0101, #3246)
Closes the platform's last identity-less execution surface: the long-lived MCP stdio transport, which previously bridged the raw metadata service + data engine with no ExecutionContext (record_by_id read via dataEngine.findOne, bypassing RLS/FLS/tenant). - plugin.ts start(): when stdio auto-start is requested, resolve OS_MCP_STDIO_API_KEY through the SAME @objectstack/core chain as HTTP/REST (resolveStdioExecutionContext → resolveAuthzContext → resolveApiKeyPrincipal), build the caller ExecutionContext, and thread it (re-resolved per call) into a principal-bound record reader. FAIL-CLOSED: no key / no objectql / an unknown|revoked|expired key throws and refuses to start stdio. No `system` bypass — full authority is a minted admin/service key. - mcp-server-runtime.ts bridgeResources: record_by_id now takes a principal-bound getRecord reader (ql.find(obj,{where:{id},context})); registered only when a reader is supplied — no unscoped fallback. Dropped the raw IDataEngine bridge. - authz matrix: mcp-stdio-authority experimental → enforced; probe/bite retargeted from bridgeResources(unscoped-stdio) → stdio-principal-bound. - ADR-0101 Proposed → Accepted; env-var docs add OS_MCP_STDIO_API_KEY. Tests: mcp 83/83 (4 new fail-closed cases: no key / no objectql / unresolvable key refuse to start; disabled stdio needs no key); dogfood authz-conformance 9/9. BREAKING (stdio auto-start only): OS_MCP_STDIO_ENABLED/autoStart now requires OS_MCP_STDIO_API_KEY; keyless stdio no longer starts. HTTP surface unaffected. Refs #3246, #3167, ADR-0101, ADR-0096. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0115eg8dAaCfWaDYYAm3ma36
1 parent bd7df45 commit 6dff1e2

9 files changed

Lines changed: 211 additions & 80 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/mcp': minor
3+
---
4+
5+
feat(mcp)!: stdio transport requires an API-key principal — fail-closed, no unscoped bridge (ADR-0101, #3246)
6+
7+
The long-lived MCP **stdio** transport no longer reads data unscoped. It now runs
8+
under an env-supplied identity, closing the platform's last identity-less
9+
execution surface (the `mcp-stdio-authority` conformance row graduates
10+
`experimental``enforced`).
11+
12+
- `OS_MCP_STDIO_API_KEY=osk_...` supplies the stdio identity, resolved through
13+
the SAME `@objectstack/core` verify + authorization chain as the HTTP/REST
14+
surfaces; the `record_by_id` resource reads via `ql.find(obj, { where:{id},
15+
context })`, so RLS/FLS/tenant apply exactly as on REST `/data`. Re-resolved
16+
per read, so a revoked/expired key stops working on a live session.
17+
- **Fail-closed** — enabling stdio auto-start (`OS_MCP_STDIO_ENABLED=true` /
18+
`autoStart`) without a resolvable key throws and refuses to start. There is no
19+
unscoped fallback and deliberately no `system` bypass; full authority is a key
20+
minted on a platform-admin or dedicated service identity.
21+
22+
**BREAKING (stdio auto-start only):** previously `OS_MCP_STDIO_ENABLED=true`
23+
(or the plugin `autoStart` option) started stdio with full, unscoped authority
24+
and no credential. It now requires `OS_MCP_STDIO_API_KEY`; without it, boot
25+
fails closed. The default-on HTTP surface and any deployment that never enables
26+
stdio auto-start are unaffected.

content/docs/deployment/environment-variables.mdx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,15 +226,16 @@ The **HTTP surface** and the long-lived **stdio transport** are separate switche
226226
see the note below):
227227

228228
```bash
229-
os start # HTTP MCP served at /api/v1/mcp by default
230-
OS_MCP_SERVER_ENABLED=false os start # opt out of the HTTP MCP surface
231-
OS_MCP_STDIO_ENABLED=true os start # additionally auto-start the local stdio transport
229+
os start # HTTP MCP served at /api/v1/mcp by default
230+
OS_MCP_SERVER_ENABLED=false os start # opt out of the HTTP MCP surface
231+
OS_MCP_STDIO_ENABLED=true OS_MCP_STDIO_API_KEY=osk_... os start # start the local stdio transport as that key's identity
232232
```
233233

234234
| Variable | Type | Default | Description |
235235
|:---|:---|:---|:---|
236236
| `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). |
237-
| `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. |
237+
| `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]. |
238+
| `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. |
238239
| `OS_MCP_SERVER_NAME` | string | `objectstack` | Server name advertised to MCP clients. |
239240
| `OS_MCP_SERVER_TRANSPORT` | enum | `stdio` | `stdio` \| `http`. Use `http` (Streamable HTTP) for a remote client; `stdio` for a local one. |
240241

docs/adr/0101-mcp-stdio-principal-admission.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0101: MCP stdio Principal Admission — env-supplied API-key identity, fail-closed, no system bypass
22

3-
**Status**: Proposed (2026-07-19)
3+
**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`
44
**Deciders**: ObjectStack Protocol Architects
55
**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)
66
**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)

packages/mcp/src/__tests__/mcp-server-runtime.test.ts

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -104,25 +104,6 @@ function createMockMetadataService() {
104104
};
105105
}
106106

107-
function createMockDataEngine() {
108-
const records: Record<string, Record<string, any>> = {
109-
'account:abc123': { id: 'abc123', name: 'Acme Corp', status: 'active' },
110-
'contact:xyz789': { id: 'xyz789', first_name: 'John', last_name: 'Doe' },
111-
};
112-
113-
return {
114-
find: vi.fn(async () => []),
115-
findOne: vi.fn(async (objectName: string, options: any) => {
116-
const recordId = options?.where?.id;
117-
return records[`${objectName}:${recordId}`] ?? null;
118-
}),
119-
insert: vi.fn(),
120-
update: vi.fn(),
121-
delete: vi.fn(),
122-
count: vi.fn(async () => 0),
123-
aggregate: vi.fn(async () => []),
124-
};
125-
}
126107

127108
function createMockLogger() {
128109
return {
@@ -224,10 +205,10 @@ describe('MCPServerRuntime', () => {
224205
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 3 resource endpoints');
225206
});
226207

227-
it('should bridge record resources when DataEngine is available', () => {
208+
it('should bridge record resources when a principal-bound reader is provided', () => {
228209
const metadataService = createMockMetadataService();
229-
const dataEngine = createMockDataEngine();
230-
runtime.bridgeResources(metadataService as any, dataEngine as any);
210+
const getRecord = async () => null; // principal-bound reader (ADR-0101)
211+
runtime.bridgeResources(metadataService as any, getRecord);
231212

232213
// Should register: object_list, object_schema, record_by_id, metadata_types (4 resources)
233214
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 4 resource endpoints');

packages/mcp/src/__tests__/plugin.test.ts

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -182,20 +182,6 @@ describe('MCPServerPlugin', () => {
182182
);
183183
});
184184

185-
it('should handle missing data engine gracefully', async () => {
186-
const aiService = createMockAIService();
187-
const metadataService = createMockMetadataService();
188-
const ctx = createMockPluginContext({ ai: aiService, metadata: metadataService });
189-
190-
const plugin = new MCPServerPlugin();
191-
await plugin.init(ctx as any);
192-
await plugin.start(ctx as any);
193-
194-
expect(ctx.logger.debug).toHaveBeenCalledWith(
195-
'[MCP] Data engine not available, skipping record resources',
196-
);
197-
});
198-
199185
it('should not auto-start when MCP_SERVER_ENABLED is not set', async () => {
200186
const ctx = createMockPluginContext();
201187

@@ -219,6 +205,52 @@ describe('MCPServerPlugin', () => {
219205
});
220206
});
221207

208+
describe('stdio principal admission — fail-closed (ADR-0101)', () => {
209+
beforeEach(() => {
210+
delete process.env.OS_MCP_STDIO_ENABLED;
211+
delete process.env.OS_MCP_STDIO_API_KEY;
212+
});
213+
214+
it('refuses to start stdio when enabled without OS_MCP_STDIO_API_KEY', async () => {
215+
const ctx = createMockPluginContext({
216+
metadata: createMockMetadataService(),
217+
objectql: createMockDataEngine(),
218+
});
219+
const plugin = new MCPServerPlugin({ autoStart: true });
220+
await plugin.init(ctx as any);
221+
await expect(plugin.start(ctx as any)).rejects.toThrow(/OS_MCP_STDIO_API_KEY/);
222+
});
223+
224+
it('refuses to start stdio when the objectql service is unavailable', async () => {
225+
process.env.OS_MCP_STDIO_API_KEY = 'osk_test';
226+
const ctx = createMockPluginContext({ metadata: createMockMetadataService() }); // no objectql
227+
const plugin = new MCPServerPlugin({ autoStart: true });
228+
await plugin.init(ctx as any);
229+
await expect(plugin.start(ctx as any)).rejects.toThrow(/objectql/);
230+
});
231+
232+
it('refuses to start stdio when the key does not resolve to an identity', async () => {
233+
process.env.OS_MCP_STDIO_API_KEY = 'osk_unknown';
234+
// find() returns no rows → no sys_api_key match → no principal → no userId.
235+
const ql = { find: vi.fn(async () => []) };
236+
const ctx = createMockPluginContext({ metadata: createMockMetadataService(), objectql: ql });
237+
const plugin = new MCPServerPlugin({ autoStart: true });
238+
await plugin.init(ctx as any);
239+
await expect(plugin.start(ctx as any)).rejects.toThrow(/did not resolve to a valid identity/);
240+
});
241+
242+
it('does NOT require a key when stdio is not enabled (HTTP surface only)', async () => {
243+
// No autoStart, no OS_MCP_STDIO_ENABLED → shouldStart false → no key needed.
244+
const ctx = createMockPluginContext({ metadata: createMockMetadataService() });
245+
const plugin = new MCPServerPlugin();
246+
await plugin.init(ctx as any);
247+
await expect(plugin.start(ctx as any)).resolves.toBeUndefined();
248+
expect(ctx.logger.info).toHaveBeenCalledWith(
249+
expect.stringContaining('[MCP] Transport not auto-started'),
250+
);
251+
});
252+
});
253+
222254
describe('destroy', () => {
223255
it('should clean up on destroy', async () => {
224256
const ctx = createMockPluginContext();

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
44
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
55
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
6-
import type { Logger, IMetadataService, IDataEngine, AIToolDefinition } from '@objectstack/spec/contracts';
6+
import type { Logger, IMetadataService, AIToolDefinition } from '@objectstack/spec/contracts';
77
import type { Agent } from '@objectstack/spec/ai';
88
import type { ToolRegistry, ToolExecutionResult } from './types.js';
99
import { registerObjectTools, registerActionTools } from './mcp-http-tools.js';
@@ -241,7 +241,10 @@ export class MCPServerRuntime {
241241
* - `objectstack://objects/{objectName}/records/{recordId}` — Get a specific record
242242
* - `objectstack://metadata/types` — List all metadata types
243243
*/
244-
bridgeResources(metadataService: IMetadataService, dataEngine?: IDataEngine): void {
244+
bridgeResources(
245+
metadataService: IMetadataService,
246+
getRecord?: (objectName: string, recordId: string) => Promise<Record<string, unknown> | null>,
247+
): void {
245248
const logger = this.config.logger;
246249
let resourceCount = 0;
247250

@@ -320,22 +323,27 @@ export class MCPServerRuntime {
320323
resourceCount++;
321324

322325
// ── Template resource: Record by ID ──
323-
if (dataEngine) {
326+
// The ONE resource that reads ROW data, so it MUST run under a principal
327+
// (ADR-0101): the caller supplies a principal-bound reader that applies
328+
// RLS/FLS/tenant (e.g. `ql.find(obj, { where: { id }, context })`). Without
329+
// one, the resource is NOT registered — there is deliberately no unscoped
330+
// fallback. The long-lived stdio server reaches this only after the plugin
331+
// resolved `OS_MCP_STDIO_API_KEY` to an identity; the reader re-resolves per
332+
// call, so a revoked/expired key stops working on the next read.
333+
if (getRecord) {
324334
this.mcpServer.registerResource(
325335
'record_by_id',
326336
new ResourceTemplate('objectstack://objects/{objectName}/records/{recordId}', { list: undefined }),
327337
{
328-
description: 'Get a specific record by ID from a data object',
338+
description: 'Get a specific record by ID from a data object (under the caller\'s permissions and row-level security)',
329339
mimeType: 'application/json',
330340
},
331341
async (_uri, variables) => {
332342
const objectName = String(variables.objectName);
333343
const recordId = String(variables.recordId);
334344

335345
try {
336-
const record = await dataEngine.findOne(objectName, {
337-
where: { id: recordId },
338-
});
346+
const record = await getRecord(objectName, recordId);
339347

340348
if (!record) {
341349
return {

packages/mcp/src/plugin.ts

Lines changed: 101 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,46 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { Plugin, PluginContext } from '@objectstack/core';
4+
import { resolveAuthzContext } from '@objectstack/core';
45
import { readEnvWithDeprecation, isMcpServerEnabled, resolveMcpStdioAutoStart } from '@objectstack/types';
5-
import type { IAIService, IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
6+
import type { ExecutionContext } from '@objectstack/spec/kernel';
7+
import type { IAIService, IMetadataService } from '@objectstack/spec/contracts';
68
import { MCPServerRuntime } from './mcp-server-runtime.js';
79
import type { MCPServerRuntimeConfig } from './mcp-server-runtime.js';
810
import type { ToolRegistry } from './types.js';
911
import { CONNECT_AGENT_UI_BUNDLE } from './connect-ui.js';
1012

13+
/**
14+
* Resolve `OS_MCP_STDIO_API_KEY` into an {@link ExecutionContext} through the
15+
* SAME `@objectstack/core` verify + authorization chain the HTTP and REST
16+
* surfaces use (`resolveApiKeyPrincipal` → `resolveAuthzContext`), so a stdio
17+
* read is scoped exactly like the same identity over REST (RLS / FLS / tenant).
18+
*
19+
* Fail-closed: returns `undefined` for an unknown / revoked / expired /
20+
* owner-less key (no `userId` resolved). Re-run per read, so revocation of a
21+
* key takes effect on the next call of a live stdio session (ADR-0101 D1).
22+
*/
23+
async function resolveStdioExecutionContext(
24+
ql: { find: (object: string, opts: unknown) => Promise<unknown> },
25+
apiKey: string,
26+
): Promise<ExecutionContext | undefined> {
27+
const authz = await resolveAuthzContext({ ql, headers: { 'x-api-key': apiKey } });
28+
if (!authz.userId) return undefined;
29+
const ec: ExecutionContext = {
30+
positions: authz.positions,
31+
permissions: authz.permissions,
32+
systemPermissions: authz.systemPermissions,
33+
isSystem: false,
34+
principalKind: 'human',
35+
userId: authz.userId,
36+
};
37+
if (authz.tenantId) ec.tenantId = authz.tenantId;
38+
if (authz.email) ec.email = authz.email;
39+
if (authz.posture) ec.posture = authz.posture;
40+
(ec as unknown as { org_user_ids?: string[] }).org_user_ids = authz.org_user_ids;
41+
return ec;
42+
}
43+
1144
/**
1245
* Configuration options for the MCPServerPlugin.
1346
*/
@@ -101,33 +134,18 @@ export class MCPServerPlugin implements Plugin {
101134
ctx.logger.debug('[MCP] AI service not available, skipping tool bridging');
102135
}
103136

104-
// ── Bridge resources from MetadataService & DataEngine ──
137+
// ── Metadata service for the resource bridge ──
105138
let metadataService: IMetadataService | undefined;
106-
let dataEngine: IDataEngine | undefined;
107-
108139
try {
109140
metadataService = ctx.getService<IMetadataService>('metadata');
110141
} catch {
111142
ctx.logger.debug('[MCP] Metadata service not available, skipping resource bridging');
112143
}
113144

114-
try {
115-
dataEngine = ctx.getService<IDataEngine>('data');
116-
} catch {
117-
ctx.logger.debug('[MCP] Data engine not available, skipping record resources');
118-
}
119-
120-
if (metadataService) {
121-
this.runtime.bridgeResources(metadataService, dataEngine);
122-
this.runtime.bridgePrompts(metadataService);
123-
}
124-
125-
// ── Auto-start if configured ──
145+
// ── stdio auto-start decision (opt-in, its OWN switch) ──
126146
// Deliberately stricter than the HTTP-surface default (`isMcpServerEnabled`,
127-
// default-on): start() attaches a long-lived transport — for stdio that
128-
// means claiming the process's stdin/stdout AND bridging the RAW services
129-
// with no per-request principal (unscoped — see the mcp-stdio-authority
130-
// conformance row) — so it stays opt-in via a SEPARATE switch
147+
// default-on): start() attaches a long-lived transport claiming the
148+
// process's stdin/stdout, so it stays opt-in via a SEPARATE switch
131149
// (`OS_MCP_STDIO_ENABLED` / the `autoStart` option), never the HTTP var.
132150
// The HTTP surface does not depend on this: the runtime dispatcher serves
133151
// `/api/v1/mcp` per-request regardless.
@@ -138,6 +156,69 @@ export class MCPServerPlugin implements Plugin {
138156
'[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.',
139157
);
140158
}
159+
160+
// ── Principal-bound record reader for the stdio transport (ADR-0101) ──
161+
// The long-lived stdio server reads ROW data only under an env-supplied
162+
// API-key identity, resolved through the same @objectstack/core chain as the
163+
// HTTP/REST surfaces (RLS/FLS/tenant apply). FAIL-CLOSED: stdio auto-start
164+
// without a resolvable key REFUSES to start — no unscoped fallback, and no
165+
// `system`/identity-skipping bypass. Full authority = an admin/service key.
166+
let getRecord:
167+
| ((objectName: string, recordId: string) => Promise<Record<string, unknown> | null>)
168+
| undefined;
169+
if (shouldStart) {
170+
const apiKey = readEnvWithDeprecation('OS_MCP_STDIO_API_KEY', [], { silent: true });
171+
let ql: { find: (object: string, opts: unknown) => Promise<unknown> } | undefined;
172+
try {
173+
ql = ctx.getService('objectql');
174+
} catch {
175+
ql = undefined;
176+
}
177+
if (!apiKey) {
178+
throw new Error(
179+
'[MCP] The stdio transport is enabled (OS_MCP_STDIO_ENABLED / autoStart) but OS_MCP_STDIO_API_KEY is not set. ' +
180+
'stdio must run under a real identity — mint an API key (Setup → Connect an Agent, or POST /api/v1/keys) and set ' +
181+
'OS_MCP_STDIO_API_KEY=osk_.... Refusing to start an unscoped stdio server (ADR-0101).',
182+
);
183+
}
184+
if (!ql || typeof ql.find !== 'function') {
185+
throw new Error(
186+
'[MCP] The stdio transport requires the objectql data service to resolve its principal, but it is not available. ' +
187+
'Refusing to start (ADR-0101).',
188+
);
189+
}
190+
// Validate the key up-front (fail-closed) before attaching the transport.
191+
const initial = await resolveStdioExecutionContext(ql, apiKey);
192+
if (!initial) {
193+
throw new Error(
194+
'[MCP] OS_MCP_STDIO_API_KEY did not resolve to a valid identity (unknown / revoked / expired / owner-less). ' +
195+
'Refusing to start stdio (ADR-0101).',
196+
);
197+
}
198+
const scopedQl = ql;
199+
// Re-resolve per call so a revoked/expired key stops working on the next read.
200+
getRecord = async (objectName, recordId) => {
201+
const ec = await resolveStdioExecutionContext(scopedQl, apiKey);
202+
if (!ec) throw new Error('MCP stdio identity is no longer valid (key revoked or expired)');
203+
const res = (await scopedQl.find(objectName, {
204+
where: { id: recordId },
205+
limit: 1,
206+
context: ec,
207+
})) as unknown;
208+
const rows = res && (res as { value?: unknown }).value ? (res as { value: unknown }).value : res;
209+
const row = Array.isArray(rows) ? rows[0] : rows;
210+
return (row ?? null) as Record<string, unknown> | null;
211+
};
212+
ctx.logger.info(
213+
`[MCP] stdio transport principal-bound to OS_MCP_STDIO_API_KEY identity ${initial.userId} (RLS/FLS/tenant applied)`,
214+
);
215+
}
216+
217+
if (metadataService) {
218+
this.runtime.bridgeResources(metadataService, getRecord);
219+
this.runtime.bridgePrompts(metadataService);
220+
}
221+
141222
if (shouldStart) {
142223
await this.runtime.start();
143224
ctx.logger.info('[MCP] Server started automatically');

0 commit comments

Comments
 (0)