Skip to content

Add values and connectors to Kody search - #139

Merged
kentcdodds merged 5 commits into
mainfrom
cursor/search-values-and-connectors-391d
Apr 9, 2026
Merged

kentcdodds merged 5 commits into
mainfrom
cursor/search-values-and-connectors-391d

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Apr 9, 2026 •

Copy link
Copy Markdown
Owner

Fixes #138

Fixes #138

Summary

  • surface persisted values and saved connectors as first-class search results alongside capabilities, skills, apps, and secrets
  • extend search entity parsing, detail resolution, markdown rendering, and structured results to support value and connector
  • keep connector search/detail IDs consistent by indexing connectors by their stored _connector: suffix and skipping mismatched rows whose embedded config.name disagrees
  • parallelize independent search lookups and row-loading work so capability, value, connector, skill, app, and secret searches can run concurrently while preserving graceful degradation
  • update search docs/server instructions and add focused tests for ranking, formatting, and entity detail behavior

Testing

  • 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.ts
  • npm run typecheck
  • npx 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

    • Search now returns persisted values and saved connectors alongside capabilities, skills, apps, and secrets; results include usage hints and connector details.
  • Documentation

    • Docs and tool schema updated to document value/connector entity formats, examples, and result fields; server instructions and examples revised (previous authentication note removed).

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@coderabbitai

coderabbitai Bot commented Apr 9, 2026 •

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 35292c4a-e103-4a1e-ab21-6fdf99168c99

📥 Commits

Reviewing files that changed from the base of the PR and between 3852e83 and f90d160.

📒 Files selected for processing (6)
  • 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.ts
✅ Files skipped from review due to trivial changes (2)
  • docs/use/search.md
  • packages/worker/src/mcp/capabilities/unified-search.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/worker/src/mcp/server-instructions.ts

📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Documentation
docs/use/search.md, packages/worker/src/mcp/server-instructions.ts
Update docs and server instructions to list value and connector as supported search result types and adjust examples/terminology.
Unified Search Core
packages/worker/src/mcp/capabilities/unified-search.ts
Add ValueSearchHit/ConnectorSearchHit, accept userValueRows, compute lexical+vector scores for values and connectors, integrate into reciprocal-rank-fusion and unified ranking, return v:/n: keyed matches, and expose offline aggregates.
Search Orchestration
packages/worker/src/mcp/tools/search.ts, packages/worker/src/mcp/tools/search.node.test.ts
Add loadUserValues/userValueRows to row-loading, load sources with Promise.allSettled and per-source warnings, and implement entity-detail resolution for value and connector.
Formatting & Utilities
packages/worker/src/mcp/tools/search-format.ts, packages/worker/src/mcp/tools/search-entities.ts, packages/worker/src/mcp/tools/search-format.node.test.ts
Add value/connector types to formatting layer, implement buildValueEntityId/parseValueEntityId/describeValue, update markdown/structured output for new entity details, and add tests.
Tests (Unified Search)
packages/worker/src/mcp/capabilities/unified-search.workers.test.ts
Add helpers to create ValueMetadata rows, pass userValueRows into searchUnified tests, and assert connector/value-specific fields, usages, and validation behaviors.
Misc Tests
packages/worker/src/mcp/tools/search-format.node.test.ts
Add tests verifying parse/format/render for value and connector entities and their structured representations.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰
I hopped through code and docs today,
Found values and connectors on display,
Ranks that mingle, hints that glow,
Persisted things now put on show,
A little rabbit cheers — hip hooray!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add values and connectors to Kody search' directly and clearly summarizes the main change: adding two new entity types to the search functionality.
Linked Issues check ✅ Passed All acceptance criteria from issue #138 are met: values and connectors are implemented as first-class search results, entity parsing and detail resolution support both types, docs and tool descriptions are updated, result formatting is extended, and existing behavior is preserved.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the linked issue: new entity types in search, documentation updates, formatting utilities, and test coverage. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/search-values-and-connectors-391d

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@kentcdodds
kentcdodds marked this pull request as ready for review April 9, 2026 19:24
@github-actions

github-actions Bot commented Apr 9, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-139.kentcdodds.workers.dev

Worker: kody-pr-139
D1: kody-pr-139-db
KV: kody-pr-139-oauth-kv

Mocks:

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 describeValue function across two files
    • Extracted the shared describeValue helper into search-entities.ts and reused it in both callers to eliminate duplication.
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 lines

You can send follow-ups to the cloud agent here.

Comment thread packages/worker/src/mcp/tools/search.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,000 vs 1.000 vs 1 000 for 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

📥 Commits

Reviewing files that changed from the base of the PR and between 5863c62 and 8cbda28.

📒 Files selected for processing (9)
  • 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-entities.ts
  • packages/worker/src/mcp/tools/search-format.node.test.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

Comment thread docs/use/search.md Outdated
Comment thread packages/worker/src/mcp/capabilities/unified-search.ts
Comment thread packages/worker/src/mcp/tools/search.ts
cursoragent and others added 3 commits April 9, 2026 19:33
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>
@kentcdodds
kentcdodds merged commit 5ec9d31 into main Apr 9, 2026
8 of 9 checks passed
@kentcdodds
kentcdodds deleted the cursor/search-values-and-connectors-391d branch April 9, 2026 20:01

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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',
),
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f90d160. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Surface values and connectors in Kody search

2 participants