diff --git a/apps/sim/lib/mothership/integrations/application/catalog.test.ts b/apps/sim/lib/mothership/integrations/application/catalog.test.ts index becedafbe08..b495ab34592 100644 --- a/apps/sim/lib/mothership/integrations/application/catalog.test.ts +++ b/apps/sim/lib/mothership/integrations/application/catalog.test.ts @@ -1,6 +1,10 @@ import { copilotChats, member } from '@sim/db/schema' import { queueTableRows, resetDbChainMock } from '@sim/testing' import { authBanMock, authBanMockFns } from '@sim/testing/mocks/auth-ban.mock' +import { + credentialsEnvironmentMock, + credentialsEnvironmentMockFns, +} from '@sim/testing/mocks/credentials-environment.mock' import { mcpUseCasesMock, mcpUseCasesMockFns } from '@sim/testing/mocks/mcp-use-cases.mock' import { mothershipChatPayloadMock, @@ -47,7 +51,10 @@ const mocks = { listServers: mcpUseCasesMockFns.mockListMcpServersUseCase, config: permissionGroupsResolveMockFns.mockGetUserPermissionConfigForOrganization, workspace: workspaceContextMockFns.mockResolveActiveWorkspaceApplicationContext, + credentials: credentialsEnvironmentMockFns.mockGetAccessibleOAuthCredentials, + managed: credentialsEnvironmentMockFns.mockGetEnrolledManagedOAuthCredentials, } +vi.mock('@/lib/credentials/environment', () => credentialsEnvironmentMock) vi.mock('@/lib/mcp/application/use-cases', () => mcpUseCasesMock) vi.mock('@/lib/mothership/chat/payload', () => mothershipChatPayloadMock) vi.mock('@/lib/internal/mcp/discover-tools', () => ({ discoverMcpServerToolsAsExecutor: vi.fn() })) @@ -101,7 +108,349 @@ beforeEach(() => { mocks.discover.mockResolvedValue({ tools: [] }) mocks.mcpPermission.mockResolvedValue(undefined) mocks.listServers.mockResolvedValue({ servers: [usableServer('mcp-abc')] }) + mocks.credentials.mockResolvedValue([]) + mocks.managed.mockResolvedValue([]) +}) + +/** + * Real registry descriptions and parameters: email-capable and customer-facing operations, plus + * unrelated ones so term rarity resembles the full catalog, where most operations are neither. + */ +const catalogRows: [string, string, string, string, string?][] = [ + [ + 'gmail_send', + 'gmail', + 'Send emails using Gmail', + 'to,subject,body,cc,bcc,attachments', + 'google-email', + ], + [ + 'gmail_send_v2', + 'gmail', + 'Send emails using Gmail.', + 'to,subject,body,cc,bcc,attachments', + 'google-email', + ], + ['gmail_search', 'gmail', 'Search emails in Gmail', 'query,maxResults', 'google-email'], + [ + 'outlook_send', + 'outlook', + 'Send emails using Outlook', + 'to,subject,body,cc,bcc,attachments', + 'outlook', + ], + [ + 'agentmail_send_message', + 'agentmail', + 'Send an email message from an AgentMail inbox', + 'apiKey,inboxId,to,subject,text,html,cc,bcc', + ], + [ + 'agentmail_send_draft', + 'agentmail', + 'Send an existing email draft in AgentMail', + 'apiKey,inboxId,draftId', + ], + [ + 'lemlist_send_email', + 'lemlist', + 'Sends an email to a contact through the Lemlist inbox.', + 'apiKey,sendUserId,contactId,subject,message,cc', + ], + [ + 'mailgun_send_message', + 'mailgun', + 'Send an email using Mailgun API', + 'apiKey,domain,from,to,subject,text,html', + ], + [ + 'sendgrid_send_mail', + 'sendgrid', + 'Send an email using SendGrid API', + 'apiKey,from,to,subject,content,templateId', + ], + [ + 'ses_send_email', + 'ses', + 'Send an email via AWS SES using simple or HTML content', + 'region,fromAddress,toAddresses,subject,bodyText', + ], + [ + 'resend_send', + 'resend', + 'Send an email using your own Resend API key and from address', + 'fromAddress,to,subject,body', + ], + [ + 'smtp_send_mail', + 'smtp', + 'Send emails via SMTP server', + 'smtpHost,smtpPort,from,to,subject,body', + ], + ['stripe_send_invoice', 'stripe', 'Send an invoice to the customer', 'apiKey,id'], + ['stripe_list_customers', 'stripe', 'List all customers', 'apiKey,limit,email'], + [ + 'helpscout_create_conversation', + 'helpscout', + 'Create a Help Scout conversation in an inbox with a first customer message, reply, or note. A new customer is created when the email is not already known.', + 'subject,mailboxId,customerId,customerEmail,text', + ], + [ + 'hubspot_create_contact', + 'hubspot', + 'Create a new contact in HubSpot. Requires at least one of: email, firstname, or lastname', + 'properties,associations', + ], + [ + 'zendesk_create_ticket', + 'zendesk', + 'Create a new ticket in Zendesk with support for custom fields', + 'email,apiToken,subdomain,subject,description', + ], + [ + 'slack_message', + 'slack', + 'Send messages to Slack channels or direct messages.', + 'channel,text,threadTs', + 'slack', + ], + [ + 'google_calendar_list_v2', + 'google_calendar', + 'List events from Google Calendar', + 'calendarId,timeMin,timeMax', + 'google-calendar', + ], + [ + 'github_list_prs_v2', + 'github', + 'List pull requests in a GitHub repository', + 'owner,repo,state', + 'github', + ], + [ + 'github_create_issue_v2', + 'github', + 'Create a new issue in a GitHub repository', + 'owner,repo,title,body', + 'github', + ], + ['jira_write', 'jira', 'Write a Jira issue', 'domain,projectId,summary,description', 'jira'], + [ + 'jira_search_issues', + 'jira', + 'Search for Jira issues using JQL', + 'domain,jql,maxResults', + 'jira', + ], + [ + 'linear_create_issue', + 'linear', + 'Create a new issue in Linear', + 'teamId,title,description', + 'linear', + ], + [ + 'notion_create_page', + 'notion', + 'Create a new page in Notion', + 'parentId,title,content', + 'notion', + ], + [ + 'notion_query_database', + 'notion', + 'Query and filter Notion database entries with advanced filtering', + 'databaseId,filter,sorts', + 'notion', + ], + [ + 'google_sheets_read_v2', + 'google_sheets', + 'Read data from a specific sheet in a Google Sheets spreadsheet', + 'spreadsheetId,sheetName,cellRange', + 'google-sheets', + ], + [ + 'google_sheets_update_v2', + 'google_sheets', + 'Update data in a specific sheet in a Google Sheets spreadsheet', + 'spreadsheetId,sheetName,cellRange,values', + 'google-sheets', + ], + [ + 'google_drive_upload', + 'google_drive', + 'Upload a file to Google Drive', + 'fileName,content,folderId', + 'google-drive', + ], + [ + 'airtable_list_records', + 'airtable', + 'Read records from an Airtable table', + 'baseId,tableId,maxRecords', + 'airtable', + ], + ['asana_create_task', 'asana', 'Create a new task in Asana', 'workspace,name,notes', 'asana'], + [ + 'trello_create_card', + 'trello', + 'Create a new card on a Trello board', + 'listId,name,desc', + 'trello', + ], + ['dropbox_upload', 'dropbox', 'Upload a file to Dropbox', 'path,fileContent,mode', 'dropbox'], + [ + 'confluence_retrieve', + 'confluence', + 'Retrieve content from Confluence pages using the Confluence API.', + 'domain,pageId', + 'confluence', + ], + [ + 'discord_send_message', + 'discord', + 'Send a message to a Discord channel', + 'botToken,channelId,content', + ], + [ + 'postgresql_query', + 'postgresql', + 'Execute a SELECT query on PostgreSQL database', + 'host,port,database,query', + ], + [ + 'openai_embeddings', + 'openai', + 'Generate embeddings from text using OpenAI', + 'input,model,apiKey', + ], + [ + 'firecrawl_scrape', + 'firecrawl', + 'Extract structured content from web pages', + 'url,scrapeOptions,apiKey', + ], + [ + 'youtube_search', + 'youtube', + 'Search for videos on YouTube using the YouTube Data API.', + 'query,maxResults,apiKey', + ], +] +const emailCapable = catalogRows.map(([name, service, description, parameters, provider]) => ({ + name, + service, + description, + input_schema: { + type: 'object', + properties: Object.fromEntries(parameters.split(',').map((key) => [key, { type: 'string' }])), + }, + ...(provider ? { oauth: { required: true, provider } } : {}), +})) +function rankOf(toolId: string, query: string, connectedProviderIds: string[] = []) { + return ( + projectIntegrationCatalog(emailCapable, { ...input, query, limit: 0 }, { connectedProviderIds }) + .operations.map((operation) => operation.toolId) + .indexOf(toolId) + 1 + ) +} + +describe('integration catalog ranking', () => { + it('ranks a connected Gmail send operation in the top three for a natural-language email intent', () => { + const rank = rankOf('gmail_send', 'send an email to a customer', ['google-email']) + expect(rank).toBeGreaterThan(0) + expect(rank).toBeLessThanOrEqual(3) + }) + + it('ranks the connected service ahead of an equally relevant unconnected one', () => { + expect(rankOf('outlook_send', 'send email', ['outlook'])).toBeLessThan( + rankOf('gmail_send', 'send email', ['outlook']) + ) + expect(rankOf('gmail_send', 'send email', ['google-email'])).toBeLessThan( + rankOf('outlook_send', 'send email', ['google-email']) + ) + }) + + it('never matches on a stop word that folds to another term', () => { + const setnx = { + name: 'redis_setnx', + description: 'Set a key only if it does not exist', + input_schema: { type: 'object', properties: {} }, + } + expect( + projectIntegrationCatalog([setnx], { ...input, query: 'what does it do', limit: 0 }) + ).toMatchObject({ + total: 0, + }) + }) + + it('folds -es plurals onto their singular', () => { + const branch = { + name: 'github_create_branch', + description: 'Create a branch', + input_schema: { type: 'object', properties: {} }, + } + const warehouse = { + name: 'snowflake_get_warehouse', + description: 'Get a warehouse', + input_schema: { type: 'object', properties: {} }, + } + for (const query of ['branches', 'branch', 'warehouses', 'warehouse']) + expect( + projectIntegrationCatalog([branch, warehouse], { + ...input, + query, + limit: 0, + }).operations.map((operation) => operation.toolId) + ).toEqual([query.startsWith('branch') ? 'github_create_branch' : 'snowflake_get_warehouse']) + }) + + it('matches nothing for a query made only of stop words and one-character terms', () => { + for (const query of ['a the to of', 'to a', 'x y z', 'gets uses']) + expect(projectIntegrationCatalog(emailCapable, { ...input, query, limit: 0 })).toMatchObject({ + total: 0, + operations: [], + }) + }) + + it('only counts operations that share a content term with the intent', () => { + const result = projectIntegrationCatalog(emailCapable, { + ...input, + query: 'send an email to a customer', + limit: 0, + }) + expect(result.operations.map((operation) => operation.toolId)).not.toContain( + 'google_calendar_list_v2' + ) + }) + + it('ranks an exact operation ID first even over a connected, more repetitive match', () => { + expect(rankOf('outlook_send', 'outlook_send', ['google-email'])).toBe(1) + expect(rankOf('gmail_send', 'gmail_send', ['outlook'])).toBe(1) + }) + + it('breaks only score ties by preferred service, after the connected boost', () => { + const twins = ['alpha', 'beta'].map((service) => ({ + name: `${service}_send`, + service, + description: 'Send an email', + input_schema: { type: 'object', properties: {} }, + oauth: { required: true, provider: `${service}-provider` }, + })) + const order = (preferred: string[], connectedProviderIds: string[] = []) => + projectIntegrationCatalog( + twins, + { ...input, query: 'send email', limit: 0 }, + { preferredServices: new Set(preferred), connectedProviderIds } + ).operations.map((operation) => operation.toolId) + expect(order([])).toEqual(['alpha_send', 'beta_send']) + expect(order(['beta'])).toEqual(['beta_send', 'alpha_send']) + expect(order(['beta'], ['alpha-provider'])).toEqual(['alpha_send', 'beta_send']) + }) }) + describe('integration catalog projection', () => { it.each(['google_calendar', 'google-calendar', 'Google Calendar', ' GOOGLE-CALENDAR '])( 'resolves the registered Calendar service name %s without changing the operation', @@ -215,7 +564,7 @@ describe('integration catalog projection', () => { }) expect(projectIntegrationCatalog(tools, { ...input, limit: 0 }).operations).toHaveLength(2) }) - it('returns schemas only for matching query or exact ID and never guesses unknown tools', () => { + it('returns schemas only for a matching query, service filter or exact ID and never guesses unknown tools', () => { expect( projectIntegrationCatalog(tools, { ...input, query: 'email' }).operations[0]?.inputSchema ).toEqual({ type: 'object' }) @@ -226,11 +575,49 @@ describe('integration catalog projection', () => { projectIntegrationCatalog(tools, { ...input, toolId: 'gmail_delete' }).operations ).toEqual([]) expect( - projectIntegrationCatalog(tools, { ...input, service: 'gmail' }).operations[0] - ).not.toHaveProperty('inputSchema') + projectIntegrationCatalog(tools, { ...input, service: 'gmail' }).operations[0]?.inputSchema + ).toEqual({ type: 'object' }) }) }) describe('catalog authorization', () => { + it('boosts the services the requester has connected in the authorized target workspace', async () => { + queueChat('agent') + mocks.target.mockResolvedValue({ workspaceId: 'workspace-1' }) + mocks.build.mockResolvedValue(structuredClone(emailCapable)) + mocks.credentials.mockResolvedValue([{ id: 'credential-1', providerId: 'outlook' }]) + const result = await readIntegrationCatalog.execute({ + principal: principal(), + input: { ...input, mode: 'agent', workspaceId: 'workspace-1', query: 'send email' }, + }) + expect(mocks.credentials).toHaveBeenCalledWith('workspace-1', 'actor') + expect(result.operations[0]?.toolId).toBe('outlook_send') + }) + + it('boosts a service the requester connected through a managed account', async () => { + queueChat('agent') + mocks.target.mockResolvedValue({ workspaceId: 'workspace-1' }) + mocks.build.mockResolvedValue(structuredClone(emailCapable)) + mocks.managed.mockResolvedValue([{ id: 'credential-2', providerId: 'outlook' }]) + const result = await readIntegrationCatalog.execute({ + principal: principal(), + input: { ...input, mode: 'agent', workspaceId: 'workspace-1', query: 'send email' }, + }) + expect(mocks.managed).toHaveBeenCalledWith('workspace-1', 'actor') + expect(result.operations[0]?.toolId).toBe('outlook_send') + }) + + it('still lists operations, unboosted, when the connected-service lookup fails', async () => { + queueChat('agent') + mocks.target.mockResolvedValue({ workspaceId: 'workspace-1' }) + mocks.build.mockResolvedValue(structuredClone(emailCapable)) + mocks.credentials.mockRejectedValue(new Error('database unavailable')) + const result = await readIntegrationCatalog.execute({ + principal: principal(), + input: { ...input, mode: 'agent', workspaceId: 'workspace-1', query: 'send email' }, + }) + expect(result.operations.length).toBeGreaterThan(0) + }) + it.each(['agent', 'plan'] as const)( 'preserves integration discovery for %s conversations', async (mode) => { diff --git a/apps/sim/lib/mothership/integrations/application/catalog.ts b/apps/sim/lib/mothership/integrations/application/catalog.ts index 4a9cadedd3a..b767836b110 100644 --- a/apps/sim/lib/mothership/integrations/application/catalog.ts +++ b/apps/sim/lib/mothership/integrations/application/catalog.ts @@ -8,10 +8,16 @@ import { } from '@sim/auth/principal' import { INTEGRATION_METADATA } from '@sim/deployment-config/integration-metadata' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { stripVersionSuffix } from '@sim/utils/string' import { defineWorkspaceOperation } from '@/lib/core/application' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + getAccessibleOAuthCredentials, + getEnrolledManagedOAuthCredentials, +} from '@/lib/credentials/environment' +import { getOwnOrganizationManagedOAuthCredentials } from '@/lib/credentials/organization-managed' import { getIntegrationsForCredentialProvider } from '@/lib/integrations/credential-display' import { createExecutorPrincipalFromExecutionContext, @@ -51,44 +57,151 @@ export const readIntegrationCatalogOperation = defineWorkspaceOperation({ delegatedServices: ['copilot', 'executor'], }) +/** + * Filler in a natural-language intent ("send an email to a customer"). Left in, these + * match nearly every operation description and bury the words that name the operation. + */ +const STOP_WORDS: ReadonlySet = new Set( + 'a about all am an and any are as at be by can do does for from get how i in into is it its me my of on or our please so some that the their them then this to use using via want we what when where which who why will with you your'.split( + ' ' + ) +) +const MIN_TERM_LENGTH = 2 +/** Standard Okapi BM25 saturation and length normalization. */ +const BM25_K1 = 1.2 +const BM25_B = 0.75 +/** + * An operation the requester can already authenticate is the likelier intent ("send an + * email" with Gmail connected): its score is multiplied by this, so it beats an unconnected + * match up to a third stronger, but not one well beyond that. + */ +const CONNECTED_SERVICE_BOOST = 1.5 + +/** Folds a plural onto its singular: "emails" → "email", "entries" → "entry", "searches" → "search". */ +function singular(term: string): string { + if (term.length > 4 && term.endsWith('ies')) return `${term.slice(0, -3)}y` + // A consonant before -uses (statuses, buses); warehouses and houses keep their e. + if (term.length > 4 && /(ss|sh|ch|x|z|[^aeiou]us)es$/.test(term)) return term.slice(0, -2) + if (term.length > 3 && term.endsWith('s') && !/(ss|us|is)$/.test(term)) return term.slice(0, -1) + return term +} + +/** Parameter names stay whole (`customerEmail`), so a field list never outweighs the name. */ +function tokenize(text: string): string[] { + return ( + text + .toLowerCase() + .split(/[^a-z0-9]+/) + // Before folding too: "does" would fold to "doe", which no stop list names. + .filter((term) => !STOP_WORDS.has(term)) + .map(singular) + .filter((term) => term.length >= MIN_TERM_LENGTH && !STOP_WORDS.has(term)) + ) +} + +/** Name, service, description and parameter names: everything an intent may echo. */ +function operationTerms(tool: ToolSchema): string[] { + const properties = tool.input_schema.properties + const parameters = + properties && typeof properties === 'object' ? Object.keys(properties).join(' ') : '' + return tokenize(`${tool.name} ${tool.service ?? ''} ${tool.description} ${parameters}`) +} + +/** Okapi BM25 over the candidate operations; zero means no query term occurs. */ +function rankByBm25(tools: ToolSchema[], queryTerms: string[]): number[] { + const query = new Set(queryTerms) + const documents = tools.map((tool) => { + const terms = operationTerms(tool) + const frequencies = new Map() + for (const term of terms) + if (query.has(term)) frequencies.set(term, (frequencies.get(term) ?? 0) + 1) + return { length: terms.length, frequencies } + }) + const averageLength = + documents.reduce((total, document) => total + document.length, 0) / (documents.length || 1) + const inverseFrequency = new Map( + [...query].map((term) => { + const containing = documents.filter((document) => document.frequencies.has(term)).length + return [ + term, + Math.log(1 + (documents.length - containing + 0.5) / (containing + 0.5)), + ] as const + }) + ) + return documents.map((document) => { + const normalization = 1 - BM25_B + (BM25_B * document.length) / (averageLength || 1) + let score = 0 + for (const [term, frequency] of document.frequencies) + score += + ((inverseFrequency.get(term) ?? 0) * frequency * (BM25_K1 + 1)) / + (frequency + BM25_K1 * normalization) + return score + }) +} + +/** Services any of these connected credential providers can authenticate, as catalog service IDs. */ +function resolveConnectedServices( + tools: ToolSchema[], + providerIds: readonly string[] +): ReadonlySet { + const services = new Set() + for (const providerId of providerIds) { + for (const integration of getIntegrationsForCredentialProvider(providerId)) + services.add(stripVersionSuffix(integration.type).toLowerCase()) + for (const tool of tools) + if (tool.service && tool.oauth?.provider === providerId) + services.add(tool.service.toLowerCase()) + } + return services +} + interface CatalogProjectionOptions { /** Services the user named in the conversation; they rank first among equally relevant matches. */ preferredServices?: ReadonlySet + /** Credential providers the requester has connected; their services' scores are boosted. */ + connectedProviderIds?: readonly string[] } -/** Return schemas only for targeted discovery, while preserving ranked search and explicit full listings. */ +/** + * Return schemas only for targeted discovery (query, service or exact ID), while keeping + * explicit full listings compact. Free-text queries rank by BM25, with operations of the + * requester's connected services boosted; an exact operation ID always ranks first, and + * preferred services break ties in score. + */ export function projectIntegrationCatalog( tools: ToolSchema[], input: IntegrationCatalogRequest, - { preferredServices }: CatalogProjectionOptions = {} + { preferredServices, connectedProviderIds = [] }: CatalogProjectionOptions = {} ): IntegrationCatalogResponse { const preferred = (tool: ToolSchema) => Number(Boolean(tool.service && preferredServices?.has(tool.service))) const normalized = input.query?.trim().toLowerCase() ?? '' - const terms = normalized.split(/[^a-z0-9]+/).filter(Boolean) + const queryTerms = tokenize(input.query ?? '') const services = resolveServiceFilter(tools, input.service) - const matches = tools.flatMap((tool) => { - if (input.toolId && tool.name !== input.toolId) return [] - if (services && (!tool.service || !services.has(tool.service.toLowerCase()))) return [] - const name = tool.name.toLowerCase() - const description = tool.description.toLowerCase() - let score = 0 - for (const term of terms) { - if (name.includes(term)) score += 3 - if (description.includes(term)) score += 1 - if (tool.service?.toLowerCase().includes(term)) score += 2 - } - return terms.length === 0 || score > 0 ? [{ tool, score }] : [] + const candidates = tools.filter( + (tool) => + (!input.toolId || tool.name === input.toolId) && + (!services || (tool.service && services.has(tool.service.toLowerCase()))) + ) + const connected = normalized ? resolveConnectedServices(candidates, connectedProviderIds) : null + const scores = normalized ? rankByBm25(candidates, queryTerms) : [] + const matches = candidates.flatMap((tool, index) => { + if (!normalized) return [{ tool, exact: false, score: 0 }] + const exact = tool.name.toLowerCase() === normalized + const score = + (scores[index] ?? 0) * + (tool.service && connected?.has(tool.service.toLowerCase()) ? CONNECTED_SERVICE_BOOST : 1) + return exact || score > 0 ? [{ tool, exact, score }] : [] }) matches.sort( (a, b) => - Number(b.tool.name.toLowerCase() === normalized) - - Number(a.tool.name.toLowerCase() === normalized) || + Number(b.exact) - Number(a.exact) || b.score - a.score || preferred(b.tool) - preferred(a.tool) || a.tool.name.localeCompare(b.tool.name) ) const selected = input.limit === 0 ? matches : matches.slice(0, input.limit) + const targeted = Boolean(input.toolId || normalized || services) return { total: matches.length, truncated: selected.length < matches.length, @@ -96,7 +209,7 @@ export function projectIntegrationCatalog( toolId: tool.name, ...(tool.service ? { service: tool.service } : {}), description: tool.description, - ...(input.toolId || normalized ? { inputSchema: tool.input_schema } : {}), + ...(targeted ? { inputSchema: tool.input_schema } : {}), })), } } @@ -194,17 +307,22 @@ const catalogUseCase = defineAuthorizedChatUseCase({ workspaceId = (await resolveInvocationWorkspace(context, input.workspaceId)).workspaceId } const mcpOnly = input.toolId?.startsWith('mcp-') || input.service?.startsWith('mcp:') - const tools = mcpOnly - ? [] - : await buildIntegrationToolSchemas( - context.userId, - { - schemaSurface: 'copilot', - organizationId: context.organizationId, - ...(context.mode === 'assistant' ? { personalAccountsOnly: true } : {}), - }, - workspaceId - ) + const [tools, connectedProviderIds]: [ToolSchema[], string[]] = await Promise.all([ + mcpOnly + ? [] + : buildIntegrationToolSchemas( + context.userId, + { + schemaSurface: 'copilot', + organizationId: context.organizationId, + ...(context.mode === 'assistant' ? { personalAccountsOnly: true } : {}), + }, + workspaceId + ), + mcpOnly || input.toolId || !input.query?.trim() + ? [] + : listConnectedProviderIds(context.userId, workspaceId, context.organizationId), + ]) const includeMcp = context.mode !== 'assistant' && (!input.toolId || input.toolId.startsWith('mcp-')) && @@ -213,7 +331,7 @@ const catalogUseCase = defineAuthorizedChatUseCase({ const selectionScoped = Boolean(input.mcpExecution || input.mcpToolIds?.length) const selectsNothing = !input.mcpServerIds.length && !input.mcpToolIds?.length if (!includeMcp || (selectionScoped && selectsNothing)) - return projectIntegrationCatalog(tools, input) + return projectIntegrationCatalog(tools, input, { connectedProviderIds }) const targeted = Boolean(input.toolId?.startsWith('mcp-') || input.service?.startsWith('mcp:')) if (!workspaceId) { if (targeted) @@ -221,13 +339,13 @@ const catalogUseCase = defineAuthorizedChatUseCase({ 'validation', 'MCP discovery requires an explicit workspace ID' ) - return projectIntegrationCatalog(tools, input) + return projectIntegrationCatalog(tools, input, { connectedProviderIds }) } try { await assertPermissionsAllowed({ userId: context.userId, workspaceId, toolKind: 'mcp' }) } catch (error) { if (selectionScoped || targeted || !(error instanceof McpToolsNotAllowedError)) throw error - return projectIntegrationCatalog(tools, input) + return projectIntegrationCatalog(tools, input, { connectedProviderIds }) } const selectedIds = new Set(input.mcpToolIds ?? []) @@ -288,6 +406,7 @@ const catalogUseCase = defineAuthorizedChatUseCase({ : mcpTools) ) return projectIntegrationCatalog(tools, input, { + connectedProviderIds, preferredServices: new Set([...taggedIds].map((serverId) => `mcp:${serverId}`)), }) }, @@ -310,6 +429,36 @@ async function listUsableMcpServerIds(userId: string, workspaceId: string): Prom ) } +/** + * Credential providers the requester can use in this scope, a ranking hint only: the tool + * list is already authorized, and a connected service never adds or reveals an operation. + */ +async function listConnectedProviderIds( + userId: string, + workspaceId: string | undefined, + organizationId: string | undefined +): Promise { + try { + const credentials = workspaceId + ? ( + await Promise.all([ + getAccessibleOAuthCredentials(workspaceId, userId), + getEnrolledManagedOAuthCredentials(workspaceId, userId), + ]) + ).flat() + : organizationId + ? await getOwnOrganizationManagedOAuthCredentials({ organizationId, userId }) + : [] + return credentials.map((credential) => credential.providerId) + } catch (error) { + // Only ranking depends on it, so discovery proceeds unboosted. + logger.warn('Connected services unavailable for integration ranking', { + error: getErrorMessage(error), + }) + return [] + } +} + /** Restore only the canonical nonsecret origin, never a bearer token from request state. */ function catalogExecutorContext( input: IntegrationCatalogRequest,