diff --git a/.env.production.example b/.env.production.example
index b1354fd8f..041299408 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -143,6 +143,9 @@ DEFAULT_COMPUTE_PROVIDER=docker
# GITLAB_WEBHOOK_SIGNING_TOKEN=
# Optional integrations.
+# Set to true to prevent curated integrations from being configured or used.
+# Existing connections remain stored and become available again once unset.
+# R_CURATED_INTEGRATIONS_DISABLED=true
# SLACK_APP_ID=
# R_SLACK_SIGNING_SECRET=
# R_TELEGRAM_BOT_TOKEN=
diff --git a/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts b/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts
index 0325079b0..ca119b35e 100644
--- a/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts
+++ b/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts
@@ -16,6 +16,13 @@ const { createLinearAgentRunMock } = vi.hoisted(() => ({
.mockResolvedValue({ status: 'ok', runId: 77, taskId: 'task-77' }),
}));
+const envState = vi.hoisted(() => ({
+ R_LINEAR_WEBHOOK_SECRET: 'test-linear-secret',
+ R_APP_URL: 'https://app.roomote.example',
+ PREVIEW_PROXY_BASE_URL: 'https://preview.roomote.example',
+ R_CURATED_INTEGRATIONS_DISABLED: false,
+}));
+
const {
findLinearDeploymentMcpConnectionByIdentityMock,
findLinearUserMcpConnectionByIdentityMock,
@@ -40,11 +47,7 @@ vi.mock('@roomote/env', async (importOriginal) => {
return {
...actual,
- Env: {
- R_LINEAR_WEBHOOK_SECRET: 'test-linear-secret',
- R_APP_URL: 'https://app.roomote.example',
- PREVIEW_PROXY_BASE_URL: 'https://preview.roomote.example',
- },
+ Env: envState,
};
});
@@ -276,6 +279,24 @@ describe('CLO-1133: active task run takes priority over routing confirmation and
app = new Hono();
app.route('/linear', linear);
vi.clearAllMocks();
+ envState.R_CURATED_INTEGRATIONS_DISABLED = false;
+ });
+
+ it('acknowledges without processing when curated integrations are disabled', async () => {
+ envState.R_CURATED_INTEGRATIONS_DISABLED = true;
+ const { rawBody, headers } = createSignedRequest(makePayload());
+
+ const response = await app.request('/linear', {
+ method: 'POST',
+ headers,
+ body: rawBody,
+ });
+
+ expect(response.status).toBe(204);
+ expect(createLinearAgentRunMock).not.toHaveBeenCalled();
+ expect(
+ findLinearDeploymentMcpConnectionByIdentityMock,
+ ).not.toHaveBeenCalled();
});
it('delivers free-text reply to active task run even when routing confirmation key exists in Redis', async () => {
diff --git a/apps/api/src/handlers/linear/index.ts b/apps/api/src/handlers/linear/index.ts
index 88dbe8533..f6a7cea4d 100644
--- a/apps/api/src/handlers/linear/index.ts
+++ b/apps/api/src/handlers/linear/index.ts
@@ -12,7 +12,7 @@ import {
PRODUCT_NAME,
restoreSnapshotResumeVisiblePromptFields,
} from '@roomote/types';
-import { Env } from '@roomote/env';
+import { Env, areCuratedIntegrationsDisabled } from '@roomote/env';
import {
type RoutingDebugInfo,
enqueueTask,
@@ -254,6 +254,10 @@ export const linear = new Hono();
* The handler must emit a "thought" activity within 10 seconds to acknowledge receipt.
*/
linear.post('/', async (c) => {
+ if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) {
+ return c.body(null, 204);
+ }
+
const headers = c.req.header();
const rawBody = await c.req.text();
diff --git a/apps/api/src/handlers/mcp/index.ts b/apps/api/src/handlers/mcp/index.ts
index 9ede9cd0b..b71b3b316 100644
--- a/apps/api/src/handlers/mcp/index.ts
+++ b/apps/api/src/handlers/mcp/index.ts
@@ -1,4 +1,5 @@
-import { Hono } from 'hono';
+import { Hono, type MiddlewareHandler } from 'hono';
+import { Env, areCuratedIntegrationsDisabled } from '@roomote/env';
import { isNativeMcpIntegration, MCP_INTEGRATIONS } from '@roomote/types';
import type { Variables } from '../../types';
@@ -19,6 +20,21 @@ import { vercelMcp } from './vercel';
export const mcp = new Hono<{ Variables: Variables }>();
+const requireCuratedIntegrations: MiddlewareHandler<{
+ Variables: Variables;
+}> = async (c, next) => {
+ if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) {
+ return c.notFound();
+ }
+
+ await next();
+};
+
+for (const integration of MCP_INTEGRATIONS) {
+ mcp.use(`/${integration.id}`, requireCuratedIntegrations);
+ mcp.use(`/${integration.id}/*`, requireCuratedIntegrations);
+}
+
mcp.route('/asana', asanaMcp);
mcp.route('/grafana', grafanaMcp);
mcp.route('/linear', linearMcp);
diff --git a/apps/api/src/handlers/mcp/routing.ts b/apps/api/src/handlers/mcp/routing.ts
index 5b41be4f3..3210a0e86 100644
--- a/apps/api/src/handlers/mcp/routing.ts
+++ b/apps/api/src/handlers/mcp/routing.ts
@@ -1,4 +1,5 @@
-import { Hono } from 'hono';
+import { Hono, type MiddlewareHandler } from 'hono';
+import { Env, areCuratedIntegrationsDisabled } from '@roomote/env';
import type { Variables } from '../../types';
@@ -15,7 +16,19 @@ import { roomoteMcp } from './roomote';
*/
export const mcpRouting = new Hono<{ Variables: Variables }>();
+const requireCuratedIntegrations: MiddlewareHandler<{
+ Variables: Variables;
+}> = async (c, next) => {
+ if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) {
+ return c.notFound();
+ }
+
+ await next();
+};
+
mcpRouting.route('/roomote', roomoteMcp);
+mcpRouting.use('/linear', requireCuratedIntegrations);
+mcpRouting.use('/linear/*', requireCuratedIntegrations);
mcpRouting.route(
'/linear',
createLinearMcp({
diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx
index 6ee6f6445..2f540d8d5 100644
--- a/apps/docs/environment-variables.mdx
+++ b/apps/docs/environment-variables.mdx
@@ -125,6 +125,7 @@ as per-task auth tokens or workspace paths.
| `R_INSTANCE_ID` | Optional | Stable anonymous deployment identifier sent with telemetry and version checks. Use a random, non-identifying value when overriding it. |
| `ROOMOTE_FORCE_TELEMETRY` | Development only | Force-enables telemetry in development or preview environments when a Ping endpoint is explicitly configured. |
| `R_CLOUD_ENABLED` | Roomote Cloud only | Deployment-managed switch for Roomote Cloud behavior, including required anonymous analytics and Cloud support integrations. Do not set this for self-hosted deployments. |
+| `R_CURATED_INTEGRATIONS_DISABLED` | Optional | Operator policy for the curated **Settings > Integrations** catalog, which is enabled by default. Set to `true` and restart Roomote to prevent those integrations from being configured or used. Existing connections remain stored while disabled and become available again once the value is unset. Communications, source-control, inference, sandbox providers, and environment-defined MCP servers are unaffected. |
| `R_GITHUB_APP_SLUG` | GitHub setup | GitHub App slug used by server-rendered setup and GitHub integration flows. |
| `SETUP_TOKEN` | Required (non-local) | One-time bootstrap token that admits the first admin at `/setup`. Required on every non-local deployment — tokenless bootstrap is allowed only when `NODE_ENV` is not `production` and `R_APP_ENV` is `development`, so anything running with `NODE_ENV=production` needs it. Without it, first-admin bootstrap stays closed so nobody can claim the founding-admin slot by reaching the URL first. Optional only in local development. |
| `DASHBOARD_PASSWORD` | Production | Local fallback/admin password value used by the deployment. Generate a strong secret. |
diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx
index afecc1d97..24fd1d9c9 100644
--- a/apps/docs/integrations/index.mdx
+++ b/apps/docs/integrations/index.mdx
@@ -14,6 +14,13 @@ This section does not list provider categories that have their own setup paths:
communications providers, source-control providers, inference providers, and
sandbox providers. Configure those from the provider-specific docs instead.
+Deployment operators can prevent every integration in this curated catalog
+from being configured or used by setting
+`R_CURATED_INTEGRATIONS_DISABLED=true` and restarting Roomote. Existing
+connections remain stored but inactive, so unsetting the value restores
+them. This policy does not affect the separate provider categories above or
+MCP servers defined on an environment.
+
## Connection patterns
You will usually see one of these setup models:
diff --git a/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts b/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts
index abf30c14c..b6bc24a51 100644
--- a/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts
+++ b/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts
@@ -171,6 +171,22 @@ describe('GET /api/mcp-oauth/callback', () => {
expect(discoverOAuthEndpointsMock).not.toHaveBeenCalled();
});
+ it('rejects a pending callback when integrations become disabled', async () => {
+ bootstrapWebRuntimeEnvMock.mockResolvedValue({
+ R_APP_URL: 'http://localhost:13000',
+ R_PUBLIC_URL: 'https://customer.example',
+ R_CURATED_INTEGRATIONS_DISABLED: true,
+ });
+
+ const response = await GET(buildRequest('?code=auth-code&state=state-1'));
+
+ expect(response.headers.get('location')).toBe(
+ 'https://customer.example/settings?mcp=error&reason=callback_failed',
+ );
+ expect(consumeOAuthStateMock).not.toHaveBeenCalled();
+ expect(exchangeCodeForTokensMock).not.toHaveBeenCalled();
+ });
+
it('falls back to R_APP_URL for token exchange redirect_uri when R_PUBLIC_URL is unset', async () => {
bootstrapWebRuntimeEnvMock.mockResolvedValue({
R_APP_URL: 'http://localhost:13000',
diff --git a/apps/web/src/app/api/mcp-oauth/callback/route.ts b/apps/web/src/app/api/mcp-oauth/callback/route.ts
index c8e42bb25..1a3e11550 100644
--- a/apps/web/src/app/api/mcp-oauth/callback/route.ts
+++ b/apps/web/src/app/api/mcp-oauth/callback/route.ts
@@ -166,6 +166,10 @@ export async function GET(request: NextRequest) {
const redirectToResult = (result: McpOAuthResult) =>
redirectWithMcpResult(webUrl, redirectPath, result, state);
+ if (webEnv.R_CURATED_INTEGRATIONS_DISABLED === true) {
+ return redirectToResult({ status: 'error', reason: 'callback_failed' });
+ }
+
// Handle OAuth errors
if (error) {
const reason: McpOAuthErrorReason =
diff --git a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts
index 21eda8459..2c4201233 100644
--- a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts
+++ b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts
@@ -181,6 +181,24 @@ describe('GET /api/mcp-oauth/initiate/[connectionId]', () => {
});
});
+ it('rejects OAuth before authentication when integrations are disabled', async () => {
+ bootstrapWebRuntimeEnvMock.mockResolvedValue({
+ R_APP_URL: 'http://localhost:13000',
+ R_PUBLIC_URL: 'https://customer.example',
+ R_CURATED_INTEGRATIONS_DISABLED: true,
+ });
+
+ const response = await GET(buildRequest(), {
+ params: Promise.resolve({ connectionId: CONNECTION_ID }),
+ });
+
+ expect(response.headers.get('location')).toBe(
+ 'https://customer.example/settings?mcp=error&reason=disabled',
+ );
+ expect(authorizeMock).not.toHaveBeenCalled();
+ expect(mcpConnectionsFindFirstMock).not.toHaveBeenCalled();
+ });
+
it('uses the Linear API OAuth flow instead of Linear MCP OAuth', async () => {
getMcpIntegrationOauthEndpointsMock.mockReturnValue({
authorizationEndpoint: 'https://linear.app/oauth/authorize',
diff --git a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts
index 126234dd9..638f6d120 100644
--- a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts
+++ b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts
@@ -160,6 +160,12 @@ export async function GET(
DEFAULT_REDIRECT_PATH;
const replayToken = requestUrl.searchParams.get('replayToken');
+ if (webEnv.R_CURATED_INTEGRATIONS_DISABLED === true) {
+ return NextResponse.redirect(
+ withMcpQuery(webUrl, redirectPath, 'error', 'disabled'),
+ );
+ }
+
const authResult = await authorize();
if (!authResult.success) {
return NextResponse.redirect(
diff --git a/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts b/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts
index b47041d2c..05cb74077 100644
--- a/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts
+++ b/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts
@@ -27,6 +27,13 @@ export async function GET(
const webEnv = await bootstrapWebRuntimeEnv();
const webUrl = getPublicAppUrl(webEnv);
const { token } = await params;
+
+ if (webEnv.R_CURATED_INTEGRATIONS_DISABLED === true) {
+ return NextResponse.redirect(
+ new URL('/error?message=Integrations are disabled', webUrl),
+ );
+ }
+
const replay = await getMcpOauthReplay(token);
if (!replay) {
diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx
index 45563600c..9d2ce8baf 100644
--- a/apps/web/src/components/settings/Integrations.test.tsx
+++ b/apps/web/src/components/settings/Integrations.test.tsx
@@ -12,6 +12,7 @@ import { toast } from 'sonner';
import { MCP_TOOL_CATALOG_REQUIRES_PERSONAL_CONNECTION } from '@/lib/mcp-tool-errors';
const state = vi.hoisted(() => ({
+ integrationsEnabled: true,
deploymentEnablements: [] as Array<{ mcpId: string; enabled: boolean }>,
oauthReadiness: [{ mcpId: 'linear', status: 'ready' as const }] as Array<{
mcpId: string;
@@ -192,6 +193,9 @@ vi.mock('@/hooks/linear', () => ({
}));
vi.mock('@/hooks/mcp-connections', () => ({
+ useCuratedIntegrationsAvailability: () => ({
+ data: { enabled: state.integrationsEnabled },
+ }),
useDeploymentMcpEnablements: () => ({
data: state.deploymentEnablements,
}),
@@ -429,6 +433,7 @@ describe('Integrations settings', () => {
vi.clearAllMocks();
window.history.replaceState(null, '', '/settings/integrations');
state.deploymentEnablements = [];
+ state.integrationsEnabled = true;
state.oauthReadiness = [{ mcpId: 'linear', status: 'ready' }];
state.userConnections = [];
state.mcpTools = null;
@@ -855,6 +860,22 @@ describe('Integrations settings', () => {
).toBeInTheDocument();
});
+ it('shows operator policy instead of integration controls when disabled', () => {
+ state.integrationsEnabled = false;
+
+ render();
+
+ expect(
+ screen.getByText('Integrations disabled by deployment operator'),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('heading', { name: 'Connected' }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Disable Linear' }),
+ ).not.toBeInTheDocument();
+ });
+
it('connects and enables an org-scoped MCP from the integrations page', () => {
render();
diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx
index ef159afd2..d1850f2c5 100644
--- a/apps/web/src/components/settings/Integrations.tsx
+++ b/apps/web/src/components/settings/Integrations.tsx
@@ -21,6 +21,7 @@ import {
import {
useAsanaConnection,
useConnectMcp,
+ useCuratedIntegrationsAvailability,
useDisconnectMcp,
useGrafanaConnection,
useDeploymentMcpEnablements,
@@ -43,6 +44,9 @@ import {
} from '@/types';
import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
BasicTooltip,
Button,
Card,
@@ -1148,6 +1152,7 @@ export function Integrations() {
const disconnectLinear = useDisconnectLinear();
const deploymentEnablements = useDeploymentMcpEnablements();
+ const integrationsAvailability = useCuratedIntegrationsAvailability();
const oauthReadiness = useMcpOauthReadiness();
const linearOauthStatus = oauthReadiness.data?.find(
(entry) => entry.mcpId === 'linear',
@@ -1988,6 +1993,18 @@ export function Integrations() {
});
};
+ if (integrationsAvailability.data?.enabled === false) {
+ return (
+
+ Integrations disabled by deployment operator
+
+ Curated integrations cannot be connected or used on this Roomote
+ instance.
+
+
+ );
+ }
+
return (
{
+ it('allows the default and non-disabled values', () => {
+ expect(() => assertCuratedIntegrationsEnabled(undefined)).not.toThrow();
+ expect(() => assertCuratedIntegrationsEnabled(false)).not.toThrow();
+ });
+
+ it('rejects operator-disabled integrations', () => {
+ expect(() => assertCuratedIntegrationsEnabled(true)).toThrow(
+ CURATED_INTEGRATIONS_DISABLED_MESSAGE,
+ );
+ expect(() => assertCuratedIntegrationsEnabled('true')).toThrow(
+ CURATED_INTEGRATIONS_DISABLED_MESSAGE,
+ );
+ });
+});
diff --git a/apps/web/src/lib/server/curated-integrations.ts b/apps/web/src/lib/server/curated-integrations.ts
new file mode 100644
index 000000000..558c03920
--- /dev/null
+++ b/apps/web/src/lib/server/curated-integrations.ts
@@ -0,0 +1,15 @@
+import { Env, areCuratedIntegrationsDisabled } from './env';
+
+export const CURATED_INTEGRATIONS_DISABLED_MESSAGE =
+ 'Integrations are disabled by the deployment operator.';
+
+export function assertCuratedIntegrationsEnabled(
+ disabledFlag:
+ | string
+ | boolean
+ | undefined = Env.R_CURATED_INTEGRATIONS_DISABLED,
+) {
+ if (areCuratedIntegrationsDisabled(disabledFlag)) {
+ throw new Error(CURATED_INTEGRATIONS_DISABLED_MESSAGE);
+ }
+}
diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts
index 5904b2348..f988cd908 100644
--- a/apps/web/src/lib/server/env.ts
+++ b/apps/web/src/lib/server/env.ts
@@ -3,6 +3,7 @@ import path from 'node:path';
import * as dotenvx from '@dotenvx/dotenvx';
import {
+ areCuratedIntegrationsDisabled,
createRoomoteEnv,
getAllowedDevOrigins as getSharedAllowedDevOrigins,
getArtifactSigningKey,
@@ -195,6 +196,7 @@ function getWebRuntimeEnv(): RoomoteEnv {
}
export {
+ areCuratedIntegrationsDisabled,
getArtifactSigningKey,
getArtifactSigningKeyPrevious,
getBetterAuthSecret,
diff --git a/apps/web/src/trpc/commands/linear/index.test.ts b/apps/web/src/trpc/commands/linear/index.test.ts
index 2799424c6..ca32d9429 100644
--- a/apps/web/src/trpc/commands/linear/index.test.ts
+++ b/apps/web/src/trpc/commands/linear/index.test.ts
@@ -7,7 +7,7 @@ const {
resolveDeploymentEnvVarMock,
upsertDeploymentEnvironmentVariablesMock,
} = vi.hoisted(() => ({
- envState: {} as Record,
+ envState: {} as Record,
deletedConnectionsState: [] as Array<{ id: string }>,
persistedEnvVarNamesState: [] as string[],
deleteDeploymentEnvironmentVariablesMock: vi.fn(),
@@ -16,7 +16,11 @@ const {
upsertDeploymentEnvironmentVariablesMock: vi.fn(),
}));
-vi.mock('@/lib/server/env', () => ({ Env: envState }));
+vi.mock('@/lib/server/env', () => ({
+ Env: envState,
+ areCuratedIntegrationsDisabled: (value: string | boolean | undefined) =>
+ value === true || value === 'true' || value === '1',
+}));
vi.mock('@/lib/server/get-public-app-url', () => ({
getPublicAppUrl: () => 'https://roomote.example',
}));
diff --git a/apps/web/src/trpc/commands/linear/oauth-setup.ts b/apps/web/src/trpc/commands/linear/oauth-setup.ts
index e134c6070..abfaeab9d 100644
--- a/apps/web/src/trpc/commands/linear/oauth-setup.ts
+++ b/apps/web/src/trpc/commands/linear/oauth-setup.ts
@@ -11,6 +11,7 @@ import {
import { LINEAR_ORG_CONNECTION_ROLE } from '@roomote/sdk/server';
import { Env } from '@/lib/server/env';
+import { assertCuratedIntegrationsEnabled } from '@/lib/server/curated-integrations';
import { getPublicAppUrl } from '@/lib/server/get-public-app-url';
import type { UserAuthSuccess } from '@/types';
@@ -185,6 +186,7 @@ export async function saveLinearOauthSetupCommand(
input: SaveLinearOauthSetupInput,
) {
assertAdmin(auth);
+ assertCuratedIntegrationsEnabled();
const runtimeEnv = getLinearRuntimeEnv();
const currentValues = await Promise.all(
diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts
index 30d5d799e..5d12237bf 100644
--- a/apps/web/src/trpc/commands/mcp-connections/index.ts
+++ b/apps/web/src/trpc/commands/mcp-connections/index.ts
@@ -34,7 +34,8 @@ import { getValidAccessToken } from '@roomote/sdk/server';
import type { UserAuthSuccess } from '@/types';
import type { StaticOauthReadiness } from '@/lib/server/mcp-static-oauth';
import { getDeploymentStaticOauthReadiness } from '@/lib/server/deployment-static-oauth';
-import { Env } from '@/lib/server/env';
+import { Env, areCuratedIntegrationsDisabled } from '@/lib/server/env';
+import { assertCuratedIntegrationsEnabled } from '@/lib/server/curated-integrations';
import { MCP_TOOL_CATALOG_REQUIRES_PERSONAL_CONNECTION } from '@/lib/mcp-tool-errors';
import type {
SaveAsanaConnectionInput,
@@ -521,6 +522,14 @@ export async function getDeploymentMcpEnablementsCommand(
});
}
+export function getCuratedIntegrationsAvailabilityCommand() {
+ return {
+ enabled: !areCuratedIntegrationsDisabled(
+ Env.R_CURATED_INTEGRATIONS_DISABLED,
+ ),
+ };
+}
+
/**
* Return public-safe OAuth setup status for integrations that require a
* deployment-configured client. Credential names and values never leave the
@@ -553,6 +562,10 @@ export async function setDeploymentMcpEnabledCommand(
) {
assertAdmin(auth);
+ if (input.enabled) {
+ assertCuratedIntegrationsEnabled();
+ }
+
if (!ALL_DEPLOYMENT_CONTROLLED_APP_IDS.has(input.mcpId)) {
throw new Error(`Unknown deployment-controlled app: ${input.mcpId}`);
}
@@ -788,6 +801,7 @@ export async function saveSnowflakeConnectionCommand(
input: SaveSnowflakeConnectionCommandInput,
) {
assertAdmin(auth);
+ assertCuratedIntegrationsEnabled();
const existingConnection = await db.query.mcpConnections.findFirst({
where: and(
@@ -937,6 +951,7 @@ export async function saveAsanaConnectionCommand(
input: SaveAsanaConnectionInput,
) {
assertAdmin(auth);
+ assertCuratedIntegrationsEnabled();
const existingConnection = await db.query.mcpConnections.findFirst({
where: and(
@@ -1020,6 +1035,7 @@ export async function saveVercelConnectionCommand(
input: SaveVercelConnectionInput,
) {
assertAdmin(auth);
+ assertCuratedIntegrationsEnabled();
const existingConnection = await db.query.mcpConnections.findFirst({
where: and(
@@ -1107,6 +1123,7 @@ export async function saveGrafanaConnectionCommand(
input: SaveGrafanaConnectionInput,
) {
assertAdmin(auth);
+ assertCuratedIntegrationsEnabled();
const existingConnection = await db.query.mcpConnections.findFirst({
where: and(
@@ -1192,6 +1209,7 @@ export async function listDeploymentMcpIntegrationToolsCommand(
input: { mcpId: string },
) {
assertAdmin(auth);
+ assertCuratedIntegrationsEnabled();
const integration = getMcpIntegration(input.mcpId);
if (!integration) {
throw new Error(`Unknown MCP integration: ${input.mcpId}`);
@@ -1218,6 +1236,7 @@ export async function setDeploymentDisabledMcpIntegrationToolsCommand(
input: { mcpId: string; disabledTools: string[] },
) {
assertAdmin(auth);
+ assertCuratedIntegrationsEnabled();
const integration = getMcpIntegration(input.mcpId);
if (!integration) {
throw new Error(`Unknown MCP integration: ${input.mcpId}`);
@@ -1259,6 +1278,7 @@ export async function connectMcpCommand(
auth: UserAuthSuccess,
input: { mcpId: string; redirectTo?: string; role?: McpConnectionRole },
) {
+ assertCuratedIntegrationsEnabled();
const integration = getMcpIntegration(input.mcpId);
if (!integration) {
throw new Error(`Unknown MCP integration: ${input.mcpId}`);
diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts
index 958b0180c..f3c18f437 100644
--- a/apps/web/src/trpc/routers/_app.ts
+++ b/apps/web/src/trpc/routers/_app.ts
@@ -190,6 +190,7 @@ import {
} from '../commands/sandbox-session';
import {
getDeploymentMcpEnablementsCommand,
+ getCuratedIntegrationsAvailabilityCommand,
getMcpOauthReadinessCommand,
setDeploymentMcpEnabledCommand,
getUserMcpConnectionsCommand,
@@ -1542,6 +1543,10 @@ export const appRouter = createRouter({
}),
mcpConnections: createRouter({
+ availability: protectedProcedure.query(() =>
+ getCuratedIntegrationsAvailabilityCommand(),
+ ),
+
deploymentEnablements: protectedProcedure.query(({ ctx: { auth } }) =>
getDeploymentMcpEnablementsCommand(auth),
),
diff --git a/docker-compose.production.yml b/docker-compose.production.yml
index f9c377755..994a91aa9 100644
--- a/docker-compose.production.yml
+++ b/docker-compose.production.yml
@@ -22,6 +22,7 @@ x-roomote-production-env: &roomote-production-env
R_APP_URL: https://${ROOMOTE_APP_DOMAIN:?ROOMOTE_APP_DOMAIN is required}
R_PUBLIC_URL: https://${ROOMOTE_APP_DOMAIN:?ROOMOTE_APP_DOMAIN is required}
R_INSTANCE_ID: ${R_INSTANCE_ID:-}
+ R_CURATED_INTEGRATIONS_DISABLED: ${R_CURATED_INTEGRATIONS_DISABLED:-false}
TRPC_URL: http://api:3001
PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required}
NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required}
diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml
index 7565540c8..b2f30730e 100644
--- a/docker-compose.self-host.yml
+++ b/docker-compose.self-host.yml
@@ -21,6 +21,7 @@ x-roomote-env: &roomote-env
S3_BUCKET_ARTIFACTS: ${S3_BUCKET_ARTIFACTS:-roomote-artifacts}
R_APP_URL: ${R_PUBLIC_URL:-http://localhost:13000}
R_INSTANCE_ID: ${R_INSTANCE_ID:-}
+ R_CURATED_INTEGRATIONS_DISABLED: ${R_CURATED_INTEGRATIONS_DISABLED:-false}
TRPC_URL: http://api:3001
PREVIEW_PROXY_BASE_URL: ${PREVIEW_PROXY_BASE_URL:-http://localhost:18081}
PREVIEW_DOMAINS: ${PREVIEW_DOMAINS:-localhost,127.0.0.1,roomotepreview.localhost}
diff --git a/packages/env/src/__tests__/index.test.ts b/packages/env/src/__tests__/index.test.ts
index ce498505e..98a991ae6 100644
--- a/packages/env/src/__tests__/index.test.ts
+++ b/packages/env/src/__tests__/index.test.ts
@@ -1,5 +1,6 @@
import {
Env,
+ areCuratedIntegrationsDisabled,
assertSecureBootBinding,
createRoomoteEnv,
getActiveInsecureLocalSecrets,
@@ -199,6 +200,25 @@ describe('Env', () => {
expect(isRoomoteCloudEnabled('false')).toBe(false);
});
+ it('enables curated integrations by default and accepts an operator opt-out', () => {
+ const runtimeEnv = { ...process.env };
+ delete runtimeEnv.SKIP_ENV_VALIDATION;
+ delete runtimeEnv.R_CURATED_INTEGRATIONS_DISABLED;
+
+ expect(createRoomoteEnv(runtimeEnv).R_CURATED_INTEGRATIONS_DISABLED).toBe(
+ false,
+ );
+ expect(
+ createRoomoteEnv({
+ ...runtimeEnv,
+ R_CURATED_INTEGRATIONS_DISABLED: 'true',
+ }).R_CURATED_INTEGRATIONS_DISABLED,
+ ).toBe(true);
+ expect(areCuratedIntegrationsDisabled(undefined)).toBe(false);
+ expect(areCuratedIntegrationsDisabled('1')).toBe(true);
+ expect(areCuratedIntegrationsDisabled('0')).toBe(false);
+ });
+
it('accepts valid Ping instance IDs and rejects invalid ones', () => {
const runtimeEnv = { ...process.env };
delete runtimeEnv.SKIP_ENV_VALIDATION;
diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts
index 5a6cbab9d..7fce23605 100644
--- a/packages/env/src/index.ts
+++ b/packages/env/src/index.ts
@@ -118,6 +118,10 @@ const serverSchema = {
// Roomote Cloud-only analytics and support integrations. These values are
// intentionally not used by self-hosted deployments.
R_CLOUD_ENABLED: optInBoolean(),
+ // Operator policy for the curated Settings > Integrations catalog. Enabled
+ // by default; operators opt out explicitly. Existing connections remain
+ // stored but cannot be configured or used while disabled.
+ R_CURATED_INTEGRATIONS_DISABLED: optInBoolean(),
R_INTERCOM_APP_ID: z.string().min(1).optional(),
R_POSTHOG_PROJECT_KEY: z.string().min(1).optional(),
R_POSTHOG_HOST: z.string().url().optional(),
@@ -566,6 +570,21 @@ export function isRoomoteCloudEnabled(
);
}
+/**
+ * Whether the operator has switched off curated integrations on this
+ * deployment. Enabled unless explicitly disabled.
+ */
+export function areCuratedIntegrationsDisabled(
+ value: string | boolean | undefined,
+): boolean {
+ if (typeof value === 'string') {
+ const normalized = value.trim().toLowerCase();
+ return normalized === 'true' || normalized === '1';
+ }
+
+ return value === true;
+}
+
/**
* Whether the deployment opted into generating missing auth keypairs at boot
* and persisting them in the database (`R_AUTO_GENERATE_KEYS=true`).
diff --git a/packages/sdk/src/server/routers/linear-sessions.test.ts b/packages/sdk/src/server/routers/linear-sessions.test.ts
new file mode 100644
index 000000000..7a49875a1
--- /dev/null
+++ b/packages/sdk/src/server/routers/linear-sessions.test.ts
@@ -0,0 +1,68 @@
+import type { AuthTokenContext } from '@roomote/types';
+
+const { envState, findConnectionMock, getValidAccessTokenMock } = vi.hoisted(
+ () => ({
+ envState: { R_CURATED_INTEGRATIONS_DISABLED: false },
+ findConnectionMock: vi.fn(),
+ getValidAccessTokenMock: vi.fn(),
+ }),
+);
+
+vi.mock('@roomote/env', () => ({
+ Env: envState,
+ areCuratedIntegrationsDisabled: (value: boolean | undefined) =>
+ value === true,
+}));
+
+vi.mock('@roomote/db/server', () => ({
+ db: { query: { taskRuns: { findFirst: vi.fn() } } },
+ taskRuns: { id: 'taskRuns.id' },
+ eq: vi.fn(),
+}));
+
+vi.mock('@roomote/linear', () => ({
+ createLinearClient: vi.fn(),
+ drainLinearMessagesToResumeRun: vi.fn(),
+}));
+
+vi.mock('../lib/mcp/data', () => ({
+ getValidAccessToken: getValidAccessTokenMock,
+}));
+
+vi.mock('../lib/mcp/linear-connections', () => ({
+ findLinearDeploymentMcpConnection: findConnectionMock,
+ getLinearDeploymentMetadata: vi.fn(),
+}));
+
+import { linearSessionsRouter } from './linear-sessions';
+
+function createCaller() {
+ const auth: AuthTokenContext = {
+ userId: 'user-1',
+ tokenType: 'auth',
+ version: 1,
+ };
+
+ return linearSessionsRouter.createCaller({ auth, req: undefined });
+}
+
+describe('linearSessionsRouter operator policy', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ envState.R_CURATED_INTEGRATIONS_DISABLED = false;
+ });
+
+ it('blocks existing Linear sessions when curated integrations are disabled', async () => {
+ envState.R_CURATED_INTEGRATIONS_DISABLED = true;
+
+ await expect(createCaller().hasActiveConnection()).resolves.toBe(false);
+ await expect(
+ createCaller().emitThought({
+ sessionId: 'session-1',
+ content: 'Working',
+ }),
+ ).rejects.toThrow('Linear connection not found.');
+ expect(findConnectionMock).not.toHaveBeenCalled();
+ expect(getValidAccessTokenMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/sdk/src/server/routers/linear-sessions.ts b/packages/sdk/src/server/routers/linear-sessions.ts
index 1ba826784..392ad5840 100644
--- a/packages/sdk/src/server/routers/linear-sessions.ts
+++ b/packages/sdk/src/server/routers/linear-sessions.ts
@@ -1,4 +1,5 @@
import { z } from 'zod';
+import { Env, areCuratedIntegrationsDisabled } from '@roomote/env';
import { TaskPayloadKind } from '@roomote/types';
import { db, taskRuns, eq } from '@roomote/db/server';
@@ -21,6 +22,10 @@ import {
} from '../trpc';
async function findActiveConnection() {
+ if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) {
+ return null;
+ }
+
return findLinearDeploymentMcpConnection();
}
diff --git a/packages/sdk/src/server/routers/mcp-connections.test.ts b/packages/sdk/src/server/routers/mcp-connections.test.ts
index aa70944dd..80ee051c6 100644
--- a/packages/sdk/src/server/routers/mcp-connections.test.ts
+++ b/packages/sdk/src/server/routers/mcp-connections.test.ts
@@ -1,5 +1,15 @@
import type { AuthTokenContext, RunTokenContext } from '@roomote/types';
+const mockEnv = vi.hoisted(() => ({
+ R_CURATED_INTEGRATIONS_DISABLED: false,
+}));
+
+vi.mock('@roomote/env', () => ({
+ Env: mockEnv,
+ areCuratedIntegrationsDisabled: (value: boolean | undefined) =>
+ value === true,
+}));
+
const {
mockFindTaskRun,
mockFindEnablements,
@@ -178,6 +188,7 @@ function buildEnabledOnlyRow(mcpId: string) {
describe('mcpConnectionsRouter.getMcpServerConfigs', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockEnv.R_CURATED_INTEGRATIONS_DISABLED = false;
mockFindTaskRun.mockResolvedValue({
actingUserId: null,
});
@@ -186,6 +197,16 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => {
mockOrderBy.mockResolvedValue([buildJoinedConnectionRow()]);
});
+ it('returns no curated servers when the operator disables integrations', async () => {
+ mockEnv.R_CURATED_INTEGRATIONS_DISABLED = true;
+
+ const result = await createCaller().getMcpServerConfigs();
+
+ expect(result).toEqual({ servers: {} });
+ expect(mockSelect).not.toHaveBeenCalled();
+ expect(mockGetValidAccessToken).not.toHaveBeenCalled();
+ });
+
it('returns Notion proxy config without raw OAuth bearer token', async () => {
mockGetValidAccessToken.mockResolvedValue('notion-raw-access-token');
diff --git a/packages/sdk/src/server/routers/mcp-connections.ts b/packages/sdk/src/server/routers/mcp-connections.ts
index 7f070ea47..ac82b931b 100644
--- a/packages/sdk/src/server/routers/mcp-connections.ts
+++ b/packages/sdk/src/server/routers/mcp-connections.ts
@@ -1,5 +1,6 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
+import { Env, areCuratedIntegrationsDisabled } from '@roomote/env';
import {
db,
desc,
@@ -64,6 +65,10 @@ export const mcpConnectionsRouter = router({
}),
)
.query(async ({ input }) => {
+ if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) {
+ return false;
+ }
+
// Deployment-scoped enablement: valid for any authenticated
// principal, including deployment-service-principal run tokens.
const enablement = await db.query.deploymentMcpEnablements.findFirst({
@@ -133,6 +138,10 @@ export const mcpConnectionsRouter = router({
* Returns a map of sanitized server names to { url, headers }.
*/
getMcpServerConfigs: authenticatedProcedure.query(async ({ ctx }) => {
+ if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) {
+ return { servers: {} };
+ }
+
const actorContext = await resolveActorScopedUserContext(ctx.auth);
const connectionFilters = [];