From 192a079427e43a453ca19ded12b8361efdfa074d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 16:21:45 +0000 Subject: [PATCH] feat(mcp): decouple stdio auto-start from the HTTP switch + surface the MCP endpoint on `os dev` boot (#3167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3167 PR-B — the direction-ratified, non-overlapping half. The HTTP identity-admission e2e proof landed separately (#3228: showcase-based, HIGH-RISK); this rebases onto it and keeps only what that PR did not cover. 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, #3228, ADR-0096, ADR-0097. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0115eg8dAaCfWaDYYAm3ma36 --- ...stdio-switch-split-and-dev-connect-hint.md | 28 ++++++++ content/docs/ai/connect-mcp.mdx | 13 +++- .../docs/deployment/environment-variables.mdx | 21 ++++-- packages/cli/src/commands/dev.ts | 21 +++++- .../src/templates/blank/README.md | 18 +++++ packages/mcp/src/plugin.ts | 21 ++++-- .../dogfood/test/authz-conformance.matrix.ts | 4 +- packages/types/src/env.test.ts | 65 +++++++++++++++++++ packages/types/src/env.ts | 49 ++++++++++++-- 9 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 .changeset/mcp-stdio-switch-split-and-dev-connect-hint.md diff --git a/.changeset/mcp-stdio-switch-split-and-dev-connect-hint.md b/.changeset/mcp-stdio-switch-split-and-dev-connect-hint.md new file mode 100644 index 0000000000..45bcd645f5 --- /dev/null +++ b/.changeset/mcp-stdio-switch-split-and-dev-connect-hint.md @@ -0,0 +1,28 @@ +--- +'@objectstack/types': minor +'@objectstack/mcp': minor +'@objectstack/cli': minor +'create-objectstack': patch +--- + +feat(mcp): decouple the stdio auto-start switch from the HTTP surface + surface the MCP endpoint on `os dev` boot (#3167) + +The MCP HTTP surface (`/api/v1/mcp`) and the long-lived stdio transport used to +share one env var: `OS_MCP_SERVER_ENABLED=true` turned the HTTP surface on **and** +silently auto-started the stdio transport — which bridges the raw metadata service ++ data engine with no per-request principal (unscoped). An operator setting it to +"make sure MCP is on" got an unscoped transport as a side effect. + +- **`@objectstack/types`** — new `resolveMcpStdioAutoStart()`. Stdio auto-start is + now its own switch, `OS_MCP_STDIO_ENABLED` (default off); `OS_MCP_SERVER_ENABLED` + governs only the HTTP surface. The legacy `OS_MCP_SERVER_ENABLED=true` trigger + still starts stdio for one release, flagged as deprecated. `=false` is unchanged + (it only ever gated HTTP). +- **`@objectstack/mcp`** — `MCPServerPlugin.start()` gates stdio on the new switch + and logs a one-time deprecation warning when started via the legacy alias. +- **`@objectstack/cli`** — `os dev` now prints the MCP endpoint, the agent-skill + URL, and a ready-to-paste `claude mcp add` command on boot (gated on the HTTP + surface being on), so the "an agent operates the app it's building" loop is + discoverable at dev time. +- **`create-objectstack`** — the blank scaffold README documents that the app is + itself an MCP server (the serve side), distinct from the consume-side connector. diff --git a/content/docs/ai/connect-mcp.mdx b/content/docs/ai/connect-mcp.mdx index 0448d0a930..84ff374aa0 100644 --- a/content/docs/ai/connect-mcp.mdx +++ b/content/docs/ai/connect-mcp.mdx @@ -97,7 +97,10 @@ Ten data and action tools, generated from your metadata: | `create_record` / `update_record` / `delete_record` | Write data | | `list_actions` / `run_action` | Discover and invoke your business actions by name | -Two exposure rules to know: +The tools are a fixed ~10-tool **spine** with the object name as a *parameter* +(`query_records(objectName, …)`), not one tool per object — so the list stays +usable on an app with hundreds of objects. Which objects and actions that spine +can reach is the **default exposure policy (v1)**: - **Objects are exposed automatically** — except `sys_*` system objects, which are blocked fail-closed. @@ -109,6 +112,14 @@ Two exposure rules to know: dispatch here. See [Actions as Tools](/docs/ai/actions-as-tools) and [Actions](/docs/ui/actions). + +This is the default policy, not a ceiling. A finer, **metadata-declared** exposure +config (opt objects out, curate the exposed set, promote a few high-value actions +to typed per-action tools) is the planned next step, consistent with ADR-0097's +"what is exposed should itself be authorable" — tracked in +[#3167](https://github.com/objectstack-ai/framework/issues/3167). + + ## The security model - **Every call runs as the caller.** The MCP bridge resolves the same diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index cb6186cb5e..cdb001936a 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -221,18 +221,31 @@ running app and gets a generated tool surface over your objects and actions same RBAC / RLS as the Console. No custom tooling, no separate API. See [Actions as Tools](/docs/ai/actions-as-tools) for the tool set. +The **HTTP surface** and the long-lived **stdio transport** are separate switches +(they used to share one, which let `=true` silently attach an unscoped transport — +see the note below): + ```bash -os start # MCP is served at /api/v1/mcp by default -OS_MCP_SERVER_ENABLED=false os start # opt out of the MCP surface -OS_MCP_SERVER_ENABLED=true os start # additionally auto-start the 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 start # additionally auto-start the local stdio transport ``` | 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. An explicit `true` additionally auto-starts the long-lived stdio transport at boot. | +| `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_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. | + +**Deprecated (one release):** `OS_MCP_SERVER_ENABLED=true` also used to auto-start +the stdio transport — overloading the HTTP switch, so setting it to "make sure MCP +is on" silently attached the unscoped stdio bridge. That trigger still works but now +logs a deprecation warning; use `OS_MCP_STDIO_ENABLED=true` instead. +`OS_MCP_SERVER_ENABLED=false` only ever gated HTTP and is unaffected. + + --- ## Search diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 9eb80a0fc7..f690750cb9 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -8,7 +8,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { printHeader, printKV, printStep, printError } from '../utils/format.js'; -import { readEnvWithDeprecation } from '@objectstack/types'; +import { readEnvWithDeprecation, isMcpServerEnabled } from '@objectstack/types'; /** * Resolve the persistent default database URL for `objectstack dev`. @@ -285,6 +285,25 @@ export default class Dev extends Command { if (actual !== requestedPort) { console.log(chalk.dim(` ↪ server bound to port ${actual} (requested ${requestedPort})`)); } + // ── MCP connect hint (#3167) ──────────────────────────────── + // The app IS an MCP server: the dispatcher serves /api/v1/mcp + // per-request, default-on (isMcpServerEnabled). Print how a + // coding agent (Claude Code, Cursor, any MCP client) attaches so + // the "AI builds the app it's running" loop is discoverable at the + // moment it's most useful — dev boot. An opted-out deployment + // (OS_MCP_SERVER_ENABLED=false) advertises nothing, mirroring the + // connect-UI / discovery gates that follow the same switch. + if (isMcpServerEnabled()) { + const base = + typeof msg.url === 'string' && msg.url ? msg.url.replace(/\/+$/, '') : `http://localhost:${actual}`; + const name = path.basename(process.cwd()) || 'objectstack'; + console.log(); + console.log(chalk.cyan(' 🤖 MCP server — connect a coding agent:')); + console.log(` Endpoint ${base}/api/v1/mcp`); + console.log(` Skill ${base}/api/v1/mcp/skill`); + console.log(chalk.dim(` Connect claude mcp add --transport http ${name} ${base}/api/v1/mcp`)); + console.log(chalk.dim(' Disable OS_MCP_SERVER_ENABLED=false')); + } } }); diff --git a/packages/create-objectstack/src/templates/blank/README.md b/packages/create-objectstack/src/templates/blank/README.md index 06ba2d7613..7b0adb628b 100644 --- a/packages/create-objectstack/src/templates/blank/README.md +++ b/packages/create-objectstack/src/templates/blank/README.md @@ -21,6 +21,24 @@ curl -c cookies.txt -X POST http://localhost:3000/api/v1/auth/sign-in/email \ curl -b cookies.txt "http://localhost:3000/api/v1/data/" ``` +## Your app is an MCP server + +Every ObjectStack app is itself a +[Model Context Protocol](https://modelcontextprotocol.io) server — **on by +default**, no plugin to install. `pnpm dev` prints the endpoint and a +ready-to-paste connect command on boot; point a coding agent (Claude Code, +Cursor, any MCP client) at it and it can read your schema, query data, and run +your exposed actions — all under the caller's own permissions and RLS: + +```bash +claude mcp add --transport http my-app http://localhost:3000/api/v1/mcp +``` + +Set `OS_MCP_SERVER_ENABLED=false` to turn it off. This is the *serve* side — the +reverse of the `mcp` connector below (which lets your app *call* other MCP +servers). See [Connect an MCP Client](https://docs.objectstack.ai/docs/ai/connect-mcp) +for OAuth, API keys, and which objects/actions become tools. + ## Layout - `objectstack.config.ts` — environment manifest (objects, API, plugins) diff --git a/packages/mcp/src/plugin.ts b/packages/mcp/src/plugin.ts index 4658801cd1..40361ece0b 100644 --- a/packages/mcp/src/plugin.ts +++ b/packages/mcp/src/plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; -import { readEnvWithDeprecation, isMcpServerEnabled } from '@objectstack/types'; +import { readEnvWithDeprecation, isMcpServerEnabled, resolveMcpStdioAutoStart } from '@objectstack/types'; import type { IAIService, IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; import { MCPServerRuntime } from './mcp-server-runtime.js'; import type { MCPServerRuntimeConfig } from './mcp-server-runtime.js'; @@ -125,16 +125,25 @@ export class MCPServerPlugin implements Plugin { // ── Auto-start if configured ── // 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 — so it stays opt-in via - // explicit `true` or the `autoStart` option. The HTTP surface does not - // depend on this: the runtime dispatcher serves `/api/v1/mcp` per-request. - const shouldStart = this.options.autoStart || readEnvWithDeprecation('OS_MCP_SERVER_ENABLED', 'MCP_SERVER_ENABLED', { silent: true }) === 'true'; + // 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 + // (`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. + const stdio = resolveMcpStdioAutoStart(); + const shouldStart = this.options.autoStart || stdio.enabled; + if (stdio.viaDeprecatedAlias && !this.options.autoStart) { + ctx.logger.warn( + '[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.', + ); + } if (shouldStart) { await this.runtime.start(); ctx.logger.info('[MCP] Server started automatically'); } else { ctx.logger.info( - '[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.', + '[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.', ); } diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 89917b15f4..63974bb08e 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -100,9 +100,9 @@ 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_SERVER_ENABLED=true)', state: 'experimental', + { 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 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.' }, + 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: '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/types/src/env.test.ts b/packages/types/src/env.test.ts index 3c294a6643..45f9fae079 100644 --- a/packages/types/src/env.test.ts +++ b/packages/types/src/env.test.ts @@ -6,6 +6,8 @@ import { readEnvWithDeprecation, resolveAllowDegradedTenancy, resolveSearchPinyinEnabled, + isMcpServerEnabled, + resolveMcpStdioAutoStart, } from './env.js'; describe('readEnvWithDeprecation', () => { @@ -172,3 +174,66 @@ describe('resolveSearchPinyinEnabled (#2486)', () => { } }); }); + +describe('MCP switches — HTTP surface vs stdio auto-start are decoupled (#3167)', () => { + const origServer = process.env.OS_MCP_SERVER_ENABLED; + const origServerLegacy = process.env.MCP_SERVER_ENABLED; + const origStdio = process.env.OS_MCP_STDIO_ENABLED; + const restore = (key: string, val: string | undefined) => { + if (val === undefined) delete process.env[key]; + else process.env[key] = val; + }; + afterEach(() => { + restore('OS_MCP_SERVER_ENABLED', origServer); + restore('MCP_SERVER_ENABLED', origServerLegacy); + restore('OS_MCP_STDIO_ENABLED', origStdio); + }); + + it('isMcpServerEnabled (HTTP surface): default-on, only explicit falsy opts out', () => { + delete process.env.OS_MCP_SERVER_ENABLED; + expect(isMcpServerEnabled()).toBe(true); + for (const v of ['false', '0', 'off', 'no', 'FALSE']) { + process.env.OS_MCP_SERVER_ENABLED = v; + expect(isMcpServerEnabled(), `${v} should opt out`).toBe(false); + } + for (const v of ['true', '1', 'anything']) { + process.env.OS_MCP_SERVER_ENABLED = v; + expect(isMcpServerEnabled(), `${v} keeps HTTP on`).toBe(true); + } + }); + + it('stdio auto-start: default OFF when nothing is set', () => { + delete process.env.OS_MCP_SERVER_ENABLED; + delete process.env.OS_MCP_STDIO_ENABLED; + expect(resolveMcpStdioAutoStart()).toEqual({ enabled: false, viaDeprecatedAlias: false }); + }); + + it('stdio auto-start: canonical OS_MCP_STDIO_ENABLED (truthy, no deprecation)', () => { + delete process.env.OS_MCP_SERVER_ENABLED; + for (const v of ['1', 'true', 'on', 'yes', 'TRUE']) { + process.env.OS_MCP_STDIO_ENABLED = v; + expect(resolveMcpStdioAutoStart(), v).toEqual({ enabled: true, viaDeprecatedAlias: false }); + } + }); + + it('stdio auto-start: legacy OS_MCP_SERVER_ENABLED=true still starts it, flagged deprecated', () => { + delete process.env.OS_MCP_STDIO_ENABLED; + process.env.OS_MCP_SERVER_ENABLED = 'true'; + expect(resolveMcpStdioAutoStart()).toEqual({ enabled: true, viaDeprecatedAlias: true }); + }); + + it('stdio auto-start: OS_MCP_SERVER_ENABLED=false (or other) never starts stdio — no footgun', () => { + delete process.env.OS_MCP_STDIO_ENABLED; + for (const v of ['false', '0', 'off', '1', 'on', 'yes']) { + process.env.OS_MCP_SERVER_ENABLED = v; + // Only the literal `true` was ever the legacy stdio trigger. + expect(resolveMcpStdioAutoStart().enabled, `server=${v}`).toBe(false); + } + }); + + it('canonical switch wins over the legacy alias (no deprecation flag)', () => { + process.env.OS_MCP_STDIO_ENABLED = 'true'; + process.env.OS_MCP_SERVER_ENABLED = 'true'; + expect(resolveMcpStdioAutoStart()).toEqual({ enabled: true, viaDeprecatedAlias: false }); + }); +}); diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index 874d94db1d..26f1b6ae57 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -134,10 +134,11 @@ export function resolveAllowDegradedTenancy(): boolean { * re-reading the env, so the served route, the advertised route, and the * authorization track can never disagree. * - * Note the asymmetry with the MCP plugin's *stdio* auto-start, which stays - * opt-in (explicit `true` / `autoStart`): attaching a long-lived stdio - * transport to every process is a side effect no default should impose, - * while the HTTP surface is served statelessly per-request. + * Note the asymmetry with the MCP plugin's *stdio* auto-start + * ({@link resolveMcpStdioAutoStart}), which stays opt-in and is gated by a + * SEPARATE switch: attaching a long-lived stdio transport to every process is + * a side effect no default should impose, while the HTTP surface is served + * statelessly per-request. */ export function isMcpServerEnabled(): boolean { const raw = readEnvWithDeprecation('OS_MCP_SERVER_ENABLED', 'MCP_SERVER_ENABLED', { @@ -147,6 +148,46 @@ export function isMcpServerEnabled(): boolean { return !['0', 'false', 'off', 'no'].includes(raw.trim().toLowerCase()); } +/** + * SINGLE decision point for "should the MCP plugin auto-start a long-lived + * (stdio) transport?" — distinct from {@link isMcpServerEnabled}, which governs + * the stateless HTTP surface. + * + * The stdio transport is a different, stricter posture: the plugin bridges the + * RAW metadata service + data engine onto the long-lived server with NO + * per-request principal (unscoped — see the `mcp-stdio-authority` conformance + * row), so it is safe only as a single-operator LOCAL tool and MUST stay + * opt-in. It defaults OFF. + * + * Canonical switch: `OS_MCP_STDIO_ENABLED` (truthy). The plugin also starts it + * when constructed with `{ autoStart: true }` (that path is checked by the + * caller, not here). + * + * DEPRECATED alias: `OS_MCP_SERVER_ENABLED=true` historically ALSO started + * stdio — overloading the very var that gates the HTTP surface, so an operator + * setting it to "make sure MCP is on" silently attached an unscoped transport. + * That trigger still works (with a one-time warning from the caller) for one + * release; prefer the dedicated var. Note `OS_MCP_SERVER_ENABLED=false` only + * ever gated the HTTP surface and never started stdio, so it is unaffected. + * + * @returns `enabled` — whether stdio auto-start is requested by the env; and + * `viaDeprecatedAlias` — whether it came through the legacy + * `OS_MCP_SERVER_ENABLED=true` trigger (so the caller can warn once). + */ +export function resolveMcpStdioAutoStart(): { enabled: boolean; viaDeprecatedAlias: boolean } { + const stdio = readEnvWithDeprecation('OS_MCP_STDIO_ENABLED', [], { silent: true }); + if (stdio != null && ['1', 'true', 'on', 'yes'].includes(stdio.trim().toLowerCase())) { + return { enabled: true, viaDeprecatedAlias: false }; + } + // Legacy trigger: only the literal `true` ever started stdio (preserved + // exactly). `OS_MCP_SERVER_ENABLED=false`/other values never did. + const legacy = readEnvWithDeprecation('OS_MCP_SERVER_ENABLED', 'MCP_SERVER_ENABLED', { silent: true }); + if (legacy != null && legacy.trim().toLowerCase() === 'true') { + return { enabled: true, viaDeprecatedAlias: true }; + } + return { enabled: false, viaDeprecatedAlias: false }; +} + /** * Maximum number of organizations a single user may CREATE, from `OS_ORG_LIMIT`. * The auth plugin forwards this as better-auth's `organizationLimit` in function