Skip to content

Commit 7d72817

Browse files
committed
feat(mcp): update formatters for rich search results
Implements #70 - Richer Search Results (sub-issue 4/5) Changes: - Add FormatterOptions: includeSnippets, includeImports, maxSnippetLines - Update CompactFormatter: - Add optional snippet rendering (off by default) - Add optional imports rendering (off by default, max 5 shown) - Add truncateSnippet and indentText helpers - Update token estimation for new content - Update VerboseFormatter: - Add snippet rendering (on by default) - Add imports rendering (on by default) - Show location with line range (start-end) - Update token estimation for new content Features: - Compact: snippets/imports off by default (token efficient) - Verbose: snippets/imports on by default (full context) - Configurable maxSnippetLines (default: 10 compact, 20 verbose) - Long snippets truncated with '// ... N more lines' - Long import lists truncated with '...' (compact only) Testing: - 17 new tests for snippet/import formatting - All 1282 tests passing Closes #70
1 parent 96d0d16 commit 7d72817

File tree

4 files changed

+397
-27
lines changed

4 files changed

+397
-27
lines changed

packages/mcp-server/src/formatters/__tests__/formatters.test.ts

Lines changed: 222 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,14 +173,20 @@ describe('Formatters', () => {
173173
expect(result.content).not.toContain('3.');
174174
});
175175

