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
29 changes: 29 additions & 0 deletions .changeset/mcp-default-on.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@objectstack/types': minor
'@objectstack/runtime': minor
'@objectstack/rest': minor
'@objectstack/cli': minor
'@objectstack/mcp': minor
'@objectstack/plugin-auth': minor
---

feat(mcp): the MCP surface is now **default-on** — a core platform capability (#2698)

`/api/v1/mcp` is served (and advertised in `/discovery`) out of the box; the
OAuth 2.1 authorization track and Dynamic Client Registration follow it, so a
fresh deployment is connectable by any MCP client with zero configuration.
Operators opt OUT with `OS_MCP_SERVER_ENABLED=false`.

- New single decision point `isMcpServerEnabled()` in `@objectstack/types`
(default on; explicit `false`/`0`/`off`/`no` disables). The runtime
dispatcher's `/mcp` route gate, the CLI's MCP plugin auto-load, the REST
`/discovery` advertisement, and the auth service's OAuth/DCR follow-defaults
all delegate to it — the served route, the advertised route, and the
authorization track can never disagree.
- The env var is now effectively tri-state: unset → HTTP surface on;
explicit `true` → additionally auto-start the long-lived **stdio** transport
at boot (unchanged, still opt-in — a default must not claim the process's
stdin/stdout); explicit `false` → everything off, fail-closed (404, no
metadata, no DCR).
- The OAuth 2.1 TLS rule is unaffected: on a plain-HTTP non-loopback origin
the OAuth track stays dark and the default-on surface remains API-key-only.
6 changes: 3 additions & 3 deletions .changeset/mcp-oauth-authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ decisions lives in #2698; the moving parts:
the MCP spec requires) whenever the MCP surface is on — every deployment is a
distinct AS, so clients cannot ship pre-registered IDs. Force it either way
with `OS_OIDC_DCR_ENABLED` or the new `plugins.dynamicClientRegistration`
auth-config field. The embedded AS itself now auto-enables when
`OS_MCP_SERVER_ENABLED=true` (explicit `OS_OIDC_PROVIDER_ENABLED=false` still
wins).
auth-config field. The embedded AS itself auto-enables whenever the MCP
surface is on — which is now the default (explicit
`OS_OIDC_PROVIDER_ENABLED=false` still wins).
- **Authorization-code + PKCE** flow with RFC 8707 resource binding: access
tokens are minted with `aud=<origin>/api/v1/mcp` and verified locally
(signature/issuer/audience/expiry) against the deployment's own JWKS —
Expand Down
5 changes: 3 additions & 2 deletions content/docs/ai/agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ instead — see the callout in the [AI Overview](/docs/ai).)

## Connect your AI (BYO-AI over MCP)

Every deployment with `OS_MCP_SERVER_ENABLED=true` serves MCP at
`/api/v1/mcp`, with two authentication tracks:
Every deployment serves MCP at `/api/v1/mcp` by default (a core platform
capability — set `OS_MCP_SERVER_ENABLED=false` to opt out), with two
authentication tracks:

