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
28 changes: 28 additions & 0 deletions .changeset/mcp-stdio-switch-split-and-dev-connect-hint.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 12 additions & 1 deletion content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).

<Callout type="info">
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).
</Callout>

## The security model

- **Every call runs as the caller.** The MCP bridge resolves the same
Expand Down
21 changes: 17 additions & 4 deletions content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

<Callout type="warn">
**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.
</Callout>

---

## Search
Expand Down
21 changes: 20 additions & 1 deletion packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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'));
}
}
});

Expand Down
18 changes: 18 additions & 0 deletions packages/create-objectstack/src/templates/blank/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_object>"
```

## 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)
Expand Down
21 changes: 15 additions & 6 deletions packages/mcp/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.',
);
}

Expand Down
4 changes: 2 additions & 2 deletions packages/qa/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
65 changes: 65 additions & 0 deletions packages/types/src/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
readEnvWithDeprecation,
resolveAllowDegradedTenancy,
resolveSearchPinyinEnabled,
isMcpServerEnabled,
resolveMcpStdioAutoStart,
} from './env.js';

describe('readEnvWithDeprecation', () => {
Expand Down Expand Up @@ -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 });
});
});
Loading