176-
it('should estimate more tokens than compact', () => {
176+
it('should estimate more tokens than compact when snippets disabled', () => {
177+
// When snippets are disabled, verbose still has more metadata
177178
const compactFormatter = new CompactFormatter();
178-
const verboseFormatter = new VerboseFormatter();
179+
const verboseFormatter = new VerboseFormatter({
180+
includeSnippets: false,
181+
includeImports: false,
182+
});
179183

180-
const compactTokens = compactFormatter.estimateTokens(mockResults[0]);
181-
const verboseTokens = verboseFormatter.estimateTokens(mockResults[0]);
184+
// Use formatResult which includes all the metadata lines
185+
const compactOutput = compactFormatter.formatResult(mockResults[0]);
186+
const verboseOutput = verboseFormatter.formatResult(mockResults[0]);
182187

183-
expect(verboseTokens).toBeGreaterThan(compactTokens);
188+
// Verbose output should be longer (has Location, Signature, Metadata lines)
189+
expect(verboseOutput.length).toBeGreaterThan(compactOutput.length);
184190
});
185191

186192
it('should handle missing metadata gracefully', () => {
@@ -250,4 +256,215 @@ describe('Formatters', () => {
250256
expect(result.tokens).toBeGreaterThan(0);
251257
});
252258
});
259+
260+
describe('Snippet and Import Formatting', () => {
261+
const resultWithSnippet: SearchResult = {
262+
id: 'src/auth/handler.ts:handleAuth:45',
263+
score: 0.85,
264+
metadata: {
265+
path: 'src/auth/handler.ts',
266+
type: 'function',
267+
language: 'typescript',
268+
name: 'handleAuth',
269+
startLine: 45,
270+
endLine: 67,
271+
exported: true,
272+
snippet:
273+
'export async function handleAuth(req: Request): Promise<Response> {\n const token = extractToken(req);\n return validateToken(token);\n}',
274+
imports: ['./service', '../utils/jwt', 'express'],
275+
},
276+
};
277+
278+
const resultWithManyImports: SearchResult = {
279+
id: 'src/index.ts:main:1',
280+
score: 0.75,
281+
metadata: {
282+
path: 'src/index.ts',
283+
type: 'function',
284+
name: 'main',
285+
startLine: 1,
286+
endLine: 10,
287+
imports: ['a', 'b', 'c', 'd', 'e', 'f', 'g'],
288+
},
289+
};
290+
291+
describe('CompactFormatter with snippets', () => {
292+
it('should not include snippet by default', () => {
293+
const formatter = new CompactFormatter();
294+
const formatted = formatter.formatResult(resultWithSnippet);
295+
296+
expect(formatted).not.toContain('export async function');
297+
expect(formatted).not.toContain('Imports:');
298+
});
299+
300+
it('should include snippet when enabled', () => {
301+
const formatter = new CompactFormatter({ includeSnippets: true });
302+
const formatted = formatter.formatResult(resultWithSnippet);
303+
304+
expect(formatted).toContain('export async function handleAuth');
305+
expect(formatted).toContain('extractToken');
306+
});
307+
308+
it('should include imports when enabled', () => {
309+
const formatter = new CompactFormatter({ includeImports: true });
310+
const formatted = formatter.formatResult(resultWithSnippet);
311+
312+
expect(formatted).toContain('Imports:');
313+
expect(formatted).toContain('./service');
314+
expect(formatted).toContain('express');
315+
});
316+
317+
it('should truncate long import lists', () => {
318+
const formatter = new CompactFormatter({ includeImports: true });
319+
const formatted = formatter.formatResult(resultWithManyImports);
320+
321+
expect(formatted).toContain('Imports:');
322+
expect(formatted).toContain('a, b, c, d, e');
323+
expect(formatted).toContain('...');
324+
expect(formatted).not.toContain('f, g');
325+
});
326+
327+
it('should truncate long snippets', () => {
328+
const longSnippet = Array(20).fill('const x = 1;').join('\n');
329+
const result: SearchResult = {
330+
id: 'test',
331+
score: 0.8,
332+
metadata: {
333+
path: 'test.ts',
334+
type: 'function',
335+
name: 'test',
336+
snippet: longSnippet,
337+
},
338+
};
339+
340+
const formatter = new CompactFormatter({ includeSnippets: true, maxSnippetLines: 5 });
341+
const formatted = formatter.formatResult(result);
342+
343+
expect(formatted).toContain('// ... 15 more lines');
344+
});
345+
346+
it('should increase token estimate with snippets', () => {
347+
const formatterWithout = new CompactFormatter();
348+
const formatterWith = new CompactFormatter({ includeSnippets: true, includeImports: true });
349+
350+
const tokensWithout = formatterWithout.estimateTokens(resultWithSnippet);
351+
const tokensWith = formatterWith.estimateTokens(resultWithSnippet);
352+
353+
expect(tokensWith).toBeGreaterThan(tokensWithout);
354+
});
355+
});
356+
357+
describe('VerboseFormatter with snippets', () => {
358+
it('should include snippet by default', () => {
359+
const formatter = new VerboseFormatter();
360+
const formatted = formatter.formatResult(resultWithSnippet);
361+
362+
expect(formatted).toContain('Code:');
363+
expect(formatted).toContain('export async function handleAuth');
364+
});
365+
366+
it('should include imports by default', () => {
367+
const formatter = new VerboseFormatter();
368+
const formatted = formatter.formatResult(resultWithSnippet);
369+
370+
expect(formatted).toContain('Imports: ./service, ../utils/jwt, express');
371+
});
372+
373+
it('should show location with line range', () => {
374+
const formatter = new VerboseFormatter();
375+
const formatted = formatter.formatResult(resultWithSnippet);
376+
377+
expect(formatted).toContain('Location: src/auth/handler.ts:45-67');
378+
});
379+
380+
it('should not truncate imports in verbose mode', () => {
381+
const formatter = new VerboseFormatter();
382+
const formatted = formatter.formatResult(resultWithManyImports);
383+
384+
expect(formatted).toContain('Imports: a, b, c, d, e, f, g');
385+
expect(formatted).not.toContain('...');
386+
});
387+
388+
it('should respect maxSnippetLines option', () => {
389+
const longSnippet = Array(30).fill('const x = 1;').join('\n');
390+
const result: SearchResult = {
391+
id: 'test',
392+
score: 0.8,
393+
metadata: {
394+
path: 'test.ts',
395+
type: 'function',
396+
name: 'test',
397+
snippet: longSnippet,
398+
},
399+
};
400+
401+
const formatter = new VerboseFormatter({ maxSnippetLines: 10 });
402+
const formatted = formatter.formatResult(result);
403+
404+
expect(formatted).toContain('// ... 20 more lines');
405+
});
406+
407+
it('should be able to disable snippets', () => {
408+
const formatter = new VerboseFormatter({ includeSnippets: false });
409+
const formatted = formatter.formatResult(resultWithSnippet);
410+
411+
expect(formatted).not.toContain('Code:');
412+
expect(formatted).not.toContain('export async function');
413+
});
414+
415+
it('should be able to disable imports', () => {
416+
const formatter = new VerboseFormatter({ includeImports: false });
417+
const formatted = formatter.formatResult(resultWithSnippet);
418+
419+
expect(formatted).not.toContain('Imports:');
420+
});
421+
422+
it('should increase token estimate with snippets', () => {
423+
const formatterWithout = new VerboseFormatter({
424+
includeSnippets: false,
425+
includeImports: false,
426+
});
427+
const formatterWith = new VerboseFormatter();
428+
429+
const tokensWithout = formatterWithout.estimateTokens(resultWithSnippet);
430+
const tokensWith = formatterWith.estimateTokens(resultWithSnippet);
431+
432+
expect(tokensWith).toBeGreaterThan(tokensWithout);
433+
});
434+
});
435+
436+
describe('Empty snippets and imports', () => {
437+
it('should handle missing snippet gracefully', () => {
438+
const formatter = new VerboseFormatter();
439+
const formatted = formatter.formatResult(mockResults[0]);
440+
441+
expect(formatted).not.toContain('Code:');
442+
});
443+
444+
it('should handle missing imports gracefully', () => {
445+
const formatter = new VerboseFormatter();
446+
const formatted = formatter.formatResult(mockResults[0]);
447+
448+
expect(formatted).not.toContain('Imports:');
449+
});
450+
451+
it('should handle empty imports array', () => {
452+
const result: SearchResult = {
453+
id: 'test',
454+
score: 0.8,
455+
metadata: {
456+
path: 'test.ts',
457+
type: 'function',
458+
name: 'test',
459+
imports: [],
460+
},
461+
};
462+
463+
const formatter = new VerboseFormatter();
464+
const formatted = formatter.formatResult(result);
465+
466+
expect(formatted).not.toContain('Imports:');
467+
});
468+
});
469+
});
253470
});

