Skip to content

Commit 4b88f9a

Browse files
committed
feat(mcp): decouple stdio auto-start from HTTP switch + surface MCP endpoint on os dev boot (#3167)
Completes the actionable, direction-ratified half of #3167 PR-B (the e2e identity-admission proof landed in the previous commit). Decision 2 (off-switch semantics): the MCP HTTP surface and the long-lived stdio transport shared one env var — OS_MCP_SERVER_ENABLED=true turned HTTP on AND silently auto-started the UNSCOPED stdio bridge (raw services, no per-request principal). Split them: - types: new resolveMcpStdioAutoStart(); stdio is now OS_MCP_STDIO_ENABLED (default off). OS_MCP_SERVER_ENABLED governs only HTTP. Legacy OS_MCP_SERVER_ENABLED=true still starts stdio for one release, flagged deprecated (=false only ever gated HTTP, unchanged). - mcp: plugin.start() gates stdio on the new switch + warns once on the legacy alias. The bridgeResources(...) line (mcp-stdio-authority probe key) is untouched; the matrix row's env-var references are synced. Decision 3 (dev affordance): `os dev` prints the MCP endpoint, skill URL, and a ready-to-paste `claude mcp add` command on boot, gated on isMcpServerEnabled() (opted-out deployments advertise nothing). Decision 4 (exposure policy): documented the current default (non-system objects + ai.exposed actions over the ~10-tool spine) as the committed v1 policy in connect-mcp.mdx, with the metadata-authorable direction noted. Also: env-var docs updated for the split (+ deprecation callout); the blank scaffold README documents the serve-side MCP surface; unit tests pin the new switch semantics (canonical / legacy-deprecated / footgun-closed). Changeset covers types+mcp+cli (minor) and create-objectstack (patch). Refs #3167, ADR-0096, ADR-0097. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0115eg8dAaCfWaDYYAm3ma36
1 parent 6ba2977 commit 4b88f9a

9 files changed

Lines changed: 222 additions & 18 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/types': minor
3+
'@objectstack/mcp': minor
4+
'@objectstack/cli': minor
5+
'create-objectstack': patch
6+
---
7+
8+
feat(mcp): decouple the stdio auto-start switch from the HTTP surface + surface the MCP endpoint on `os dev` boot (#3167)
9+
10+
The MCP HTTP surface (`/api/v1/mcp`) and the long-lived stdio transport used to
11+
share one env var: `OS_MCP_SERVER_ENABLED=true` turned the HTTP surface on **and**
12+
silently auto-started the stdio transport — which bridges the raw metadata service
13+
+ data engine with no per-request principal (unscoped). An operator setting it to
14+
"make sure MCP is on" got an unscoped transport as a side effect.
15+
16+
- **`@objectstack/types`** — new `resolveMcpStdioAutoStart()`. Stdio auto-start is
17+
now its own switch, `OS_MCP_STDIO_ENABLED` (default off); `OS_MCP_SERVER_ENABLED`
18+
governs only the HTTP surface. The legacy `OS_MCP_SERVER_ENABLED=true` trigger
19+
still starts stdio for one release, flagged as deprecated. `=false` is unchanged
20+
(it only ever gated HTTP).
21+
- **`@objectstack/mcp`**`MCPServerPlugin.start()` gates stdio on the new switch
22+
and logs a one-time deprecation warning when started via the legacy alias.
23+
- **`@objectstack/cli`**`os dev` now prints the MCP endpoint, the agent-skill
24+
URL, and a ready-to-paste `claude mcp add` command on boot (gated on the HTTP
25+
surface being on), so the "an agent operates the app it's building" loop is
26+
discoverable at dev time.
27+
- **`create-objectstack`** — the blank scaffold README documents that the app is
28+
itself an MCP server (the serve side), distinct from the consume-side connector.

content/docs/ai/connect-mcp.mdx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,10 @@ Ten data and action tools, generated from your metadata:
9797
| `create_record` / `update_record` / `delete_record` | Write data |
9898
| `list_actions` / `run_action` | Discover and invoke your business actions by name |
9999

100-
Two exposure rules to know:
100+
The tools are a fixed ~10-tool **spine** with the object name as a *parameter*
101+
(`query_records(objectName, …)`), not one tool per object — so the list stays
102+
usable on an app with hundreds of objects. Which objects and actions that spine
103+
can reach is the **default exposure policy (v1)**:
101104

102105
- **Objects are exposed automatically** — except `sys_*` system objects, which
103106
are blocked fail-closed.
@@ -109,6 +112,14 @@ Two exposure rules to know:
109112
dispatch here. See [Actions as Tools](/docs/ai/actions-as-tools) and
110113
[Actions](/docs/ui/actions).
111114

115+
<Callout type="info">
116+
This is the default policy, not a ceiling. A finer, **metadata-declared** exposure
117+
config (opt objects out, curate the exposed set, promote a few high-value actions
118+
to typed per-action tools) is the planned next step, consistent with ADR-0097's
119+
"what is exposed should itself be authorable" — tracked in
120+
[#3167](https://github.com/objectstack-ai/framework/issues/3167).
121+
</Callout>
122+
112123
## The security model
113124

114125
- **Every call runs as the caller.** The MCP bridge resolves the same

content/docs/deployment/environment-variables.mdx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,18 +221,31 @@ running app and gets a generated tool surface over your objects and actions
221221
same RBAC / RLS as the Console. No custom tooling, no separate API. See
222222
[Actions as Tools](/docs/ai/actions-as-tools) for the tool set.
223223

224+
The **HTTP surface** and the long-lived **stdio transport** are separate switches
225+
(they used to share one, which let `=true` silently attach an unscoped transport —
226+
see the note below):
227+
224228
```bash
225-
os start # MCP is served at /api/v1/mcp by default
226-
OS_MCP_SERVER_ENABLED=false os start # opt out of the MCP surface
227-
OS_MCP_SERVER_ENABLED=true os start # additionally auto-start the 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 start # additionally auto-start the local stdio transport
228232
```
229233

230234
| Variable | Type | Default | Description |
231235
|:---|:---|:---|:---|
232-
| `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. An explicit `true` additionally auto-starts the long-lived stdio transport at boot. |
236+
| `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. |
233238
| `OS_MCP_SERVER_NAME` | string | `objectstack` | Server name advertised to MCP clients. |
234239
| `OS_MCP_SERVER_TRANSPORT` | enum | `stdio` | `stdio` \| `http`. Use `http` (Streamable HTTP) for a remote client; `stdio` for a local one. |
235240

241+
<Callout type="warn">
242+
**Deprecated (one release):** `OS_MCP_SERVER_ENABLED=true` also used to auto-start
243+
the stdio transport — overloading the HTTP switch, so setting it to "make sure MCP
244+
is on" silently attached the unscoped stdio bridge. That trigger still works but now
245+
logs a deprecation warning; use `OS_MCP_STDIO_ENABLED=true` instead.
246+
`OS_MCP_SERVER_ENABLED=false` only ever gated HTTP and is unaffected.
247+
</Callout>
248+
236249
---
237250

238251
## Search

packages/cli/src/commands/dev.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import fs from 'fs';
88
import os from 'os';
99
import path from 'path';
1010
import { printHeader, printKV, printStep, printError } from '../utils/format.js';
11-
import { readEnvWithDeprecation } from '@objectstack/types';
11+
import { readEnvWithDeprecation, isMcpServerEnabled } from '@objectstack/types';
1212

1313
/**
1414
* Resolve the persistent default database URL for `objectstack dev`.
@@ -285,6 +285,25 @@ export default class Dev extends Command {
285285
if (actual !== requestedPort) {
286286
console.log(chalk.dim(` ↪ server bound to port ${actual} (requested ${requestedPort})`));
287287
}
288+
// ── MCP connect hint (#3167) ────────────────────────────────
289+
// The app IS an MCP server: the dispatcher serves /api/v1/mcp
290+
// per-request, default-on (isMcpServerEnabled). Print how a
291+
// coding agent (Claude Code, Cursor, any MCP client) attaches so
292+
// the "AI builds the app it's running" loop is discoverable at the
293+
// moment it's most useful — dev boot. An opted-out deployment
294+
// (OS_MCP_SERVER_ENABLED=false) advertises nothing, mirroring the
295+
// connect-UI / discovery gates that follow the same switch.
296+
if (isMcpServerEnabled()) {
297+
const base =
298+
typeof msg.url === 'string' && msg.url ? msg.url.replace(/\/+$/, '') : `http://localhost:${actual}`;
299+
const name = path.basename(process.cwd()) || 'objectstack';
300+
console.log();
301+
console.log(chalk.cyan(' 🤖 MCP server — connect a coding agent:'));
302+
console.log(` Endpoint ${base}/api/v1/mcp`);
303+
console.log(` Skill ${base}/api/v1/mcp/skill`);
304+
console.log(chalk.dim(` Connect claude mcp add --transport http ${name} ${base}/api/v1/mcp`));
305+
console.log(chalk.dim(' Disable OS_MCP_SERVER_ENABLED=false'));
306+
}
288307
}
289308
});
290309

packages/create-objectstack/src/templates/blank/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,24 @@ curl -c cookies.txt -X POST http://localhost:3000/api/v1/auth/sign-in/email \
2121
curl -b cookies.txt "http://localhost:3000/api/v1/data/<your_object>"
2222
```
2323

24+
## Your app is an MCP server
25+
26+
Every ObjectStack app is itself a
27+
[Model Context Protocol](https://modelcontextprotocol.io) server — **on by
28+
default**, no plugin to install. `pnpm dev` prints the endpoint and a
29+
ready-to-paste connect command on boot; point a coding agent (Claude Code,
30+
Cursor, any MCP client) at it and it can read your schema, query data, and run
31+
your exposed actions — all under the caller's own permissions and RLS:
32+
33+
```bash
34+
claude mcp add --transport http my-app http://localhost:3000/api/v1/mcp
35+
```
36+
37+
Set `OS_MCP_SERVER_ENABLED=false` to turn it off. This is the *serve* side — the
38+
reverse of the `mcp` connector below (which lets your app *call* other MCP
39+
servers). See [Connect an MCP Client](https://docs.objectstack.ai/docs/ai/connect-mcp)
40+
for OAuth, API keys, and which objects/actions become tools.
41+
2442
## Layout
2543

2644
- `objectstack.config.ts` — environment manifest (objects, API, plugins)

packages/mcp/src/plugin.ts

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

33
import type { Plugin, PluginContext } from '@objectstack/core';
4-
import { readEnvWithDeprecation, isMcpServerEnabled } from '@objectstack/types';
4+
import { readEnvWithDeprecation, isMcpServerEnabled, resolveMcpStdioAutoStart } from '@objectstack/types';
55
import type { IAIService, IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
66
import { MCPServerRuntime } from './mcp-server-runtime.js';
77
import type { MCPServerRuntimeConfig } from './mcp-server-runtime.js';
@@ -125,16 +125,25 @@ export class MCPServerPlugin implements Plugin {
125125
// ── Auto-start if configured ──
126126
// Deliberately stricter than the HTTP-surface default (`isMcpServerEnabled`,
127127
// default-on): start() attaches a long-lived transport — for stdio that
128-
// means claiming the process's stdin/stdout — so it stays opt-in via
129-
// explicit `true` or the `autoStart` option. The HTTP surface does not
130-
// depend on this: the runtime dispatcher serves `/api/v1/mcp` per-request.
131-
const shouldStart = this.options.autoStart || readEnvWithDeprecation('OS_MCP_SERVER_ENABLED', 'MCP_SERVER_ENABLED', { silent: true }) === 'true';
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
131+
// (`OS_MCP_STDIO_ENABLED` / the `autoStart` option), never the HTTP var.
132+
// The HTTP surface does not depend on this: the runtime dispatcher serves
133+
// `/api/v1/mcp` per-request regardless.
134+
const stdio = resolveMcpStdioAutoStart();
135+
const shouldStart = this.options.autoStart || stdio.enabled;
136+
if (stdio.viaDeprecatedAlias && !this.options.autoStart) {
137+
ctx.logger.warn(
138+
'[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.',
139+
);
140+
}
132141
if (shouldStart) {
133142
await this.runtime.start();
134143
ctx.logger.info('[MCP] Server started automatically');
135144
} else {
136145
ctx.logger.info(
137-
'[MCP] Transport not auto-started (HTTP is served per-request at /api/v1/mcp regardless). Set OS_MCP_SERVER_ENABLED=true or autoStart for a long-lived (stdio) transport.',
146+
'[MCP] Transport not auto-started (HTTP is served per-request at /api/v1/mcp regardless). Set OS_MCP_STDIO_ENABLED=true or autoStart for a long-lived (stdio) transport.',
138147
);
139148
}
140149

packages/qa/dogfood/test/authz-conformance.matrix.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,9 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
100100
covers: ['mcp:http-dispatcher.ts:handleMcp', 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)'],
101101
proof: 'mcp-http-identity.dogfood.test.ts',
102102
note: 'The per-request principal-bound tool server is isolated from the long-lived UNSCOPED stdio server (see mcp-stdio-authority). End-to-end proven in mcp-http-identity.dogfood.test.ts (#3167): booting MCPServerPlugin default-on exactly as os dev/serve wire it, then driving POST /api/v1/mcp — anonymous tools/call → 401, and an admitted member\'s query_records/get_record are RLS-scoped through the threaded caller EC, with the MCP id-set equal to REST /data for the same principal. 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.' },
103-
{ 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_SERVER_ENABLED=true)', state: 'experimental',
103+
{ 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',
104104
covers: ['mcp:plugin.ts:bridgeResources(unscoped-stdio)'],
105-
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 keeps stdio opt-in, stricter than the default-on HTTP surface). 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.' },
105+
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.' },
106106
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
107107
enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' },
108108
{ 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',

packages/types/src/env.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
readEnvWithDeprecation,
77
resolveAllowDegradedTenancy,
88
resolveSearchPinyinEnabled,
9+
isMcpServerEnabled,
10+
resolveMcpStdioAutoStart,
911
} from './env.js';
1012

1113
describe('readEnvWithDeprecation', () => {
@@ -172,3 +174,66 @@ describe('resolveSearchPinyinEnabled (#2486)', () => {
172174
}
173175
});
174176
});
177+
178+
describe('MCP switches — HTTP surface vs stdio auto-start are decoupled (#3167)', () => {
179+
const origServer = process.env.OS_MCP_SERVER_ENABLED;
180+
const origServerLegacy = process.env.MCP_SERVER_ENABLED;
181+
const origStdio = process.env.OS_MCP_STDIO_ENABLED;
182+
const restore = (key: string, val: string | undefined) => {
183+
if (val === undefined) delete process.env[key];
184+
else process.env[key] = val;
185+
};
186+
afterEach(() => {
187+
restore('OS_MCP_SERVER_ENABLED', origServer);
188+
restore('MCP_SERVER_ENABLED', origServerLegacy);
189+
restore('OS_MCP_STDIO_ENABLED', origStdio);
190+
});
191+
192+
it('isMcpServerEnabled (HTTP surface): default-on, only explicit falsy opts out', () => {
193+
delete process.env.OS_MCP_SERVER_ENABLED;
194+
expect(isMcpServerEnabled()).toBe(true);
195+
for (const v of ['false', '0', 'off', 'no', 'FALSE']) {
196+
process.env.OS_MCP_SERVER_ENABLED = v;
197+
expect(isMcpServerEnabled(), `${v} should opt out`).toBe(false);
198+
}
199+
for (const v of ['true', '1', 'anything']) {
200+
process.env.OS_MCP_SERVER_ENABLED = v;
201+
expect(isMcpServerEnabled(), `${v} keeps HTTP on`).toBe(true);
202+
}
203+
});
204+
205+
it('stdio auto-start: default OFF when nothing is set', () => {
206+
delete process.env.OS_MCP_SERVER_ENABLED;
207+
delete process.env.OS_MCP_STDIO_ENABLED;
208+
expect(resolveMcpStdioAutoStart()).toEqual({ enabled: false, viaDeprecatedAlias: false });
209+
});
210+
211+
it('stdio auto-start: canonical OS_MCP_STDIO_ENABLED (truthy, no deprecation)', () => {
212+
delete process.env.OS_MCP_SERVER_ENABLED;
213+
for (const v of ['1', 'true', 'on', 'yes', 'TRUE']) {
214+
process.env.OS_MCP_STDIO_ENABLED = v;
215+
expect(resolveMcpStdioAutoStart(), v).toEqual({ enabled: true, viaDeprecatedAlias: false });
216+
}
217+
});
218+
219+
it('stdio auto-start: legacy OS_MCP_SERVER_ENABLED=true still starts it, flagged deprecated', () => {
220+
delete process.env.OS_MCP_STDIO_ENABLED;
221+
process.env.OS_MCP_SERVER_ENABLED = 'true';
222+
expect(resolveMcpStdioAutoStart()).toEqual({ enabled: true, viaDeprecatedAlias: true });
223+
});
224+
225+
it('stdio auto-start: OS_MCP_SERVER_ENABLED=false (or other) never starts stdio — no footgun', () => {
226+
delete process.env.OS_MCP_STDIO_ENABLED;
227+
for (const v of ['false', '0', 'off', '1', 'on', 'yes']) {
228+
process.env.OS_MCP_SERVER_ENABLED = v;
229+
// Only the literal `true` was ever the legacy stdio trigger.
230+
expect(resolveMcpStdioAutoStart().enabled, `server=${v}`).toBe(false);
231+
}
232+
});
233+
234+
it('canonical switch wins over the legacy alias (no deprecation flag)', () => {
235+
process.env.OS_MCP_STDIO_ENABLED = 'true';
236+
process.env.OS_MCP_SERVER_ENABLED = 'true';
237+
expect(resolveMcpStdioAutoStart()).toEqual({ enabled: true, viaDeprecatedAlias: false });
238+
});
239+
});

0 commit comments

Comments
 (0)