Repository navigation
Add values and connectors to Kody search - #139
Conversation
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
|
Caution Review failedPull request was closed or merged during review No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
✅ Files skipped from review due to trivial changes (2)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAdds persisted values and saved connectors as first-class searchable entities: threads user value metadata through the MCP search stack, extends unified search to produce value/connector hits with ranking, updates search tooling/formatting and server instructions, and adds tests to cover the new types. Changes
Sequence DiagramsequenceDiagram
participant Client as Client
participant SearchTool as Search Tool
participant ValueStore as Value Store
participant Unified as Unified Search
participant Ranker as Ranking Pipeline
participant Formatter as Formatter
Client->>SearchTool: search(query, userId)
SearchTool->>ValueStore: loadUserValues(userId)
ValueStore-->>SearchTool: userValueRows[]
SearchTool->>Unified: searchUnified({query, userId, userValueRows})
par concurrent scoring
Unified->>Ranker: scoreCapabilities(query)
Unified->>Ranker: scoreSecrets(query)
Unified->>Ranker: scoreSkills(query)
Unified->>Ranker: scoreValues(userValueRows, query)
Unified->>Ranker: scoreConnectors(userValueRows, query)
end
Ranker->>Ranker: lexicalRank + vectorRank
Ranker->>Ranker: reciprocalRankFusion(...)
Ranker-->>Unified: fusedMatches[]
Unified-->>SearchTool: {matches, offline}
SearchTool->>Formatter: formatMatches(matches)
Formatter-->>Client: markdown + structured detail
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
|
🔎 Preview deployed: https://kody-pr-139.kentcdodds.workers.dev Worker: Mocks:
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Duplicated
describeValuefunction across two files- Extracted the shared describeValue helper into
search-entities.tsand reused it in both callers to eliminate duplication.
- Extracted the shared describeValue helper into
Preview (3852e83f59)
diff --git a/docs/use/search.md b/docs/use/search.md
--- a/docs/use/search.md
+++ b/docs/use/search.md
@@ -1,8 +1,8 @@
# Search
-The **search** tool finds **builtin capabilities**, **saved skills**, **saved
-apps** (MCP App artifacts), and **user secret references** (metadata only, not
-values).
+The **search** tool finds **builtin capabilities**, **persisted values**,
+**saved connectors**, **saved skills**, **saved apps** (MCP App artifacts), and
+**user secret references** (metadata only, not secret values).
## Queries and ranking
@@ -18,17 +18,21 @@
small.
Optional **`skill_collection`** narrows saved skills to one collection slug
-while still searching capabilities, apps, and secrets normally.
+while still searching capabilities, values, connectors, apps, and secrets
+normally.
## Single-entity detail
To get **full markdown and schemas for one hit** (for example a capability’s
`inputSchema` / `outputSchema`), call **search** again with **`entity`** set to
-`"{id}:{type}"` where **`type`** is `capability`, `skill`, `app`, or `secret`.
+`"{id}:{type}"` where **`type`** is `capability`, `value`, `connector`, `skill`,
+`app`, or `secret`.
Examples:
- `page_to_markdown:capability`
+- `user:preferred_org:value`
+- `github:connector`
- `my-skill-name:skill`
There is **no separate `detail` flag** on search. Deeper inspection of one
@@ -44,7 +48,7 @@
## Authentication
-Saved **skills** and **apps** require a signed-in MCP user. Capabilities and
-builtin behavior still work without user-scoped data.
+Saved **values**, **connectors**, **skills**, and **apps** require a signed-in
+MCP user. Capabilities and builtin behavior still work without user-scoped data.
Long-term memory retrieval also requires a signed-in MCP user.
diff --git a/packages/worker/src/mcp/capabilities/unified-search.ts b/packages/worker/src/mcp/capabilities/unified-search.ts
--- a/packages/worker/src/mcp/capabilities/unified-search.ts
+++ b/packages/worker/src/mcp/capabilities/unified-search.ts
@@ -12,6 +12,12 @@
sortIdsByScore,
} from './capability-search.ts'
import { type CapabilitySpec } from './types.ts'
+import {
+ type ConnectorConfig,
+ parseConnectorConfig,
+ parseConnectorJson,
+ parseConnectorValueName,
+} from '#mcp/capabilities/values/connector-shared.ts'
import { buildSkillEmbedText } from '#mcp/skills/skill-embed-and-flags.ts'
import { type McpSkillRow } from '#mcp/skills/mcp-skills-types.ts'
import { parseSkillParameters } from '#mcp/skills/skill-parameters.ts'
@@ -19,11 +25,13 @@
type SecretMetadata,
type SecretSearchRow,
} from '#mcp/secrets/types.ts'
+import { buildValueEntityId, describeValue } from '#mcp/tools/search-entities.ts'
import {
type UiArtifactSearchHit,
searchUiArtifactsForUser,
} from '#mcp/ui-artifacts-search.ts'
import { type UiArtifactRow } from '#mcp/ui-artifacts-types.ts'
+import { type ValueMetadata, type ValueScope } from '#mcp/values/types.ts'
function parseJsonStringArray(raw: string): Array<string> {
try {
@@ -119,6 +127,85 @@
return lexicalScore(query, doc) + bonus
}
+function buildValueUsage(name: string, scope: ValueScope): string {
+ return `Read with value_get: ${JSON.stringify({ name, scope })}. List related persisted config with value_list${scope === 'user' ? '({ scope: "user" })' : '({ ... })'}.`
+}
+
+function scoreValueLexicalMatch(
+ query: string,
+ 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
+}
+
+function buildValueEmbedDoc(row: ValueMetadata): string {
+ return [
+ `value ${row.name}`,
+ `scope ${row.scope}`,
+ row.description,
+ row.value,
+ row.appId ? `app ${row.appId}` : '',
+ ].join('\n')
+}
+
+function buildConnectorUsage(name: string): string {
+ return `Read with connector_get: ${JSON.stringify({ name })}. Browse saved connector configs with connector_list({}).`
+}
+
+function describeConnector(
+ config: ConnectorConfig,
+ description: string | null | undefined,
+): string {
+ const trimmed = description?.trim()
+ if (trimmed) return trimmed
+ return `Saved OAuth connector configuration (${config.flow} flow).`
+}
+
+function scoreConnectorLexicalMatch(
+ query: string,
+ 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
+}
+
+function buildConnectorEmbedDoc(entry: ConnectorSearchEntry): string {
+ const requiredHosts = entry.config.requiredHosts ?? []
+ return [
+ `connector ${entry.config.name}`,
+ describeConnector(entry.config, entry.row.description),
+ entry.config.flow,
+ entry.config.tokenUrl,
+ entry.config.apiBaseUrl ?? '',
+ entry.config.clientIdValueName,
+ entry.config.clientSecretSecretName ?? '',
+ entry.config.accessTokenSecretName,
+ entry.config.refreshTokenSecretName ?? '',
+ requiredHosts.join('\n'),
+ ].join('\n')
+}
+
function skillRowEmbedDoc(
row: McpSkillRow,
specs: Record<string, CapabilitySpec>,
@@ -186,10 +273,56 @@
export type SecretSearchHit = SecretSearchHitSummary
+export type ValueSearchHitSummary = {
+ type: 'value'
+ valueId: string
+ name: string
+ scope: ValueScope
+ description: string
+ value: string
+ appId: string | null
+ updatedAt: string
+ ttlMs: number | null
+ usage: string
+ fusedScore: number
+ lexicalRank?: number
+ vectorRank?: number
+}
+
+export type ValueSearchHit = ValueSearchHitSummary
+
+type ConnectorSearchEntry = {
+ row: ValueMetadata
+ config: ConnectorConfig
+}
+
+export type ConnectorSearchHitSummary = {
+ type: 'connector'
+ connectorName: string
+ title: string
+ description: string
+ flow: ConnectorConfig['flow']
+ tokenUrl: string
+ apiBaseUrl: string | null
+ clientIdValueName: string
+ clientSecretSecretName: string | null
+ accessTokenSecretName: string
+ refreshTokenSecretName: string | null
+ requiredHosts: Array<string>
+ usage: string
+ fusedScore: number
+ lexicalRank?: number
+ vectorRank?: number
+}
+
+export type ConnectorSearchHit = ConnectorSearchHitSummary
+
export type UnifiedSearchMatch =
| CapabilitySearchHitTyped
| SkillSearchHit
| SecretSearchHit
+ | ValueSearchHit
+ | ConnectorSearchHit
| UiArtifactSearchHit
function buildSecretUsage(name: string) {
@@ -214,6 +347,55 @@
}
}
+function rowToValueHit(
+ row: ValueMetadata,
+ fusedScore: number,
+ lexicalRank?: number,
+ vectorRank?: number,
+): ValueSearchHit {
+ return {
+ type: 'value',
+ valueId: buildValueEntityId(row),
+ name: row.name,
+ scope: row.scope,
+ description: describeValue(row),
+ value: row.value,
+ appId: row.appId,
+ updatedAt: row.updatedAt,
+ ttlMs: row.ttlMs,
+ usage: buildValueUsage(row.name, row.scope),
+ fusedScore,
+ lexicalRank,
+ vectorRank,
+ }
+}
+
+function rowToConnectorHit(
+ entry: ConnectorSearchEntry,
+ fusedScore: number,
+ lexicalRank?: number,
+ vectorRank?: number,
+): ConnectorSearchHit {
+ return {
+ type: 'connector',
+ connectorName: entry.config.name,
+ title: entry.config.name,
+ description: describeConnector(entry.config, entry.row.description),
+ flow: entry.config.flow,
+ tokenUrl: entry.config.tokenUrl,
+ apiBaseUrl: entry.config.apiBaseUrl ?? null,
+ clientIdValueName: entry.config.clientIdValueName,
+ clientSecretSecretName: entry.config.clientSecretSecretName ?? null,
+ accessTokenSecretName: entry.config.accessTokenSecretName,
+ refreshTokenSecretName: entry.config.refreshTokenSecretName ?? null,
+ requiredHosts: entry.config.requiredHosts ?? [],
+ usage: buildConnectorUsage(entry.config.name),
+ fusedScore,
+ lexicalRank,
+ vectorRank,
+ }
+}
+
function rowToSkillHit(
row: McpSkillRow,
fusedScore: number,
@@ -439,6 +621,134 @@
}
}
+async function searchValuesForUser(input: {
+ query: string
+ limit: number
+ rows: Array<ValueMetadata>
+}): Promise<{ matches: Array<ValueSearchHit>; offline: boolean }> {
+ const rowsById = new Map(
+ input.rows
+ .filter((row) => parseConnectorValueName(row.name) == null)
+ .map((row) => [buildValueEntityId(row), row] as const),
+ )
+ const ids = [...rowsById.keys()]
+ if (ids.length === 0) {
+ return { matches: [], offline: false }
+ }
+
+ const docsById = Object.fromEntries(
+ [...rowsById.values()].map(
+ (row) => [buildValueEntityId(row), buildValueEmbedDoc(row)] as const,
+ ),
+ )
+ const lexicalOrder = sortIdsByScore(ids, (id) =>
+ scoreValueLexicalMatch(input.query, rowsById.get(id)!, docsById[id]!),
+ )
+ const queryVector = deterministicEmbedding(input.query)
+ const vectorOrder = sortIdsByScore(ids, (id) =>
+ cosineSimilarity(queryVector, deterministicEmbedding(docsById[id]!)),
+ )
+
+ const lexicalRankById = new Map<string, number>()
+ for (let index = 0; index < lexicalOrder.length; index += 1) {
+ lexicalRankById.set(lexicalOrder[index]!, index + 1)
+ }
+ const vectorRankById = new Map<string, number>()
+ for (let index = 0; index < vectorOrder.length; index += 1) {
+ vectorRankById.set(vectorOrder[index]!, index + 1)
+ }
+
+ const fused = reciprocalRankFusion(
+ [lexicalOrder, vectorOrder],
+ CAPABILITY_SEARCH_RRF_K,
+ )
+ const ordered = sortIdsByScore(ids, (id) => fused.get(id) ?? 0).slice(
+ 0,
+ Math.max(1, Math.min(input.limit, ids.length)),
+ )
+
+ return {
+ matches: ordered.map((id) =>
+ rowToValueHit(
+ rowsById.get(id)!,
+ fused.get(id) ?? 0,
+ lexicalRankById.get(id),
+ vectorRankById.get(id),
+ ),
+ ),
+ offline: false,
+ }
+}
+
+async function searchConnectorsForUser(input: {
+ query: string
+ limit: number
+ rows: Array<ValueMetadata>
+}): Promise<{ matches: Array<ConnectorSearchHit>; offline: boolean }> {
+ const entries = input.rows
+ .map((row) => {
+ const connectorName = parseConnectorValueName(row.name)
+ if (!connectorName) return null
+ const config = parseConnectorConfig(
+ parseConnectorJson(row.value),
+ connectorName,
+ )
+ if (!config) return null
+ return { row, config } satisfies ConnectorSearchEntry
+ })
+ .filter((entry): entry is ConnectorSearchEntry => entry != null)
+ const entryById = new Map(
+ entries.map((entry) => [entry.config.name, entry] as const),
+ )
+ const ids = [...entryById.keys()]
+ if (ids.length === 0) {
+ return { matches: [], offline: false }
+ }
+
+ const docsById = Object.fromEntries(
+ entries.map(
+ (entry) => [entry.config.name, buildConnectorEmbedDoc(entry)] as const,
+ ),
+ )
+ const lexicalOrder = sortIdsByScore(ids, (id) =>
+ scoreConnectorLexicalMatch(input.query, entryById.get(id)!, docsById[id]!),
+ )
+ const queryVector = deterministicEmbedding(input.query)
+ const vectorOrder = sortIdsByScore(ids, (id) =>
+ cosineSimilarity(queryVector, deterministicEmbedding(docsById[id]!)),
+ )
+
+ const lexicalRankById = new Map<string, number>()
+ for (let index = 0; index < lexicalOrder.length; index += 1) {
+ lexicalRankById.set(lexicalOrder[index]!, index + 1)
+ }
+ const vectorRankById = new Map<string, number>()
+ for (let index = 0; index < vectorOrder.length; index += 1) {
+ vectorRankById.set(vectorOrder[index]!, index + 1)
+ }
+
+ const fused = reciprocalRankFusion(
+ [lexicalOrder, vectorOrder],
+ CAPABILITY_SEARCH_RRF_K,
+ )
+ const ordered = sortIdsByScore(ids, (id) => fused.get(id) ?? 0).slice(
+ 0,
+ Math.max(1, Math.min(input.limit, ids.length)),
+ )
+
+ return {
+ matches: ordered.map((id) =>
+ rowToConnectorHit(
+ entryById.get(id)!,
+ fused.get(id) ?? 0,
+ lexicalRankById.get(id),
+ vectorRankById.get(id),
+ ),
+ ),
+ offline: false,
+ }
+}
+
export async function searchUnified(input: {
env: Env
baseUrl: string
@@ -450,33 +760,37 @@
skillRows: Array<McpSkillRow>
uiArtifactRows: Array<UiArtifactRow>
userSecretRows: Array<SecretSearchRow>
+ userValueRows: Array<ValueMetadata>
appSecretsByAppId: Map<string, Array<SecretMetadata>>
}): Promise<{ matches: Array<UnifiedSearchMatch>; offline: boolean }> {
const builtinFilter: VectorizeVectorMetadataFilter = {
kind: { $eq: 'builtin' },
}
const candidateLimit = Math.min(100, Math.max(input.limit * 3, 25))
- const capResult = await searchCapabilities({
+ const offlineByEnv = isCapabilitySearchOffline(input.env)
+ const capResultPromise = searchCapabilities({
env: input.env,
query: input.query,
limit: candidateLimit,
detail: false,
specs: input.specs,
- vectorMetadataFilter: isCapabilitySearchOffline(input.env)
- ? undefined
- : builtinFilter,
+ vectorMetadataFilter: offlineByEnv ? undefined : builtinFilter,
})
+ let capResult: Awaited<ReturnType<typeof searchCapabilities>> = {
+ matches: [],
+ offline: offlineByEnv,
+ }
let skillResult: { matches: Array<SkillSearchHit>; offline: boolean } = {
matches: [],
- offline: capResult.offline,
+ offline: offlineByEnv,
}
let uiArtifactResult: {
matches: Array<UiArtifactSearchHit>
offline: boolean
} = {
matches: [],
- offline: capResult.offline,
+ offline: offlineByEnv,
}
let secretResult: {
matches: Array<SecretSearchHit>
@@ -485,30 +799,66 @@
matches: [],
offline: false,
}
+ let valueResult: {
+ matches: Array<ValueSearchHit>
+ offline: boolean
+ } = {
+ matches: [],
+ offline: false,
+ }
+ let connectorResult: {
+ matches: Array<ConnectorSearchHit>
+ offline: boolean
+ } = {
+ matches: [],
+ offline: false,
+ }
if (input.userId) {
- secretResult = await searchSecretsForUser({
- query: input.query,
- limit: candidateLimit,
- rows: input.userSecretRows,
- })
- skillResult = await searchSkillsForUser({
- env: input.env,
- query: input.query,
- limit: candidateLimit,
- specs: input.specs,
- userId: input.userId,
- collectionSlug: input.skillCollectionSlug,
- rows: input.skillRows,
- })
- uiArtifactResult = await searchUiArtifactsForUser({
- baseUrl: input.baseUrl,
- env: input.env,
- query: input.query,
- limit: candidateLimit,
- userId: input.userId,
- rows: input.uiArtifactRows,
- appSecretsByAppId: input.appSecretsByAppId,
- })
+ ;[
+ capResult,
+ secretResult,
+ valueResult,
+ connectorResult,
+ skillResult,
+ uiArtifactResult,
+ ] = await Promise.all([
+ capResultPromise,
+ searchSecretsForUser({
+ query: input.query,
+ limit: candidateLimit,
+ rows: input.userSecretRows,
+ }),
+ searchValuesForUser({
+ query: input.query,
+ limit: candidateLimit,
+ rows: input.userValueRows,
+ }),
+ searchConnectorsForUser({
+ query: input.query,
+ limit: candidateLimit,
+ rows: input.userValueRows,
+ }),
+ searchSkillsForUser({
+ env: input.env,
+ query: input.query,
+ limit: candidateLimit,
+ specs: input.specs,
+ userId: input.userId,
+ collectionSlug: input.skillCollectionSlug,
+ rows: input.skillRows,
+ }),
+ searchUiArtifactsForUser({
+ baseUrl: input.baseUrl,
+ env: input.env,
+ query: input.query,
+ limit: candidateLimit,
+ userId: input.userId,
+ rows: input.uiArtifactRows,
+ appSecretsByAppId: input.appSecretsByAppId,
+ }),
+ ])
+ } else {
+ capResult = await capResultPromise
}
const capByName = new Map(capResult.matches.map((m) => [m.name, m] as const))
@@ -518,21 +868,41 @@
const secretByName = new Map(
secretResult.matches.map((m) => [m.name, m] as const),
)
+ const valueById = new Map(
+ valueResult.matches.map((m) => [m.valueId, m] as const),
+ )
+ const connectorByName = new Map(
+ connectorResult.matches.map((m) => [m.connectorName, m] as const),
+ )
const uiArtifactById = new Map(
uiArtifactResult.matches.map((m) => [m.appId, m] as const),
)
const capKeys = capResult.matches.map((m) => `c:${m.name}`)
const skillKeys = skillResult.matches.map((m) => `s:${m.skillName}`)
const secretKeys = secretResult.matches.map((m) => `u:${m.name}`)
+ const valueKeys = valueResult.matches.map((m) => `v:${m.valueId}`)
+ const connectorKeys = connectorResult.matches.map(
+ (m) => `n:${m.connectorName}`,
+ )
const uiArtifactKeys = uiArtifactResult.matches.map((m) => `a:${m.appId}`)
const fusedCross = reciprocalRankFusion(
- [capKeys, skillKeys, secretKeys, uiArtifactKeys],
+ [capKeys, skillKeys, secretKeys, valueKeys, connectorKeys, uiArtifactKeys],
CAPABILITY_SEARCH_RRF_K,
)
const allKeys = [
- ...new Set([...capKeys, ...skillKeys, ...secretKeys, ...uiArtifactKeys]),
+ ...new Set([
+ ...capKeys,
+ ...skillKeys,
+ ...secretKeys,
+ ...valueKeys,
+ ...connectorKeys,
+ ...uiArtifactKeys,
+ ]),
]
function getEntityScore(key: string): number {
+ if (key.startsWith('n:')) {
+ return connectorByName.get(key.slice(2))?.fusedScore ?? 0
+ }
if (key.startsWith('c:')) {
return capByName.get(key.slice(2))?.fusedScore ?? 0
}
@@ -542,12 +912,30 @@
if (key.startsWith('u:')) {
return secretByName.get(key.slice(2))?.fusedScore ?? 0
}
+ if (key.startsWith('v:')) {
+ return valueById.get(key.slice(2))?.fusedScore ?? 0
+ }
if (key.startsWith('a:')) {
return uiArtifactById.get(key.slice(2))?.fusedScore ?? 0
}
return 0
}
function getUnifiedLexicalScore(key: string): number {
+ 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'),
+ )
+ }
if (key.startsWith('c:')) {
const hit = capByName.get(key.slice(2))
return hit ? scoreCapabilityLexicalMatch(input.query, hit) : 0
@@ -584,6 +972,16 @@
const hit = secretByName.get(key.slice(2))
return hit ? scoreSecretLexicalMatch(input.query, hit) : 0
}
+ 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',
+ ),
+ )
+ }
if (key.startsWith('a:')) {
const hit = uiArtifactById.get(key.slice(2))
return hit ? scoreUiArtifactLexicalMatch(input.query, hit) : 0
@@ -627,6 +1025,18 @@
if (hit) {
matches.push({ ...hit, fusedScore: score })
}
+ } else if (key.startsWith('v:')) {
+ const id = key.slice(2)
+ const hit = valueById.get(id)
+ if (hit) {
+ matches.push({ ...hit, fusedScore: score })
+ }
+ } else if (key.startsWith('n:')) {
+ const name = key.slice(2)
+ const hit = connectorByName.get(name)
+ if (hit) {
+ matches.push({ ...hit, fusedScore: score })
+ }
} else if (key.startsWith('a:')) {
const id = key.slice(2)
const hit = uiArtifactById.get(id)
@@ -640,6 +1050,8 @@
capResult.offline ||
skillResult.offline ||
secretResult.offline ||
+ valueResult.offline ||
+ connectorResult.offline ||
uiArtifactResult.offline
return { matches, offline }
}
diff --git a/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts b/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts
--- a/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts
+++ b/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts
@@ -3,6 +3,7 @@
import { type CapabilitySpec } from '#mcp/capabilities/types.ts'
import { type McpSkillRow } from '#mcp/skills/mcp-skills-types.ts'
import { type UiArtifactRow } from '#mcp/ui-artifacts-types.ts'
+import { type ValueMetadata } from '#mcp/values/types.ts'
function createSkillRow(skillId: string): McpSkillRow {
return {
@@ -50,6 +51,23 @@
}
}
+function createValueRow(
+ name: string,
+ overrides: Partial<ValueMetadata> = {},
+): ValueMetadata {
+ return {
+ name,
+ scope: 'user',
+ value: 'stored-value',
+ description: 'Stored config value',
+ appId: null,
+ createdAt: '2026-03-20T00:00:00.000Z',
+ updatedAt: '2026-03-20T00:00:00.000Z',
+ ttlMs: null,
+ ...overrides,
+ }
+}
+
test('skill search hits include usage hints', async () => {
const env = { SENTRY_ENVIRONMENT: 'test' } as Env
const skillRow = createSkillRow('skill-usage-hint')
@@ -60,12 +78,12 @@
baseUrl: 'http://localhost',
query: 'open pull requests for cursor agents',
limit: 5,
- detail: false,
specs,
userId: 'user-123',
skillRows: [skillRow],
uiArtifactRows: [],
userSecretRows: [],
+ userValueRows: [],
appSecretsByAppId: new Map(),
})
@@ -107,6 +125,7 @@
skillRows: [matchingRow, otherRow],
uiArtifactRows: [],
userSecretRows: [],
+ userValueRows: [],
appSecretsByAppId: new Map(),
})
@@ -184,6 +203,7 @@
skillRows: [skillRow],
uiArtifactRows: [],
userSecretRows: [],
+ userValueRows: [],
appSecretsByAppId: new Map(),
})
@@ -217,6 +237,7 @@
updatedAt: '2026-03-20T00:00:00.000Z',
},
],
+ userValueRows: [],
appSecretsByAppId: new Map([
[
appRow.id,
@@ -253,3 +274,58 @@
expect(app.usage).toContain('"params"')
expect(app.hostedUrl).toBe('http://localhost/ui/app-123')
})
+
+test('search returns value and connector entities as first-class matches', async () => {
+ const env = { SENTRY_ENVIRONMENT: 'test' } as Env
+ const specs = {} as Record<string, CapabilitySpec>
+ const valueRow = createValueRow('github_repo', {
+ value: 'kentcdodds/kody',
+ description: 'Default GitHub repository',
+ })
+ 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 connector config repo value',
+ limit: 10,
+ specs,
+ userId: 'user-123',
+ skillRows: [],
+ uiArtifactRows: [],
+ userSecretRows: [],
+ userValueRows: [valueRow, connectorRow],
+ appSecretsByAppId: new Map(),
+ })
+
+ const connector = result.matches.find((match) => match.type === 'connector')
+ if (!connector || connector.type !== 'connector') {
+ throw new Error('Expected a connector result.')
+ }
+ expect(connector.connectorName).toBe('github')
+ expect(connector.apiBaseUrl).toBe('https://api.github.com')
+ expect(connector.requiredHosts).toEqual(['api.github.com'])
+ expect(connector.usage).toContain('connector_get')
+
+ const value = result.matches.find((match) => match.type === 'value')
+ if (!value || value.type !== 'value') {
+ throw new Error('Expected a value result.')
+ }
+ expect(value.name).toBe('github_repo')
+ expect(value.scope).toBe('user')
+ expect(value.value).toBe('kentcdodds/kody')
+ expect(value.usage).toContain('value_get')
... diff truncated: showing 800 of 1782 linesYou can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
packages/worker/src/mcp/tools/search-format.ts (1)
761-764: Consider using a locale-independent format for consistency.
toLocaleString()produces output that varies based on server locale settings (e.g.,1,000vs1.000vs1 000for thousands separators). For deterministic output across environments, consider a fixed format.♻️ Suggested fix for consistent formatting
function formatTtlMs(ttlMs: number | null) { if (ttlMs == null) return 'none' - return `\`${ttlMs.toLocaleString()}\`` + return `\`${ttlMs.toLocaleString('en-US')}\`` }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/mcp/tools/search-format.ts` around lines 761 - 764, The formatTtlMs function uses ttlMs.toLocaleString(), which varies by server locale; change it to a locale-independent formatter (e.g., use Intl.NumberFormat with a fixed locale like 'en-US' or format via ttlMs.toString() when no grouping is desired) so output is deterministic across environments; update formatTtlMs to call a fixed formatter (Intl.NumberFormat('en-US').format(ttlMs) or ttlMs.toString()) and keep the existing null handling and backtick-wrapping.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/use/search.md`:
- Around line 51-52: In the sentence "Saved **values**, **connectors**,
**skills**, and **apps** require a signed-in MCP user. Capabilities and builtin
behavior still work without user-scoped data." replace the nonstandard term
"builtin behavior" with the user-facing phrase "built-in behavior" so the docs
use the correct hyphenated form; update the string containing "builtin behavior"
accordingly in the docs content.
In `@packages/worker/src/mcp/capabilities/unified-search.ts`:
- Around line 382-405: The connector hits use config.name but
resolveEntityDetail() looks up connectors by the `_connector:` value-name
suffix, causing mismatched IDs; pick one canonical ID and make both sides
consistent (e.g., use config.name everywhere). Update rowToConnectorHit (and the
similar block at 697-711) to emit the exact identifier string that
resolveEntityDetail() expects (or update resolveEntityDetail() to resolve by
config.name), and add a guard when building hits to reject or normalize rows
whose config.name and stored `_connector:` id differ so only matching IDs are
indexed and returned.
In `@packages/worker/src/mcp/tools/search.ts`:
- Around line 428-463: The current branches for ref.type === 'value' and
'connector' treat an empty searchRows.userValueRows as "not found", which masks
persisted-values outages (loadOptionalSearchRows turns failures into []); update
these branches to detect when loadOptionalSearchRows failed (propagate its
warning/availability flag from input.searchRows) and in that case either throw
an availability error (not a "not found" error) or call the authoritative fetch
path to retrieve the persisted value/connector live; specifically, in the value
branch (parseValueEntityId, buildValueEntityId, describeValue) and the connector
branch (findConnectorDetail) check for an input.searchRows persisted-values
failure indicator and then either rethrow that availability error or invoke the
live fetch for the value/connector instead of returning "Persisted
value/connector not found".
---
Nitpick comments:
In `@packages/worker/src/mcp/tools/search-format.ts`:
- Around line 761-764: The formatTtlMs function uses ttlMs.toLocaleString(),
which varies by server locale; change it to a locale-independent formatter
(e.g., use Intl.NumberFormat with a fixed locale like 'en-US' or format via
ttlMs.toString() when no grouping is desired) so output is deterministic across
environments; update formatTtlMs to call a fixed formatter
(Intl.NumberFormat('en-US').format(ttlMs) or ttlMs.toString()) and keep the
existing null handling and backtick-wrapping.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 624fe01f-40ff-48c9-9559-ef851f1d02fc
📒 Files selected for processing (9)
docs/use/search.mdpackages/worker/src/mcp/capabilities/unified-search.tspackages/worker/src/mcp/capabilities/unified-search.workers.test.tspackages/worker/src/mcp/server-instructions.tspackages/worker/src/mcp/tools/search-entities.tspackages/worker/src/mcp/tools/search-format.node.test.tspackages/worker/src/mcp/tools/search-format.tspackages/worker/src/mcp/tools/search.node.test.tspackages/worker/src/mcp/tools/search.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
…thub.com/kentcdodds/kody into cursor/search-values-and-connectors-391d Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is ON, but it could not run because the branch was deleted or merged before autofix could start.
Reviewed by Cursor Bugbot for commit f90d160. Configure here.
| '\n', | ||
| ), | ||
| ) | ||
| } |
There was a problem hiding this comment.
Values and connectors lack phrase-match bonuses in unified ranking
Medium Severity
The getUnifiedLexicalScore function for v: (value) and n: (connector) keys only uses base lexicalScore without any phrase-match bonuses. In contrast, capabilities use scoreCapabilityLexicalMatch, secrets use scoreSecretLexicalMatch, skills add manual scoreSkillPhraseMatch bonuses, and apps use scoreUiArtifactLexicalMatch — all of which include significant bonus weighting. Since items from different entity types at the same per-type rank share identical fusedCross scores, this lexical tiebreaker systematically underranks values and connectors relative to other entity types.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit f90d160. Configure here.


