Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/mcp-stdio-principal-admission.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 5 additions & 4 deletions content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0101-mcp-stdio-principal-admission.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
25 changes: 3 additions & 22 deletions packages/mcp/src/__tests__/mcp-server-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,25 +104,6 @@ function createMockMetadataService() {
};
}

function createMockDataEngine() {
const records: Record<string, Record<string, any>> = {
'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 {
Expand Down Expand Up @@ -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');
Expand Down
60 changes: 46 additions & 14 deletions packages/mcp/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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();
Expand Down
22 changes: 15 additions & 7 deletions packages/mcp/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Record<string, unknown> | null>,
): void {
const logger = this.config.logger;
let resourceCount = 0;

Expand Down Expand Up @@ -320,22 +323,27 @@ 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) => {
const objectName = String(variables.objectName);
const recordId = String(variables.recordId);

try {
const record = await dataEngine.findOne(objectName, {
where: { id: recordId },
});
const record = await getRecord(objectName, recordId);

if (!record) {
return {
Expand Down
121 changes: 101 additions & 20 deletions packages/mcp/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> },
apiKey: string,
): Promise<ExecutionContext | undefined> {
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.
*/
Expand Down Expand Up @@ -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<IMetadataService>('metadata');
} catch {
ctx.logger.debug('[MCP] Metadata service not available, skipping resource bridging');
}

try {
dataEngine = ctx.getService<IDataEngine>('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.
Expand All @@ -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<Record<string, unknown> | null>)
| undefined;
if (shouldStart) {
const apiKey = readEnvWithDeprecation('OS_MCP_STDIO_API_KEY', [], { silent: true });
let ql: { find: (object: string, opts: unknown) => Promise<unknown> } | 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<string, unknown> | 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');
Expand Down
Loading