Skip to content

Commit 15e645f

Browse files
committed
feat(mcp): implement SearchAdapter with token-efficient formatters
Implements Issue #28 - First Adapter + Formatters ## Features ### Formatters - **CompactFormatter**: Token-efficient summaries (score, type, name, path) - **VerboseFormatter**: Full details with signatures and metadata - **Token Estimation**: ~4 chars/token heuristic for GPT-4 ### SearchAdapter - Implements `dev_search` MCP tool - Semantic code search via RepositoryIndexer - Configurable format modes (compact/verbose) - Input validation with structured errors - Token-aware result formatting ### API - Format modes: `compact` (default) | `verbose` - Configurable limits: 1-50 results (default: 10) - Score threshold: 0-1 (default: 0) - Token budgets: 1K (compact) | 5K (verbose) ## Benefits - ✅ Token-efficient by default (compact mode) - ✅ Opt-in verbosity for detailed exploration - ✅ Type-safe metadata access - ✅ Structured error handling - ✅ Extensible formatter framework ## Next Steps - Add comprehensive tests (unit + integration) - Test with real MCP clients - Measure token estimation accuracy
1 parent ff16990 commit 15e645f

File tree

8 files changed

+556
-1
lines changed

8 files changed

+556
-1
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Built-in Adapters
3+
* Production-ready adapters included with the MCP server
4+
*/
5+
6+
export { SearchAdapter, type SearchAdapterConfig } from './search-adapter';
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/**
2+
* Search Adapter
3+
* Provides semantic code search via the dev_search tool
4+
*/
5+
6+
import type { RepositoryIndexer } from '@lytics/dev-agent-core';
7+
import { CompactFormatter, type FormatMode, VerboseFormatter } from '../../formatters';
8+
import { ToolAdapter } from '../tool-adapter';
9+
import type { AdapterContext, ToolDefinition, ToolExecutionContext, ToolResult } from '../types';
10+
11+
/**
12+
* Search adapter configuration
13+
*/
14+
export interface SearchAdapterConfig {
15+
/**
16+
* Repository indexer instance
17+
*/
18+
repositoryIndexer: RepositoryIndexer;
19+
20+
/**
21+
* Default format mode
22+
*/
23+
defaultFormat?: FormatMode;
24+
25+
/**
26+
* Default result limit
27+
*/
28+
defaultLimit?: number;
29+
}
30+
31+
/**
32+
* Search Adapter
33+
* Implements the dev_search tool for semantic code search
34+
*/
35+
export class SearchAdapter extends ToolAdapter {
36+
readonly metadata = {
37+
name: 'search-adapter',
38+
version: '1.0.0',
39+
description: 'Semantic code search adapter',
40+
author: 'Dev-Agent Team',
41+
};
42+
43+
private indexer: RepositoryIndexer;
44+
private compactFormatter: CompactFormatter;
45+
private verboseFormatter: VerboseFormatter;
46+
private config: Required<SearchAdapterConfig>;
47+
48+
constructor(config: SearchAdapterConfig) {
49+
super();
50+
this.indexer = config.repositoryIndexer;
51+
this.config = {
52+
repositoryIndexer: config.repositoryIndexer,
53+
defaultFormat: config.defaultFormat ?? 'compact',
54+
defaultLimit: config.defaultLimit ?? 10,
55+
};
56+
57+
// Initialize formatters
58+
this.compactFormatter = new CompactFormatter({
59+
maxResults: this.config.defaultLimit,
60+
tokenBudget: 1000,
61+
});
62+
63+
this.verboseFormatter = new VerboseFormatter({
64+
maxResults: this.config.defaultLimit,
65+
tokenBudget: 5000,
66+
});
67+
}
68+
69+
async initialize(context: AdapterContext): Promise<void> {
70+
context.logger.info('SearchAdapter initialized', {
71+
defaultFormat: this.config.defaultFormat,
72+
defaultLimit: this.config.defaultLimit,
73+
});
74+
}
75+
76+
getToolDefinition(): ToolDefinition {
77+
return {
78+
name: 'dev_search',
79+
description:
80+
'Semantic search for code components (functions, classes, interfaces) in the indexed repository',
81+
inputSchema: {
82+
type: 'object',
83+
properties: {
84+
query: {
85+
type: 'string',
86+
description:
87+
'Natural language search query (e.g., "authentication middleware", "database connection logic")',
88+
},
89+
format: {
90+
type: 'string',
91+
enum: ['compact', 'verbose'],
92+
description:
93+
'Output format: "compact" for summaries (default), "verbose" for full details',
94+
default: this.config.defaultFormat,
95+
},
96+
limit: {
97+
type: 'number',
98+
description: `Maximum number of results to return (default: ${this.config.defaultLimit})`,
99+
minimum: 1,
100+
maximum: 50,
101+
default: this.config.defaultLimit,
102+
},
103+
scoreThreshold: {
104+
type: 'number',
105+
description: 'Minimum similarity score (0-1). Lower = more results (default: 0)',
106+
minimum: 0,
107+
maximum: 1,
108+
default: 0,
109+
},
110+
},
111+
required: ['query'],
112+
},
113+
};
114+
}
115+
116+
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<ToolResult> {
117+
const {
118+
query,
119+
format = this.config.defaultFormat,
120+
limit = this.config.defaultLimit,
121+
scoreThreshold = 0,
122+
} = args;
123+
124+
// Validate query
125+
if (typeof query !== 'string' || query.trim().length === 0) {
126+
return {
127+
success: false,
128+
error: {
129+
code: 'INVALID_QUERY',
130+
message: 'Query must be a non-empty string',
131+
},
132+
};
133+
}
134+
135+
// Validate format
136+
if (format !== 'compact' && format !== 'verbose') {
137+
return {
138+
success: false,
139+
error: {
140+
code: 'INVALID_FORMAT',
141+
message: 'Format must be either "compact" or "verbose"',
142+
},
143+
};
144+
}
145+
146+
// Validate limit
147+
if (typeof limit !== 'number' || limit < 1 || limit > 50) {
148+
return {
149+
success: false,
150+
error: {
151+
code: 'INVALID_LIMIT',
152+
message: 'Limit must be a number between 1 and 50',
153+
},
154+
};
155+
}
156+
157+
// Validate scoreThreshold
158+
if (typeof scoreThreshold !== 'number' || scoreThreshold < 0 || scoreThreshold > 1) {
159+
return {
160+
success: false,
161+
error: {
162+
code: 'INVALID_SCORE_THRESHOLD',
163+
message: 'Score threshold must be a number between 0 and 1',
164+
},
165+
};
166+
}
167+
168+
try {
169+
context.logger.debug('Executing search', { query, format, limit, scoreThreshold });
170+
171+
// Perform search
172+
const results = await this.indexer.search(query as string, {
173+
limit: limit as number,
174+
scoreThreshold: scoreThreshold as number,
175+
});
176+
177+
// Format results
178+
const formatter = format === 'verbose' ? this.verboseFormatter : this.compactFormatter;
179+
const formatted = formatter.formatResults(results);
180+
181+
context.logger.info('Search completed', {
182+
query,
183+
resultCount: results.length,
184+
tokenEstimate: formatted.tokenEstimate,
185+
});
186+
187+
return {
188+
success: true,
189+
data: {
190+
query,
191+
resultCount: results.length,
192+
format,
193+
results: formatted.content,
194+
tokenEstimate: formatted.tokenEstimate,
195+
},
196+
};
197+
} catch (error) {
198+
context.logger.error('Search failed', { error });
199+
return {
200+
success: false,
201+
error: {
202+
code: 'SEARCH_FAILED',
203+
message: error instanceof Error ? error.message : 'Unknown error',
204+
details: error,
205+
},
206+
};
207+
}
208+
}
209+
210+
estimateTokens(args: Record<string, unknown>): number {
211+
const { format = this.config.defaultFormat, limit = this.config.defaultLimit } = args;
212+
213+
// Rough estimate based on format and limit
214+
const tokensPerResult = format === 'verbose' ? 100 : 20;
215+
return (limit as number) * tokensPerResult + 50; // +50 for overhead
216+
}
217+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Compact Formatter
3+
* Token-efficient formatter that returns summaries only
4+
*/
5+
6+
import type { SearchResult } from '@lytics/dev-agent-core';
7+
import type { FormattedResult, FormatterOptions, ResultFormatter } from './types';
8+
import { estimateTokensForText } from './utils';
9+
10+
/**
11+
* Compact formatter - optimized for token efficiency
12+
* Returns: path, type, name, score
13+
*/
14+
export class CompactFormatter implements ResultFormatter {
15+
private options: Required<FormatterOptions>;
16+
17+
constructor(options: FormatterOptions = {}) {
18+
this.options = {
19+
maxResults: options.maxResults ?? 10,
20+
includePaths: options.includePaths ?? true,
21+
includeLineNumbers: options.includeLineNumbers ?? true,
22+
includeTypes: options.includeTypes ?? true,
23+
includeSignatures: options.includeSignatures ?? false, // Compact mode excludes signatures
24+
tokenBudget: options.tokenBudget ?? 1000,
25+
};
26+
}
27+
28+
formatResult(result: SearchResult): string {
29+
const parts: string[] = [];
30+
31+
// Score (2 decimals)
32+
parts.push(`[${(result.score * 100).toFixed(0)}%]`);
33+
34+
// Type
35+
if (this.options.includeTypes && typeof result.metadata.type === 'string') {
36+
parts.push(`${result.metadata.type}:`);
37+
}
38+
39+
// Name
40+
if (typeof result.metadata.name === 'string') {
41+
parts.push(result.metadata.name);
42+
}
43+
44+
// Path
45+
if (this.options.includePaths && typeof result.metadata.path === 'string') {
46+
const pathPart =
47+
this.options.includeLineNumbers && typeof result.metadata.startLine === 'number'
48+
? `(${result.metadata.path}:${result.metadata.startLine})`
49+
: `(${result.metadata.path})`;
50+
parts.push(pathPart);
51+
}
52+
53+
return parts.join(' ');
54+
}
55+
56+
formatResults(results: SearchResult[]): FormattedResult {
57+
// Respect max results
58+
const limitedResults = results.slice(0, this.options.maxResults);
59+
60+
// Format each result
61+
const formatted = limitedResults.map((result, index) => {
62+
return `${index + 1}. ${this.formatResult(result)}`;
63+
});
64+
65+
// Calculate total tokens
66+
const content = formatted.join('\n');
67+
const tokenEstimate = estimateTokensForText(content);
68+
69+
return {
70+
content,
71+
tokenEstimate,
72+
};
73+
}
74+
75+
estimateTokens(result: SearchResult): number {
76+
return estimateTokensForText(this.formatResult(result));
77+
}
78+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Formatters
3+
* Token-efficient result formatters for MCP responses
4+
*/
5+
6+
export { CompactFormatter } from './compact-formatter';
7+
export * from './types';
8+
export * from './utils';
9+
export { VerboseFormatter } from './verbose-formatter';
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Formatter Types
3+
* Types for result formatting and token estimation
4+
*/
5+
6+
import type { SearchResult } from '@lytics/dev-agent-core';
7+
8+
/**
9+
* Format mode for search results
10+
*/
11+
export type FormatMode = 'compact' | 'verbose';
12+
13+
/**
14+
* Formatted search result
15+
*/
16+
export interface FormattedResult {
17+
content: string;
18+
tokenEstimate: number;
19+
}
20+
21+
/**
22+
* Result formatter interface
23+
*/
24+
export interface ResultFormatter {
25+
/**
26+
* Format a single search result
27+
*/
28+
formatResult(result: SearchResult): string;
29+
30+
/**
31+
* Format multiple search results
32+
*/
33+
formatResults(results: SearchResult[]): FormattedResult;
34+
35+
/**
36+
* Estimate tokens for a search result
37+
*/
38+
estimateTokens(result: SearchResult): number;
39+
}
40+
41+
/**
42+
* Formatter options
43+
*/
44+
export interface FormatterOptions {
45+
/**
46+
* Maximum number of results to include
47+
*/
48+
maxResults?: number;
49+
50+
/**
51+
* Include file paths in output
52+
*/
53+
includePaths?: boolean;
54+
55+
/**
56+
* Include line numbers
57+
*/
58+
includeLineNumbers?: boolean;
59+
60+
/**
61+
* Include type information
62+
*/
63+
includeTypes?: boolean;
64+
65+
/**
66+
* Include signatures
67+
*/
68+
includeSignatures?: boolean;
69+
70+
/**
71+
* Token budget (soft limit)
72+
*/
73+
tokenBudget?: number;
74+
}

0 commit comments

Comments
 (0)