packages/mcp-server/src/formatters/compact-formatter.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ import type { SearchResult } from '@lytics/dev-agent-core';
77
import type { FormattedResult, FormatterOptions, ResultFormatter } from './types';
88
import { estimateTokensForText } from './utils';
99

10+
/** Default max snippet lines for compact mode */
11+
const DEFAULT_MAX_SNIPPET_LINES = 10;
12+
/** Max imports to show before truncating */
13+
const MAX_IMPORTS_DISPLAY = 5;
14+
1015
/**
1116
* Compact formatter - optimized for token efficiency
12-
* Returns: path, type, name, score
17+
* Returns: path, type, name, score, optional snippet and imports
1318
*/
1419
export class CompactFormatter implements ResultFormatter {
1520
private options: Required<FormatterOptions>;
@@ -21,14 +26,45 @@ export class CompactFormatter implements ResultFormatter {
2126
includeLineNumbers: options.includeLineNumbers ?? true,
2227
includeTypes: options.includeTypes ?? true,
2328
includeSignatures: options.includeSignatures ?? false, // Compact mode excludes signatures
29+
includeSnippets: options.includeSnippets ?? false, // Off by default for compact
30+
includeImports: options.includeImports ?? false, // Off by default for compact
31+
maxSnippetLines: options.maxSnippetLines ?? DEFAULT_MAX_SNIPPET_LINES,
2432
tokenBudget: options.tokenBudget ?? 1000,
2533
};
2634
}
2735

