diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 7a635bb5..789a5ea0 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -115,10 +115,20 @@ Example: "Create a task called 'Review PR #123'" ``` ### topgun_search -Perform hybrid search (BM25 full-text + exact matching). +Tri-hybrid search combining exact, full-text (BM25), and semantic methods fused with Reciprocal Rank Fusion. Defaults to full-text only. + +**Parameters:** +- `map` (required) — name of the map to search +- `query` (required) — search query text +- `methods` (optional, default `["fullText"]`) — array of methods to combine: `"exact"`, `"fullText"`, `"semantic"`. `"semantic"` requires server-side auto-embedding. +- `limit` (optional, default 10) — maximum results to return (maps to `k` in the hybrid search engine internally) +- `minScore` (optional, default 0) — minimum fused relevance score (0–1) + +Returns results ranked by fused score with per-method score breakdown. ``` Example: "Find tasks about authentication" +Example with methods: { map: "docs", query: "auth flow", methods: ["exact", "fullText"] } ``` ### topgun_subscribe diff --git a/packages/mcp-server/src/__tests__/tools.test.ts b/packages/mcp-server/src/__tests__/tools.test.ts index 7bf319ae..dd27969b 100644 --- a/packages/mcp-server/src/__tests__/tools.test.ts +++ b/packages/mcp-server/src/__tests__/tools.test.ts @@ -42,10 +42,23 @@ interface MockSearchHit { matchedTerms: string[]; } +interface MockHybridSearchHit { + key: string; + score: number; + methodScores: Partial>; + value?: unknown; +} + class MockTopGunClient { private maps = new Map(); - // Configurable search results so individual tests can inject non-empty hit lists. + // Configurable BM25 search results (legacy — not used by handleSearch after this spec). searchResults: MockSearchHit[] = []; + // Configurable hybrid search results + recorded options for assertion. + hybridSearchResults: MockHybridSearchHit[] = []; + lastHybridSearchOptions: unknown = null; + // When set, hybridSearch rejects with this error to simulate server-side failures + // (no embedding model, FTS not enabled for the map, etc.). + hybridSearchRejection: Error | null = null; // When set, queryOnce rejects with this error to simulate offline / not-settled. queryOnceRejection: Error | null = null; @@ -88,6 +101,15 @@ class MockTopGunClient { return this.searchResults; } + async hybridSearch(_map: string, _query: string, options?: unknown) { + // Record the options so tests can assert which methods/k were forwarded. + this.lastHybridSearchOptions = options; + if (this.hybridSearchRejection) { + throw this.hybridSearchRejection; + } + return this.hybridSearchResults; + } + // One-shot read mirroring TopGunClient.queryOnce: resolves with settled, // authoritative server data, or rejects to simulate offline / not-settled. async queryOnce( @@ -553,9 +575,9 @@ describe('MCP Tools', () => { const mockClient = ctx.client as unknown as MockTopGunClient; // Pre-populate the map so the local read-by-key finds the body. mockClient.getMap('tasks').set('task1', { title: 'Test Task', status: 'todo' }); - // Wire search() to return a hit mirroring the server's wire shape (value: null). - mockClient.searchResults = [ - { key: 'task1', value: null, score: 0.42, matchedTerms: ['test'] }, + // Wire hybridSearch() to return a hit with the hybrid result shape (methodScores, no matchedTerms). + mockClient.hybridSearchResults = [ + { key: 'task1', score: 0.42, methodScores: { fullText: 0.42 } }, ]; const result = await handleSearch({ map: 'tasks', query: 'test' }, ctx); @@ -568,26 +590,27 @@ describe('MCP Tools', () => { expect(result.content[0].text).not.toContain('Data: null'); }); - it('should preserve score with fixed-precision rendering and matched-term metadata', async () => { + it('should preserve score with fixed-precision rendering and per-method score breakdown', async () => { const ctx = createTestContext(); const mockClient = ctx.client as unknown as MockTopGunClient; mockClient.getMap('tasks').set('task1', { title: 'Test Task', status: 'todo' }); mockClient.getMap('tasks').set('task2', { title: 'Smoke Task', status: 'done' }); // Two hits: one plain three-decimal score, one rounding-sensitive score matching // the real smoke output (0.2876 → "0.288") to guard toFixed(3) against digit-dropping. - mockClient.searchResults = [ - { key: 'task1', value: null, score: 0.42, matchedTerms: ['test'] }, - { key: 'task2', value: null, score: 0.2876, matchedTerms: ['smoke'] }, + mockClient.hybridSearchResults = [ + { key: 'task1', score: 0.42, methodScores: { fullText: 0.42 } }, + { key: 'task2', score: 0.2876, methodScores: { fullText: 0.2876 } }, ]; const result = await handleSearch({ map: 'tasks', query: 'test smoke' }, ctx); expect(result.isError).toBeUndefined(); expect(result.content[0].text).toContain('[Score: 0.420]'); - expect(result.content[0].text).toContain('Matched: test'); // Rounding-sensitive assertion: 0.2876 must render as 0.288, not 0.287. expect(result.content[0].text).toContain('[Score: 0.288]'); - expect(result.content[0].text).toContain('Matched: smoke'); + // Per-method scores must appear in the output — the old matchedTerms line is gone. + expect(result.content[0].text).toContain('Method scores:'); + expect(result.content[0].text).not.toContain('Matched:'); }); it('should emit the not-available-locally marker when the key is absent from the local replica', async () => { @@ -595,8 +618,8 @@ describe('MCP Tools', () => { const mockClient = ctx.client as unknown as MockTopGunClient; // Intentionally do NOT pre-populate the map — simulates an evicted or // partially-replicated record where lwwMap.get() returns undefined. - mockClient.searchResults = [ - { key: 'missing-key', value: null, score: 0.9, matchedTerms: ['term'] }, + mockClient.hybridSearchResults = [ + { key: 'missing-key', score: 0.9, methodScores: { fullText: 0.9 } }, ]; // Handler must not throw; the hit must still appear with the pinned fallback marker. @@ -608,12 +631,88 @@ describe('MCP Tools', () => { it('should return the empty-results message when search returns no hits', async () => { const ctx = createTestContext(); - // searchResults defaults to [] — no configuration needed. + // hybridSearchResults defaults to [] — no configuration needed. const result = await handleSearch({ map: 'tasks', query: 'nothing' }, ctx); expect(result.isError).toBeUndefined(); expect(result.content[0].text).toContain('No results found'); }); + + it('should call hybridSearch (not the BM25 search path) with the default fullText method', async () => { + const ctx = createTestContext(); + const mockClient = ctx.client as unknown as MockTopGunClient; + mockClient.hybridSearchResults = [ + { key: 'doc1', score: 0.75, methodScores: { fullText: 0.75 } }, + ]; + + // Invoke with no methods arg — the default ['fullText'] must be forwarded. + await handleSearch({ map: 'docs', query: 'auth' }, ctx); + + // The tool must have gone through hybridSearch, not the old BM25 search path. + // If hybridSearch was called, lastHybridSearchOptions is populated; if search() + // was called instead, it would remain null. + const opts = mockClient.lastHybridSearchOptions as { + methods: string[]; + k: number; + minScore: number; + }; + expect(opts).not.toBeNull(); + expect(opts.methods).toEqual(['fullText']); + }); + + it('should forward custom methods to hybridSearch and render multi-method score breakdown', async () => { + const ctx = createTestContext(); + const mockClient = ctx.client as unknown as MockTopGunClient; + mockClient.hybridSearchResults = [ + { key: 'doc1', score: 0.82, methodScores: { exact: 0.6, fullText: 0.5 } }, + ]; + + const result = await handleSearch( + { map: 'docs', query: 'login', methods: ['exact', 'fullText'] }, + ctx, + ); + + const opts = mockClient.lastHybridSearchOptions as { + methods: string[]; + k: number; + minScore: number; + }; + // Both requested methods must be forwarded verbatim. + expect(opts.methods).toEqual(['exact', 'fullText']); + // Output must render per-method scores for both legs so the agent can see the breakdown. + expect(result.content[0].text).toContain('exact:'); + expect(result.content[0].text).toContain('fullText:'); + }); + + it('should surface an actionable retry message when the server cannot embed for the semantic leg', async () => { + const ctx = createTestContext(); + const mockClient = ctx.client as unknown as MockTopGunClient; + mockClient.hybridSearchRejection = new Error( + 'failed to embed query: no embedding model configured', + ); + + const result = await handleSearch( + { map: 'docs', query: 'login', methods: ['fullText', 'semantic'] }, + ctx, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Semantic search requires server-side embedding'); + // The message must tell the agent how to retry without the semantic leg. + expect(result.content[0].text).toContain('methods: ["fullText"]'); + }); + + it('should point the agent at topgun_query when full-text search is not enabled for the map', async () => { + const ctx = createTestContext(); + const mockClient = ctx.client as unknown as MockTopGunClient; + mockClient.hybridSearchRejection = new Error("FTS is not enabled for map 'docs'"); + + const result = await handleSearch({ map: 'docs', query: 'login' }, ctx); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Full-text search is not enabled for map 'docs'"); + expect(result.content[0].text).toContain('topgun_query'); + }); }); }); diff --git a/packages/mcp-server/src/schemas.ts b/packages/mcp-server/src/schemas.ts index 247e6a8f..3eaf639f 100644 --- a/packages/mcp-server/src/schemas.ts +++ b/packages/mcp-server/src/schemas.ts @@ -62,6 +62,18 @@ export const SearchArgsSchema = z.object({ query: z.string().describe('Search query (keywords or phrases to find)'), limit: z.number().optional().default(10).describe('Maximum number of results to return'), minScore: z.number().optional().default(0).describe('Minimum relevance score (0-1) for results'), + methods: z + .array(z.enum(['exact', 'fullText', 'semantic'])) + .optional() + .default(['fullText']) + .describe( + 'Search methods to combine via Reciprocal Rank Fusion. ' + + '"exact" matches field values exactly; ' + + '"fullText" uses BM25 full-text search; ' + + '"semantic" uses vector similarity (requires server-side auto-embedding — ' + + 'the tool sends a text query, not a vector). ' + + 'Defaults to ["fullText"] to preserve existing behaviour when omitted.', + ), }); export type SearchArgs = z.infer; @@ -200,6 +212,21 @@ export const toolSchemas = { description: 'Minimum relevance score (0-1) for results', default: 0, }, + methods: { + type: 'array', + items: { + type: 'string', + enum: ['exact', 'fullText', 'semantic'], + }, + description: + 'Search methods to combine via Reciprocal Rank Fusion. ' + + '"exact" matches field values exactly; ' + + '"fullText" uses BM25 full-text search; ' + + '"semantic" uses vector similarity (requires server-side auto-embedding — ' + + 'the tool sends a text query, not a vector). ' + + 'Defaults to ["fullText"] to preserve existing behaviour when omitted.', + default: ['fullText'], + }, }, required: ['map', 'query'], }, diff --git a/packages/mcp-server/src/tools/search.ts b/packages/mcp-server/src/tools/search.ts index 6870105a..2b2c46af 100644 --- a/packages/mcp-server/src/tools/search.ts +++ b/packages/mcp-server/src/tools/search.ts @@ -1,5 +1,6 @@ /** - * topgun_search - Perform hybrid search (exact + full-text) across a map + * topgun_search — Tri-hybrid search (exact + full-text BM25 + semantic) via RRF fusion. + * Routes through the hybridSearch client method so the tool actually performs what it advertises. */ import type { MCPTool, MCPToolResult, ToolContext } from '../types'; @@ -8,9 +9,12 @@ import { SearchArgsSchema, toolSchemas, type SearchArgs } from '../schemas'; export const searchTool: MCPTool = { name: 'topgun_search', description: - 'Perform hybrid search across a TopGun map using BM25 full-text search. ' + - 'Returns results ranked by relevance score. ' + - 'Use this when searching for text content or when the exact field values are unknown.', + 'Search a TopGun map combining exact, full-text (BM25), and semantic methods, ' + + 'fused with Reciprocal Rank Fusion. ' + + 'Defaults to full-text only. ' + + 'Pass methods: ["exact","fullText"] or ["fullText","semantic"] to enable additional legs. ' + + '"semantic" requires server-side auto-embedding (the tool sends a text query, not a vector). ' + + 'Returns results ranked by a fused relevance score with per-method score breakdown.', inputSchema: toolSchemas.search as MCPTool['inputSchema'], }; @@ -28,7 +32,7 @@ export async function handleSearch(rawArgs: unknown, ctx: ToolContext): Promise< } const args: SearchArgs = parseResult.data; - const { map, query, limit, minScore } = args; + const { map, query, limit, minScore, methods } = args; // Validate map access if (ctx.config.allowedMaps && !ctx.config.allowedMaps.includes(map)) { @@ -43,12 +47,15 @@ export async function handleSearch(rawArgs: unknown, ctx: ToolContext): Promise< }; } + // Map the existing `limit` arg to hybridSearch's `k` so agents keep using the same param name. const effectiveLimit = Math.min(limit ?? ctx.config.defaultLimit, ctx.config.maxLimit); + const effectiveMethods = methods ?? ['fullText']; const effectiveMinScore = minScore ?? 0; try { - const results = await ctx.client.search>(map, query, { - limit: effectiveLimit, + const results = await ctx.client.hybridSearch(map, query, { + methods: effectiveMethods, + k: effectiveLimit, minScore: effectiveMinScore, }); @@ -74,9 +81,18 @@ export async function handleSearch(rawArgs: unknown, ctx: ToolContext): Promise< body !== undefined ? `Data: ${JSON.stringify(body, null, 2).split('\n').join('\n ')}` : `Data: (record body not available locally)`; + + // Render per-method score breakdown so the calling agent can see which leg contributed. + const methodScoreEntries = Object.entries(result.methodScores) + .map(([method, score]) => `${method}: ${(score as number).toFixed(3)}`) + .join(', '); + const methodLine = methodScoreEntries + ? `Method scores: ${methodScoreEntries}` + : 'Method scores: (none)'; + return ( `${idx + 1}. [Score: ${result.score.toFixed(3)}] [${result.key}]\n` + - ` Matched: ${result.matchedTerms.join(', ')}\n` + + ` ${methodLine}\n` + ` ${dataLine}` ); }) @@ -93,7 +109,27 @@ export async function handleSearch(rawArgs: unknown, ctx: ToolContext): Promise< } catch (error) { const message = error instanceof Error ? error.message : String(error); - // Handle case where FTS is not enabled for the map + // When the server cannot embed the query for semantic search, surface an actionable message + // so the calling agent knows to retry without the semantic leg. + if ( + message.toLowerCase().includes('embed') || + (effectiveMethods.includes('semantic') && + (message.includes('semantic') || message.includes('vector'))) + ) { + return { + content: [ + { + type: 'text', + text: + `Semantic search requires server-side embedding, which is not available for map '${map}'. ` + + `Retry with methods: ["fullText"] or methods: ["exact", "fullText"] to avoid the semantic leg.`, + }, + ], + isError: true, + }; + } + + // Handle case where full-text search is not enabled for the map if (message.includes('not enabled') || message.includes('FTS')) { return { content: [ diff --git a/packages/mcp-server/src/types.ts b/packages/mcp-server/src/types.ts index 59ec461d..fcfbb36b 100644 --- a/packages/mcp-server/src/types.ts +++ b/packages/mcp-server/src/types.ts @@ -155,6 +155,7 @@ export interface SearchToolArgs { query: string; limit?: number; minScore?: number; + methods?: Array<'exact' | 'fullText' | 'semantic'>; } /**