Skip to content
21 changes: 8 additions & 13 deletions packages/worker/src/agent-turn/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from 'zod'
import { type McpCallerContext } from '@kody-internal/shared/chat.ts'
import { getCapabilityRegistryForContext } from '#mcp/capabilities/registry.ts'
import {
buildSavedPackageSearchRows,
loadDownRemoteConnectorStatuses,
loadOptionalSearchRows,
resolveSearchMemoryContext,
Expand Down Expand Up @@ -56,19 +57,12 @@ export async function createAgentTurnToolSet(input: {
input.env.APP_DB,
{ userId: userId! },
)
return savedPackages.map((savedPackage) => ({
record: savedPackage,
projection: {
name: savedPackage.name,
kodyId: savedPackage.kodyId,
description: savedPackage.description,
tags: savedPackage.tags,
searchText: savedPackage.searchText,
hasApp: savedPackage.hasApp,
exports: [],
jobs: [],
},
}))
return await buildSavedPackageSearchRows({
env: input.env,
baseUrl: input.callerContext.baseUrl,
userId: userId!,
records: savedPackages,
})
},
loadUserSecrets: () =>
listUserSecretsForSearch({
Expand Down Expand Up @@ -110,6 +104,7 @@ export async function createAgentTurnToolSet(input: {
return {
offline: result.offline,
warnings: optionalRows.warnings,
guidance: result.guidance,
memories: memoryToolContext
? {
surfaced: memoryToolContext.memories,
Expand Down
6 changes: 6 additions & 0 deletions packages/worker/src/mcp/tools/search-format.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ test('search markdown and entity detail formatting preserve structured behavior'
const markdown = formatSearchMarkdown({
baseUrl: 'http://localhost',
warnings: [],
guidance:
'Inspect connector detail with `search({ entity: "github:connector" })` next.',
matches: [
{
type: 'value',
Expand Down Expand Up @@ -56,6 +58,8 @@ test('search markdown and entity detail formatting preserve structured behavior'

expect(markdown).toContain('`user:preferred_repo:value`')
expect(markdown).toContain('`https://api.github.com`')
expect(markdown).toContain('## Recommended next step')
expect(markdown).toContain('`search({ entity: "github:connector" })`')

expect(
toSlimStructuredMatches({
Expand Down Expand Up @@ -116,6 +120,8 @@ test('search markdown and entity detail formatting preserve structured behavior'
flow: 'confidential',
apiBaseUrl: 'https://api.github.com',
requiredHosts: ['api.github.com'],
nextStep:
'Inspect connector detail with search({ entity: "github:connector" }) and then run a minimal authenticated execute smoke test before building or calling integration-backed code.',
},
])

Expand Down
13 changes: 13 additions & 0 deletions packages/worker/src/mcp/tools/search-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type SearchResultStructuredContent = {
matches: Array<SlimSearchMatch>
offline: boolean
warnings: Array<string>
guidance?: string
telemetry?: {
intent: {
task: string
Expand Down Expand Up @@ -102,6 +103,7 @@ export type SlimSearchMatch =
tags: Array<string>
hasApp: boolean
hostedUrl: string | null
nextStep?: string
}
| {
type: 'secret'
Expand Down Expand Up @@ -133,6 +135,7 @@ export type SlimSearchMatch =
flow: string
apiBaseUrl: string | null
requiredHosts: Array<string>
nextStep?: string
}

export type SearchEntityDetailStructured =
Expand Down Expand Up @@ -357,6 +360,7 @@ export function formatSearchMarkdown(input: {
warnings: Array<string>
baseUrl: string
includePreamble?: boolean
guidance?: string
memories?: {
surfaced: Array<{
category: string | null
Expand Down Expand Up @@ -401,6 +405,10 @@ export function formatSearchMarkdown(input: {
}
}

if (input.guidance?.trim()) {
lines.push('', '## Recommended next step', '', input.guidance.trim())
}

for (const match of input.matches) {
lines.push('', ...formatMatchBlock(match, input.baseUrl))
}
Expand Down Expand Up @@ -498,6 +506,9 @@ export function toSlimStructuredMatches(input: {
}
}
if (match.type === 'package') {
const nextStep = match.hasApp
? `Open the app with open_generated_ui({ kody_id: "${match.kodyId}" }) or inspect package detail with search({ entity: "${match.kodyId}:package" }).`
: `Inspect package detail with search({ entity: "${match.kodyId}:package" }) to review exports, then import the needed entry from "${buildPackageImportSpecifier(match.kodyId, '.')}".`
return {
type: 'package',
id: match.kodyId,
Expand All @@ -514,6 +525,7 @@ export function toSlimStructuredMatches(input: {
hostedUrl: match.hasApp
? buildPackageHostedUrl(input.baseUrl, match.kodyId)
: null,
nextStep,
}
}
if (match.type === 'value') {
Expand Down Expand Up @@ -541,6 +553,7 @@ export function toSlimStructuredMatches(input: {
flow: match.flow,
apiBaseUrl: match.apiBaseUrl,
requiredHosts: match.requiredHosts,
nextStep: `Inspect connector detail with search({ entity: "${match.connectorName}:connector" }) and then run a minimal authenticated execute smoke test before building or calling integration-backed code.`,
}
}
return {
Expand Down
124 changes: 124 additions & 0 deletions packages/worker/src/mcp/tools/search.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect, test } from 'vitest'
import { buildCapabilityRegistry } from '#mcp/capabilities/build-capability-registry.ts'
import { buildConnectorValueName } from '#mcp/capabilities/values/connector-shared.ts'
import {
buildSavedPackageSearchRows,
loadDownHomeConnectorStatus,
loadOptionalSearchRows,
resolveSearchMemoryContext,
Expand Down Expand Up @@ -55,6 +56,7 @@ test('searchUnified ranks mixed search rows through one shared pipeline', () =>
tags: ['delta'],
searchText: 'epsilon',
hasApp: false,
appEntry: null,
exports: [],
jobs: [],
},
Expand Down Expand Up @@ -219,6 +221,7 @@ test('optional search rows include saved packages when lookup succeeds', async (
tags: ['roku'],
searchText: null,
hasApp: true,
appEntry: 'src/app.ts',
exports: ['.'],
jobs: [],
},
Expand All @@ -234,6 +237,127 @@ test('optional search rows include saved packages when lookup succeeds', async (
expect(result.warnings).toEqual([])
})

test('searchUnified uses package exports and connector aliases for operate queries', () => {
const registry = buildCapabilityRegistry([])
const optionalRows = {
packageRows: [
{
record: {
id: 'spotify-pkg',
userId: 'user-1',
name: '@kentcdodds/spotify',
kodyId: 'spotify',
description:
'Package-first Spotify playback controls, queue helpers, device management, and a hosted remote app.',
tags: ['spotify', 'music', 'playback'],
searchText: 'spotify remote playback package music queue player',
sourceId: 'source-spotify',
hasApp: true,
createdAt: '2026-04-20T00:00:00.000Z',
updatedAt: '2026-04-20T00:00:00.000Z',
},
projection: {
name: '@kentcdodds/spotify',
kodyId: 'spotify',
description:
'Package-first Spotify playback controls, queue helpers, device management, and a hosted remote app.',
tags: ['spotify', 'music', 'playback'],
searchText: 'spotify remote playback package music queue player',
hasApp: true,
appEntry: 'src/app/server.ts',
exports: [
'./playback-state',
'./play-pause',
'./transfer-playback',
'./add-to-queue',
'./playback-controller',
],
jobs: [],
},
},
],
userSecretRows: [],
userValueRows: [
{
name: buildConnectorValueName('spotify'),
scope: 'user',
value: JSON.stringify({
tokenUrl: 'https://accounts.spotify.com/api/token',
apiBaseUrl: 'https://api.spotify.com/v1',
flow: 'pkce',
clientIdValueName: 'spotify-client-id',
clientSecretSecretName: null,
accessTokenSecretName: 'spotify-access-token',
refreshTokenSecretName: 'spotify-refresh-token',
requiredHosts: ['api.spotify.com'],
}),
description: 'Spotify OAuth connector config',
appId: null,
createdAt: '2026-04-20T00:00:00.000Z',
updatedAt: '2026-04-20T00:00:00.000Z',
ttlMs: null,
},
],
warnings: [],
} satisfies OptionalSearchRowsResult

const result = searchUnified({
env: {} as Env,
query: 'play a lofi song on spotify',
limit: 5,
registry,
optionalRows,
})

expect(result.matches).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'package',
kodyId: 'spotify',
}),
expect.objectContaining({
type: 'connector',
connectorName: 'spotify',
}),
]),
)
expect(result.guidance).toContain('Found saved package `spotify` and connector `spotify`')
})

test('buildSavedPackageSearchRows falls back when package source resolution fails', async () => {
const rows = await buildSavedPackageSearchRows({
env: {} as Env,
baseUrl: 'http://localhost',
userId: 'user-123',
records: [
{
id: 'package-123',
userId: 'user-123',
name: '@kody/observed',
kodyId: 'observed',
description: 'Observed package',
tags: ['observed'],
searchText: 'search text',
sourceId: 'missing-source',
hasApp: true,
createdAt: '2026-03-24T00:00:00.000Z',
updatedAt: '2026-03-24T00:00:00.000Z',
},
],
})

expect(rows).toEqual([
expect.objectContaining({
projection: expect.objectContaining({
hasApp: true,
appEntry: null,
exports: [],
jobs: [],
}),
}),
])
})

test('optional search rows fall back when persisted values lookup fails', async () => {
const result = await loadOptionalSearchRows({
userId: 'user-123',
Expand Down
Loading
Loading