Skip to content

Commit fe615b1

Browse files
committed
Enable read-only integration lookups for Search Assistant
1 parent a7ea8fd commit fe615b1

13 files changed

Lines changed: 143 additions & 68 deletions

File tree

‎apps/sim/blocks/blocks/github.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2521,7 +2521,10 @@ export const GitHubV2Block: BlockConfig<GitHubResponse> = {
25212521
integrationType: IntegrationType.DevOps,
25222522
tools: {
25232523
...GitHubBlock.tools,
2524-
access: (GitHubBlock.tools?.access || []).map((toolId) => `${toolId}_v2`),
2524+
access: [
2525+
...(GitHubBlock.tools?.access || []).map((toolId) => `${toolId}_v2`),
2526+
'github_list_review_threads',
2527+
],
25252528
config: {
25262529
...GitHubBlock.tools?.config,
25272530
tool: createVersionedToolSelector({

‎apps/sim/blocks/blocks/gmail.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,7 @@ export const GmailV2Block: BlockConfig<GmailToolResponse> = {
679679
'gmail_delete_v2',
680680
'gmail_add_label_v2',
681681
'gmail_remove_label_v2',
682+
'gmail_list_labels_v2',
682683
],
683684
config: {
684685
...GmailBlock.tools?.config,

‎apps/sim/lib/mothership/assistant/tool-policy.test.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
import type { ToolMetadata } from '@/tools/metadata'
77

88
const tool: ToolMetadata = {
9-
id: 'service_write',
9+
id: 'google_drive_get_file',
1010
oauth: { required: true, provider: 'google-drive', authoritativeParams: ['instanceUrl'] },
1111
params: {
1212
credential: { type: 'string', visibility: 'user-only' },
@@ -19,7 +19,7 @@ const tool: ToolMetadata = {
1919

2020
describe('Assistant integration policy', () => {
2121
const tokenTool: ToolMetadata = {
22-
id: 'gitlab_get_project',
22+
id: 'gitlab_list_projects',
2323
personalToken: { provider: 'gitlab', tokenParam: 'accessToken', hostParam: 'host' },
2424
params: {
2525
accessToken: { type: 'string', required: true, visibility: 'user-only' },
@@ -41,12 +41,22 @@ describe('Assistant integration policy', () => {
4141
expect(isAssistantIntegrationTool({ ...tokenTool, params: {} })).toBe(false)
4242
})
4343

44-
it('allows writes with one explicit connected account', () => {
44+
it('allows selected reads with one explicit connected account', () => {
4545
expect(() =>
4646
assertAssistantIntegrationCall(tool, { credential: 'mine', body: 'updated content' })
4747
).not.toThrow()
4848
})
4949

50+
it.each(['gmail_send', 'google_drive_create_file', 'new_provider_operation'])(
51+
'rejects unapproved operation %s even with a personal account',
52+
(id) => {
53+
expect(isAssistantIntegrationTool({ ...tool, id })).toBe(false)
54+
expect(() =>
55+
assertAssistantIntegrationCall({ ...tool, id }, { credential: 'mine' })
56+
).toThrow()
57+
}
58+
)
59+
5060
it.each(['accessToken', 'apiKey', 'headers', '_context', 'impersonateUserEmail', 'instanceUrl'])(
5161
'rejects model-supplied %s before execution',
5262
(name) =>

‎apps/sim/lib/mothership/assistant/tool-policy.ts‎

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,52 @@ export const ASSISTANT_TOOLS = new Set([
1111

1212
const CREDENTIAL_PARAMS = new Set(['credential', 'credentialId', 'oauthCredential'])
1313

14-
/** Assistant uses the regular integration registry, with authentication supplied by the caller's account. */
14+
/** Read-only lookups complement search_workspace without exposing provider writes. */
15+
const ASSISTANT_INTEGRATION_TOOLS = new Set([
16+
'slack_list_users',
17+
'slack_get_user',
18+
'slack_list_channels',
19+
'slack_list_user_conversations',
20+
'slack_get_channel_info',
21+
'slack_list_members',
22+
'gmail_list_labels_v2',
23+
'google_calendar_list_calendars_v2',
24+
'google_calendar_get_v2',
25+
'google_calendar_instances_v2',
26+
'google_calendar_freebusy_v2',
27+
'google_drive_get_file',
28+
'google_drive_list_comments',
29+
'google_sheets_get_spreadsheet_v2',
30+
'google_sheets_read_v2',
31+
'jira_search_users',
32+
'jira_list_projects',
33+
'jira_get_project',
34+
'jira_get_fields',
35+
'jira_get_comments',
36+
'confluence_list_spaces',
37+
'confluence_get_user',
38+
'confluence_get_page_children',
39+
'confluence_get_page_ancestors',
40+
'confluence_list_comments',
41+
'github_search_users_v2',
42+
'github_repo_info_v2',
43+
'github_get_tree_v2',
44+
'github_list_review_threads',
45+
'github_get_pr_files_v2',
46+
'gitlab_search_users',
47+
'gitlab_list_members',
48+
'gitlab_list_projects',
49+
'gitlab_get_merge_request_changes',
50+
'coda_resolve_browser_link',
51+
'coda_list_pages',
52+
'coda_list_tables',
53+
'coda_list_columns',
54+
'coda_list_rows',
55+
])
56+
57+
/** Discovery and execution share the same operations and personal-account requirements. */
1558
export function isAssistantIntegrationTool(tool: ToolMetadata | undefined): boolean {
16-
if (!tool) return false
59+
if (!tool || !ASSISTANT_INTEGRATION_TOOLS.has(tool.id)) return false
1760
tool = projectAssistantConnectedAccountTool(tool, isLiveEnterpriseSearchEnabled)
1861
const tokenBinding = tool.personalToken
1962
const supportsToken =

‎apps/sim/lib/mothership/chat/payload.test.ts‎

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { envFlagsMockFns, resetEnvFlagsMock, setEnvFlags, workflowsUtilsMock } f
22
import { beforeEach, describe, expect, it, vi } from 'vitest'
33
import { getExposedIntegrationTools } from '@/lib/integrations/tool-catalog'
44
import { ChatPayloadSchema } from '@/lib/mothership/generated/protocol'
5-
import { searchIssuesV2Tool } from '@/tools/github/search_issues'
5+
import { searchUsersV2Tool } from '@/tools/github/search_users'
6+
import { gmailListLabelsV2Tool } from '@/tools/gmail/list_labels'
67

78
const {
89
mockCreateUserToolSchema,
@@ -153,15 +154,17 @@ vi.mock('@/tools/params', () => ({
153154

154155
vi.mock('@/tools/metadata', () => ({
155156
getToolMetadata: (id: string) =>
156-
id === 'github_search_issues_v2'
157-
? searchIssuesV2Tool
158-
: id === 'gmail_send'
159-
? {
160-
id,
161-
params: { accessToken: { type: 'string', visibility: 'hidden', required: true } },
162-
oauth: { required: true, provider: 'google-email' },
163-
}
164-
: undefined,
157+
id === gmailListLabelsV2Tool.id
158+
? gmailListLabelsV2Tool
159+
: id === 'github_search_users_v2'
160+
? searchUsersV2Tool
161+
: id === 'gmail_send'
162+
? {
163+
id,
164+
params: { accessToken: { type: 'string', visibility: 'hidden', required: true } },
165+
oauth: { required: true, provider: 'google-email' },
166+
}
167+
: undefined,
165168
}))
166169

167170
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
@@ -630,16 +633,16 @@ describe('Assistant payload', () => {
630633
mockCreateUserToolSchema.mockReturnValue({ type: 'object', properties: {} })
631634
mockSearchApprovals.mockResolvedValue(new Map())
632635
})
633-
it('discovers the existing GitHub PR-count tool with a personal credential in live Search', async () => {
636+
it('discovers the existing GitHub user lookup tool with a personal credential in live Search', async () => {
634637
clearIntegrationToolSchemaCacheForTests()
635638
setEnvFlags({ isLiveEnterpriseSearchEnabled: true })
636639
mockSearchApprovals.mockResolvedValue(new Map([['github', true]]))
637640
vi.mocked(getExposedIntegrationTools).mockReturnValueOnce([
638641
{
639-
toolId: searchIssuesV2Tool.id,
640-
config: searchIssuesV2Tool,
642+
toolId: searchUsersV2Tool.id,
643+
config: searchUsersV2Tool,
641644
service: 'github',
642-
operation: 'search_issues',
645+
operation: 'search_users',
643646
blockType: 'github_v2',
644647
owners: [{ service: 'github', blockType: 'github_v2' }],
645648
},
@@ -653,7 +656,7 @@ describe('Assistant payload', () => {
653656
})
654657
expect(tools).toHaveLength(1)
655658
expect(tools[0]).toMatchObject({
656-
name: 'github_search_issues_v2',
659+
name: 'github_search_users_v2',
657660
oauth: { provider: 'github-repositories' },
658661
input_schema: { required: expect.arrayContaining(['q', 'credentialId']) },
659662
})
@@ -668,14 +671,25 @@ describe('Assistant payload', () => {
668671
).toEqual([])
669672
})
670673
it('advertises approved personal organization integrations and rechecks revocation', async () => {
674+
clearIntegrationToolSchemaCacheForTests()
675+
vi.mocked(getExposedIntegrationTools).mockReturnValueOnce([
676+
{
677+
toolId: gmailListLabelsV2Tool.id,
678+
config: gmailListLabelsV2Tool,
679+
service: 'gmail',
680+
operation: 'list_labels',
681+
blockType: 'gmail',
682+
owners: [{ service: 'gmail', blockType: 'gmail' }],
683+
},
684+
])
671685
mockSearchApprovals.mockResolvedValue(new Map([['gmail', true]]))
672686
const options = {
673687
schemaSurface: 'copilot' as const,
674688
personalAccountsOnly: true,
675689
organizationId: 'org',
676690
}
677691
const approved = await buildIntegrationToolSchemas('person', options)
678-
expect(approved.map((tool) => tool.name)).toContain('gmail_send')
692+
expect(approved.map((tool) => tool.name)).toEqual(['gmail_list_labels_v2'])
679693
mockSearchApprovals.mockResolvedValue(new Map([['gmail', false]]))
680694
expect(await buildIntegrationToolSchemas('person', options)).toEqual([])
681695
mockSearchApprovals.mockResolvedValue(new Map([['gmail', true]]))
@@ -709,7 +723,7 @@ describe('Assistant payload', () => {
709723
expect(mockTrackChatUpload).not.toHaveBeenCalled()
710724
})
711725

712-
it('forwards organization scope without workspace, integration, or desktop authority', async () => {
726+
it('forwards organization scope without workspace or desktop authority', async () => {
713727
const payload = await buildCopilotRequestPayload(
714728
{
715729
message: 'Find the policy',
@@ -726,13 +740,13 @@ describe('Assistant payload', () => {
726740
{ selectedModel: '' }
727741
)
728742
expect(payload.organizationId).toBe('org-1')
729-
expect(payload).not.toHaveProperty('integrationCatalog')
743+
expect(payload.integrationCatalog).toEqual({ mcpServerIds: [] })
730744
expect(payload).not.toHaveProperty('workspaceId')
731745
expect(payload).not.toHaveProperty('desktopCapabilities')
732746
expect(payload).not.toHaveProperty('integrationTools')
733747
})
734748

735-
it('keeps the shared search scope without an integration gateway catalog', async () => {
749+
it('keeps the shared search scope with native discovery and no MCP servers', async () => {
736750
clearIntegrationToolSchemaCacheForTests()
737751
const payload = await buildCopilotRequestPayload(
738752
{
@@ -762,7 +776,7 @@ describe('Assistant payload', () => {
762776
expect(payload).not.toHaveProperty(field)
763777
}
764778
expect(payload).not.toHaveProperty('integrationTools')
765-
expect(payload).not.toHaveProperty('integrationCatalog')
779+
expect(payload.integrationCatalog).toEqual({ mcpServerIds: [] })
766780
})
767781
})
768782

‎apps/sim/lib/mothership/chat/payload.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -464,9 +464,9 @@ export async function buildCopilotRequestPayload(
464464
messageId: userMessageId,
465465
...(chatId ? { chatId } : {}),
466466
...(allContexts.length > 0 ? { context: allContexts } : {}),
467-
...(!isAssistant && {
468-
integrationCatalog: { mcpServerIds: [...new Set(params.mcpServerIds ?? [])] },
469-
}),
467+
integrationCatalog: {
468+
mcpServerIds: isAssistant ? [] : [...new Set(params.mcpServerIds ?? [])],
469+
},
470470
...(params.userTimezone ? { userTimezone: params.userTimezone } : {}),
471471
...(params.effort ? { effort: params.effort } : {}),
472472
...(params.modelSelection ? { modelSelection: params.modelSelection } : {}),

‎apps/sim/lib/mothership/integrations/application/catalog.test.ts‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -223,16 +223,14 @@ describe('catalog authorization', () => {
223223
}
224224
)
225225

226-
it('rejects Search Assistant discovery before building native or MCP catalogs', async () => {
226+
it('does not discover MCP operations in Search even with selected servers', async () => {
227227
queueChat()
228-
await expect(
229-
readIntegrationCatalog.execute({
230-
principal: principal(),
231-
input: { ...input, mcpServerIds: ['mcp-abc'] },
232-
})
233-
).rejects.toThrow('Search Assistant uses scoped search and document reads')
234-
expect(mocks.build).not.toHaveBeenCalled()
235-
expect(mocks.mcp).not.toHaveBeenCalled()
228+
mocks.mcp.mockResolvedValue([{ ...tools[0], name: 'mcp-abc-send', service: 'mcp:mcp-abc' }])
229+
const result = await readIntegrationCatalog.execute({
230+
principal: principal(),
231+
input: { ...input, service: 'mcp:mcp-abc', mcpServerIds: ['mcp-abc'] },
232+
})
233+
expect(result.operations).toEqual([])
236234
})
237235
it.each(['user', 'organization', 'expired', 'audience', 'mode', 'membership'] as const)(
238236
'rejects invalid %s before catalog building',

‎apps/sim/lib/mothership/integrations/application/catalog.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,6 @@ const catalogUseCase = defineAuthorizedChatUseCase({
166166
delegation: { audience: INTEGRATION_CATALOG_AUDIENCE, isWithinScope: () => true },
167167
},
168168
async execute({ input, context }) {
169-
if (context.mode === 'assistant')
170-
throw new OrchestrationError(
171-
'forbidden',
172-
'Search Assistant uses scoped search and document reads for connected sources.'
173-
)
174169
if (input.mcpExecution && context.organizationId)
175170
throw new OrchestrationError('forbidden', 'Executor catalogs require workspace agent scope')
176171
let workspaceId = context.workspaceId
@@ -185,10 +180,12 @@ const catalogUseCase = defineAuthorizedChatUseCase({
185180
{
186181
schemaSurface: 'copilot',
187182
organizationId: context.organizationId,
183+
...(context.mode === 'assistant' ? { personalAccountsOnly: true } : {}),
188184
},
189185
workspaceId
190186
)
191187
const includeMcp =
188+
context.mode !== 'assistant' &&
192189
(!input.toolId || input.toolId.startsWith('mcp-')) &&
193190
(!input.service || input.service.startsWith('mcp:'))
194191
if (includeMcp && (input.mcpServerIds.length || input.mcpToolIds?.length)) {

‎apps/sim/lib/mothership/tool-executor/executor.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ describe('copilot tool executor fallback', () => {
112112
)
113113
expect(result).toEqual({
114114
success: false,
115-
error: 'Search Assistant uses scoped search and document reads for connected sources.',
115+
error: 'This operation is not available in Search Assistant.',
116116
})
117117
expect(handler).not.toHaveBeenCalled()
118118
expect(executeAppTool).not.toHaveBeenCalled()

‎apps/sim/lib/mothership/tool-executor/executor.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import { toError } from '@sim/utils/errors'
44
import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
55
import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.server'
66
import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target'
7-
import { ASSISTANT_TOOLS } from '@/lib/mothership/assistant/tool-policy'
7+
import { ASSISTANT_TOOLS, isAssistantIntegrationTool } from '@/lib/mothership/assistant/tool-policy'
88
import { prepareCopilotEnvironmentContext } from '@/lib/mothership/environment-context'
99
import { projectToolErrorMessageForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result'
1010
import { recordSecretUsage } from '@/lib/secrets/usage/record'
1111
import { executeTool as executeAppTool } from '@/tools'
12+
import { getToolMetadata } from '@/tools/metadata'
1213
import { getToolEntry, isClientExecuted, isKnownTool, isSimExecuted } from './router'
1314
import type { ToolExecutionContext, ToolExecutionResult, ToolHandler } from './types'
1415

@@ -56,7 +57,10 @@ export async function executeTool(
5657
'search_sources',
5758
...(params.scope !== 'workspace' ? ['settings'] : []),
5859
]
59-
if (organizationTools.includes(toolId)) {
60+
if (
61+
organizationTools.includes(toolId) ||
62+
(context.requestMode === 'assistant' && isAssistantIntegrationTool(getToolMetadata(toolId)))
63+
) {
6064
if (context.targetWorkspaceId)
6165
return {
6266
success: false,
@@ -67,7 +71,7 @@ export async function executeTool(
6771
if (context.requestMode === 'assistant') {
6872
return {
6973
success: false,
70-
error: 'Search Assistant uses scoped search and document reads for connected sources.',
74+
error: 'This operation is not available in Search Assistant.',
7175
}
7276
}
7377
if (toolId === 'sim_cli') return executeBoundTool(toolId, params, context)
@@ -129,10 +133,14 @@ async function executeBoundTool(
129133
params: Record<string, unknown>,
130134
context: ToolExecutionContext
131135
): Promise<ToolExecutionResult> {
132-
if (context.requestMode === 'assistant' && !ASSISTANT_TOOLS.has(toolId)) {
136+
if (
137+
context.requestMode === 'assistant' &&
138+
!ASSISTANT_TOOLS.has(toolId) &&
139+
!isAssistantIntegrationTool(getToolMetadata(toolId))
140+
) {
133141
return {
134142
success: false,
135-
error: 'Search Assistant uses scoped search and document reads for connected sources.',
143+
error: 'This operation is not available in Search Assistant.',
136144
}
137145
}
138146
// Client-routed tools (e.g. run_workflow) are normally executed in the browser and never

0 commit comments

Comments
 (0)