Fixes #138
Fixes #138
Summary
searchresults alongside capabilities, skills, apps, and secretsvalueandconnector_connector:suffix and skipping mismatched rows whose embeddedconfig.namedisagreesTesting
npm run test -- --run packages/worker/src/mcp/capabilities/unified-search.workers.test.ts packages/worker/src/mcp/tools/search.node.test.ts packages/worker/src/mcp/tools/search-format.node.test.tsnpm run typechecknpx oxlint "packages/worker/src/mcp/capabilities/unified-search.ts" "packages/worker/src/mcp/capabilities/unified-search.workers.test.ts" "packages/worker/src/mcp/server-instructions.ts" "packages/worker/src/mcp/tools/search-format.ts" "packages/worker/src/mcp/tools/search.node.test.ts" "packages/worker/src/mcp/tools/search.ts" "packages/worker/src/mcp/tools/search-entities.ts" "packages/worker/src/mcp/tools/search-format.node.test.ts"npx oxfmt "docs/use/search.md" "packages/worker/src/mcp/capabilities/unified-search.ts" "packages/worker/src/mcp/capabilities/unified-search.workers.test.ts" "packages/worker/src/mcp/server-instructions.ts" "packages/worker/src/mcp/tools/search-format.ts" "packages/worker/src/mcp/tools/search.node.test.ts" "packages/worker/src/mcp/tools/search.ts" "packages/worker/src/mcp/tools/search-entities.ts" "packages/worker/src/mcp/tools/search-format.node.test.ts"Summary by CodeRabbit
New Features
Documentation