From 5cf53a43c1b4c18416388eb852dd73655e796bf1 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Sep 2026 10:30:22 -0700 Subject: [PATCH 1/5] Enable read-only integration lookups for Search Assistant --- apps/sim/blocks/blocks/github.ts | 5 +- apps/sim/blocks/blocks/gmail.ts | 1 + .../mothership/assistant/tool-policy.test.ts | 16 ++++-- .../lib/mothership/assistant/tool-policy.ts | 47 +++++++++++++++- apps/sim/lib/mothership/chat/payload.test.ts | 54 ++++++++++++------- apps/sim/lib/mothership/chat/payload.ts | 6 +-- .../integrations/application/catalog.test.ts | 16 +++--- .../integrations/application/catalog.ts | 7 +-- .../mothership/tool-executor/executor.test.ts | 2 +- .../lib/mothership/tool-executor/executor.ts | 18 +++++-- apps/sim/tools/assistant-execution.test.ts | 18 +++---- apps/sim/tools/index.test.ts | 20 +++---- apps/sim/vitest.config.ts | 1 + 13 files changed, 143 insertions(+), 68 deletions(-) diff --git a/apps/sim/blocks/blocks/github.ts b/apps/sim/blocks/blocks/github.ts index ba9294c3e71..622a3c41eac 100644 --- a/apps/sim/blocks/blocks/github.ts +++ b/apps/sim/blocks/blocks/github.ts @@ -2521,7 +2521,10 @@ export const GitHubV2Block: BlockConfig = { integrationType: IntegrationType.DevOps, tools: { ...GitHubBlock.tools, - access: (GitHubBlock.tools?.access || []).map((toolId) => `${toolId}_v2`), + access: [ + ...(GitHubBlock.tools?.access || []).map((toolId) => `${toolId}_v2`), + 'github_list_review_threads', + ], config: { ...GitHubBlock.tools?.config, tool: createVersionedToolSelector({ diff --git a/apps/sim/blocks/blocks/gmail.ts b/apps/sim/blocks/blocks/gmail.ts index f2cb348a157..a8d602143ad 100644 --- a/apps/sim/blocks/blocks/gmail.ts +++ b/apps/sim/blocks/blocks/gmail.ts @@ -679,6 +679,7 @@ export const GmailV2Block: BlockConfig = { 'gmail_delete_v2', 'gmail_add_label_v2', 'gmail_remove_label_v2', + 'gmail_list_labels_v2', ], config: { ...GmailBlock.tools?.config, diff --git a/apps/sim/lib/mothership/assistant/tool-policy.test.ts b/apps/sim/lib/mothership/assistant/tool-policy.test.ts index fec3f2a06d1..bcae30fb477 100644 --- a/apps/sim/lib/mothership/assistant/tool-policy.test.ts +++ b/apps/sim/lib/mothership/assistant/tool-policy.test.ts @@ -9,7 +9,7 @@ import { import type { ToolMetadata } from '@/tools/metadata' const tool: ToolMetadata = { - id: 'service_write', + id: 'google_drive_get_file', oauth: { required: true, provider: 'google-drive', authoritativeParams: ['instanceUrl'] }, params: { credential: { type: 'string', visibility: 'user-only' }, @@ -22,7 +22,7 @@ const tool: ToolMetadata = { describe('Assistant integration policy', () => { const tokenTool: ToolMetadata = { - id: 'gitlab_get_project', + id: 'gitlab_list_projects', personalToken: { provider: 'gitlab', tokenParam: 'accessToken', hostParam: 'host' }, params: { accessToken: { type: 'string', required: true, visibility: 'user-only' }, @@ -44,12 +44,22 @@ describe('Assistant integration policy', () => { expect(isAssistantIntegrationTool({ ...tokenTool, params: {} })).toBe(false) }) - it('allows writes with one explicit connected account', () => { + it('allows selected reads with one explicit connected account', () => { expect(() => assertAssistantIntegrationCall(tool, { credential: 'mine', body: 'updated content' }) ).not.toThrow() }) + it.each(['gmail_send', 'google_drive_create_file', 'new_provider_operation'])( + 'rejects unapproved operation %s even with a personal account', + (id) => { + expect(isAssistantIntegrationTool({ ...tool, id })).toBe(false) + expect(() => + assertAssistantIntegrationCall({ ...tool, id }, { credential: 'mine' }) + ).toThrow() + } + ) + it.each(['accessToken', 'apiKey', 'headers', '_context', 'impersonateUserEmail', 'instanceUrl'])( 'rejects model-supplied %s before execution', (name) => diff --git a/apps/sim/lib/mothership/assistant/tool-policy.ts b/apps/sim/lib/mothership/assistant/tool-policy.ts index 3ccb8b824e6..70f31177c05 100644 --- a/apps/sim/lib/mothership/assistant/tool-policy.ts +++ b/apps/sim/lib/mothership/assistant/tool-policy.ts @@ -11,9 +11,52 @@ export const ASSISTANT_TOOLS = new Set([ const CREDENTIAL_PARAMS = new Set(['credential', 'credentialId', 'oauthCredential']) -/** Assistant uses the regular integration registry, with authentication supplied by the caller's account. */ +/** Read-only lookups complement search_workspace without exposing provider writes. */ +const ASSISTANT_INTEGRATION_TOOLS = new Set([ + 'slack_list_users', + 'slack_get_user', + 'slack_list_channels', + 'slack_list_user_conversations', + 'slack_get_channel_info', + 'slack_list_members', + 'gmail_list_labels_v2', + 'google_calendar_list_calendars_v2', + 'google_calendar_get_v2', + 'google_calendar_instances_v2', + 'google_calendar_freebusy_v2', + 'google_drive_get_file', + 'google_drive_list_comments', + 'google_sheets_get_spreadsheet_v2', + 'google_sheets_read_v2', + 'jira_search_users', + 'jira_list_projects', + 'jira_get_project', + 'jira_get_fields', + 'jira_get_comments', + 'confluence_list_spaces', + 'confluence_get_user', + 'confluence_get_page_children', + 'confluence_get_page_ancestors', + 'confluence_list_comments', + 'github_search_users_v2', + 'github_repo_info_v2', + 'github_get_tree_v2', + 'github_list_review_threads', + 'github_get_pr_files_v2', + 'gitlab_search_users', + 'gitlab_list_members', + 'gitlab_list_projects', + 'gitlab_get_merge_request_changes', + 'coda_resolve_browser_link', + 'coda_list_pages', + 'coda_list_tables', + 'coda_list_columns', + 'coda_list_rows', +]) + +/** Discovery and execution share the same operations and personal-account requirements. */ export function isAssistantIntegrationTool(tool: ToolMetadata | undefined): boolean { - if (!tool) return false + if (!tool || !ASSISTANT_INTEGRATION_TOOLS.has(tool.id)) return false tool = projectAssistantConnectedAccountTool(tool, isLiveEnterpriseSearchEnabled) const tokenBinding = tool.personalToken const supportsToken = diff --git a/apps/sim/lib/mothership/chat/payload.test.ts b/apps/sim/lib/mothership/chat/payload.test.ts index 9f6d9e31ebf..e7dad5712f9 100644 --- a/apps/sim/lib/mothership/chat/payload.test.ts +++ b/apps/sim/lib/mothership/chat/payload.test.ts @@ -5,7 +5,8 @@ import { envFlagsMockFns, resetEnvFlagsMock, setEnvFlags, workflowsUtilsMock } f import { beforeEach, describe, expect, it, vi } from 'vitest' import { getExposedIntegrationTools } from '@/lib/integrations/tool-catalog' import { ChatPayloadSchema } from '@/lib/mothership/generated/protocol' -import { searchIssuesV2Tool } from '@/tools/github/search_issues' +import { searchUsersV2Tool } from '@/tools/github/search_users' +import { gmailListLabelsV2Tool } from '@/tools/gmail/list_labels' const { mockCreateUserToolSchema, @@ -162,15 +163,17 @@ vi.mock('@/tools/params', () => ({ vi.mock('@/tools/metadata', () => ({ getToolMetadata: (id: string) => - id === 'github_search_issues_v2' - ? searchIssuesV2Tool - : id === 'gmail_send' - ? { - id, - params: { accessToken: { type: 'string', visibility: 'hidden', required: true } }, - oauth: { required: true, provider: 'google-email' }, - } - : undefined, + id === gmailListLabelsV2Tool.id + ? gmailListLabelsV2Tool + : id === 'github_search_users_v2' + ? searchUsersV2Tool + : id === 'gmail_send' + ? { + id, + params: { accessToken: { type: 'string', visibility: 'hidden', required: true } }, + oauth: { required: true, provider: 'google-email' }, + } + : undefined, })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ @@ -692,16 +695,16 @@ describe('Assistant payload', () => { mockCreateUserToolSchema.mockReturnValue({ type: 'object', properties: {} }) mockSearchApprovals.mockResolvedValue(new Map()) }) - it('discovers the existing GitHub PR-count tool with a personal credential in live Search', async () => { + it('discovers the existing GitHub user lookup tool with a personal credential in live Search', async () => { clearIntegrationToolSchemaCacheForTests() setEnvFlags({ isLiveEnterpriseSearchEnabled: true }) mockSearchApprovals.mockResolvedValue(new Map([['github', true]])) vi.mocked(getExposedIntegrationTools).mockReturnValueOnce([ { - toolId: searchIssuesV2Tool.id, - config: searchIssuesV2Tool, + toolId: searchUsersV2Tool.id, + config: searchUsersV2Tool, service: 'github', - operation: 'search_issues', + operation: 'search_users', blockType: 'github_v2', owners: [{ service: 'github', blockType: 'github_v2' }], }, @@ -715,7 +718,7 @@ describe('Assistant payload', () => { }) expect(tools).toHaveLength(1) expect(tools[0]).toMatchObject({ - name: 'github_search_issues_v2', + name: 'github_search_users_v2', oauth: { provider: 'github-repositories' }, input_schema: { required: expect.arrayContaining(['q', 'credentialId']) }, }) @@ -730,6 +733,17 @@ describe('Assistant payload', () => { ).toEqual([]) }) it('advertises approved personal organization integrations and rechecks revocation', async () => { + clearIntegrationToolSchemaCacheForTests() + vi.mocked(getExposedIntegrationTools).mockReturnValueOnce([ + { + toolId: gmailListLabelsV2Tool.id, + config: gmailListLabelsV2Tool, + service: 'gmail', + operation: 'list_labels', + blockType: 'gmail', + owners: [{ service: 'gmail', blockType: 'gmail' }], + }, + ]) mockSearchApprovals.mockResolvedValue(new Map([['gmail', true]])) const options = { schemaSurface: 'copilot' as const, @@ -737,7 +751,7 @@ describe('Assistant payload', () => { organizationId: 'org', } const approved = await buildIntegrationToolSchemas('person', options) - expect(approved.map((tool) => tool.name)).toContain('gmail_send') + expect(approved.map((tool) => tool.name)).toEqual(['gmail_list_labels_v2']) mockSearchApprovals.mockResolvedValue(new Map([['gmail', false]])) expect(await buildIntegrationToolSchemas('person', options)).toEqual([]) mockSearchApprovals.mockResolvedValue(new Map([['gmail', true]])) @@ -771,7 +785,7 @@ describe('Assistant payload', () => { expect(mockTrackChatUpload).not.toHaveBeenCalled() }) - it('forwards organization scope without workspace, integration, or desktop authority', async () => { + it('forwards organization scope without workspace or desktop authority', async () => { const payload = await buildCopilotRequestPayload( { message: 'Find the policy', @@ -788,13 +802,13 @@ describe('Assistant payload', () => { { selectedModel: '' } ) expect(payload.organizationId).toBe('org-1') - expect(payload).not.toHaveProperty('integrationCatalog') + expect(payload.integrationCatalog).toEqual({ mcpServerIds: [] }) expect(payload).not.toHaveProperty('workspaceId') expect(payload).not.toHaveProperty('desktopCapabilities') expect(payload).not.toHaveProperty('integrationTools') }) - it('keeps the shared search scope without an integration gateway catalog', async () => { + it('keeps the shared search scope with native discovery and no MCP servers', async () => { clearIntegrationToolSchemaCacheForTests() const payload = await buildCopilotRequestPayload( { @@ -824,7 +838,7 @@ describe('Assistant payload', () => { expect(payload).not.toHaveProperty(field) } expect(payload).not.toHaveProperty('integrationTools') - expect(payload).not.toHaveProperty('integrationCatalog') + expect(payload.integrationCatalog).toEqual({ mcpServerIds: [] }) }) }) diff --git a/apps/sim/lib/mothership/chat/payload.ts b/apps/sim/lib/mothership/chat/payload.ts index feabc6f2ca6..3a1ccc10629 100644 --- a/apps/sim/lib/mothership/chat/payload.ts +++ b/apps/sim/lib/mothership/chat/payload.ts @@ -468,9 +468,9 @@ export async function buildCopilotRequestPayload( messageId: userMessageId, ...(chatId ? { chatId } : {}), ...(allContexts.length > 0 ? { context: allContexts } : {}), - ...(!isAssistant && { - integrationCatalog: { mcpServerIds: [...new Set(params.mcpServerIds ?? [])] }, - }), + integrationCatalog: { + mcpServerIds: isAssistant ? [] : [...new Set(params.mcpServerIds ?? [])], + }, ...(params.userTimezone ? { userTimezone: params.userTimezone } : {}), ...(params.effort ? { effort: params.effort } : {}), ...(params.modelSelection ? { modelSelection: params.modelSelection } : {}), diff --git a/apps/sim/lib/mothership/integrations/application/catalog.test.ts b/apps/sim/lib/mothership/integrations/application/catalog.test.ts index d03153fc696..88af94656b4 100644 --- a/apps/sim/lib/mothership/integrations/application/catalog.test.ts +++ b/apps/sim/lib/mothership/integrations/application/catalog.test.ts @@ -225,16 +225,14 @@ describe('catalog authorization', () => { } ) - it('rejects Search Assistant discovery before building native or MCP catalogs', async () => { + it('does not discover MCP operations in Search even with selected servers', async () => { queueChat() - await expect( - readIntegrationCatalog.execute({ - principal: principal(), - input: { ...input, mcpServerIds: ['mcp-abc'] }, - }) - ).rejects.toThrow('Search Assistant uses scoped search and document reads') - expect(mocks.build).not.toHaveBeenCalled() - expect(mocks.mcp).not.toHaveBeenCalled() + mocks.mcp.mockResolvedValue([{ ...tools[0], name: 'mcp-abc-send', service: 'mcp:mcp-abc' }]) + const result = await readIntegrationCatalog.execute({ + principal: principal(), + input: { ...input, service: 'mcp:mcp-abc', mcpServerIds: ['mcp-abc'] }, + }) + expect(result.operations).toEqual([]) }) it.each(['user', 'organization', 'expired', 'audience', 'mode', 'membership'] as const)( 'rejects invalid %s before catalog building', diff --git a/apps/sim/lib/mothership/integrations/application/catalog.ts b/apps/sim/lib/mothership/integrations/application/catalog.ts index 99b66ef5435..7ffc88c6f1b 100644 --- a/apps/sim/lib/mothership/integrations/application/catalog.ts +++ b/apps/sim/lib/mothership/integrations/application/catalog.ts @@ -166,11 +166,6 @@ const catalogUseCase = defineAuthorizedChatUseCase({ delegation: { audience: INTEGRATION_CATALOG_AUDIENCE, isWithinScope: () => true }, }, async execute({ input, context }) { - if (context.mode === 'assistant') - throw new OrchestrationError( - 'forbidden', - 'Search Assistant uses scoped search and document reads for connected sources.' - ) if (input.mcpExecution && context.organizationId) throw new OrchestrationError('forbidden', 'Executor catalogs require workspace agent scope') let workspaceId = context.workspaceId @@ -185,10 +180,12 @@ const catalogUseCase = defineAuthorizedChatUseCase({ { schemaSurface: 'copilot', organizationId: context.organizationId, + ...(context.mode === 'assistant' ? { personalAccountsOnly: true } : {}), }, workspaceId ) const includeMcp = + context.mode !== 'assistant' && (!input.toolId || input.toolId.startsWith('mcp-')) && (!input.service || input.service.startsWith('mcp:')) if (includeMcp && (input.mcpServerIds.length || input.mcpToolIds?.length)) { diff --git a/apps/sim/lib/mothership/tool-executor/executor.test.ts b/apps/sim/lib/mothership/tool-executor/executor.test.ts index 9773af57a23..6c38570bd8e 100644 --- a/apps/sim/lib/mothership/tool-executor/executor.test.ts +++ b/apps/sim/lib/mothership/tool-executor/executor.test.ts @@ -121,7 +121,7 @@ describe('copilot tool executor fallback', () => { ) expect(result).toEqual({ success: false, - error: 'Search Assistant uses scoped search and document reads for connected sources.', + error: 'This operation is not available in Search Assistant.', }) expect(handler).not.toHaveBeenCalled() expect(executeAppTool).not.toHaveBeenCalled() diff --git a/apps/sim/lib/mothership/tool-executor/executor.ts b/apps/sim/lib/mothership/tool-executor/executor.ts index a3c4028d538..21008e1bc6f 100644 --- a/apps/sim/lib/mothership/tool-executor/executor.ts +++ b/apps/sim/lib/mothership/tool-executor/executor.ts @@ -4,11 +4,12 @@ import { toError } from '@sim/utils/errors' import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope' import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.server' import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target' -import { ASSISTANT_TOOLS } from '@/lib/mothership/assistant/tool-policy' +import { ASSISTANT_TOOLS, isAssistantIntegrationTool } from '@/lib/mothership/assistant/tool-policy' import { prepareCopilotEnvironmentContext } from '@/lib/mothership/environment-context' import { projectToolErrorMessageForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' import { recordSecretUsage } from '@/lib/secrets/usage/record' import { executeTool as executeAppTool } from '@/tools' +import { getToolMetadata } from '@/tools/metadata' import { getToolEntry, isClientExecuted, isKnownTool, isSimExecuted } from './router' import type { ToolExecutionContext, ToolExecutionResult, ToolHandler } from './types' @@ -56,7 +57,10 @@ export async function executeTool( 'search_sources', ...(params.scope !== 'workspace' ? ['settings'] : []), ] - if (organizationTools.includes(toolId)) { + if ( + organizationTools.includes(toolId) || + (context.requestMode === 'assistant' && isAssistantIntegrationTool(getToolMetadata(toolId))) + ) { if (context.targetWorkspaceId) return { success: false, @@ -67,7 +71,7 @@ export async function executeTool( if (context.requestMode === 'assistant') { return { success: false, - error: 'Search Assistant uses scoped search and document reads for connected sources.', + error: 'This operation is not available in Search Assistant.', } } if (toolId === 'sim_cli') return executeBoundTool(toolId, params, context) @@ -129,10 +133,14 @@ async function executeBoundTool( params: Record, context: ToolExecutionContext ): Promise { - if (context.requestMode === 'assistant' && !ASSISTANT_TOOLS.has(toolId)) { + if ( + context.requestMode === 'assistant' && + !ASSISTANT_TOOLS.has(toolId) && + !isAssistantIntegrationTool(getToolMetadata(toolId)) + ) { return { success: false, - error: 'Search Assistant uses scoped search and document reads for connected sources.', + error: 'This operation is not available in Search Assistant.', } } // Client-routed tools (e.g. run_workflow) are normally executed in the browser and never diff --git a/apps/sim/tools/assistant-execution.test.ts b/apps/sim/tools/assistant-execution.test.ts index a7e7ce0a391..357f2908092 100644 --- a/apps/sim/tools/assistant-execution.test.ts +++ b/apps/sim/tools/assistant-execution.test.ts @@ -111,7 +111,7 @@ describe('Assistant integration execution boundary', () => { it.each(['oauth', 'personal_token'])( 'protects resolved %s credentials in results, errors, diagnostics, and resume provenance', async (kind) => { - const selected = tool('personal_read') + const selected = tool('google_drive_get_file') if (kind === 'personal_token') { selected.oauth = undefined selected.personalToken = { @@ -194,7 +194,7 @@ describe('Assistant integration execution boundary', () => { it('refuses token-bearing execution when its trusted registry is missing', async () => { const { resolvedSecretTraceRegistry, ...withoutRegistry } = assistantContext const result = await executeTool( - 'personal_read', + 'google_drive_get_file', { credentialId: 'mine' }, { operationContext: withoutRegistry, @@ -207,7 +207,7 @@ describe('Assistant integration execution boundary', () => { it('refuses provider dispatch when its secret projection registry is incomplete', async () => { assistantContext.resolvedSecretTraceRegistry?.markIncomplete('unspecified') const result = await executeTool( - 'personal_read', + 'google_drive_get_file', { credentialId: 'mine' }, { operationContext: assistantContext, @@ -218,7 +218,7 @@ describe('Assistant integration execution boundary', () => { }) it('binds the personal token and host through the authorized operation', async () => { - const gitlab = tool('gitlab_read') + const gitlab = tool('gitlab_list_projects') gitlab.oauth = undefined gitlab.personalToken = { provider: 'gitlab', tokenParam: 'accessToken', hostParam: 'host' } gitlab.params.host = { type: 'string', visibility: 'user-only' } @@ -251,7 +251,7 @@ describe('Assistant integration execution boundary', () => { }) it('does not call the provider after personal token access is revoked', async () => { - const gitlab = tool('gitlab_read') + const gitlab = tool('gitlab_list_projects') gitlab.oauth = undefined gitlab.personalToken = { provider: 'gitlab', tokenParam: 'accessToken', hostParam: 'host' } gitlab.params.host = { type: 'string', visibility: 'user-only' } @@ -270,7 +270,7 @@ describe('Assistant integration execution boundary', () => { it('keeps the Assistant person when a direct call also carries workflow authority', async () => { const result = await executeTool( - 'personal_read', + 'google_drive_get_file', { credential: 'mine' }, { operationContext: assistantContext, @@ -295,10 +295,10 @@ describe('Assistant integration execution boundary', () => { }) it('pins Assistant authority through nested post-processing calls', async () => { - const parent = tool('personal_parent') + const parent = tool('google_drive_list_comments') parent.postProcess = async (_result, _params, nested) => nested( - 'personal_read', + 'google_drive_get_file', { credential: 'mine', _context: { @@ -341,7 +341,7 @@ describe('Assistant integration execution boundary', () => { }) it('still rejects non-OAuth nested operations after a forged mode downgrade', async () => { - const parent = tool('personal_parent') + const parent = tool('google_drive_list_comments') parent.postProcess = async (_result, _params, nested) => nested( 'not_personal', diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 93192519e9b..93f967f38fe 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -44,7 +44,7 @@ import { ErrorExtractorId } from '@/tools/error-extractors' import { fileGetContentTool } from '@/tools/file/get' import { fileFetchTool } from '@/tools/file/parser' import { buildFunctionExecuteBody, functionExecuteTool } from '@/tools/function/execute' -import { searchIssuesV2Tool } from '@/tools/github/search_issues' +import { searchUsersV2Tool } from '@/tools/github/search_users' import { memoryAddTool } from '@/tools/memory/add' import { createInternalToolOperationInput } from '@/tools/operation-input' import { slackListsItemsListTool } from '@/tools/slack_lists/items_list' @@ -209,7 +209,7 @@ vi.mock('@/executor/handlers/workflow/custom-block-tool-runner', () => ({ // Only the tools actually exercised in tests are provided. const mockRegistryTools: Record = { slack_lists_items_list: slackListsItemsListTool, - github_search_issues_v2: searchIssuesV2Tool, + github_search_users_v2: searchUsersV2Tool, bitbucket_get_pipeline_step_log: bitbucketGetPipelineStepLogTool, deployed_block_executor: customBlockExecutorTool, workflow_executor: workflowExecutorTool, @@ -7208,26 +7208,26 @@ describe('Live Search Assistant GitHub OAuth binding', () => { requestMode: 'assistant' as const, }, }) - it('executes the existing issue/PR counting tool using the selected personal OAuth account', async () => { + it('executes the existing user lookup tool using the selected personal OAuth account', async () => { const { getToolMetadata } = await import('@/tools/metadata') const { isLiveEnterpriseSearchEnabled } = await import('@/lib/core/config/env-flags') expect(isLiveEnterpriseSearchEnabled).toBe(true) - expect(getToolMetadata('github_search_issues_v2')).toMatchObject({ - id: 'github_search_issues_v2', + expect(getToolMetadata('github_search_users_v2')).toMatchObject({ + id: 'github_search_users_v2', params: { apiKey: { required: true } }, }) const params = { credentialId: 'own-account', - q: 'repo:simstudioai/sim is:pr author:icecrasher321', + q: 'icecrasher321 in:login', } - const result = await executeTool('github_search_issues_v2', params, options()) + const result = await executeTool('github_search_users_v2', params, options()) expect(result.success, result.error).toBe(true) expect(result.output).toMatchObject({ total_count: 137, incomplete_results: false }) expect(mockResolveExecutorCredentialToken).toHaveBeenCalledWith( expect.objectContaining({ credentialId: 'own-account', userId: 'person', - toolId: 'github_search_issues_v2', + toolId: 'github_search_users_v2', copilotExecutionContext: expect.objectContaining({ organizationId: 'org' }), }) ) @@ -7243,8 +7243,8 @@ describe('Live Search Assistant GitHub OAuth binding', () => { }) it('rejects model-supplied credentials before resolving any account or requesting GitHub', async () => { const result = await executeTool( - 'github_search_issues_v2', - { credentialId: 'own-account', q: 'is:pr', apiKey: 'injected-admin-token' }, + 'github_search_users_v2', + { credentialId: 'own-account', q: 'icecrasher321', apiKey: 'injected-admin-token' }, options() ) expect(result.success).toBe(false) diff --git a/apps/sim/vitest.config.ts b/apps/sim/vitest.config.ts index 32394343d3c..42b545ceb2b 100644 --- a/apps/sim/vitest.config.ts +++ b/apps/sim/vitest.config.ts @@ -21,6 +21,7 @@ export default defineConfig(({ mode }) => { test: { css: false, globals: true, + clearMocks: true, environment: 'node', include: integration ? ['**/*.integration.ts'] : ['**/*.test.{ts,tsx}'], exclude: [ From 0478814d6eda77be7a4d0812f49e956a1d5a60cc Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Sep 2026 10:37:18 -0700 Subject: [PATCH 2/5] Preserve inherited tool versions in generated integration docs --- .../docs/content/docs/integrations/github.mdx | 40 +++++++++++++++++++ apps/docs/content/docs/integrations/gmail.mdx | 15 +++++++ .../content/docs/integrations/greptile.mdx | 29 -------------- scripts/generate-docs.test.ts | 17 ++++++++ scripts/generate-docs.ts | 28 ++++++++++--- 5 files changed, 94 insertions(+), 35 deletions(-) diff --git a/apps/docs/content/docs/integrations/github.mdx b/apps/docs/content/docs/integrations/github.mdx index 605df7d74b5..f592f28bd83 100644 --- a/apps/docs/content/docs/integrations/github.mdx +++ b/apps/docs/content/docs/integrations/github.mdx @@ -3414,6 +3414,46 @@ List users who have starred a repository | ↳ `repos_url` | string | Repos API URL | | `count` | number | Number of stargazers returned | +### GitHub List Review Threads + +List one page of a pull request's review threads with their comments, plus the newest submitted review. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `owner` | string | Yes | Repository owner | +| `repo` | string | Yes | Repository name | +| `pullNumber` | number | Yes | Pull request number | +| `threadsPerPage` | number | No | Review threads to fetch in this page \(1-100\) | +| `commentsPerThread` | number | No | Comments to fetch per thread \(1-100\) | +| `cursor` | string | No | Cursor from a previous page \(endCursor\) to continue from | +| `apiKey` | string | Yes | GitHub API token with pull request read access | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `threads` | array | Review threads in this page | +| ↳ `id` | string | Review thread node ID | +| ↳ `isResolved` | boolean | Whether the thread is resolved | +| ↳ `path` | string | Repository-relative file path | +| ↳ `line` | number | Line the thread is anchored to | +| ↳ `commentsTotalCount` | number | Total comments on the thread; exceeds the fetched count when the thread was truncated | +| ↳ `comments` | array | Fetched comments, oldest first | +| ↳ `body` | string | Comment body | +| ↳ `authorAssociation` | string | Author's association with the repository \(OWNER, MEMBER, ...\) | +| ↳ `authorLogin` | string | Author login | +| ↳ `authorType` | string | Author GraphQL type \(User, Bot, Organization\) | +| `totalCount` | number | Total review threads on the pull request | +| `hasNextPage` | boolean | Whether more thread pages remain | +| `endCursor` | string | Cursor to pass as `cursor` for the next page | +| `latestReview` | object | Newest submitted review on the pull request | +| ↳ `state` | string | Review state | +| ↳ `submittedAt` | string | Submission timestamp | +| ↳ `authorLogin` | string | Reviewer login | +| ↳ `authorType` | string | Reviewer GraphQL type \(User, Bot\) | + ## Triggers diff --git a/apps/docs/content/docs/integrations/gmail.mdx b/apps/docs/content/docs/integrations/gmail.mdx index 4054e97f24b..9f4d6b1d4c5 100644 --- a/apps/docs/content/docs/integrations/gmail.mdx +++ b/apps/docs/content/docs/integrations/gmail.mdx @@ -300,6 +300,21 @@ Remove label(s) from a Gmail message. Returns API-aligned fields only. | `threadId` | string | Gmail thread ID | | `labelIds` | array | Updated email labels | +### Gmail List Labels + +List all labels in a Gmail account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `labels` | json | Array of label objects with id, name, type, and visibility settings | + ## Triggers diff --git a/apps/docs/content/docs/integrations/greptile.mdx b/apps/docs/content/docs/integrations/greptile.mdx index 5df5d794748..43438c7019a 100644 --- a/apps/docs/content/docs/integrations/greptile.mdx +++ b/apps/docs/content/docs/integrations/greptile.mdx @@ -53,35 +53,6 @@ Query repositories in natural language and get answers with relevant code refere | ↳ `summary` | string | Summary of the code section | | ↳ `distance` | number | Similarity score \(lower = more relevant\) | -### Greptile Search - -Search repositories in natural language and get relevant code references without generating an answer. Useful for finding specific code locations. - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `query` | string | Yes | Natural language search query to find relevant code. Example: "authentication middleware" or "database connection handling" | -| `repositories` | string | Yes | Comma-separated list of repositories. Format: "github:branch:owner/repo" or just "owner/repo" \(defaults to github:main\). Example: "facebook/react" or "github:main:facebook/react,github:main:facebook/relay" | -| `sessionId` | string | No | Session ID for conversation continuity. Use the same sessionId across multiple searches to maintain context. Example: "session-abc123" | -| `genius` | boolean | No | Enable genius mode for more thorough search \(slower but more accurate\) | -| `apiKey` | string | Yes | Greptile API key | -| `githubToken` | string | Yes | GitHub Personal Access Token with repo read access | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `sources` | array | Relevant code references matching the search query | -| ↳ `repository` | string | Repository name \(owner/repo\) | -| ↳ `remote` | string | Git remote \(github/gitlab\) | -| ↳ `branch` | string | Branch name | -| ↳ `filepath` | string | Path to the file | -| ↳ `linestart` | number | Starting line number | -| ↳ `lineend` | number | Ending line number | -| ↳ `summary` | string | Summary of the code section | -| ↳ `distance` | number | Similarity score \(lower = more relevant\) | - ### Greptile Index Repository Submit a repository to be indexed by Greptile. Indexing must complete before the repository can be queried. Small repos take 3-5 minutes, larger ones can take over an hour. diff --git a/scripts/generate-docs.test.ts b/scripts/generate-docs.test.ts index a614fe6886e..b39c2f6796d 100644 --- a/scripts/generate-docs.test.ts +++ b/scripts/generate-docs.test.ts @@ -50,6 +50,23 @@ describe('documentation editor icon metadata', () => { }) describe('documentation tool metadata', () => { + it('preserves versioned inherited tools when the access array appends another operation', () => { + const [block] = extractAllBlockConfigs(` + export const ExampleBlock: BlockConfig = { + type: 'example', name: 'Example', category: 'tools', hideFromToolbar: true, + tools: { access: ['example_read', 'example_list'] }, + } + export const ExampleV2Block: BlockConfig = { + ...ExampleBlock, type: 'example_v2', hideFromToolbar: false, + tools: { access: [ + ...(ExampleBlock.tools?.access || []).map((toolId) => \`\${toolId}_v2\`), + 'example_comments', + ] }, + } + `) + expect(block.tools?.access).toEqual(['example_read_v2', 'example_list_v2', 'example_comments']) + }) + it('preserves a satisfies block and replaces only the versioned download operation', () => { const [block] = extractAllBlockConfigs(` export const DownloadBlock = ({ diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 9849117afbe..f069c037c19 100755 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -2275,17 +2275,20 @@ function extractBlockConfigFromContent( : ownOutputs const toolsAccess = extractToolsAccessFromContent(blockContent) - // For tools.access, if not found directly, check if it's derived from base via map + /** Versioned mapped spreads retain their base tools alongside explicit additions. */ let finalToolsAccess = toolsAccess - if (toolsAccess.length === 0 && baseConfig?.tools?.access) { + if (baseConfig?.tools?.access) { // Check if there's a map operation on base tools // Pattern: access: (SomeBlock.tools?.access || []).map((toolId) => `${toolId}_v2`) const mapMatch = blockContent.match( - /access\s*:\s*\(\s*\w+Block\.tools\?\.access\s*\|\|\s*\[\]\s*\)\.map\s*\(\s*\(\s*\w+\s*\)\s*=>\s*`\$\{\s*\w+\s*\}_v(\d+)`\s*\)/ + /access\s*:\s*(?:\[\s*\.\.\.)?\(\s*\w+Block\.tools\?\.access\s*\|\|\s*\[\]\s*\)\.map\s*\(\s*\(\s*\w+\s*\)\s*=>\s*`\$\{\s*\w+\s*\}_v(\d+)`\s*\)/ ) if (mapMatch) { const versionSuffix = `_v${mapMatch[1]}` - finalToolsAccess = baseConfig.tools.access.map((tool) => `${tool}${versionSuffix}`) + finalToolsAccess = [ + ...baseConfig.tools.access.map((tool) => `${tool}${versionSuffix}`), + ...toolsAccess, + ] } const replacement = blockContent.match( /access\s*:\s*\w+Block\.tools\.access\.map\s*\(\s*\(\s*(\w+)\s*\)\s*=>\s*\1\s*===\s*['"]([^'"]+)['"]\s*\?\s*['"]([^'"]+)['"]\s*:\s*\1\s*\)/ @@ -2694,9 +2697,22 @@ function extractToolsAccessFromContent(content: string): string[] { if (toolsEnd === -1) return [] const toolsContent = content.substring(toolsStart, toolsEnd) - const accessMatch = toolsContent.match(/access\s*:\s*\[\s*([^\]]+)\s*\]/) + const accessMatch = /access\s*:\s*\[/.exec(toolsContent) if (!accessMatch) return [] - return [...accessMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1]) + const start = accessMatch.index + accessMatch[0].lastIndexOf('[') + const end = findMatchingClose(toolsContent, start, '[', ']') + if (end === -1) return [] + const source = ts.createSourceFile( + 'tool-access.ts', + `const access = ${toolsContent.slice(start, end)}`, + ts.ScriptTarget.Latest, + true + ) + const statement = source.statements[0] + if (!statement || !ts.isVariableStatement(statement)) return [] + const array = statement.declarationList.declarations[0]?.initializer + if (!array || !ts.isArrayLiteralExpression(array)) return [] + return array.elements.flatMap((element) => (ts.isStringLiteral(element) ? [element.text] : [])) } /** From efce9f282e974267a859edc71d51e2bfe530e5c3 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Sep 2026 10:48:29 -0700 Subject: [PATCH 3/5] Align provider tests with Search read-only operations --- apps/sim/lib/atlassian/assistant.test.ts | 43 ++++++++--------------- apps/sim/tools/slack/oauth-scopes.test.ts | 2 -- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/apps/sim/lib/atlassian/assistant.test.ts b/apps/sim/lib/atlassian/assistant.test.ts index d7615be2b92..26927792788 100644 --- a/apps/sim/lib/atlassian/assistant.test.ts +++ b/apps/sim/lib/atlassian/assistant.test.ts @@ -5,15 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { clearAtlassianCloudIdCache } from '@/lib/atlassian/discovery' import { createConfluenceClient } from '@/lib/internal/confluence/client' import { createJiraClient } from '@/lib/internal/jira/client' -import { - assertAssistantIntegrationCall, - isAssistantIntegrationTool, -} from '@/lib/mothership/assistant/tool-policy' +import { assertAssistantIntegrationCall } from '@/lib/mothership/assistant/tool-policy' import { getToolMetadata } from '@/tools/metadata' -import { getToolIds } from '@/tools/tool-ids' vi.unmock('@/tools/metadata') -vi.unmock('@/tools/tool-ids') const CLOUD_ID = '12345678-1234-1234-1234-123456789012' const OTHER_CLOUD_ID = '12345678-1234-1234-1234-123456789013' @@ -32,29 +27,21 @@ describe('Atlassian Assistant resource selection', () => { afterEach(() => vi.unstubAllGlobals()) - it.each(['jira', 'confluence'])( - 'offers %s operations with a site selector and personal credential', - (service) => { - const tools = getToolIds() - .filter((id) => id.startsWith(`${service}_`)) - .map((id) => getToolMetadata(id)) - .filter((tool) => tool?.params.domain) - expect(tools.length).toBeGreaterThan(0) - for (const tool of tools) { - expect(tool?.params.domain.visibility, tool?.id).toBe('user-or-llm') - expect(isAssistantIntegrationTool(tool), tool?.id).toBe(true) + it.each(['jira_get_project', 'confluence_list_spaces'])( + 'allows site selection for %s without accepting credential overrides', + (toolId) => { + const tool = getToolMetadata(toolId) + expect(() => + assertAssistantIntegrationCall(tool, { credentialId: 'mine', domain: DOMAIN }) + ).not.toThrow() + for (const name of ['cloudId', 'accessToken', '_context']) { expect(() => - assertAssistantIntegrationCall(tool, { credentialId: 'mine', domain: DOMAIN }) - ).not.toThrow() - for (const name of ['cloudId', 'accessToken', '_context']) { - expect(() => - assertAssistantIntegrationCall(tool, { - credentialId: 'mine', - domain: DOMAIN, - [name]: 'override', - }) - ).toThrow() - } + assertAssistantIntegrationCall(tool, { + credentialId: 'mine', + domain: DOMAIN, + [name]: 'override', + }) + ).toThrow() } } ) diff --git a/apps/sim/tools/slack/oauth-scopes.test.ts b/apps/sim/tools/slack/oauth-scopes.test.ts index 30ac105dc88..8c2a176ab30 100644 --- a/apps/sim/tools/slack/oauth-scopes.test.ts +++ b/apps/sim/tools/slack/oauth-scopes.test.ts @@ -26,14 +26,12 @@ describe('Slack personal-token scope policy', () => { 'slack_list_members', ])('lets Slack evaluate conversation-specific scope alternatives for %s', (toolId) => { expect(tools[toolId].oauth?.requiredScopes).toEqual([]) - expect(isAssistantIntegrationTool(tools[toolId])).toBe(true) }) it.each(['slack_message', 'slack_update_message', 'slack_delete_message'])( 'requires the personal writing scope for %s', (toolId) => { expect(tools[toolId].oauth?.requiredScopes).toEqual(['chat:write']) - expect(isAssistantIntegrationTool(tools[toolId])).toBe(true) } ) From d8a7eebf94e78379c729bf37cdab19e054d19891 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Sep 2026 11:14:28 -0700 Subject: [PATCH 4/5] Gate Search integration tools with AppConfig --- apps/sim/lib/core/config/feature-flags.ts | 18 ++++++--- apps/sim/lib/mothership/chat/payload.test.ts | 34 ++++++++++++++++ apps/sim/lib/mothership/chat/payload.ts | 12 ++++-- apps/sim/lib/mothership/feature-flags.ts | 5 +++ .../integrations/application/catalog.test.ts | 14 +++++++ .../integrations/application/catalog.ts | 3 ++ .../mothership/tool-executor/executor.test.ts | 39 ++++++++++++++++++- .../lib/mothership/tool-executor/executor.ts | 4 +- 8 files changed, 118 insertions(+), 11 deletions(-) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 68c1ff3b711..0ddf3963fa8 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -31,8 +31,8 @@ export type FeatureFlagContext = AppConfigGateContext /** * The single definition of a feature flag. Everything about a flag lives in one * place: its name (the registry key), a human-readable `description`, and the - * `fallback` secret consulted when AppConfig isn't the source of truth (truthy ⇒ on - * globally). + * optional `fallback` secret consulted when AppConfig is not the source of truth. + * A null fallback keeps the flag off outside AppConfig. * * Gating by workspace/org/user/admin is deliberately NOT part of a definition — it lives only * in the hosted AppConfig document, so no environment can grant access from a code @@ -40,8 +40,8 @@ export type FeatureFlagContext = AppConfigGateContext */ interface FeatureFlagDefinition { description: string - /** Env/secret key consulted when AppConfig isn't the source of truth. Truthy ⇒ on. */ - fallback: keyof typeof env + /** Null means AppConfig-only; otherwise a truthy env/secret enables the fallback. */ + fallback: keyof typeof env | null } /** The single registry of known flags. To add a flag, add one entry here. */ @@ -51,6 +51,12 @@ const FEATURE_FLAGS = { 'Enable native macOS computer use in Mothership. Global on/off only; each device must also opt in.', fallback: 'MSHIP_COMPUTER_USE', }, + 'mothership-search-integration-tools': { + description: + 'Give Search Assistant read-only integration discovery, calls, and matching prompt ' + + 'instructions. Global AppConfig on/off only; disabled by default with no env fallback.', + fallback: null, + }, 'mothership-model-selector': { description: 'Show the Mothership model selector, model-specific effort levels, and Fast for supported ' + @@ -143,7 +149,7 @@ const FEATURE_FLAGS = { /** * The closed set of known feature flags. Derived from the registry, so a flag - * cannot exist — or be checked — without a definition (and its mandatory fallback). + * cannot exist — or be checked — without a definition (and its explicit fallback policy). */ export type FeatureFlagName = keyof typeof FEATURE_FLAGS @@ -153,7 +159,7 @@ function fallbackFlags(): FeatureFlagsConfig { for (const [name, def] of Object.entries(FEATURE_FLAGS) as Array< [string, FeatureFlagDefinition] >) { - flags[name] = { enabled: isTruthy(env[def.fallback]) } + flags[name] = { enabled: def.fallback !== null && isTruthy(env[def.fallback]) } } return flags } diff --git a/apps/sim/lib/mothership/chat/payload.test.ts b/apps/sim/lib/mothership/chat/payload.test.ts index e7dad5712f9..3b577466492 100644 --- a/apps/sim/lib/mothership/chat/payload.test.ts +++ b/apps/sim/lib/mothership/chat/payload.test.ts @@ -18,6 +18,7 @@ const { mockSearchApprovals, mockSecretNames, mockComputerUseAvailable, + mockSearchIntegrationToolsEnabled, } = vi.hoisted(() => ({ mockComputerUseAvailable: vi.fn(async () => false), mockCreateUserToolSchema: vi.fn(() => ({ type: 'object', properties: {} })), @@ -28,12 +29,19 @@ const { mockTrackChatUpload: vi.fn(), mockSearchApprovals: vi.fn(async () => new Map()), mockSecretNames: vi.fn(async () => ({ names: [] as string[] })), + mockSearchIntegrationToolsEnabled: vi.fn(async () => true), })) vi.mock('@/lib/computer-use/availability.server', () => ({ isComputerUseAvailable: mockComputerUseAvailable, })) +vi.mock('@/lib/mothership/feature-flags', () => ({ + isSearchIntegrationToolsEnabled: mockSearchIntegrationToolsEnabled, +})) + +beforeEach(() => mockSearchIntegrationToolsEnabled.mockResolvedValue(true)) + // The inventory reads nine application worlds; these suites exercise the request shape, not the reads. vi.mock('@/lib/mothership/application/execute-organization-secret-use-case', () => ({ executeOrganizationSecretUseCase: mockSecretNames, @@ -962,3 +970,29 @@ it('carries only enabled MCP IDs without eager catalog discovery while preservin expect(payload).not.toHaveProperty('mothershipTools') expect(payload.desktop).toMatchObject({ browser: true, terminal: true }) }) + +/** The worker derives both its gateway tools and prompt instructions from this capability. */ +it.each([{ organizationId: 'org-1' }, { workspaceId: 'ws-1' }])( + 'switches Search integration capability per turn while preserving Build for %j', + async (scope) => { + for (const enabled of [true, false, true]) { + mockSearchIntegrationToolsEnabled.mockResolvedValue(enabled) + for (const mode of ['assistant', 'agent', 'plan']) { + const payload = await buildCopilotRequestPayload( + { + message: 'Find a person', + userId: 'person', + userMessageId: 'message', + mode, + model: '', + ...scope, + }, + { selectedModel: '' } + ) + expect(payload.integrationCatalog).toEqual( + mode === 'assistant' && !enabled ? undefined : { mcpServerIds: [] } + ) + } + } + } +) diff --git a/apps/sim/lib/mothership/chat/payload.ts b/apps/sim/lib/mothership/chat/payload.ts index 3a1ccc10629..0798f611c87 100644 --- a/apps/sim/lib/mothership/chat/payload.ts +++ b/apps/sim/lib/mothership/chat/payload.ts @@ -30,6 +30,7 @@ import { import type { AssistantImageContent } from '@/lib/mothership/chat/assistant-images' import { buildUploadedFileContext } from '@/lib/mothership/chat/upload-context' import { buildWorkspaceInventory } from '@/lib/mothership/chat/workspace-inventory' +import { isSearchIntegrationToolsEnabled } from '@/lib/mothership/feature-flags' import type { AssistantSearchLevel } from '@/lib/mothership/generated/assistant' import type { ChatRequest, ModelSelection } from '@/lib/mothership/generated/protocol' import type { VfsSnapshotV1 } from '@/lib/mothership/generated/vfs-snapshot-v1' @@ -326,6 +327,7 @@ export async function buildCopilotRequestPayload( const isAssistant = effectiveMode === 'assistant' const computerUse = !isAssistant && params.computerUse === true && (await isComputerUseAvailable()) + const integrationGateway = !isAssistant || (await isSearchIntegrationToolsEnabled()) // Track uploaded files in the DB and build context tags instead of base64 inlining. // Tracking writes `workspace_files` rows, so it needs the same write grant the @@ -468,9 +470,13 @@ export async function buildCopilotRequestPayload( messageId: userMessageId, ...(chatId ? { chatId } : {}), ...(allContexts.length > 0 ? { context: allContexts } : {}), - integrationCatalog: { - mcpServerIds: isAssistant ? [] : [...new Set(params.mcpServerIds ?? [])], - }, + ...(integrationGateway + ? { + integrationCatalog: { + mcpServerIds: isAssistant ? [] : [...new Set(params.mcpServerIds ?? [])], + }, + } + : {}), ...(params.userTimezone ? { userTimezone: params.userTimezone } : {}), ...(params.effort ? { effort: params.effort } : {}), ...(params.modelSelection ? { modelSelection: params.modelSelection } : {}), diff --git a/apps/sim/lib/mothership/feature-flags.ts b/apps/sim/lib/mothership/feature-flags.ts index aa30f295a06..77aec3c7891 100644 --- a/apps/sim/lib/mothership/feature-flags.ts +++ b/apps/sim/lib/mothership/feature-flags.ts @@ -9,3 +9,8 @@ export function isMothershipModelSelectorEnabled(): Promise { export function isPlanModeEnabled(): Promise { return isFeatureEnabled('mothership-plan-mode') } + +/** One AppConfig gate controls Search integration discovery, execution, and prompt capability. */ +export function isSearchIntegrationToolsEnabled(): Promise { + return isFeatureEnabled('mothership-search-integration-tools') +} diff --git a/apps/sim/lib/mothership/integrations/application/catalog.test.ts b/apps/sim/lib/mothership/integrations/application/catalog.test.ts index 88af94656b4..f3eab59dbb8 100644 --- a/apps/sim/lib/mothership/integrations/application/catalog.test.ts +++ b/apps/sim/lib/mothership/integrations/application/catalog.test.ts @@ -15,6 +15,7 @@ import { const mocks = vi.hoisted(() => ({ build: vi.fn(), + flag: vi.fn(async () => true), mcp: vi.fn(), config: vi.fn(), banned: vi.fn(), @@ -22,6 +23,7 @@ const mocks = vi.hoisted(() => ({ workspace: vi.fn(), listServers: vi.fn(), })) +vi.mock('@/lib/mothership/feature-flags', () => ({ isSearchIntegrationToolsEnabled: mocks.flag })) vi.mock('@/lib/mcp/application/use-cases', () => ({ listMcpServersUseCase: { execute: mocks.listServers }, })) @@ -68,6 +70,7 @@ function queueChat(mode = 'assistant', role = 'member') { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + mocks.flag.mockResolvedValue(true) mocks.banned.mockResolvedValue([]) mocks.config.mockResolvedValue(null) mocks.build.mockResolvedValue([...tools]) @@ -372,3 +375,14 @@ it('filters organization enabled servers to the authorized target before broad M }) expect(mocks.mcp).toHaveBeenCalledWith('actor', 'workspace-1', ['mcp-abc'], undefined) }) + +it('removes previously discoverable Search operations when the runtime flag turns off', async () => { + for (const enabled of [true, false, true]) { + mocks.flag.mockResolvedValue(enabled) + queueChat() + const result = await readIntegrationCatalog.execute({ principal: principal(), input }) + expect(result.operations.map((operation) => operation.toolId)).toEqual( + enabled ? ['gmail_send', 'slack_send'] : [] + ) + } +}) diff --git a/apps/sim/lib/mothership/integrations/application/catalog.ts b/apps/sim/lib/mothership/integrations/application/catalog.ts index 7ffc88c6f1b..f014f55f932 100644 --- a/apps/sim/lib/mothership/integrations/application/catalog.ts +++ b/apps/sim/lib/mothership/integrations/application/catalog.ts @@ -25,6 +25,7 @@ import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-de import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case' import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context' import { buildIntegrationToolSchemas, type ToolSchema } from '@/lib/mothership/chat/payload' +import { isSearchIntegrationToolsEnabled } from '@/lib/mothership/feature-flags' import type { IntegrationCatalogRequest, IntegrationCatalogResponse, @@ -166,6 +167,8 @@ const catalogUseCase = defineAuthorizedChatUseCase({ delegation: { audience: INTEGRATION_CATALOG_AUDIENCE, isWithinScope: () => true }, }, async execute({ input, context }) { + if (context.mode === 'assistant' && !(await isSearchIntegrationToolsEnabled())) + return { total: 0, truncated: false, operations: [] } if (input.mcpExecution && context.organizationId) throw new OrchestrationError('forbidden', 'Executor catalogs require workspace agent scope') let workspaceId = context.workspaceId diff --git a/apps/sim/lib/mothership/tool-executor/executor.test.ts b/apps/sim/lib/mothership/tool-executor/executor.test.ts index 6c38570bd8e..c87998438d6 100644 --- a/apps/sim/lib/mothership/tool-executor/executor.test.ts +++ b/apps/sim/lib/mothership/tool-executor/executor.test.ts @@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { slackGetUserTool } from '@/tools/slack/get_user' const { getToolEntry, isKnownTool, isSimExecuted, isClientExecuted } = vi.hoisted(() => ({ getToolEntry: vi.fn(), @@ -13,8 +14,9 @@ const { getToolEntry, isKnownTool, isSimExecuted, isClientExecuted } = vi.hoiste isClientExecuted: vi.fn(), })) -const { executeAppTool, recordSecretUsage } = vi.hoisted(() => ({ +const { executeAppTool, recordSecretUsage, searchIntegrationToolsEnabled } = vi.hoisted(() => ({ executeAppTool: vi.fn(), + searchIntegrationToolsEnabled: vi.fn(async () => true), recordSecretUsage: vi.fn(), })) @@ -32,6 +34,15 @@ vi.mock('./router', () => ({ isClientExecuted, })) +vi.mock('@/lib/mothership/feature-flags', () => ({ + isSearchIntegrationToolsEnabled: searchIntegrationToolsEnabled, +})) +beforeEach(() => searchIntegrationToolsEnabled.mockResolvedValue(true)) + +vi.mock('@/tools/metadata', () => ({ + getToolMetadata: (id: string) => (id === slackGetUserTool.id ? slackGetUserTool : undefined), +})) + vi.mock('@/tools', () => ({ executeTool: executeAppTool, })) @@ -772,3 +783,29 @@ describe('organization direct tool targets', () => { expect(targets.environment).not.toHaveBeenCalled() }) }) + +it.each([{ organizationId: 'org-1' }, { workspaceId: 'ws-1' }])( + 'stops a previously admitted Search integration call after flag revocation for %j', + async (scope) => { + isKnownTool.mockReturnValue(false) + isClientExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: { user: { id: 'U123' } } }) + for (const enabled of [true, false, true]) { + searchIntegrationToolsEnabled.mockResolvedValue(enabled) + const result = await executeTool( + 'slack_get_user', + { credentialId: 'own', userId: 'U123' }, + { + userId: 'person', + requestMode: 'assistant', + ...scope, + } + ) + expect(result).toEqual( + enabled + ? { success: true, output: { user: { id: 'U123' } } } + : { success: false, error: 'This operation is not available in Search Assistant.' } + ) + } + } +) diff --git a/apps/sim/lib/mothership/tool-executor/executor.ts b/apps/sim/lib/mothership/tool-executor/executor.ts index 21008e1bc6f..2da653e9621 100644 --- a/apps/sim/lib/mothership/tool-executor/executor.ts +++ b/apps/sim/lib/mothership/tool-executor/executor.ts @@ -6,6 +6,7 @@ import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.ser import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target' import { ASSISTANT_TOOLS, isAssistantIntegrationTool } from '@/lib/mothership/assistant/tool-policy' import { prepareCopilotEnvironmentContext } from '@/lib/mothership/environment-context' +import { isSearchIntegrationToolsEnabled } from '@/lib/mothership/feature-flags' import { projectToolErrorMessageForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' import { recordSecretUsage } from '@/lib/secrets/usage/record' import { executeTool as executeAppTool } from '@/tools' @@ -136,7 +137,8 @@ async function executeBoundTool( if ( context.requestMode === 'assistant' && !ASSISTANT_TOOLS.has(toolId) && - !isAssistantIntegrationTool(getToolMetadata(toolId)) + (!isAssistantIntegrationTool(getToolMetadata(toolId)) || + !(await isSearchIntegrationToolsEnabled())) ) { return { success: false, From fb7923cea45da8777c8d4c9995bde04860015052 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Sep 2026 11:23:14 -0700 Subject: [PATCH 5/5] Preserve dev test runner isolation during promotion --- apps/sim/vitest.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/sim/vitest.config.ts b/apps/sim/vitest.config.ts index 42b545ceb2b..32394343d3c 100644 --- a/apps/sim/vitest.config.ts +++ b/apps/sim/vitest.config.ts @@ -21,7 +21,6 @@ export default defineConfig(({ mode }) => { test: { css: false, globals: true, - clearMocks: true, environment: 'node', include: integration ? ['**/*.integration.ts'] : ['**/*.test.{ts,tsx}'], exclude: [