From 3ac918b631c85856d2d0670dd22e959b92bd96d4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 30 Sep 2026 16:53:39 -0700 Subject: [PATCH] fix(search): gate Zoom rollout and correct Lucid tiles --- apps/docs/components/icons.tsx | 3 +- .../integrations/live-member-integrations.tsx | 23 +++-- .../integrations/live-search-settings.tsx | 9 +- .../integrations-showcase.tsx | 9 +- .../components/tool-input/tool-input.tsx | 16 +++- apps/sim/components/icons.tsx | 3 +- .../credential-group-provider-tile.tsx | 9 +- apps/sim/hooks/mcp/use-mcp-tools.ts | 3 +- .../knowledge/search-integrations.ts | 7 +- .../contracts/mothership-search-sources.ts | 7 +- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/core/config/feature-flags.ts | 5 + .../hubspot-mcp.integration.ts | 96 +++++++++++++++++++ .../application/manage-groups.ts | 5 +- .../application/organization-accounts.ts | 5 +- apps/sim/lib/credential-groups/enrollments.ts | 6 ++ .../managed-mcp-connectors.ts | 25 +++-- .../credential-groups/managed-mcp-service.ts | 9 +- .../provider-availability.ts | 11 ++- apps/sim/lib/credentials/managed-mcp.ts | 5 + .../search-mcp-setup.integration.ts | 40 +++++++- .../application/search-integrations.ts | 23 ++++- apps/sim/lib/mcp/oauth/provider.ts | 6 ++ apps/sim/lib/sim-search/live/README.md | 12 +++ .../lib/sim-search/live/account-session.ts | 8 ++ .../lib/sim-search/live/application.test.ts | 5 +- apps/sim/lib/sim-search/live/mcp-accounts.ts | 10 +- apps/sim/lib/sim-search/live/member-setup.ts | 3 + .../lib/sim-search/live/provider-rollout.ts | 25 +++++ 29 files changed, 343 insertions(+), 46 deletions(-) create mode 100644 apps/sim/lib/sim-search/live/provider-rollout.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 598d8a41ff0..ef5cc9b3895 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -6,8 +6,7 @@ interface LucidIconProps extends SVGProps {} export function LucidIcon(props: LucidIconProps) { return ( - - + diff --git a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx index 86d9c1d59dd..613ff6b6069 100644 --- a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx @@ -74,6 +74,7 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt ) const available = LIVE_SEARCH_SOURCE_TYPES.filter( ([provider]) => + (approvals.get(provider)?.available !== false || accountsForProvider(provider).length > 0) && LIVE_SEARCH_SCOPE_FIELDS[provider] && ((provider !== 'hubspot' && provider !== 'zoom') || data.availableMcpConnectors.includes(provider) || @@ -130,6 +131,7 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt : undefined const accounts = accountsForProvider(provider) const ready = + approval?.available !== false && group?.status === 'active' && Boolean(option || server) && approved && @@ -140,15 +142,18 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt approval?.policy?.accessMode === 'service_account' ? 'Selected resources you can access' : 'All accessible content' - const state = !approved - ? 'Disabled by your organization' - : group && group.status !== 'active' - ? 'Connections are paused by your organization' - : !ready - ? 'Not configured' - : accounts.length - ? scope - : undefined + const state = + approval?.available === false + ? 'Currently unavailable' + : !approved + ? 'Disabled by your organization' + : group && group.status !== 'active' + ? 'Connections are paused by your organization' + : !ready + ? 'Not configured' + : accounts.length + ? scope + : undefined const description = [ accounts .map( diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx index 70c1d60a522..2170b304e25 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx @@ -82,7 +82,11 @@ export function LiveSearchSettings() { secretSource && GENERIC_SECRETS_META.name.toLowerCase().includes(search.toLowerCase()) const availableToAdd = [ ...LIVE_SEARCH_SOURCE_TYPES.filter( - ([type]) => LIVE_SEARCH_SCOPE_FIELDS[type] && !added.some((row) => row.connectorType === type) + ([type]) => + (type !== 'zoom' || + policies.data?.some((row) => row.connectorType === type && row.available !== false)) && + LIVE_SEARCH_SCOPE_FIELDS[type] && + !added.some((row) => row.connectorType === type) ).map(([type, meta]) => ({ type, meta, @@ -160,6 +164,7 @@ export function LiveSearchSettings() { const mcpProvider = liveSearchMcpConnector(type) const group = accounts.data?.credentialGroup const needsMemberSetup = + integration.available !== false && accounts.data && (memberProvider || mcpProvider) && (group?.status !== 'active' || @@ -188,7 +193,7 @@ export function LiveSearchSettings() { iconVariant='custom' icon={} title={meta.name} - description={scope} + description={integration.available === false ? 'Currently unavailable' : scope} trailing={
{serviceAccount && ( diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx index a461e2ad280..04993a39a24 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx @@ -1,5 +1,6 @@ import type { ComponentType } from 'react' import { cn } from '@sim/emcn' +import { getManagedMcpConnectorBgColor } from '@/lib/credential-groups/managed-mcp-connectors' import { BrandTile } from '@/app/workspace/[workspaceId]/components/resource-tile' import { getBlock } from '@/blocks' import { getTileIconColorClass } from '@/blocks/icon-color' @@ -47,11 +48,11 @@ const SHOWCASE_TILES = [ ] as const /** - * Resolves the brand background color for a block type from the block registry. - * Returns `null` when the block is unknown or has no brand color configured. + * Resolves the brand background color for workflow blocks and managed MCP connectors. + * Returns `null` when neither catalog provides a brand color. */ function resolveBrandTileBg(blockType: string): string | null { - return getBlock(blockType)?.bgColor || null + return getBlock(blockType)?.bgColor || getManagedMcpConnectorBgColor(blockType) || null } interface IntegrationTileProps { @@ -61,7 +62,7 @@ interface IntegrationTileProps { } /** - * Brand-colored square tile that renders a block's icon. The unframed variant + * Brand-colored square tile that renders an integration's icon. The unframed variant * is a 36px tile used in list rows and headers; the framed variant adds an * outer 44px halo used inside the showcase grid. */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index efd6bd34b4f..241f0c1f0bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -20,6 +20,7 @@ import { useParams } from 'next/navigation' import { McpIcon, WorkflowIcon } from '@/components/icons' import { McpOperationPolicyEditor } from '@/components/mcp/operation-policy-editor' import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' +import { getManagedMcpConnectorBgColor } from '@/lib/credential-groups/managed-mcp-connectors' import { MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { getIssueBadgeLabel, @@ -1200,7 +1201,10 @@ export const ToolInput = memo(function ToolInput({ serverToolItems.push({ label: 'Configure operations access', value: `mcp-server-all-${mcpServerDrilldown}`, - iconElement: createToolIcon('var(--brand-agent)', ServerIcon), + iconElement: createToolIcon( + getManagedMcpConnectorBgColor(server?.managedConnectorId) ?? 'var(--brand-agent)', + ServerIcon + ), onSelect: () => { if (allAlreadySelected) return const filteredTools = selectedTools.filter( @@ -1350,7 +1354,10 @@ export const ToolInput = memo(function ToolInput({ serverItems.push({ label: `${serverName} (${toolCount} tools)`, value: `mcp-server-folder-${serverId}`, - iconElement: createToolIcon('#6366F1', ServerIcon), + iconElement: createToolIcon( + getManagedMcpConnectorBgColor(server.managedConnectorId) ?? '#6366F1', + ServerIcon + ), suffixElement: , onSelect: () => { setMcpServerDrilldown(serverId) @@ -1572,7 +1579,10 @@ export const ToolInput = memo(function ToolInput({ : advancedMcpServer?.managedConnectorId ? getManagedMcpConnectorIcon(advancedMcpServer.managedConnectorId) : McpIcon - const mcpTileColor = mcpTool?.bgColor || 'var(--brand-agent)' + const mcpTileColor = + mcpTool?.bgColor || + getManagedMcpConnectorBgColor(advancedMcpServer?.managedConnectorId) || + 'var(--brand-agent)' const mcpToolSchema = isMcpTool ? tool.schema || mcpTool?.inputSchema : null // Canonical name wins; stored title only when nothing resolves diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 598d8a41ff0..ef5cc9b3895 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -6,8 +6,7 @@ interface LucidIconProps extends SVGProps {} export function LucidIcon(props: LucidIconProps) { return ( - - + diff --git a/apps/sim/ee/credential-groups/components/credential-group-provider-tile.tsx b/apps/sim/ee/credential-groups/components/credential-group-provider-tile.tsx index 34ff2c1e2c6..c5ba9b5fc4a 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-provider-tile.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-provider-tile.tsx @@ -1,7 +1,10 @@ import type { ComponentType } from 'react' import { getIntegrationTypesForOAuthServiceId } from '@sim/deployment-config/integration-availability' import { INTEGRATION_METADATA } from '@sim/deployment-config/integration-metadata' -import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' +import { + getManagedMcpConnectorBgColor, + type ManagedMcpConnectorId, +} from '@/lib/credential-groups/managed-mcp-connectors' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { blockTypeToIconMap } from '@/lib/integrations/icon-mapping' import { BrandTile } from '@/app/workspace/[workspaceId]/components/resource-tile' @@ -18,7 +21,9 @@ export function CredentialGroupProviderTile({ provider, icon }: CredentialGroupP return ( ) } diff --git a/apps/sim/hooks/mcp/use-mcp-tools.ts b/apps/sim/hooks/mcp/use-mcp-tools.ts index 9ff721a2205..afcb68da5f9 100644 --- a/apps/sim/hooks/mcp/use-mcp-tools.ts +++ b/apps/sim/hooks/mcp/use-mcp-tools.ts @@ -11,6 +11,7 @@ import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { McpIcon } from '@/components/icons' import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' +import { getManagedMcpConnectorBgColor } from '@/lib/credential-groups/managed-mcp-connectors' import { createMcpToolId } from '@/lib/mcp/shared' import type { McpToolSchema } from '@/lib/mcp/types' import { useMcpToolsQuery } from '@/hooks/queries/mcp' @@ -54,7 +55,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { serverName: tool.serverName, type: 'mcp' as const, inputSchema: tool.inputSchema, - bgColor: '#6366F1', + bgColor: getManagedMcpConnectorBgColor(tool.managedConnectorId) ?? '#6366F1', icon: tool.managedConnectorId ? getManagedMcpConnectorIcon(tool.managedConnectorId) : McpIcon, })) }, [mcpToolsData]) diff --git a/apps/sim/lib/api/contracts/knowledge/search-integrations.ts b/apps/sim/lib/api/contracts/knowledge/search-integrations.ts index 0689b8de524..b0e199baef0 100644 --- a/apps/sim/lib/api/contracts/knowledge/search-integrations.ts +++ b/apps/sim/lib/api/contracts/knowledge/search-integrations.ts @@ -11,6 +11,11 @@ export const searchIntegrationApprovalSchema = z.object({ }) export type SearchIntegrationApproval = z.output +export const searchIntegrationStatusSchema = searchIntegrationApprovalSchema.extend({ + available: z.boolean().optional(), +}) +export type SearchIntegrationStatus = z.output + export const listSearchIntegrationsQuerySchema = z.object({ organizationId: organizationIdSchema }) export type ListSearchIntegrationsQuery = z.input export const listSearchIntegrationsContract = defineRouteContract({ @@ -19,7 +24,7 @@ export const listSearchIntegrationsContract = defineRouteContract({ query: listSearchIntegrationsQuerySchema, response: { mode: 'json', - schema: successResponseSchema(z.array(searchIntegrationApprovalSchema).max(100)), + schema: successResponseSchema(z.array(searchIntegrationStatusSchema).max(100)), }, }) diff --git a/apps/sim/lib/api/contracts/mothership-search-sources.ts b/apps/sim/lib/api/contracts/mothership-search-sources.ts index b2634017d5c..573cb1d3179 100644 --- a/apps/sim/lib/api/contracts/mothership-search-sources.ts +++ b/apps/sim/lib/api/contracts/mothership-search-sources.ts @@ -3,7 +3,10 @@ import { searchSourcePageSchema, searchSourceSummarySchema, } from '@/lib/api/contracts/knowledge/connectors' -import { searchIntegrationApprovalSchema } from '@/lib/api/contracts/knowledge/search-integrations' +import { + searchIntegrationApprovalSchema, + searchIntegrationStatusSchema, +} from '@/lib/api/contracts/knowledge/search-integrations' const connectorTypeSchema = z.string().trim().min(1).max(100) @@ -36,7 +39,7 @@ export const organizationSearchSourcesOutputSchema = z.discriminatedUnion('actio z.object({ action: z.literal('get'), source: searchSourceSummarySchema }), z.object({ action: z.literal('providers'), - providers: z.array(searchIntegrationApprovalSchema).max(100), + providers: z.array(searchIntegrationStatusSchema).max(100), }), z.object({ action: z.literal('setup'), diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 5d5c07a1775..b3c3a204fee 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -580,6 +580,7 @@ export const env = createEnv({ INSTAGRAM_CLIENT_SECRET: z.string().optional(), // Instagram App Secret (Business Login) SHOPIFY_CLIENT_ID: z.string().optional(), // Shopify OAuth client ID SHOPIFY_CLIENT_SECRET: z.string().optional(), // Shopify OAuth client secret + ZOOM_SEARCH: z.boolean().optional(), ZOOM_MCP_CLIENT_ID: z.string().optional(), // Zoom Search MCP OAuth client ID ZOOM_MCP_CLIENT_SECRET: z.string().optional(), // Zoom Search MCP OAuth client secret ZOOM_CLIENT_ID: z.string().optional(), // Zoom OAuth client ID diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 5ff9c68972b..83925454c75 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -68,6 +68,11 @@ const FEATURE_FLAGS = { 'Capture durable Workflow Agent tool history and continue existing retries. Supports workspace rollout targeting; version-aware memory storage remains active when capture is disabled.', fallback: 'AGENT_MEMORY_HISTORY', }, + 'zoom-search': { + description: + 'Enable Zoom Search setup, personal authorization and retrieval. Organization targeting only; disabled by default. Standard workflow Zoom OAuth is unchanged.', + fallback: 'ZOOM_SEARCH', + }, 'slack-search-shared-app': { description: 'Enable the official shared Slack app for existing Search customers. Supports orgId ' + diff --git a/apps/sim/lib/credential-groups/__integration__/hubspot-mcp.integration.ts b/apps/sim/lib/credential-groups/__integration__/hubspot-mcp.integration.ts index 4115e03b8ea..157ab6a0ce7 100644 --- a/apps/sim/lib/credential-groups/__integration__/hubspot-mcp.integration.ts +++ b/apps/sim/lib/credential-groups/__integration__/hubspot-mcp.integration.ts @@ -5,22 +5,27 @@ import { credential, credentialGroupEnrollment, mcpServers, + member, organization, user, } from '@sim/db/schema' import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure' import { sha256Hex } from '@sim/security/hash' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { env } from '@/lib/core/config/env' import { encryptSecret } from '@/lib/core/security/encryption' +import { getOrganizationAccountsSettings } from '@/lib/credential-groups/application/organization-accounts' +import { disconnectPersonalOrganizationAccount } from '@/lib/credential-groups/application/personal-organization-accounts' import { completePublicCredentialGroupMcpOAuth, startPublicCredentialGroupMcpOAuth, } from '@/lib/credential-groups/application/public-enrollment' import { createManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-service' import { consumeCredentialGroupMcpOAuthAttempt } from '@/lib/credential-groups/mcp-oauth-state' +import { listConfiguredManagedMcpConnectors } from '@/lib/credential-groups/provider-availability' import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service' import { encryptManagedMcpTokens, @@ -37,6 +42,7 @@ import { } from '@/lib/mcp/oauth/provider' import { getOrCreateOauthRow, saveClientInformation } from '@/lib/mcp/oauth/storage' import { mcpService } from '@/lib/mcp/service' +import { listManagedMcpSearchAccounts } from '@/lib/sim-search/live/mcp-accounts' const SECRET = 'isolated-shared-client-secret' const SHARED_CLIENTS = [ @@ -67,6 +73,7 @@ describe.each(SHARED_CLIENTS)('$name shared member connector', (connector) => { beforeEach(async () => { Object.assign(env, { REDIS_URL: readTestRedisUrl(), + ZOOM_SEARCH: true, [connector.clientIdKey]: 'fixture-shared-client', [connector.clientSecretKey]: SECRET, }) @@ -253,6 +260,74 @@ describe.each(SHARED_CLIENTS)('$name shared member connector', (connector) => { await expect(runtime(id)).rejects.toThrow(/authorization/) }) if (connector.id === 'zoom') { + it('refuses new Zoom sign-in servers when the rollout is off without persisting setup', async () => { + Object.assign(env, { ZOOM_SEARCH: false }) + await expect(create()).rejects.toThrow(/Zoom Search.*not available/) + expect( + await db.select().from(mcpServers).where(eq(mcpServers.credentialGroupId, group)) + ).toEqual([]) + }) + it('hides existing Zoom grants from enrollment and Search inventories when the rollout is off', async () => { + await db.insert(member).values({ + id: generateId(), + organizationId: org, + userId: owner, + role: 'owner', + }) + const { mcpServer } = await create() + const client = await loadPreregisteredClient(mcpServer.id) + const id = await grant(mcpServer.id, client?.configurationFingerprint) + const scope = { kind: 'organization', organizationId: org } as const + expect(await listConfiguredManagedMcpConnectors(group, scope)).toContain('zoom') + expect( + (await listManagedMcpSearchAccounts({ organizationId: org }, owner)).map((row) => row.id) + ).toContain(id) + Object.assign(env, { ZOOM_SEARCH: false }) + expect(await listConfiguredManagedMcpConnectors(group, scope)).not.toContain('zoom') + expect(await listManagedMcpSearchAccounts({ organizationId: org }, owner)).toEqual([]) + const principal = createSessionPrincipal({ userId: owner, sessionId: generateId() }) + const settings = await getOrganizationAccountsSettings.execute({ + principal, + input: { organizationId: org }, + }) + expect(settings.viewerMcpAccounts).toContainEqual( + expect.objectContaining({ credentialId: id, mcpServerId: mcpServer.id }) + ) + expect(settings.availableMcpConnectors).not.toContain('zoom') + Object.assign(env, { ZOOM_SEARCH: true }) + expect( + (await listManagedMcpSearchAccounts({ organizationId: org }, owner)).map((row) => row.id) + ).toContain(id) + Object.assign(env, { ZOOM_SEARCH: false }) + await disconnectPersonalOrganizationAccount.execute({ + principal, + input: { credentialId: id }, + }) + const disconnected = await getOrganizationAccountsSettings.execute({ + principal, + input: { organizationId: org }, + }) + expect(disconnected.viewerMcpAccounts).toEqual([]) + Object.assign(env, { ZOOM_SEARCH: true }) + expect(await listManagedMcpSearchAccounts({ organizationId: org }, owner)).toEqual([]) + }) + it('blocks existing Zoom runtime grants after rollout disablement and permits them after re-enable', async () => { + const { mcpServer } = await create() + const client = await loadPreregisteredClient(mcpServer.id) + const id = await grant(mcpServer.id, client?.configurationFingerprint) + expect((await runtime(id)).tokens.access_token).toBe('fixture-access') + Object.assign(env, { ZOOM_SEARCH: false }) + await expect(runtime(id)).rejects.toThrow(/Zoom Search.*not available/) + Object.assign(env, { ZOOM_SEARCH: true }) + expect((await runtime(id)).tokens.access_token).toBe('fixture-access') + }) + it('does not release the shared Zoom OAuth registration when the organization rollout is off', async () => { + const { mcpServer } = await create() + Object.assign(env, { ZOOM_SEARCH: false }) + await expect(loadPreregisteredClient(mcpServer.id)).rejects.toThrow( + /Zoom Search.*not available/ + ) + }) it.each(['initial consent', 'runtime scope challenge'] as const)( 'restricts generic OAuth %s to registered read permissions', async (phase) => { @@ -433,6 +508,27 @@ describe.each(SHARED_CLIENTS)('$name shared member connector', (connector) => { return attempt! } const attempt = await start() + if (connector.id === 'zoom') { + Object.assign(env, { ZOOM_SEARCH: false }) + await expect(start()).rejects.toThrow(/invalid|expired|available/i) + await expect( + completePublicCredentialGroupMcpOAuth.execute({ + principal, + input: { attempt, code: 'disabled-code' }, + }) + ).rejects.toMatchObject({ + name: 'CredentialGroupInvitationUnavailableError', + statusCode: 409, + }) + expect(exchanges).toBe(0) + expect( + await db + .select() + .from(credential) + .where(eq(credential.credentialGroupEnrollmentId, enrollmentId)) + ).toEqual([]) + Object.assign(env, { ZOOM_SEARCH: true }) + } await completePublicCredentialGroupMcpOAuth.execute({ principal, input: { attempt, code: 'fixture-code' }, diff --git a/apps/sim/lib/credential-groups/application/manage-groups.ts b/apps/sim/lib/credential-groups/application/manage-groups.ts index f42af48462c..e22b2434b3b 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.ts @@ -43,7 +43,10 @@ export const getWorkspaceAccountsSettings = defineAuthorizedWorkspaceUseCase({ return { credentialGroup, availableProviders: listConfiguredCredentialGroupProviders(), - availableMcpConnectors: await listConfiguredManagedMcpConnectors(credentialGroup?.id), + availableMcpConnectors: await listConfiguredManagedMcpConnectors(credentialGroup?.id, { + kind: 'workspace', + workspaceId: context.workspaceId, + }), } }, }) diff --git a/apps/sim/lib/credential-groups/application/organization-accounts.ts b/apps/sim/lib/credential-groups/application/organization-accounts.ts index bd51cf4cf16..4c61403dd2d 100644 --- a/apps/sim/lib/credential-groups/application/organization-accounts.ts +++ b/apps/sim/lib/credential-groups/application/organization-accounts.ts @@ -176,7 +176,10 @@ export const getOrganizationAccountsSettings = defineOrganizationAccountsUseCase }) : [], availableProviders: listConfiguredCredentialGroupProviders(), - availableMcpConnectors: await listConfiguredManagedMcpConnectors(credentialGroup?.id), + availableMcpConnectors: await listConfiguredManagedMcpConnectors(credentialGroup?.id, { + kind: 'organization', + organizationId: context.organizationId, + }), canManage: context.role === 'owner' || context.role === 'admin', indexingAvailable: await isKnowledgeMemberAccessAvailable({ organizationId: context.organizationId, diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index c2246d2f682..4a06c489db3 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -48,6 +48,7 @@ import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' import type { DbTransaction } from '@/lib/db/types' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' +import { isSearchProviderEnabled } from '@/lib/sim-search/live/provider-rollout' const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1000 const DELIVERY_CONCURRENCY = 5 @@ -1474,6 +1475,11 @@ async function credentialGroupMcpOAuthContextFromRow( throw new Error(`Credential Group MCP server ${server.id} has no managed connector ID`) } getManagedMcpConnector(server.managedConnectorId) + if ( + server.managedConnectorId === 'zoom' && + !(await isSearchProviderEnabled('zoom', resourceScopeFromOwner(row))) + ) + return null return { enrollmentId: row.enrollment.id, userId: row.enrollment.userId, diff --git a/apps/sim/lib/credential-groups/managed-mcp-connectors.ts b/apps/sim/lib/credential-groups/managed-mcp-connectors.ts index 2acacd496bb..e19e67c2705 100644 --- a/apps/sim/lib/credential-groups/managed-mcp-connectors.ts +++ b/apps/sim/lib/credential-groups/managed-mcp-connectors.ts @@ -11,25 +11,25 @@ export const MANAGED_MCP_CONNECTOR_IDS = [ export type ManagedMcpConnectorId = (typeof MANAGED_MCP_CONNECTOR_IDS)[number] -interface FixedManagedMcpConnector { - id: Exclude +interface ManagedMcpConnectorMetadata { name: string description: string + bgColor?: string +} + +interface FixedManagedMcpConnector extends ManagedMcpConnectorMetadata { + id: Exclude url: string oauthClientRegistration: 'dynamic' } -interface DatabricksManagedMcpConnector { +interface DatabricksManagedMcpConnector extends ManagedMcpConnectorMetadata { id: 'databricks' - name: string - description: string oauthClientRegistration: 'preregistered' } -interface FixedPreregisteredManagedMcpConnector { +interface FixedPreregisteredManagedMcpConnector extends ManagedMcpConnectorMetadata { id: 'hubspot' | 'zoom' - name: string - description: string url: string oauthClientRegistration: 'preregistered' } @@ -50,6 +50,7 @@ export const MANAGED_MCP_CONNECTORS = { lucid: { id: 'lucid', name: 'Lucid', + bgColor: '#282C33', description: 'Search Lucidchart diagrams and Lucidspark boards using your Lucid account', url: 'https://mcp.lucid.app/mcp/readonly', oauthClientRegistration: 'dynamic', @@ -123,6 +124,14 @@ export function getManagedMcpConnector(connectorId: string): ManagedMcpConnector return MANAGED_MCP_CONNECTORS[connectorId] } +export function getManagedMcpConnectorBgColor( + connectorId: string | null | undefined +): string | undefined { + return connectorId && isManagedMcpConnectorId(connectorId) + ? getManagedMcpConnector(connectorId).bgColor + : undefined +} + function hostnameHasSuffix(hostname: string, suffixes: readonly string[]): boolean { return suffixes.some((suffix) => hostname.endsWith(suffix)) } diff --git a/apps/sim/lib/credential-groups/managed-mcp-service.ts b/apps/sim/lib/credential-groups/managed-mcp-service.ts index a86c5edbe42..881f1d1f4f7 100644 --- a/apps/sim/lib/credential-groups/managed-mcp-service.ts +++ b/apps/sim/lib/credential-groups/managed-mcp-service.ts @@ -30,6 +30,7 @@ import { } from '@/lib/mcp/domain-check' import { getSharedHubSpotMcpClient, getSharedZoomMcpClient } from '@/lib/mcp/oauth/shared-clients' import { generateMcpServerId } from '@/lib/mcp/utils' +import { isSearchProviderEnabled } from '@/lib/sim-search/live/provider-rollout' export class ManagedMcpConnectorError extends Error { constructor( @@ -228,9 +229,15 @@ export async function createManagedMcpConnector( ({ input: CreateManagedMcpConnectorInput } | { validated: ValidatedManagedMcpConnectorInput }), executor?: DbTransaction ): Promise { + const scope = resourceScopeFromOwner(params) + const requested = 'validated' in params ? params.validated.input : params.input + if (requested.connectorId === 'zoom' && !(await isSearchProviderEnabled('zoom', scope))) + throw new ManagedMcpConnectorError( + 'Zoom Search is not available for this organization', + 'forbidden' + ) const { input, url } = 'validated' in params ? params.validated : await validateManagedMcpConnectorInput(params.input) - const scope = resourceScopeFromOwner(params) const connector = getManagedMcpConnector(input.connectorId) const serverId = generateMcpServerId( scope.kind === 'workspace' ? scope.workspaceId : resourceScopeKey(scope), diff --git a/apps/sim/lib/credential-groups/provider-availability.ts b/apps/sim/lib/credential-groups/provider-availability.ts index cad0a88da1f..92426bd1460 100644 --- a/apps/sim/lib/credential-groups/provider-availability.ts +++ b/apps/sim/lib/credential-groups/provider-availability.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' import { and, eq, isNotNull, isNull, ne } from 'drizzle-orm' import { inspectConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import type { ResourceScope } from '@/lib/core/resource-scope' import { MANAGED_MCP_CONNECTOR_IDS, MANAGED_MCP_CONNECTORS, @@ -12,6 +13,7 @@ import { getCredentialGroupProviderId, isCredentialGroupStandardOAuthProvider, } from '@/lib/credential-groups/providers' +import { isSearchProviderEnabled } from '@/lib/sim-search/live/provider-rollout' /** * The providers this deployment can actually enroll. @@ -34,7 +36,10 @@ export function listConfiguredCredentialGroupProviders(): CredentialGroupProvide } /** Existing group registrations remain usable when the deployment has no shared HubSpot client. */ -export async function listConfiguredManagedMcpConnectors(credentialGroupId?: string) { +export async function listConfiguredManagedMcpConnectors( + credentialGroupId: string | undefined, + scope: ResourceScope +) { let hubspotReady = inspectConfiguredOAuthClient('hubspot-mcp').state === 'ready' if (!hubspotReady && credentialGroupId) { const [registration] = await db @@ -57,7 +62,9 @@ export async function listConfiguredManagedMcpConnectors(credentialGroupId?: str .limit(1) hubspotReady = Boolean(registration) } - const zoomReady = inspectConfiguredOAuthClient('zoom-mcp').state === 'ready' + const zoomReady = + inspectConfiguredOAuthClient('zoom-mcp').state === 'ready' && + (await isSearchProviderEnabled('zoom', scope)) return MANAGED_MCP_CONNECTOR_IDS.filter( (id) => (id !== 'hubspot' || hubspotReady) && (id !== 'zoom' || zoomReady) ) diff --git a/apps/sim/lib/credentials/managed-mcp.ts b/apps/sim/lib/credentials/managed-mcp.ts index a49ca311f7c..15a1c837e6e 100644 --- a/apps/sim/lib/credentials/managed-mcp.ts +++ b/apps/sim/lib/credentials/managed-mcp.ts @@ -29,6 +29,7 @@ import { import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { loadPreregisteredClient } from '@/lib/mcp/oauth/provider' import { generateManagedMcpConnectionId } from '@/lib/mcp/utils' +import { isSearchProviderEnabled } from '@/lib/sim-search/live/provider-rollout' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' const MANAGED_MCP_TOKEN_SET_TYPE = 'managed-mcp-oauth-token-set' as const @@ -266,6 +267,8 @@ export async function loadScopedManagedMcpRuntimeCredential( throw new ManagedMcpCredentialError('Managed MCP connector metadata is missing', 500) } const connector = getManagedMcpConnector(row.managedConnectorId) + if (connector.id === 'zoom' && !(await isSearchProviderEnabled('zoom', scope))) + throw new ManagedMcpCredentialError('Zoom Search is not available for this organization', 403) if (!row.serverUrl) throw new ManagedMcpCredentialError('Managed MCP endpoint is missing', 500) requireManagedMcpConnectorUrl(connector.id, row.serverUrl) if ( @@ -385,6 +388,8 @@ export async function persistManagedMcpCredential(params: { throw new ManagedMcpCredentialError('Managed MCP connection is no longer available', 404) } getManagedMcpConnector(source.managedConnectorId) + if (source.managedConnectorId === 'zoom' && !(await isSearchProviderEnabled('zoom', scope))) + throw new ManagedMcpCredentialError('Zoom Search is not available for this organization', 403) const [existing] = await tx .select({ id: credential.id }) diff --git a/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts b/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts index a97bdcfd82e..e5ce46f2bb5 100644 --- a/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts @@ -15,9 +15,14 @@ import { generateId } from '@sim/utils/id' import { toRecord } from '@sim/utils/object' import { eq, inArray, sql } from 'drizzle-orm' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { listSearchIntegrationsContract } from '@/lib/api/contracts/knowledge/search-integrations' +import { env } from '@/lib/core/config/env' import { createOrganizationAccountsGroup } from '@/lib/credential-groups/workspace-accounts' import { tryAcquireAdvisoryXactLock } from '@/lib/db/advisory-locks' -import { approveSearchIntegration } from '@/lib/knowledge/application/search-integrations' +import { + approveSearchIntegration, + listSearchIntegrations, +} from '@/lib/knowledge/application/search-integrations' import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema' /** @@ -41,6 +46,9 @@ describe('atomic organization live Search MCP setup', () => { }) beforeEach(async () => { + Object.assign(env, { + ZOOM_SEARCH: false, + }) ids = { organization: generateId(), owner: generateId(), @@ -105,6 +113,36 @@ describe('atomic organization live Search MCP setup', () => { return { groups, servers, approvals, policies, metadata: toRecord(organizations[0]?.metadata) } } + it('keeps disabled Zoom approvals visible and removable without permitting reapproval', async () => { + const connectorType = 'zoom' + await db.insert(organizationSearchIntegration).values({ + organizationId: ids.organization, + connectorType, + approved: true, + }) + const principal = createSessionPrincipal({ userId: ids.owner, sessionId: generateId() }) + const data = await listSearchIntegrations.execute({ + principal, + input: { organizationId: ids.organization }, + }) + const response = listSearchIntegrationsContract.response.schema.parse({ success: true, data }) + expect(response.data).toContainEqual( + expect.objectContaining({ connectorType, approved: true, available: false }) + ) + expect(response.data).toContainEqual( + expect.objectContaining({ connectorType: 'gmail', available: true }) + ) + await approveSearchIntegration.execute({ + principal, + input: { organizationId: ids.organization, connectorType, approved: false }, + }) + await expect(approve(connectorType)).rejects.toThrow(/Search.*not available/) + const state = await snapshot() + expect(state.approvals).toEqual([expect.objectContaining({ connectorType, approved: false })]) + expect(state.groups).toEqual([]) + expect(state.policies).toEqual([]) + }) + it.each([ ['fireflies', 'https://api.fireflies.ai/mcp'], ['granola', 'https://mcp.granola.ai/mcp'], diff --git a/apps/sim/lib/knowledge/application/search-integrations.ts b/apps/sim/lib/knowledge/application/search-integrations.ts index e3765463e8f..9456a2de42f 100644 --- a/apps/sim/lib/knowledge/application/search-integrations.ts +++ b/apps/sim/lib/knowledge/application/search-integrations.ts @@ -27,6 +27,7 @@ import { normalizeLiveSearchPolicy, } from '@/lib/sim-search/live/policy-schema' import { livePolicyFor, loadLiveSearchPolicies } from '@/lib/sim-search/live/policy-store' +import { isSearchProviderEnabled } from '@/lib/sim-search/live/provider-rollout' import { loadLiveServiceSource } from '@/lib/sim-search/live/service-sources' import { LIVE_SEARCH_SOURCE_TYPES, @@ -54,11 +55,19 @@ export const listSearchIntegrations = defineAuthorizedKnowledgeUseCase({ const policies = isLiveEnterpriseSearchEnabled ? await loadLiveSearchPolicies({ organizationId: context.organizationId }) : undefined + const scope = { kind: 'organization', organizationId: context.organizationId } as const + const zoomEnabled = + !isLiveEnterpriseSearchEnabled || (await isSearchProviderEnabled('zoom', scope)) return (isLiveEnterpriseSearchEnabled ? LIVE_SEARCH_SOURCE_TYPES : SEARCH_SOURCE_TYPES).map( ([connectorType]) => ({ connectorType, approved: approvals.get(connectorType) ?? false, - ...(policies ? { policy: livePolicyFor(policies, connectorType) } : {}), + ...(policies + ? { + policy: livePolicyFor(policies, connectorType), + available: connectorType !== 'zoom' || zoomEnabled, + } + : {}), }) ) }, @@ -78,6 +87,18 @@ export const approveSearchIntegration = defineAuthorizedKnowledgeUseCase({ if (!source) { throw new OrchestrationError('validation', 'This integration is not supported by Sim Search') } + if ( + isLiveEnterpriseSearchEnabled && + input.approved && + !(await isSearchProviderEnabled(input.connectorType, { + kind: 'organization', + organizationId: context.organizationId, + })) + ) + throw new OrchestrationError( + 'forbidden', + 'Zoom Search is not available for this organization' + ) if (input.policy && !isLiveEnterpriseSearchEnabled) throw new OrchestrationError('validation', 'Live search settings are not enabled') const memberProvider = diff --git a/apps/sim/lib/mcp/oauth/provider.ts b/apps/sim/lib/mcp/oauth/provider.ts index e3b7dd11a96..9249db768dc 100644 --- a/apps/sim/lib/mcp/oauth/provider.ts +++ b/apps/sim/lib/mcp/oauth/provider.ts @@ -10,6 +10,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' +import { resourceScopeFromOwner } from '@/lib/core/resource-scope' import { decryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' import { MANAGED_MCP_CONNECTORS } from '@/lib/credential-groups/managed-mcp-connectors' @@ -25,6 +26,7 @@ import { saveState, saveTokens as saveTokensDb, } from '@/lib/mcp/oauth/storage' +import { isSearchProviderEnabled } from '@/lib/sim-search/live/provider-rollout' const logger = createLogger('SimMcpOauthProvider') @@ -172,6 +174,8 @@ export async function loadPreregisteredClient( clientId: mcpServers.oauthClientId, clientSecret: mcpServers.oauthClientSecret, connectorId: mcpServers.managedConnectorId, + workspaceId: mcpServers.workspaceId, + organizationId: mcpServers.organizationId, url: mcpServers.url, authType: mcpServers.authType, groupId: mcpServers.credentialGroupId, @@ -191,6 +195,8 @@ export async function loadPreregisteredClient( row.deletedAt ) return undefined + if (!(await isSearchProviderEnabled('zoom', resourceScopeFromOwner(row)))) + throw new Error('Zoom Search is not available for this organization') if (row.clientId || row.clientSecret) throw new Error('Zoom Search uses the deployment OAuth registration') const shared = getSharedZoomMcpClient() diff --git a/apps/sim/lib/sim-search/live/README.md b/apps/sim/lib/sim-search/live/README.md index 18baee8477f..44a94b4e185 100644 --- a/apps/sim/lib/sim-search/live/README.md +++ b/apps/sim/lib/sim-search/live/README.md @@ -149,6 +149,18 @@ Reads preserve provider page/region JSON, including node and edge properties, wi `test-search-lucid-e2e.ts` exercises the production MCP transport, payload parser and adapter over loopback HTTP with synthetic provider responses and writes `SEARCH_LUCID_REPORT_PATH`. It is separate from real-account acceptance; do not present deterministic fixtures as live Lucid evidence. +### Zoom Search rollout + +Zoom Search defaults off for organization-scoped rollout. Enable selected organizations through the `feature-flags` AppConfig profile: + +```json +{ + "zoom-search": { "enabled": false, "orgIds": [""] } +} +``` + +Only the canonical organization ID participates in this rollout check. For local or self-hosted deployments, `ZOOM_SEARCH=true` enables Zoom Search globally; leave that boolean fallback off for an organization-targeted rollout. Setup, enrollment and retrieval enforce the flag. The dedicated Zoom MCP Search connector is gated wherever it is invoked, including generic MCP tools; the standard workflow Zoom OAuth/tools remain available. Disabling the flag preserves saved grants and conversations while denying subsequent Search use; existing approvals can still be removed and connected accounts disconnected. Other providers retain the shared Search and credential-group availability policies without a separate provider rollout gate. + ### Shared invariants - Keep provider parsing isolated from authorization. The application operation owns current membership, policy loading, active account resolution, scoped references, and result projection. diff --git a/apps/sim/lib/sim-search/live/account-session.ts b/apps/sim/lib/sim-search/live/account-session.ts index b784412cc0b..cde7178ed15 100644 --- a/apps/sim/lib/sim-search/live/account-session.ts +++ b/apps/sim/lib/sim-search/live/account-session.ts @@ -1,5 +1,6 @@ import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge' import type { ResourceOwner } from '@/lib/core/resource-scope' +import { resourceScopeFromOwner } from '@/lib/core/resource-scope' import type { PinnedConnectionPool } from '@/lib/core/security/input-validation.server' import type { ResolvedLiveAccount } from '@/lib/sim-search/live/accounts' import { createCodaMcpClient, readCodaMcp, searchCodaMcp } from '@/lib/sim-search/live/coda-mcp' @@ -20,6 +21,7 @@ import { createPolicyVerifier } from '@/lib/sim-search/live/policy' import type { LiveSearchPolicy } from '@/lib/sim-search/live/policy-schema' import { livePolicyFor, loadLiveSearchPolicies } from '@/lib/sim-search/live/policy-store' import { LIVE_SEARCH_PROVIDER_CATALOG } from '@/lib/sim-search/live/provider-catalog' +import { isSearchProviderEnabled } from '@/lib/sim-search/live/provider-rollout' import { readNativeProvider, searchNativeProvider } from '@/lib/sim-search/live/providers' import { searchWithinPolicy } from '@/lib/sim-search/live/scoped-search' import { createLiveServiceSession } from '@/lib/sim-search/live/service-session' @@ -67,6 +69,11 @@ export async function openLiveAccountSession( const { owner, userId, resolved, signal } = input const { account } = resolved const provider = account.provider + if (!(await isSearchProviderEnabled(provider, resourceScopeFromOwner(owner)))) + throw new NativeSearchError( + 'unavailable', + 'This Search provider is not available for this organization' + ) const origin = 'origin' in resolved ? resolved.origin : LIVE_SEARCH_PROVIDER_CATALOG[provider].origin const client = @@ -198,6 +205,7 @@ export async function openLiveAccountSession( return readMcp(reference) }, async verifyCurrent(document) { + if (!(await isSearchProviderEnabled(provider, resourceScopeFromOwner(owner)))) return false const current = await sourceBoundary( livePolicyFor(await loadLiveSearchPolicies(owner), provider), true diff --git a/apps/sim/lib/sim-search/live/application.test.ts b/apps/sim/lib/sim-search/live/application.test.ts index 50f288bbb0b..7af995cd48b 100644 --- a/apps/sim/lib/sim-search/live/application.test.ts +++ b/apps/sim/lib/sim-search/live/application.test.ts @@ -1,5 +1,6 @@ -import { resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { setEnv } from '@sim/testing/mocks/env.mock' import { inputValidationMock, inputValidationMockFns, @@ -159,6 +160,8 @@ describe('authorized live retrieval', () => { let providerUsesCutoff = true beforeEach(() => { providerUsesCutoff = true + setEnv({ ZOOM_SEARCH: true }) + dbChainMockFns.limit.mockResolvedValue([{ organizationId: 'org' }]) mocks.accounts.mockResolvedValue([connected]) mocks.mcpCall.mockImplementation(async (name: string, args: Record) => { if (name === 'search_meetings') { diff --git a/apps/sim/lib/sim-search/live/mcp-accounts.ts b/apps/sim/lib/sim-search/live/mcp-accounts.ts index 2087b4e3906..2494d6064f3 100644 --- a/apps/sim/lib/sim-search/live/mcp-accounts.ts +++ b/apps/sim/lib/sim-search/live/mcp-accounts.ts @@ -19,6 +19,7 @@ import { MANAGED_SEARCH_MCP_READ_TOOLS, type ManagedSearchMcpProvider, } from '@/lib/sim-search/live/managed-mcp-config' +import { isSearchProviderEnabled } from '@/lib/sim-search/live/provider-rollout' import type { LiveAccount } from '@/lib/sim-search/live/types' /** Only the caller's grants at fixed trusted providers qualify; org grants obey current workspace policy. */ @@ -34,6 +35,11 @@ async function listOwnManagedMcpAccounts( ? await resolveKnowledgeWorkspaceContext({ workspaceId: scope.workspaceId }) : undefined const orgId = workspace?.workspaceOrganizationId + const enabledProviders = + providers.includes('zoom') && !(await isSearchProviderEnabled('zoom', scope)) + ? providers.filter((provider) => provider !== 'zoom') + : providers + if (!enabledProviders.length) return [] const rows = await db .select({ id: credential.id, @@ -62,7 +68,7 @@ async function listOwnManagedMcpAccounts( sameResourceScopeCondition(credential, mcpServers), eq(mcpServers.credentialGroupId, credentialGroup.id), or( - ...providers.map((provider) => + ...enabledProviders.map((provider) => and( eq(mcpServers.managedConnectorId, provider), eq(mcpServers.url, MANAGED_MCP_CONNECTORS[provider].url) @@ -84,7 +90,7 @@ async function listOwnManagedMcpAccounts( if ( !row.connectorId || !isManagedSearchMcpProvider(row.connectorId) || - !providers.includes(row.connectorId) + !enabledProviders.includes(row.connectorId) ) continue if (workspace && row.organizationId) { diff --git a/apps/sim/lib/sim-search/live/member-setup.ts b/apps/sim/lib/sim-search/live/member-setup.ts index 55b525153db..7e6246c3744 100644 --- a/apps/sim/lib/sim-search/live/member-setup.ts +++ b/apps/sim/lib/sim-search/live/member-setup.ts @@ -13,6 +13,7 @@ import { import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service' import type { DbTransaction } from '@/lib/db/types' import type { ManagedSearchMcpProvider } from '@/lib/sim-search/live/managed-mcp-config' +import { isSearchProviderEnabled } from '@/lib/sim-search/live/provider-rollout' /** * A search provider ready for source approval. `validated` is set only when approval will create @@ -62,6 +63,8 @@ export async function prepareSearchMcpProvider( organizationId: string, provider: ManagedSearchMcpProvider ): Promise { + if (!(await isSearchProviderEnabled(provider, { kind: 'organization', organizationId }))) + throw new OrchestrationError('forbidden', 'Zoom Search is not available for this organization') if (await hasProviderServer(organizationId, provider)) return { provider, validated: null } try { return { diff --git a/apps/sim/lib/sim-search/live/provider-rollout.ts b/apps/sim/lib/sim-search/live/provider-rollout.ts new file mode 100644 index 00000000000..b97f3c9b961 --- /dev/null +++ b/apps/sim/lib/sim-search/live/provider-rollout.ts @@ -0,0 +1,25 @@ +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import type { ResourceScope } from '@/lib/core/resource-scope' + +/** Zoom Search rollout follows the canonical organization; authorization is checked separately. */ +export async function isSearchProviderEnabled( + provider: string, + scope: ResourceScope +): Promise { + if (provider !== 'zoom') return true + const orgId = + scope.kind === 'organization' + ? scope.organizationId + : ( + await db + .select({ organizationId: workspace.organizationId }) + .from(workspace) + .where(eq(workspace.id, scope.workspaceId)) + .limit(1) + )[0]?.organizationId + if (!orgId) return false + return isFeatureEnabled('zoom-search', { orgId }) +}