Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 112 additions & 13 deletions packages/mcp-server/src/__tests__/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,23 @@ interface MockSearchHit {
matchedTerms: string[];
}

interface MockHybridSearchHit {
key: string;
score: number;
methodScores: Partial<Record<'exact' | 'fullText' | 'semantic', number>>;
value?: unknown;
}

class MockTopGunClient {
private maps = new Map<string, MockLWWMap>();
// 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;

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand All @@ -568,35 +590,36 @@ 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 () => {
const ctx = createTestContext();
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.
Expand All @@ -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');
});
});
});
27 changes: 27 additions & 0 deletions packages/mcp-server/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof SearchArgsSchema>;
Expand Down Expand Up @@ -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'],
},
Expand Down
54 changes: 45 additions & 9 deletions packages/mcp-server/src/tools/search.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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'],
};

Expand All @@ -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)) {
Expand All @@ -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<Record<string, unknown>>(map, query, {
limit: effectiveLimit,
const results = await ctx.client.hybridSearch(map, query, {
methods: effectiveMethods,
k: effectiveLimit,
minScore: effectiveMinScore,
});

Expand All @@ -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}`
);
})
Expand All @@ -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: [
Expand Down
1 change: 1 addition & 0 deletions packages/mcp-server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export interface SearchToolArgs {
query: string;
limit?: number;
minScore?: number;
methods?: Array<'exact' | 'fullText' | 'semantic'>;
}

/**
Expand Down
Loading