- **OAuth 2.1 (interactive clients — recommended).** Each deployment is its
own spec-compliant authorization server: the endpoint publishes
Expand Down
5 changes: 3 additions & 2 deletions content/docs/api/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ REST and GraphQL are how *code* consumes your app. **MCP is how *AI* consumes it
Because every object and action is typed metadata, ObjectStack can expose the whole app
as a [Model Context Protocol](https://modelcontextprotocol.io) server — so an AI client
(Claude Code, Claude Desktop, Cursor, a local model) can inspect and *operate* the app
you built, under the same permissions and RLS as the UI. Enable it with
`OS_MCP_SERVER_ENABLED=true` (see [environment variables](/docs/deployment/environment-variables#mcp-server)).
you built, under the same permissions and RLS as the UI. It is served at `/api/v1/mcp`
by default — set `OS_MCP_SERVER_ENABLED=false` to opt out
(see [environment variables](/docs/deployment/environment-variables#mcp-server)).

The generated tools mirror the surfaces you already defined:
`list_objects` / `describe_object` (discover the schema), `query_records` / `get_record`
Expand Down
6 changes: 4 additions & 2 deletions content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,14 @@ 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.

```bash
OS_MCP_SERVER_ENABLED=true os start # boot with the MCP server (stdio by default)
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
```

| Variable | Type | Default | Description |
|:---|:---|:---|:---|
| `OS_MCP_SERVER_ENABLED` | boolean | `false` | When `true`, start the Model Context Protocol server at boot — exposing your objects and actions to MCP clients. |
| `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_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
6 changes: 3 additions & 3 deletions content/docs/deployment/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,9 @@ convention as `OS_MAIL_<KEY>` (e.g. `OS_MAIL_SMTP_HOST`, `OS_MAIL_SMTP_PORT`),
not a raw `SMTP_*` name.

<Callout type="tip">
**Make the deployed app AI-operable:** set `OS_MCP_SERVER_ENABLED=true` to start an MCP
server alongside any deployment mode, exposing your objects and actions to AI clients
under the same permissions and RLS. See
**Every deployed app is AI-operable by default:** an MCP server is served at
`/api/v1/mcp` in every deployment mode, exposing your objects and actions to AI clients
under the same permissions and RLS (set `OS_MCP_SERVER_ENABLED=false` to opt out). See
[MCP Server env vars](/docs/deployment/environment-variables#mcp-server) and
[Your app as an MCP server](/docs/api#your-app-as-an-mcp-server).
</Callout>
Expand Down
4 changes: 2 additions & 2 deletions content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,8 @@ security apply to the agent exactly as they do to a person. The support desk you
built in six steps is now a backend an agent can *run* — not just a database it
can read.

Connecting is self-serve. Start the app with the MCP surface on
(`OS_MCP_SERVER_ENABLED=true`), then add it to your client — the deployment is
Connecting is self-serve. The MCP surface is on by default — just add the
deployment to your client — and the deployment is
its own OAuth 2.1 authorization server, so interactive clients just open a
browser login (you connect as yourself, no admin-minted credentials):

Expand Down
15 changes: 8 additions & 7 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import chalk from 'chalk';
import { bundleRequire } from 'bundle-require';
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
import { readEnvWithDeprecation, resolveMultiOrgEnabled } from '@objectstack/types';
import { readEnvWithDeprecation, resolveMultiOrgEnabled, isMcpServerEnabled } from '@objectstack/types';
import { resolveObjectStackHome } from '@objectstack/runtime';
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
import {
Expand Down Expand Up @@ -474,12 +474,13 @@ export default class Serve extends Command {
if (requires.includes('auth') && !requires.includes('email')) {
requires.push('email');
}
// `OS_MCP_SERVER_ENABLED=true` says "serve MCP at /api/v1/mcp" — the
// dispatcher gates the route on the SAME env var, so honoring the flag
// without also loading the MCP plugin would 501 every request (#2698:
// flipping one switch must yield a connectable MCP endpoint). Explicit
// `requires: ['mcp']` in config works without the env var too.
if (process.env.OS_MCP_SERVER_ENABLED === 'true' && !requires.includes('mcp')) {
// MCP is a default-on core capability: serve `/api/v1/mcp` unless
// `OS_MCP_SERVER_ENABLED=false` opts out. The dispatcher gates the route
// on the SAME decision point (`isMcpServerEnabled`), so serving the
// route without also loading the MCP plugin would 501 every request
// (#2698: the default must yield a connectable MCP endpoint). Explicit
// `requires: ['mcp']` in config works regardless of the env var.
if (isMcpServerEnabled() && !requires.includes('mcp')) {
requires.push('mcp');
}
// Default capability slate — every preset except `minimal` gets the
Expand Down
5 changes: 3 additions & 2 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,9 @@ Please provide:

### Connecting to a running deployment (remote HTTP)

A running ObjectStack deployment (with `OS_MCP_SERVER_ENABLED=true`) serves
MCP over Streamable HTTP at `/api/v1/mcp`. Two authentication tracks:
A running ObjectStack deployment serves MCP over Streamable HTTP at
`/api/v1/mcp` by default (set `OS_MCP_SERVER_ENABLED=false` to opt out). Two
authentication tracks:

**OAuth 2.1 — the human-client track (recommended).** Each deployment is its
own spec-compliant authorization server (backed by the embedded better-auth
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ describe('MCPServerPlugin', () => {
await plugin.start(ctx as any);

expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringContaining('[MCP] Server ready but not started'),
expect.stringContaining('[MCP] Transport not auto-started'),
);
expect(ctx.trigger).toHaveBeenCalledWith('mcp:ready', expect.any(Object));
});
Expand Down Expand Up @@ -204,7 +204,7 @@ describe('MCPServerPlugin', () => {
await plugin.start(ctx as any);

expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringContaining('[MCP] Server ready but not started'),
expect.stringContaining('[MCP] Transport not auto-started'),
);
});

Expand Down
17 changes: 13 additions & 4 deletions packages/mcp/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@ export interface MCPServerPluginOptions {
* Lifecycle:
* 1. **init** — Creates {@link MCPServerRuntime} and registers as `'mcp'` service.
* 2. **start** — Bridges ToolRegistry, MetadataService, DataEngine, and Agents
* to the MCP server. Starts the transport if `autoStart` is enabled or
* the `OS_MCP_SERVER_ENABLED` environment variable is set.
* to the MCP server. Starts the long-lived transport (stdio) only when
* `autoStart` is enabled or `OS_MCP_SERVER_ENABLED` is explicitly `true` —
* the HTTP surface needs no start: the runtime dispatcher serves it
* per-request at `/api/v1/mcp` (default-on; `OS_MCP_SERVER_ENABLED=false`
* opts out — see `isMcpServerEnabled` in `@objectstack/types`).
* 3. **destroy** — Stops the MCP transport.
*
* Environment Variables:
* - `OS_MCP_SERVER_ENABLED=true` — Enable MCP server at startup
* - `OS_MCP_SERVER_ENABLED` — HTTP surface default-on; `false` disables it,
* explicit `true` additionally auto-starts the stdio transport
* - `OS_MCP_SERVER_NAME` — Override server name
* - `OS_MCP_SERVER_TRANSPORT` — Override transport ('stdio' | 'http')
* (legacy `MCP_SERVER_*` names still honoured with a deprecation warning)
Expand Down Expand Up @@ -118,13 +122,18 @@ 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';
if (shouldStart) {
await this.runtime.start();
ctx.logger.info('[MCP] Server started automatically');
} else {
ctx.logger.info(
'[MCP] Server ready but not started. Set OS_MCP_SERVER_ENABLED=true or use autoStart option.',
'[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.',
);
}

Expand Down
21 changes: 17 additions & 4 deletions packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ describe('isOAuthEligibleBaseUrl (OAuth 2.1 TLS rule, loopback exempt)', () => {
});

describe('enable-flag resolution (env → config → follows MCP surface)', () => {
it('defaults OFF when neither env nor config nor MCP is on', () => {
it('defaults ON — the MCP surface is a default-on core capability and AS/DCR follow it', () => {
expect(resolveOidcProviderEnabled({})).toBe(true);
expect(resolveDcrEnabled({})).toBe(true);
});

it('follows the MCP surface off when OS_MCP_SERVER_ENABLED=false', () => {
process.env.OS_MCP_SERVER_ENABLED = 'false';
expect(resolveOidcProviderEnabled({})).toBe(false);
expect(resolveDcrEnabled({})).toBe(false);
});
Expand Down Expand Up @@ -123,6 +129,7 @@ describe('canonical issuer / resource URLs', () => {
});

it('resource metadata URL is null when the AS is off (nothing advertised, fail-closed)', () => {
process.env.OS_MCP_SERVER_ENABLED = 'false'; // opt out of the default-on surface
expect(manager().getMcpResourceMetadataUrl()).toBeNull();
});

Expand Down Expand Up @@ -240,7 +247,7 @@ describe('verifyMcpAccessToken (local JWKS verification, fail-closed)', () => {
});

it('rejects every token when the OAuth track is off (provider disabled)', async () => {
delete process.env.OS_MCP_SERVER_ENABLED;
process.env.OS_MCP_SERVER_ENABLED = 'false'; // opt out of the default-on surface
const token = await signToken();
expect(await manager().verifyMcpAccessToken(token)).toBeNull();
});
Expand Down Expand Up @@ -282,8 +289,14 @@ describe('oauthProvider plugin wiring (DCR + scopes + audiences)', () => {
expect(opts.allowUnauthenticatedClientRegistration).toBe(false);
});

it('does not register the oauthProvider plugin at all when nothing enables it', async () => {
await capturePluginOpts({});
it('does not register the oauthProvider plugin when the MCP surface is opted out', async () => {
await capturePluginOpts({ OS_MCP_SERVER_ENABLED: 'false' });
expect((oauthProvider as any).mock.calls.length).toBe(0);
});

it('registers the oauthProvider plugin by default (MCP surface default-on)', async () => {
const opts = await capturePluginOpts({});
expect(opts).toBeDefined();
expect(opts.allowDynamicClientRegistration).toBe(true);
});
});
8 changes: 8 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,22 @@ import { betterAuth } from 'better-auth';

describe('AuthManager', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED;

beforeEach(() => {
vi.clearAllMocks();
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// This suite exercises the config-driven plugin list / public config in
// isolation. The MCP surface is default-ON and would append jwt +
// oauth-provider everywhere; pin it off here — the default-on behaviour
// has its own coverage in auth-manager.mcp-oauth.test.ts.
process.env.OS_MCP_SERVER_ENABLED = 'false';
});

afterEach(() => {
consoleSpy.mockRestore();
if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv;
});

describe('handleRequest – error response logging', () => {
Expand Down
11 changes: 8 additions & 3 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type {
} from '@objectstack/spec/system';
import type { IDataEngine } from '@objectstack/core';
import type { IEmailService } from '@objectstack/spec/contracts';
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit } from '@objectstack/types';
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit, isMcpServerEnabled } from '@objectstack/types';
import { mapMembershipRole, BUILTIN_IDENTITY_PLATFORM_ADMIN } from '@objectstack/spec';
import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js';
Expand Down Expand Up @@ -160,9 +160,14 @@ function readSsoOnlyEnv(): boolean | undefined {
return readBooleanEnv('OS_AUTH_SSO_ONLY');
}

/** Whether this runtime serves the HTTP MCP surface (`/api/v1/mcp`). */
/**
* Whether this runtime serves the HTTP MCP surface (`/api/v1/mcp`).
* Delegates to the platform-wide decision point (`isMcpServerEnabled` in
* `@objectstack/types`): default ON, explicit `false` opts out — so the
* OAuth/DCR follow-defaults below track the surface they exist to serve.
*/
export function readMcpServerEnabledEnv(): boolean {
return readBooleanEnv('OS_MCP_SERVER_ENABLED') ?? false;
return isMcpServerEnabled();
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/rest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@objectstack/platform-objects": "workspace:*",
"@objectstack/service-package": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*",
"exceljs": "^4.4.0",
"zod": "^4.4.3"
},
Expand Down
20 changes: 10 additions & 10 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { IHttpServer, resolveAuthzContext, resolveLocalizationContext, isAuthGateAllowlisted } from '@objectstack/core';
import { isMcpServerEnabled } from '@objectstack/types';
import { RouteManager } from './route-manager.js';
import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api';
import { ObjectStackProtocol } from '@objectstack/spec/api';
Expand Down Expand Up @@ -1931,16 +1932,15 @@ export class RestServer {
discovery.routes.ui = `${realBase}/ui`;
}

// MCP (Streamable HTTP) is opt-in per env — advertise it
// only when OS_MCP_SERVER_ENABLED=true so the objectui
// Integrations page surfaces the connect card. The /mcp
// route is mounted bare (not project-scoped), so point at
// the unscoped base. This `/discovery` (served by
// @objectstack/rest) is separate from the dispatcher's
// getDiscoveryInfo — both must advertise `mcp`.
const mcpEnabled =
(globalThis as any)?.process?.env?.OS_MCP_SERVER_ENABLED === 'true';
if (mcpEnabled) {
// MCP (Streamable HTTP) is a default-on core capability —
// advertise it unless OS_MCP_SERVER_ENABLED=false opts the
// env out, so the objectui Integrations page surfaces the
// connect card. The /mcp route is mounted bare (not
// project-scoped), so point at the unscoped base. This
// `/discovery` (served by @objectstack/rest) is separate
// from the dispatcher's getDiscoveryInfo — both must
// advertise `mcp` (single source: isMcpServerEnabled).
if (isMcpServerEnabled()) {
const unscopedBase = isScoped
? basePath.replace(/\/(environments|projects)\/:environmentId$/, '')
: basePath;
Expand Down
8 changes: 7 additions & 1 deletion packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2048,9 +2048,15 @@ describe('discovery — routes.mcp (ADR-0036, #152)', () => {
expect(body.routes.mcp).toBe('/api/v1/mcp');
});

it('omits routes.mcp when MCP is not enabled (opt-in)', async () => {
it('advertises routes.mcp by default — unset env means enabled (core capability)', async () => {
delete process.env.OS_MCP_SERVER_ENABLED;
const body = await invoke(discoveryHandler());
expect(body.routes.mcp).toBe('/api/v1/mcp');
});

it('omits routes.mcp when MCP is explicitly disabled (opt-out)', async () => {
process.env.OS_MCP_SERVER_ENABLED = 'false';
const body = await invoke(discoveryHandler());
expect(body.routes.mcp).toBeUndefined();
});
});
Expand Down
Loading