diff --git a/packages/worker/src/mcp/capabilities/unified-search.ts b/packages/worker/src/mcp/capabilities/unified-search.ts index 936181641a..9570b692e9 100644 --- a/packages/worker/src/mcp/capabilities/unified-search.ts +++ b/packages/worker/src/mcp/capabilities/unified-search.ts @@ -25,7 +25,10 @@ import { type SecretMetadata, type SecretSearchRow, } from '#mcp/secrets/types.ts' -import { buildValueEntityId, describeValue } from '#mcp/tools/search-entities.ts' +import { + buildValueEntityId, + describeValue, +} from '#mcp/tools/search-entities.ts' import { type UiArtifactSearchHit, searchUiArtifactsForUser, @@ -89,6 +92,45 @@ function scoreSkillLexicalMatch( return lexicalScore(query, doc) + bonus } +type ValueLexicalFields = Pick< + ValueMetadata, + 'name' | 'description' | 'scope' | 'value' | 'appId' +> + +function scoreValuePhraseBonus(query: string, row: ValueLexicalFields): number { + const normalizedQuery = normalizeSearchPhrase(query) + let bonus = 0 + bonus += scoreSkillPhraseMatch(normalizedQuery, row.name) * 2 + bonus += scoreSkillPhraseMatch(normalizedQuery, row.description) * 1.5 + bonus += scoreSkillPhraseMatch(normalizedQuery, row.scope) * 0.5 + bonus += scoreSkillPhraseMatch(normalizedQuery, row.appId) * 0.5 + bonus += scoreSkillPhraseMatch(normalizedQuery, row.value) * 1 + return bonus +} + +type ConnectorLexicalFields = { + connectorName: string + description: string | null | undefined + apiBaseUrl: string | null | undefined + tokenUrl: string | null | undefined + requiredHosts: ReadonlyArray +} + +function scoreConnectorPhraseBonus( + query: string, + entry: ConnectorLexicalFields, +): number { + const normalizedQuery = normalizeSearchPhrase(query) + let bonus = 0 + bonus += scoreSkillPhraseMatch(normalizedQuery, entry.connectorName) * 2 + bonus += scoreSkillPhraseMatch(normalizedQuery, entry.description) * 1.5 + bonus += scoreSkillPhraseMatch(normalizedQuery, entry.apiBaseUrl) * 1 + bonus += scoreSkillPhraseMatch(normalizedQuery, entry.tokenUrl) * 0.75 + bonus += + scoreSkillPhraseMatch(normalizedQuery, entry.requiredHosts.join(' ')) * 0.75 + return bonus +} + function scoreCapabilityLexicalMatch( query: string, hit: CapabilitySearchHit, @@ -136,14 +178,7 @@ function scoreValueLexicalMatch( row: ValueMetadata, doc: string, ): number { - const normalizedQuery = normalizeSearchPhrase(query) - let bonus = 0 - bonus += scoreSkillPhraseMatch(normalizedQuery, row.name) * 2 - bonus += scoreSkillPhraseMatch(normalizedQuery, row.description) * 1.5 - bonus += scoreSkillPhraseMatch(normalizedQuery, row.scope) * 0.5 - bonus += scoreSkillPhraseMatch(normalizedQuery, row.appId) * 0.5 - bonus += scoreSkillPhraseMatch(normalizedQuery, row.value) * 1 - return lexicalScore(query, doc) + bonus + return lexicalScore(query, doc) + scoreValuePhraseBonus(query, row) } function buildValueEmbedDoc(row: ValueMetadata): string { @@ -174,20 +209,16 @@ function scoreConnectorLexicalMatch( entry: ConnectorSearchEntry, doc: string, ): number { - const requiredHosts = entry.config.requiredHosts ?? [] - const normalizedQuery = normalizeSearchPhrase(query) - let bonus = 0 - bonus += scoreSkillPhraseMatch(normalizedQuery, entry.config.name) * 2 - bonus += - scoreSkillPhraseMatch( - normalizedQuery, - describeConnector(entry.config, entry.row.description), - ) * 1.5 - bonus += scoreSkillPhraseMatch(normalizedQuery, entry.config.apiBaseUrl) * 1 - bonus += scoreSkillPhraseMatch(normalizedQuery, entry.config.tokenUrl) * 0.75 - bonus += - scoreSkillPhraseMatch(normalizedQuery, requiredHosts.join(' ')) * 0.75 - return lexicalScore(query, doc) + bonus + return ( + lexicalScore(query, doc) + + scoreConnectorPhraseBonus(query, { + connectorName: entry.config.name, + description: describeConnector(entry.config, entry.row.description), + apiBaseUrl: entry.config.apiBaseUrl, + tokenUrl: entry.config.tokenUrl, + requiredHosts: entry.config.requiredHosts ?? [], + }) + ) } function buildConnectorEmbedDoc(entry: ConnectorSearchEntry): string { @@ -930,16 +961,23 @@ export async function searchUnified(input: { if (key.startsWith('n:')) { const hit = connectorByName.get(key.slice(2)) if (!hit) return 0 - return lexicalScore( - input.query, - [ - hit.connectorName, - hit.description, - hit.flow, - hit.tokenUrl, - hit.apiBaseUrl ?? '', - hit.requiredHosts.join(' '), - ].join('\n'), + const doc = [ + hit.connectorName, + hit.description, + hit.flow, + hit.tokenUrl, + hit.apiBaseUrl ?? '', + hit.requiredHosts.join(' '), + ].join('\n') + return ( + lexicalScore(input.query, doc) + + scoreConnectorPhraseBonus(input.query, { + connectorName: hit.connectorName, + description: hit.description, + apiBaseUrl: hit.apiBaseUrl, + tokenUrl: hit.tokenUrl, + requiredHosts: hit.requiredHosts, + }) ) } if (key.startsWith('c:')) { @@ -981,11 +1019,15 @@ export async function searchUnified(input: { if (key.startsWith('v:')) { const hit = valueById.get(key.slice(2)) if (!hit) return 0 - return lexicalScore( - input.query, - [hit.name, hit.description, hit.scope, hit.value, hit.appId ?? ''].join( - '\n', - ), + const doc = [ + hit.name, + hit.description, + hit.scope, + hit.value, + hit.appId ?? '', + ].join('\n') + return ( + lexicalScore(input.query, doc) + scoreValuePhraseBonus(input.query, hit) ) } if (key.startsWith('a:')) { diff --git a/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts b/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts index 40a549e1eb..923434e622 100644 --- a/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts +++ b/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts @@ -364,3 +364,97 @@ test('search skips connector rows whose stored id disagrees with config.name', a expect(result.matches.some((match) => match.type === 'connector')).toBe(false) }) + +test('exact value-name matches win cross-entity lexical ties', async () => { + const env = { SENTRY_ENVIRONMENT: 'test' } as Env + const specs = { + preferred_org_lookup: { + name: 'preferred_org_lookup', + domain: 'meta', + description: 'Look up preferred org settings.', + keywords: ['preferred', 'org', 'lookup'], + readOnly: true, + idempotent: true, + destructive: false, + inputFields: ['name'], + requiredInputFields: ['name'], + outputFields: ['value'], + inputSchema: {}, + }, + } satisfies Record + const valueRow = createValueRow('preferred_org', { + description: 'Stored organization preference', + value: 'kentcdodds', + }) + + const result = await searchUnified({ + env, + baseUrl: 'http://localhost', + query: 'preferred org', + limit: 5, + specs, + userId: 'user-123', + skillRows: [], + uiArtifactRows: [], + userSecretRows: [], + userValueRows: [valueRow], + appSecretsByAppId: new Map(), + }) + + expect(result.matches[0]).toMatchObject({ + type: 'value', + name: 'preferred_org', + }) +}) + +test('exact connector-name matches win cross-entity lexical ties', async () => { + const env = { SENTRY_ENVIRONMENT: 'test' } as Env + const specs = { + github_connector_lookup: { + name: 'github_connector_lookup', + domain: 'meta', + description: 'Inspect saved GitHub connector details.', + keywords: ['github', 'connector', 'lookup'], + readOnly: true, + idempotent: true, + destructive: false, + inputFields: ['name'], + requiredInputFields: ['name'], + outputFields: ['connector'], + inputSchema: {}, + }, + } satisfies Record + const connectorRow = createValueRow('_connector:github', { + value: JSON.stringify({ + name: 'github', + tokenUrl: 'https://github.com/login/oauth/access_token', + apiBaseUrl: 'https://api.github.com', + flow: 'confidential', + clientIdValueName: 'github_client_id', + clientSecretSecretName: 'github_client_secret', + accessTokenSecretName: 'github_access_token', + refreshTokenSecretName: 'github_refresh_token', + requiredHosts: ['api.github.com'], + }), + description: 'GitHub OAuth connector config', + }) + + const result = await searchUnified({ + env, + baseUrl: 'http://localhost', + query: 'github', + limit: 5, + specs, + userId: 'user-123', + skillRows: [], + uiArtifactRows: [], + userSecretRows: [], + userValueRows: [connectorRow], + appSecretsByAppId: new Map(), + }) + + expect(result.matches[0]).toMatchObject({ + type: 'connector', + connectorName: 'github', + }) +})