diff --git a/.changeset/mcp-default-on.md b/.changeset/mcp-default-on.md new file mode 100644 index 0000000000..e5cff9c396 --- /dev/null +++ b/.changeset/mcp-default-on.md @@ -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. diff --git a/.changeset/mcp-oauth-authorization.md b/.changeset/mcp-oauth-authorization.md index 10ef28e86b..2f2152029d 100644 --- a/.changeset/mcp-oauth-authorization.md +++ b/.changeset/mcp-oauth-authorization.md @@ -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=/api/v1/mcp` and verified locally (signature/issuer/audience/expiry) against the deployment's own JWKS — diff --git a/content/docs/ai/agents.mdx b/content/docs/ai/agents.mdx index b7143889a0..b8e21a5753 100644 --- a/content/docs/ai/agents.mdx +++ b/content/docs/ai/agents.mdx @@ -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 diff --git a/content/docs/api/index.mdx b/content/docs/api/index.mdx index dd863fd9f4..d1064fa5a0 100644 --- a/content/docs/api/index.mdx +++ b/content/docs/api/index.mdx @@ -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` diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index f2632211a5..59d8f565df 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -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. | diff --git a/content/docs/deployment/index.mdx b/content/docs/deployment/index.mdx index 66658cd049..4435bf1ef1 100644 --- a/content/docs/deployment/index.mdx +++ b/content/docs/deployment/index.mdx @@ -134,9 +134,9 @@ convention as `OS_MAIL_` (e.g. `OS_MAIL_SMTP_HOST`, `OS_MAIL_SMTP_PORT`), not a raw `SMTP_*` name. -**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). diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index fe025d012d..139f1c6ac3 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -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): diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 5d35c60630..785e0ef861 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -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 { @@ -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 diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 5d3f9f7323..acf5f66028 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -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 diff --git a/packages/mcp/src/__tests__/plugin.test.ts b/packages/mcp/src/__tests__/plugin.test.ts index c35ec5eb44..121892308e 100644 --- a/packages/mcp/src/__tests__/plugin.test.ts +++ b/packages/mcp/src/__tests__/plugin.test.ts @@ -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)); }); @@ -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'), ); }); diff --git a/packages/mcp/src/plugin.ts b/packages/mcp/src/plugin.ts index f32f5109fc..c6e6c1cb01 100644 --- a/packages/mcp/src/plugin.ts +++ b/packages/mcp/src/plugin.ts @@ -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) @@ -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.', ); } diff --git a/packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts b/packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts index ede9a75c02..e52d32a76c 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts @@ -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); }); @@ -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(); }); @@ -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(); }); @@ -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); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 5762c03a3d..18d76a2136 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -37,14 +37,22 @@ import { betterAuth } from 'better-auth'; describe('AuthManager', () => { let consoleSpy: ReturnType; + 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', () => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 951f130619..4ada8c3681 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -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'; @@ -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(); } /** diff --git a/packages/rest/package.json b/packages/rest/package.json index aab3475e0e..b8985a690c 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -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" }, diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b1fb7d105b..9603a06a66 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -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'; @@ -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; diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index a7f8e721ba..798f7e9bab 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -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(); }); }); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 47d022cde1..ce9a80306f 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -453,7 +453,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu } } // Discovery reflects MUTABLE runtime config (which routes/services - // are live — e.g. `mcp` only when OS_MCP_SERVER_ENABLED=true). It + // are live — e.g. `mcp` unless OS_MCP_SERVER_ENABLED=false). It // must never be cached by an edge/CDN, or a config change (enable // MCP) leaves clients reading a stale payload that still says the // route is absent — the Integrations UI then shows "MCP not diff --git a/packages/runtime/src/http-dispatcher.mcp.test.ts b/packages/runtime/src/http-dispatcher.mcp.test.ts index 6b98bba0d6..e8f5d77596 100644 --- a/packages/runtime/src/http-dispatcher.mcp.test.ts +++ b/packages/runtime/src/http-dispatcher.mcp.test.ts @@ -8,7 +8,7 @@ import { HttpDispatcher } from './http-dispatcher.js'; * These tests drive `handleMcp` directly to verify the gate + auth + bridge * wiring without standing up a full kernel. The MCP transport itself is tested * in @objectstack/mcp; here we assert: - * - opt-in gate (OS_MCP_SERVER_ENABLED) + * - default-on gate (OS_MCP_SERVER_ENABLED=false opts out) * - fail-closed auth (anonymous → 401) * - the injected bridge runs through callData bound to the request's * ExecutionContext (RLS/permissions), proving principal binding. @@ -74,14 +74,24 @@ describe('HttpDispatcher.handleMcp', () => { else process.env.OS_MCP_SERVER_ENABLED = prev; }); - it('returns 404 when MCP is not enabled (opt-in gate)', async () => { - delete process.env.OS_MCP_SERVER_ENABLED; + it('returns 404 when MCP is explicitly disabled (opt-out gate)', async () => { + process.env.OS_MCP_SERVER_ENABLED = 'false'; const { kernel } = makeKernel({ withMcp: true }); const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); const res = await d.handleMcp({}, makeContext()); expect(res.response.status).toBe(404); }); + it('serves MCP by default — unset env means enabled (core capability)', async () => { + delete process.env.OS_MCP_SERVER_ENABLED; + // No `mcp` service registered: an open gate reaches the 501 branch, + // a closed gate would have short-circuited to 404 before the lookup. + const { kernel } = makeKernel({ withMcp: false }); + const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); + const res = await d.handleMcp({}, makeContext()); + expect(res.response.status).toBe(501); + }); + describe('when enabled', () => { beforeEach(() => { process.env.OS_MCP_SERVER_ENABLED = 'true'; diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 5c1f640f1e..b74c39b628 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectKernel, getEnv, resolveLocale, evaluateAuthGate, isAuthGateAllowlisted } from '@objectstack/core'; +import { isMcpServerEnabled } from '@objectstack/types'; import { CoreServiceName } from '@objectstack/spec/system'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; import { pluralToSingular, PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; @@ -408,9 +409,10 @@ export class HttpDispatcher { * Handle an MCP request over the Streamable HTTP transport (`/mcp`). * * Gating + auth (fail-closed): - * - **opt-in**: only served when `OS_MCP_SERVER_ENABLED=true` (single-env - * runtime). Multi-tenant cloud overrides this gate per env. When off we - * return 404 so the surface isn't advertised. + * - **default-on**: served unless `OS_MCP_SERVER_ENABLED=false` (single-env + * runtime; MCP is a core platform capability). Multi-tenant cloud + * overrides this gate per env. When opted out we return 404 so the + * surface isn't advertised. * - **auth**: requires a principal already resolved by * `resolveExecutionContext` (the `sys_api_key` Bearer/header path or a * session). Anonymous → 401. @@ -516,9 +518,13 @@ export class HttpDispatcher { return { handled: true, response: { status: webRes.status, headers, body: responseBody } }; } - /** Whether the MCP HTTP surface is opted in for this single-env runtime. */ + /** + * Whether the MCP HTTP surface is on for this single-env runtime. + * Default-on core capability; `OS_MCP_SERVER_ENABLED=false` opts out + * (single decision point: `isMcpServerEnabled` in `@objectstack/types`). + */ private static isMcpEnabled(): boolean { - return typeof process !== 'undefined' && process.env?.OS_MCP_SERVER_ENABLED === 'true'; + return isMcpServerEnabled(); } /** @@ -1309,9 +1315,9 @@ export class HttpDispatcher { notifications: hasNotification ? `${prefix}/notifications` : undefined, ai: hasAi ? `${prefix}/ai` : undefined, i18n: hasI18n ? `${prefix}/i18n` : undefined, - // MCP (Streamable HTTP) is opt-in per env — only advertised - // when OS_MCP_SERVER_ENABLED=true so the surface isn't exposed - // by default. The objectui Integrations page reads this. + // MCP (Streamable HTTP) is a default-on core capability — + // advertised unless OS_MCP_SERVER_ENABLED=false opts the env + // out. The objectui Integrations page reads this. mcp: HttpDispatcher.isMcpEnabled() ? `${prefix}/mcp` : undefined, }; diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index 885eb96d2a..b478adbb92 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -103,6 +103,33 @@ export function resolveMultiOrgEnabled(): boolean { return String(raw ?? 'false').toLowerCase() !== 'false'; } +/** + * SINGLE decision point for "is the MCP HTTP surface (`/api/v1/mcp`) on?". + * + * MCP is a core platform capability and defaults ON: an unset + * `OS_MCP_SERVER_ENABLED` means the surface is served. Operators opt OUT with + * an explicit falsy value (`false`/`0`/`off`/`no`, case-insensitive); any + * other value — including the historical `true` — keeps it on. + * + * Every consumer of the flag — 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 — MUST call this instead of + * 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. + */ +export function isMcpServerEnabled(): boolean { + const raw = readEnvWithDeprecation('OS_MCP_SERVER_ENABLED', 'MCP_SERVER_ENABLED', { + silent: true, + }); + if (raw == null) return true; + return !['0', 'false', 'off', 'no'].includes(raw.trim().toLowerCase()); +} + /** * 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e260d89eeb..aaa89c0f82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1552,6 +1552,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../spec + '@objectstack/types': + specifier: workspace:* + version: link:../types exceljs: specifier: ^4.4.0 version: 4.4.0