2836
formatResult(result: SearchResult): string {
37+
const lines: string[] = [];
38+
39+
// Line 1: Header with score, type, name, path
40+
lines.push(this.formatHeader(result));
41+
42+
// Code snippet (if enabled)
43+
if (this.options.includeSnippets && typeof result.metadata.snippet === 'string') {
44+
const truncatedSnippet = this.truncateSnippet(
45+
result.metadata.snippet,
46+
this.options.maxSnippetLines
47+
);
48+
lines.push(this.indentText(truncatedSnippet, 3));
49+
}
50+
51+
// Imports (if enabled)
52+
if (this.options.includeImports && Array.isArray(result.metadata.imports)) {
53+
const imports = result.metadata.imports as string[];
54+
if (imports.length > 0) {
55+
const displayImports = imports.slice(0, MAX_IMPORTS_DISPLAY);
56+
const suffix = imports.length > MAX_IMPORTS_DISPLAY ? ' ...' : '';
57+
lines.push(` Imports: ${displayImports.join(', ')}${suffix}`);
58+
}
59+
}
60+
61+
return lines.join('\n');
62+
}
63+
64+
private formatHeader(result: SearchResult): string {
2965
const parts: string[] = [];
3066

31-
// Score (2 decimals)
67+
// Score
3268
parts.push(`[${(result.score * 100).toFixed(0)}%]`);
3369

3470
// Type
@@ -41,7 +77,7 @@ export class CompactFormatter implements ResultFormatter {
4177
parts.push(result.metadata.name);
4278
}
4379

44-
// Path
80+
// Path with line numbers
4581
if (this.options.includePaths && typeof result.metadata.path === 'string') {
4682
const pathPart =
4783
this.options.includeLineNumbers && typeof result.metadata.startLine === 'number'
@@ -53,6 +89,24 @@ export class CompactFormatter implements ResultFormatter {
5389
return parts.join(' ');
5490
}
5591

92+
private truncateSnippet(snippet: string, maxLines: number): string {
93+
const lines = snippet.split('\n');
94+
if (lines.length <= maxLines) {
95+
return snippet;
96+
}
97+
const truncated = lines.slice(0, maxLines).join('\n');
98+
const remaining = lines.length - maxLines;
99+
return `${truncated}\n// ... ${remaining} more lines`;
100+
}
101+
102+
private indentText(text: string, spaces: number): string {
103+
const indent = ' '.repeat(spaces);
104+
return text
105+
.split('\n')
106+
.map((line) => indent + line)
107+
.join('\n');
108+
}
109+
56110
formatResults(results: SearchResult[]): FormattedResult {
57111
// Handle empty results
58112
if (results.length === 0) {
@@ -82,6 +136,17 @@ export class CompactFormatter implements ResultFormatter {
82136
}
83137

84138
estimateTokens(result: SearchResult): number {
85-
return estimateTokensForText(this.formatResult(result));
139+
let estimate = estimateTokensForText(this.formatHeader(result));
140+
141+
if (this.options.includeSnippets && typeof result.metadata.snippet === 'string') {
142+
estimate += estimateTokensForText(result.metadata.snippet);
143+
}
144+
145+
if (this.options.includeImports && Array.isArray(result.metadata.imports)) {
146+
// ~3 tokens per import path
147+
estimate += (result.metadata.imports as string[]).length * 3;
148+
}
149+
150+
return estimate;
86151
}
87152
}

packages/mcp-server/src/formatters/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ export interface FormatterOptions {
6767
*/
6868
includeSignatures?: boolean;
6969

70+
/**
71+
* Include code snippets in output
72+
*/
73+
includeSnippets?: boolean;
74+
75+
/**
76+
* Include import lists in output
77+
*/
78+
includeImports?: boolean;
79+
80+
/**
81+
* Maximum lines to show in snippets (default: 10 compact, 20 verbose)
82+
*/
83+
maxSnippetLines?: number;
84+
7085
/**
7186
* Token budget (soft limit)
7287
*/

0 commit comments

Comments
 (0)