diff --git a/packages/mcp-server/tests/adapters/adapter-registry.test.ts b/packages/mcp-server/src/adapters/__tests__/adapter-registry.test.ts similarity index 98% rename from packages/mcp-server/tests/adapters/adapter-registry.test.ts rename to packages/mcp-server/src/adapters/__tests__/adapter-registry.test.ts index 4ca78ae..9e2db93 100644 --- a/packages/mcp-server/tests/adapters/adapter-registry.test.ts +++ b/packages/mcp-server/src/adapters/__tests__/adapter-registry.test.ts @@ -3,8 +3,8 @@ */ import { beforeEach, describe, expect, it } from 'vitest'; -import { AdapterRegistry } from '../../src/adapters/adapter-registry'; -import type { AdapterContext } from '../../src/adapters/types'; +import { AdapterRegistry } from '../adapter-registry'; +import type { AdapterContext } from '../types'; import { MockAdapter } from './mock-adapter'; describe('AdapterRegistry', () => { diff --git a/packages/mcp-server/tests/adapters/mock-adapter.ts b/packages/mcp-server/src/adapters/__tests__/mock-adapter.ts similarity index 96% rename from packages/mcp-server/tests/adapters/mock-adapter.ts rename to packages/mcp-server/src/adapters/__tests__/mock-adapter.ts index bcb3c30..4975ea0 100644 --- a/packages/mcp-server/tests/adapters/mock-adapter.ts +++ b/packages/mcp-server/src/adapters/__tests__/mock-adapter.ts @@ -3,14 +3,14 @@ * Simple echo adapter that returns what you send it */ -import { ToolAdapter } from '../../src/adapters/tool-adapter'; +import { ToolAdapter } from '../tool-adapter'; import type { AdapterContext, ToolDefinition, ToolExecutionContext, ToolResult, ValidationResult, -} from '../../src/adapters/types'; +} from '../types'; export class MockAdapter extends ToolAdapter { readonly metadata = { diff --git a/packages/mcp-server/src/adapters/__tests__/search-adapter.test.ts b/packages/mcp-server/src/adapters/__tests__/search-adapter.test.ts new file mode 100644 index 0000000..f01f80d --- /dev/null +++ b/packages/mcp-server/src/adapters/__tests__/search-adapter.test.ts @@ -0,0 +1,414 @@ +/** + * Tests for SearchAdapter + */ + +import type { RepositoryIndexer, SearchResult } from '@lytics/dev-agent-core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConsoleLogger } from '../../utils/logger'; +import { SearchAdapter } from '../built-in/search-adapter'; +import type { AdapterContext, ToolExecutionContext } from '../types'; + +describe('SearchAdapter', () => { + let mockIndexer: RepositoryIndexer; + let adapter: SearchAdapter; + let context: AdapterContext; + let execContext: ToolExecutionContext; + + // Mock search results + const mockSearchResults: SearchResult[] = [ + { + id: 'src/auth.ts:authenticate:10', + score: 0.92, + metadata: { + path: 'src/auth.ts', + type: 'function', + name: 'authenticate', + startLine: 10, + endLine: 25, + language: 'typescript', + exported: true, + signature: 'export function authenticate(user: User): boolean', + }, + }, + { + id: 'src/middleware.ts:AuthMiddleware:5', + score: 0.87, + metadata: { + path: 'src/middleware.ts', + type: 'class', + name: 'AuthMiddleware', + startLine: 5, + endLine: 30, + language: 'typescript', + exported: true, + }, + }, + ]; + + beforeEach(async () => { + // Create mock indexer + mockIndexer = { + search: vi.fn().mockResolvedValue(mockSearchResults), + } as unknown as RepositoryIndexer; + + // Create adapter + adapter = new SearchAdapter({ + repositoryIndexer: mockIndexer, + defaultFormat: 'compact', + defaultLimit: 10, + }); + + // Create context + const logger = new ConsoleLogger('error'); // Quiet for tests + context = { + agentName: 'test-agent', + logger, + config: {}, + }; + + execContext = { + logger, + requestId: 'test-request', + }; + + await adapter.initialize(context); + }); + + describe('Tool Definition', () => { + it('should provide valid tool definition', () => { + const def = adapter.getToolDefinition(); + + expect(def.name).toBe('dev_search'); + expect(def.description).toContain('Semantic search'); + expect(def.inputSchema.type).toBe('object'); + expect(def.inputSchema.properties).toHaveProperty('query'); + expect(def.inputSchema.properties).toHaveProperty('format'); + expect(def.inputSchema.properties).toHaveProperty('limit'); + expect(def.inputSchema.properties).toHaveProperty('scoreThreshold'); + expect(def.inputSchema.required).toContain('query'); + }); + + it('should have correct format enum', () => { + const def = adapter.getToolDefinition(); + const formatProp = def.inputSchema.properties?.format; + + expect(formatProp).toBeDefined(); + expect(formatProp).toHaveProperty('enum'); + expect((formatProp as { enum: string[] }).enum).toEqual(['compact', 'verbose']); + }); + }); + + describe('Query Validation', () => { + it('should reject empty query', async () => { + const result = await adapter.execute({ query: '' }, execContext); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.code).toBe('INVALID_QUERY'); + }); + + it('should reject non-string query', async () => { + const result = await adapter.execute({ query: 123 }, execContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_QUERY'); + }); + + it('should accept valid query', async () => { + const result = await adapter.execute({ query: 'test function' }, execContext); + + expect(result.success).toBe(true); + }); + }); + + describe('Format Validation', () => { + it('should accept compact format', async () => { + const result = await adapter.execute( + { + query: 'test', + format: 'compact', + }, + execContext + ); + + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('format', 'compact'); + }); + + it('should accept verbose format', async () => { + const result = await adapter.execute( + { + query: 'test', + format: 'verbose', + }, + execContext + ); + + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('format', 'verbose'); + }); + + it('should reject invalid format', async () => { + const result = await adapter.execute( + { + query: 'test', + format: 'invalid', + }, + execContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_FORMAT'); + }); + + it('should use default format when not specified', async () => { + const result = await adapter.execute({ query: 'test' }, execContext); + + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('format', 'compact'); + }); + }); + + describe('Limit Validation', () => { + it('should accept valid limit', async () => { + const result = await adapter.execute( + { + query: 'test', + limit: 5, + }, + execContext + ); + + expect(result.success).toBe(true); + }); + + it('should reject limit below 1', async () => { + const result = await adapter.execute( + { + query: 'test', + limit: 0, + }, + execContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_LIMIT'); + }); + + it('should reject limit above 50', async () => { + const result = await adapter.execute( + { + query: 'test', + limit: 51, + }, + execContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_LIMIT'); + }); + }); + + describe('Score Threshold Validation', () => { + it('should accept valid threshold', async () => { + const result = await adapter.execute( + { + query: 'test', + scoreThreshold: 0.5, + }, + execContext + ); + + expect(result.success).toBe(true); + }); + + it('should reject threshold below 0', async () => { + const result = await adapter.execute( + { + query: 'test', + scoreThreshold: -0.1, + }, + execContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_SCORE_THRESHOLD'); + }); + + it('should reject threshold above 1', async () => { + const result = await adapter.execute( + { + query: 'test', + scoreThreshold: 1.1, + }, + execContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_SCORE_THRESHOLD'); + }); + }); + + describe('Search Execution', () => { + it('should return search results', async () => { + const result = await adapter.execute( + { + query: 'authentication', + }, + execContext + ); + + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('query', 'authentication'); + expect(result.data).toHaveProperty('resultCount', 2); + expect(result.data).toHaveProperty('results'); + expect(result.data).toHaveProperty('tokenEstimate'); + expect(mockIndexer.search).toHaveBeenCalledWith('authentication', { + limit: 10, + scoreThreshold: 0, + }); + }); + + it('should return formatted results', async () => { + const result = await adapter.execute( + { + query: 'authentication', + }, + execContext + ); + + expect(result.success).toBe(true); + expect(typeof result.data?.results).toBe('string'); + expect((result.data?.results as string).length).toBeGreaterThan(0); + expect(result.data?.results as string).toContain('authenticate'); + }); + + it('should respect limit parameter', async () => { + const result = await adapter.execute( + { + query: 'test', + limit: 3, + }, + execContext + ); + + expect(result.success).toBe(true); + expect(mockIndexer.search).toHaveBeenCalledWith('test', { + limit: 3, + scoreThreshold: 0, + }); + expect(result.data?.resultCount).toBe(2); // Mock returns 2 results + }); + + it('should respect score threshold parameter', async () => { + const result = await adapter.execute( + { + query: 'test', + scoreThreshold: 0.9, + }, + execContext + ); + + expect(result.success).toBe(true); + expect(mockIndexer.search).toHaveBeenCalledWith('test', { + limit: 10, + scoreThreshold: 0.9, + }); + }); + + it('compact format should use fewer tokens than verbose', async () => { + const compactResult = await adapter.execute( + { + query: 'test', + format: 'compact', + }, + execContext + ); + + const verboseResult = await adapter.execute( + { + query: 'test', + format: 'verbose', + }, + execContext + ); + + expect(compactResult.success).toBe(true); + expect(verboseResult.success).toBe(true); + + const compactTokens = compactResult.data?.tokenEstimate as number; + const verboseTokens = verboseResult.data?.tokenEstimate as number; + + expect(verboseTokens).toBeGreaterThan(compactTokens); + }); + + it('should handle empty results', async () => { + // Override mock to return no results + vi.mocked(mockIndexer.search).mockResolvedValueOnce([]); + + const result = await adapter.execute( + { + query: 'nonexistent', + }, + execContext + ); + + expect(result.success).toBe(true); + expect(result.data?.resultCount).toBe(0); + expect(result.data?.results as string).toContain('No results'); + }); + }); + + describe('Token Estimation', () => { + it('should estimate tokens for queries', () => { + const estimate = adapter.estimateTokens({ + query: 'test', + format: 'compact', + limit: 10, + }); + + expect(estimate).toBeGreaterThan(0); + expect(estimate).toBeLessThan(500); + }); + + it('verbose should estimate more tokens', () => { + const compactEstimate = adapter.estimateTokens({ + query: 'test', + format: 'compact', + limit: 10, + }); + + const verboseEstimate = adapter.estimateTokens({ + query: 'test', + format: 'verbose', + limit: 10, + }); + + expect(verboseEstimate).toBeGreaterThan(compactEstimate); + }); + + it('more results should estimate more tokens', () => { + const fewResults = adapter.estimateTokens({ + query: 'test', + format: 'compact', + limit: 5, + }); + + const manyResults = adapter.estimateTokens({ + query: 'test', + format: 'compact', + limit: 20, + }); + + expect(manyResults).toBeGreaterThan(fewResults); + }); + }); + + describe('Metadata', () => { + it('should have correct metadata', () => { + expect(adapter.metadata.name).toBe('search-adapter'); + expect(adapter.metadata.version).toBe('1.0.0'); + expect(adapter.metadata.description).toContain('search'); + }); + }); +}); diff --git a/packages/mcp-server/src/adapters/built-in/index.ts b/packages/mcp-server/src/adapters/built-in/index.ts new file mode 100644 index 0000000..e7b6c2c --- /dev/null +++ b/packages/mcp-server/src/adapters/built-in/index.ts @@ -0,0 +1,6 @@ +/** + * Built-in Adapters + * Production-ready adapters included with the MCP server + */ + +export { SearchAdapter, type SearchAdapterConfig } from './search-adapter'; diff --git a/packages/mcp-server/src/adapters/built-in/search-adapter.ts b/packages/mcp-server/src/adapters/built-in/search-adapter.ts new file mode 100644 index 0000000..8f9a5d3 --- /dev/null +++ b/packages/mcp-server/src/adapters/built-in/search-adapter.ts @@ -0,0 +1,217 @@ +/** + * Search Adapter + * Provides semantic code search via the dev_search tool + */ + +import type { RepositoryIndexer } from '@lytics/dev-agent-core'; +import { CompactFormatter, type FormatMode, VerboseFormatter } from '../../formatters'; +import { ToolAdapter } from '../tool-adapter'; +import type { AdapterContext, ToolDefinition, ToolExecutionContext, ToolResult } from '../types'; + +/** + * Search adapter configuration + */ +export interface SearchAdapterConfig { + /** + * Repository indexer instance + */ + repositoryIndexer: RepositoryIndexer; + + /** + * Default format mode + */ + defaultFormat?: FormatMode; + + /** + * Default result limit + */ + defaultLimit?: number; +} + +/** + * Search Adapter + * Implements the dev_search tool for semantic code search + */ +export class SearchAdapter extends ToolAdapter { + readonly metadata = { + name: 'search-adapter', + version: '1.0.0', + description: 'Semantic code search adapter', + author: 'Dev-Agent Team', + }; + + private indexer: RepositoryIndexer; + private compactFormatter: CompactFormatter; + private verboseFormatter: VerboseFormatter; + private config: Required; + + constructor(config: SearchAdapterConfig) { + super(); + this.indexer = config.repositoryIndexer; + this.config = { + repositoryIndexer: config.repositoryIndexer, + defaultFormat: config.defaultFormat ?? 'compact', + defaultLimit: config.defaultLimit ?? 10, + }; + + // Initialize formatters + this.compactFormatter = new CompactFormatter({ + maxResults: this.config.defaultLimit, + tokenBudget: 1000, + }); + + this.verboseFormatter = new VerboseFormatter({ + maxResults: this.config.defaultLimit, + tokenBudget: 5000, + }); + } + + async initialize(context: AdapterContext): Promise { + context.logger.info('SearchAdapter initialized', { + defaultFormat: this.config.defaultFormat, + defaultLimit: this.config.defaultLimit, + }); + } + + getToolDefinition(): ToolDefinition { + return { + name: 'dev_search', + description: + 'Semantic search for code components (functions, classes, interfaces) in the indexed repository', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'Natural language search query (e.g., "authentication middleware", "database connection logic")', + }, + format: { + type: 'string', + enum: ['compact', 'verbose'], + description: + 'Output format: "compact" for summaries (default), "verbose" for full details', + default: this.config.defaultFormat, + }, + limit: { + type: 'number', + description: `Maximum number of results to return (default: ${this.config.defaultLimit})`, + minimum: 1, + maximum: 50, + default: this.config.defaultLimit, + }, + scoreThreshold: { + type: 'number', + description: 'Minimum similarity score (0-1). Lower = more results (default: 0)', + minimum: 0, + maximum: 1, + default: 0, + }, + }, + required: ['query'], + }, + }; + } + + async execute(args: Record, context: ToolExecutionContext): Promise { + const { + query, + format = this.config.defaultFormat, + limit = this.config.defaultLimit, + scoreThreshold = 0, + } = args; + + // Validate query + if (typeof query !== 'string' || query.trim().length === 0) { + return { + success: false, + error: { + code: 'INVALID_QUERY', + message: 'Query must be a non-empty string', + }, + }; + } + + // Validate format + if (format !== 'compact' && format !== 'verbose') { + return { + success: false, + error: { + code: 'INVALID_FORMAT', + message: 'Format must be either "compact" or "verbose"', + }, + }; + } + + // Validate limit + if (typeof limit !== 'number' || limit < 1 || limit > 50) { + return { + success: false, + error: { + code: 'INVALID_LIMIT', + message: 'Limit must be a number between 1 and 50', + }, + }; + } + + // Validate scoreThreshold + if (typeof scoreThreshold !== 'number' || scoreThreshold < 0 || scoreThreshold > 1) { + return { + success: false, + error: { + code: 'INVALID_SCORE_THRESHOLD', + message: 'Score threshold must be a number between 0 and 1', + }, + }; + } + + try { + context.logger.debug('Executing search', { query, format, limit, scoreThreshold }); + + // Perform search + const results = await this.indexer.search(query as string, { + limit: limit as number, + scoreThreshold: scoreThreshold as number, + }); + + // Format results + const formatter = format === 'verbose' ? this.verboseFormatter : this.compactFormatter; + const formatted = formatter.formatResults(results); + + context.logger.info('Search completed', { + query, + resultCount: results.length, + tokenEstimate: formatted.tokenEstimate, + }); + + return { + success: true, + data: { + query, + resultCount: results.length, + format, + results: formatted.content, + tokenEstimate: formatted.tokenEstimate, + }, + }; + } catch (error) { + context.logger.error('Search failed', { error }); + return { + success: false, + error: { + code: 'SEARCH_FAILED', + message: error instanceof Error ? error.message : 'Unknown error', + details: error, + }, + }; + } + } + + estimateTokens(args: Record): number { + const { format = this.config.defaultFormat, limit = this.config.defaultLimit } = args; + + // Rough estimate based on format and limit + const tokensPerResult = format === 'verbose' ? 100 : 20; + return (limit as number) * tokensPerResult + 50; // +50 for overhead + } +} diff --git a/packages/mcp-server/src/formatters/__tests__/formatters.test.ts b/packages/mcp-server/src/formatters/__tests__/formatters.test.ts new file mode 100644 index 0000000..02aa326 --- /dev/null +++ b/packages/mcp-server/src/formatters/__tests__/formatters.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for CompactFormatter and VerboseFormatter + */ + +import type { SearchResult } from '@lytics/dev-agent-core'; +import { describe, expect, it } from 'vitest'; +import { CompactFormatter, VerboseFormatter } from '../index'; + +describe('Formatters', () => { + const mockResults: SearchResult[] = [ + { + id: 'src/auth/middleware.ts:AuthMiddleware:15', + score: 0.89, + metadata: { + path: 'src/auth/middleware.ts', + type: 'class', + language: 'typescript', + name: 'AuthMiddleware', + startLine: 15, + endLine: 42, + exported: true, + signature: 'export class AuthMiddleware implements Middleware {...}', + }, + }, + { + id: 'src/auth/jwt.ts:verifyToken:5', + score: 0.84, + metadata: { + path: 'src/auth/jwt.ts', + type: 'function', + language: 'typescript', + name: 'verifyToken', + startLine: 5, + endLine: 12, + exported: true, + }, + }, + { + id: 'src/db/connection.ts:connectDB:20', + score: 0.72, + metadata: { + path: 'src/db/connection.ts', + type: 'function', + language: 'typescript', + name: 'connectDB', + startLine: 20, + endLine: 35, + exported: false, + }, + }, + ]; + + describe('CompactFormatter', () => { + it('should format single result compactly', () => { + const formatter = new CompactFormatter(); + const formatted = formatter.formatResult(mockResults[0]); + + expect(formatted).toContain('[89%]'); + expect(formatted).toContain('class:'); + expect(formatted).toContain('AuthMiddleware'); + expect(formatted).toContain('src/auth/middleware.ts'); + expect(formatted).toContain(':15'); // Line number + }); + + it('should format multiple results', () => { + const formatter = new CompactFormatter(); + const result = formatter.formatResults(mockResults); + + expect(result.content).toContain('1. [89%]'); + expect(result.content).toContain('2. [84%]'); + expect(result.content).toContain('3. [72%]'); + expect(result.tokenEstimate).toBeGreaterThan(0); + }); + + it('should respect maxResults option', () => { + const formatter = new CompactFormatter({ maxResults: 2 }); + const result = formatter.formatResults(mockResults); + + const lines = result.content.split('\n'); + expect(lines).toHaveLength(2); // Only 2 results + }); + + it('should exclude signatures by default', () => { + const formatter = new CompactFormatter(); + const formatted = formatter.formatResult(mockResults[0]); + + expect(formatted).not.toContain('export class'); + expect(formatted).not.toContain('implements Middleware'); + }); + + it('should handle missing metadata gracefully', () => { + const minimalResult: SearchResult = { + id: 'test', + score: 0.5, + metadata: {}, + }; + + const formatter = new CompactFormatter(); + const formatted = formatter.formatResult(minimalResult); + + expect(formatted).toContain('[50%]'); + expect(formatted).not.toContain('undefined'); + }); + + it('should estimate tokens reasonably', () => { + const formatter = new CompactFormatter(); + const tokens = formatter.estimateTokens(mockResults[0]); + + // Should be roughly 20-50 tokens for compact format + expect(tokens).toBeGreaterThan(5); + expect(tokens).toBeLessThan(100); + }); + }); + + describe('VerboseFormatter', () => { + it('should format single result verbosely', () => { + const formatter = new VerboseFormatter(); + const formatted = formatter.formatResult(mockResults[0]); + + expect(formatted).toContain('[Score: 89.0%]'); + expect(formatted).toContain('class:'); + expect(formatted).toContain('AuthMiddleware'); + expect(formatted).toContain('Location: src/auth/middleware.ts:15'); + expect(formatted).toContain('Signature:'); + expect(formatted).toContain('export class AuthMiddleware'); + expect(formatted).toContain('Metadata:'); + expect(formatted).toContain('language: typescript'); + expect(formatted).toContain('exported: true'); + expect(formatted).toContain('lines: 28'); // endLine - startLine + 1 + }); + + it('should format multiple results with separators', () => { + const formatter = new VerboseFormatter(); + const result = formatter.formatResults(mockResults); + + expect(result.content).toContain('1. [Score: 89.0%]'); + expect(result.content).toContain('2. [Score: 84.0%]'); + expect(result.content).toContain('3. [Score: 72.0%]'); + + // Should have double newlines between results + expect(result.content).toContain('\n\n'); + }); + + it('should include signatures by default', () => { + const formatter = new VerboseFormatter(); + const formatted = formatter.formatResult(mockResults[0]); + + expect(formatted).toContain('Signature:'); + expect(formatted).toContain('export class AuthMiddleware'); + }); + + it('should handle missing signature gracefully', () => { + const formatter = new VerboseFormatter(); + const formatted = formatter.formatResult(mockResults[1]); + + // Should not have Signature line if signature is missing + expect(formatted).not.toContain('Signature:'); + }); + + it('should respect maxResults option', () => { + const formatter = new VerboseFormatter({ maxResults: 2 }); + const result = formatter.formatResults(mockResults); + + expect(result.content).toContain('1.'); + expect(result.content).toContain('2.'); + expect(result.content).not.toContain('3.'); + }); + + it('should estimate more tokens than compact', () => { + const compactFormatter = new CompactFormatter(); + const verboseFormatter = new VerboseFormatter(); + + const compactTokens = compactFormatter.estimateTokens(mockResults[0]); + const verboseTokens = verboseFormatter.estimateTokens(mockResults[0]); + + expect(verboseTokens).toBeGreaterThan(compactTokens); + }); + + it('should handle missing metadata gracefully', () => { + const minimalResult: SearchResult = { + id: 'test', + score: 0.5, + metadata: { + name: 'TestFunc', + }, + }; + + const formatter = new VerboseFormatter(); + const formatted = formatter.formatResult(minimalResult); + + expect(formatted).toContain('[Score: 50.0%]'); + expect(formatted).toContain('TestFunc'); + expect(formatted).not.toContain('undefined'); + }); + }); + + describe('Token Estimation Comparison', () => { + it('compact should use ~5x fewer tokens than verbose', () => { + const compactFormatter = new CompactFormatter(); + const verboseFormatter = new VerboseFormatter(); + + const compactResult = compactFormatter.formatResults(mockResults); + const verboseResult = verboseFormatter.formatResults(mockResults); + + // Verbose should be significantly larger + expect(verboseResult.tokenEstimate).toBeGreaterThan(compactResult.tokenEstimate * 2); + }); + + it('token estimates should scale with result count', () => { + const formatter = new CompactFormatter(); + + const oneResult = formatter.formatResults([mockResults[0]]); + const threeResults = formatter.formatResults(mockResults); + + expect(threeResults.tokenEstimate).toBeGreaterThan(oneResult.tokenEstimate * 2); + }); + }); +}); diff --git a/packages/mcp-server/src/formatters/__tests__/utils.test.ts b/packages/mcp-server/src/formatters/__tests__/utils.test.ts new file mode 100644 index 0000000..c1399ec --- /dev/null +++ b/packages/mcp-server/src/formatters/__tests__/utils.test.ts @@ -0,0 +1,159 @@ +/** + * Tests for formatter utilities (token estimation) + */ + +import { describe, expect, it } from 'vitest'; +import { estimateTokensForJSON, estimateTokensForText, truncateToTokenBudget } from '../utils'; + +describe('Formatter Utils', () => { + describe('estimateTokensForText', () => { + it('should estimate tokens for simple text', () => { + const text = 'Hello world'; + const tokens = estimateTokensForText(text); + + // "Hello world" is 2 words, ~11 chars + // Should be roughly 3-4 tokens + expect(tokens).toBeGreaterThan(1); + expect(tokens).toBeLessThan(10); + }); + + it('should estimate tokens for code', () => { + const code = 'function authenticate(user: User): boolean { return true; }'; + const tokens = estimateTokensForText(code); + + // Should be roughly 15-20 tokens + expect(tokens).toBeGreaterThan(10); + expect(tokens).toBeLessThan(30); + }); + + it('should handle empty string', () => { + const tokens = estimateTokensForText(''); + expect(tokens).toBe(0); + }); + + it('should normalize whitespace', () => { + const text1 = 'Hello world'; + const text2 = 'Hello world'; + + expect(estimateTokensForText(text1)).toBe(estimateTokensForText(text2)); + }); + + it('should estimate higher for longer text', () => { + const short = 'Hello'; + const long = 'Hello world, this is a much longer piece of text'; + + expect(estimateTokensForText(long)).toBeGreaterThan(estimateTokensForText(short) * 3); + }); + + it('should use conservative estimates', () => { + // Test that we're using the higher of char-based and word-based estimates + const text = 'verylongwordwithnospaces'; + const tokens = estimateTokensForText(text); + + // Should estimate based on characters (conservative) + expect(tokens).toBeGreaterThan(5); + }); + }); + + describe('truncateToTokenBudget', () => { + it('should not truncate if within budget', () => { + const text = 'Hello world'; + const budget = 100; + + const result = truncateToTokenBudget(text, budget); + expect(result).toBe(text); + }); + + it('should truncate if exceeds budget', () => { + const text = 'A'.repeat(1000); // Very long text + const budget = 10; + + const result = truncateToTokenBudget(text, budget); + expect(result).not.toBe(text); + expect(result).toContain('...'); + expect(result.length).toBeLessThan(text.length); + }); + + it('should add ellipsis when truncating', () => { + const text = 'A'.repeat(1000); + const budget = 10; + + const result = truncateToTokenBudget(text, budget); + expect(result).toMatch(/\.\.\.$/); + }); + + it('should respect token budget roughly', () => { + const text = 'This is a long piece of text that will definitely exceed our token budget'; + const budget = 5; + + const result = truncateToTokenBudget(text, budget); + const resultTokens = estimateTokensForText(result); + + // Should be at or below budget (with some tolerance for ellipsis) + expect(resultTokens).toBeLessThanOrEqual(budget + 2); + }); + }); + + describe('estimateTokensForJSON', () => { + it('should estimate tokens for JSON object', () => { + const obj = { + name: 'test', + value: 123, + items: ['a', 'b', 'c'], + }; + + const tokens = estimateTokensForJSON(obj); + expect(tokens).toBeGreaterThan(5); + expect(tokens).toBeLessThan(50); + }); + + it('should handle nested objects', () => { + const simple = { name: 'test' }; + const complex = { + name: 'test', + nested: { + deep: { + value: 'data', + }, + }, + }; + + expect(estimateTokensForJSON(complex)).toBeGreaterThan(estimateTokensForJSON(simple)); + }); + + it('should handle arrays', () => { + const obj = { + items: Array.from({ length: 10 }).fill('test'), + }; + + const tokens = estimateTokensForJSON(obj); + expect(tokens).toBeGreaterThan(10); + }); + + it('should handle empty objects', () => { + const tokens = estimateTokensForJSON({}); + expect(tokens).toBeGreaterThan(0); + expect(tokens).toBeLessThan(5); + }); + }); + + describe('Token Estimation Accuracy', () => { + it('should be within 50% of actual for typical code snippets', () => { + // These are approximate known token counts for GPT-4 + const testCases = [ + { text: 'Hello world', expected: 2 }, + { text: 'The quick brown fox jumps over the lazy dog', expected: 10 }, + { text: 'function test() { return 42; }', expected: 10 }, + ]; + + for (const { text, expected } of testCases) { + const estimate = estimateTokensForText(text); + const ratio = estimate / expected; + + // Should be within 50% (0.5x to 1.5x) + expect(ratio).toBeGreaterThan(0.5); + expect(ratio).toBeLessThan(2); + } + }); + }); +}); diff --git a/packages/mcp-server/src/formatters/compact-formatter.ts b/packages/mcp-server/src/formatters/compact-formatter.ts new file mode 100644 index 0000000..230e5f8 --- /dev/null +++ b/packages/mcp-server/src/formatters/compact-formatter.ts @@ -0,0 +1,87 @@ +/** + * Compact Formatter + * Token-efficient formatter that returns summaries only + */ + +import type { SearchResult } from '@lytics/dev-agent-core'; +import type { FormattedResult, FormatterOptions, ResultFormatter } from './types'; +import { estimateTokensForText } from './utils'; + +/** + * Compact formatter - optimized for token efficiency + * Returns: path, type, name, score + */ +export class CompactFormatter implements ResultFormatter { + private options: Required; + + constructor(options: FormatterOptions = {}) { + this.options = { + maxResults: options.maxResults ?? 10, + includePaths: options.includePaths ?? true, + includeLineNumbers: options.includeLineNumbers ?? true, + includeTypes: options.includeTypes ?? true, + includeSignatures: options.includeSignatures ?? false, // Compact mode excludes signatures + tokenBudget: options.tokenBudget ?? 1000, + }; + } + + formatResult(result: SearchResult): string { + const parts: string[] = []; + + // Score (2 decimals) + parts.push(`[${(result.score * 100).toFixed(0)}%]`); + + // Type + if (this.options.includeTypes && typeof result.metadata.type === 'string') { + parts.push(`${result.metadata.type}:`); + } + + // Name + if (typeof result.metadata.name === 'string') { + parts.push(result.metadata.name); + } + + // Path + if (this.options.includePaths && typeof result.metadata.path === 'string') { + const pathPart = + this.options.includeLineNumbers && typeof result.metadata.startLine === 'number' + ? `(${result.metadata.path}:${result.metadata.startLine})` + : `(${result.metadata.path})`; + parts.push(pathPart); + } + + return parts.join(' '); + } + + formatResults(results: SearchResult[]): FormattedResult { + // Handle empty results + if (results.length === 0) { + const content = 'No results found'; + return { + content, + tokenEstimate: estimateTokensForText(content), + }; + } + + // Respect max results + const limitedResults = results.slice(0, this.options.maxResults); + + // Format each result + const formatted = limitedResults.map((result, index) => { + return `${index + 1}. ${this.formatResult(result)}`; + }); + + // Calculate total tokens + const content = formatted.join('\n'); + const tokenEstimate = estimateTokensForText(content); + + return { + content, + tokenEstimate, + }; + } + + estimateTokens(result: SearchResult): number { + return estimateTokensForText(this.formatResult(result)); + } +} diff --git a/packages/mcp-server/src/formatters/index.ts b/packages/mcp-server/src/formatters/index.ts new file mode 100644 index 0000000..d290e16 --- /dev/null +++ b/packages/mcp-server/src/formatters/index.ts @@ -0,0 +1,9 @@ +/** + * Formatters + * Token-efficient result formatters for MCP responses + */ + +export { CompactFormatter } from './compact-formatter'; +export * from './types'; +export * from './utils'; +export { VerboseFormatter } from './verbose-formatter'; diff --git a/packages/mcp-server/src/formatters/types.ts b/packages/mcp-server/src/formatters/types.ts new file mode 100644 index 0000000..183d86a --- /dev/null +++ b/packages/mcp-server/src/formatters/types.ts @@ -0,0 +1,74 @@ +/** + * Formatter Types + * Types for result formatting and token estimation + */ + +import type { SearchResult } from '@lytics/dev-agent-core'; + +/** + * Format mode for search results + */ +export type FormatMode = 'compact' | 'verbose'; + +/** + * Formatted search result + */ +export interface FormattedResult { + content: string; + tokenEstimate: number; +} + +/** + * Result formatter interface + */ +export interface ResultFormatter { + /** + * Format a single search result + */ + formatResult(result: SearchResult): string; + + /** + * Format multiple search results + */ + formatResults(results: SearchResult[]): FormattedResult; + + /** + * Estimate tokens for a search result + */ + estimateTokens(result: SearchResult): number; +} + +/** + * Formatter options + */ +export interface FormatterOptions { + /** + * Maximum number of results to include + */ + maxResults?: number; + + /** + * Include file paths in output + */ + includePaths?: boolean; + + /** + * Include line numbers + */ + includeLineNumbers?: boolean; + + /** + * Include type information + */ + includeTypes?: boolean; + + /** + * Include signatures + */ + includeSignatures?: boolean; + + /** + * Token budget (soft limit) + */ + tokenBudget?: number; +} diff --git a/packages/mcp-server/src/formatters/utils.ts b/packages/mcp-server/src/formatters/utils.ts new file mode 100644 index 0000000..c85baec --- /dev/null +++ b/packages/mcp-server/src/formatters/utils.ts @@ -0,0 +1,66 @@ +/** + * Formatter Utilities + * Token estimation and text processing utilities + */ + +/** + * Estimate tokens for text using a simple heuristic + * + * Rule of thumb: ~4 characters per token for English text + * This is a conservative estimate (GPT-4 tokenization) + * + * @param text - The text to estimate tokens for + * @returns Estimated token count + */ +export function estimateTokensForText(text: string): number { + // Remove extra whitespace + const normalized = text.trim().replace(/\s+/g, ' '); + + // Handle empty string + if (normalized.length === 0) { + return 0; + } + + // Estimate: 4 characters per token (conservative for code/technical text) + const charBasedEstimate = Math.ceil(normalized.length / 4); + + // Word-based estimate (fallback) + const words = normalized.split(/\s+/).length; + const wordBasedEstimate = Math.ceil(words * 1.3); // ~1.3 tokens per word + + // Use the higher estimate (more conservative) + return Math.max(charBasedEstimate, wordBasedEstimate); +} + +/** + * Truncate text to fit within a token budget + * + * @param text - The text to truncate + * @param tokenBudget - Maximum number of tokens + * @returns Truncated text + */ +export function truncateToTokenBudget(text: string, tokenBudget: number): string { + const currentTokens = estimateTokensForText(text); + + if (currentTokens <= tokenBudget) { + return text; + } + + // Calculate target character count (conservative) + const targetChars = tokenBudget * 4; + + // Truncate and add ellipsis + const truncated = text.slice(0, targetChars - 3); + return `${truncated}...`; +} + +/** + * Estimate tokens for JSON object + * + * @param obj - The object to estimate tokens for + * @returns Estimated token count + */ +export function estimateTokensForJSON(obj: unknown): number { + const jsonString = JSON.stringify(obj); + return estimateTokensForText(jsonString); +} diff --git a/packages/mcp-server/src/formatters/verbose-formatter.ts b/packages/mcp-server/src/formatters/verbose-formatter.ts new file mode 100644 index 0000000..4f9218a --- /dev/null +++ b/packages/mcp-server/src/formatters/verbose-formatter.ts @@ -0,0 +1,117 @@ +/** + * Verbose Formatter + * Full-detail formatter that includes signatures and metadata + */ + +import type { SearchResult } from '@lytics/dev-agent-core'; +import type { FormattedResult, FormatterOptions, ResultFormatter } from './types'; +import { estimateTokensForText } from './utils'; + +/** + * Verbose formatter - includes all available information + * Returns: path, type, name, signature, metadata, score + */ +export class VerboseFormatter implements ResultFormatter { + private options: Required; + + constructor(options: FormatterOptions = {}) { + this.options = { + maxResults: options.maxResults ?? 10, + includePaths: options.includePaths ?? true, + includeLineNumbers: options.includeLineNumbers ?? true, + includeTypes: options.includeTypes ?? true, + includeSignatures: options.includeSignatures ?? true, // Verbose mode includes signatures + tokenBudget: options.tokenBudget ?? 5000, + }; + } + + formatResult(result: SearchResult): string { + const lines: string[] = []; + + // Header: score + type + name + const header: string[] = []; + header.push(`[Score: ${(result.score * 100).toFixed(1)}%]`); + + if (this.options.includeTypes && typeof result.metadata.type === 'string') { + header.push(`${result.metadata.type}:`); + } + + if (typeof result.metadata.name === 'string') { + header.push(result.metadata.name); + } + + lines.push(header.join(' ')); + + // Path with line numbers + if (this.options.includePaths && typeof result.metadata.path === 'string') { + const location = + this.options.includeLineNumbers && typeof result.metadata.startLine === 'number' + ? `${result.metadata.path}:${result.metadata.startLine}` + : result.metadata.path; + lines.push(` Location: ${location}`); + } + + // Signature (if available and enabled) + if (this.options.includeSignatures && typeof result.metadata.signature === 'string') { + lines.push(` Signature: ${result.metadata.signature}`); + } + + // Additional metadata + const metadata: string[] = []; + + if (typeof result.metadata.language === 'string') { + metadata.push(`language: ${result.metadata.language}`); + } + + if (result.metadata.exported !== undefined) { + metadata.push(`exported: ${result.metadata.exported}`); + } + + if ( + typeof result.metadata.endLine === 'number' && + typeof result.metadata.startLine === 'number' && + this.options.includeLineNumbers + ) { + const lineCount = result.metadata.endLine - result.metadata.startLine + 1; + metadata.push(`lines: ${lineCount}`); + } + + if (metadata.length > 0) { + lines.push(` Metadata: ${metadata.join(', ')}`); + } + + return lines.join('\n'); + } + + formatResults(results: SearchResult[]): FormattedResult { + // Handle empty results + if (results.length === 0) { + const content = 'No results found'; + return { + content, + tokenEstimate: estimateTokensForText(content), + }; + } + + // Respect max results + const limitedResults = results.slice(0, this.options.maxResults); + + // Format each result with separator + const formatted = limitedResults.map((result, index) => { + return `${index + 1}. ${this.formatResult(result)}`; + }); + + // Calculate total tokens + const content = formatted.join('\n\n'); // Double newline for separation + const tokenEstimate = estimateTokensForText(content); + + return { + content, + tokenEstimate, + }; + } + + estimateTokens(result: SearchResult): number { + return estimateTokensForText(this.formatResult(result)); + } +} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index e965f80..d909108 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -6,8 +6,11 @@ // Adapter exports export { Adapter } from './adapters/adapter'; export { AdapterRegistry, type RegistryConfig } from './adapters/adapter-registry'; +export * from './adapters/built-in'; export { ToolAdapter } from './adapters/tool-adapter'; export * from './adapters/types'; +// Formatter exports +export * from './formatters'; // Core exports export { MCPServer, type MCPServerConfig } from './server/mcp-server'; // Protocol exports @@ -20,6 +23,5 @@ export { type TransportConfig, type TransportMessage, } from './server/transport/transport'; - // Utility exports export { ConsoleLogger } from './utils/logger'; diff --git a/packages/mcp-server/tests/integration/server.integration.test.ts b/packages/mcp-server/src/server/__tests__/server.integration.test.ts similarity index 95% rename from packages/mcp-server/tests/integration/server.integration.test.ts rename to packages/mcp-server/src/server/__tests__/server.integration.test.ts index 92e310b..1ea4596 100644 --- a/packages/mcp-server/tests/integration/server.integration.test.ts +++ b/packages/mcp-server/src/server/__tests__/server.integration.test.ts @@ -4,9 +4,8 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { MCPServer } from '../../src/server/mcp-server'; -import type { JSONRPCRequest, JSONRPCResponse } from '../../src/server/protocol/types'; -import { MockAdapter } from '../adapters/mock-adapter'; +import { MockAdapter } from '../../adapters/__tests__/mock-adapter'; +import { MCPServer } from '../mcp-server'; describe('MCP Server Integration', () => { let server: MCPServer; diff --git a/packages/mcp-server/tests/server/jsonrpc.test.ts b/packages/mcp-server/src/server/protocol/__tests__/jsonrpc.test.ts similarity index 98% rename from packages/mcp-server/tests/server/jsonrpc.test.ts rename to packages/mcp-server/src/server/protocol/__tests__/jsonrpc.test.ts index de132ee..516880f 100644 --- a/packages/mcp-server/tests/server/jsonrpc.test.ts +++ b/packages/mcp-server/src/server/protocol/__tests__/jsonrpc.test.ts @@ -3,8 +3,8 @@ */ import { describe, expect, it } from 'vitest'; -import { JSONRPCHandler } from '../../src/server/protocol/jsonrpc'; -import { ErrorCode } from '../../src/server/protocol/types'; +import { JSONRPCHandler } from '../jsonrpc'; +import { ErrorCode } from '../types'; describe('JSONRPCHandler', () => { describe('parse', () => { diff --git a/packages/mcp-server/tests/server/utils/message-handlers.test.ts b/packages/mcp-server/src/server/utils/__tests__/message-handlers.test.ts similarity index 97% rename from packages/mcp-server/tests/server/utils/message-handlers.test.ts rename to packages/mcp-server/src/server/utils/__tests__/message-handlers.test.ts index 7a3101d..53ac911 100644 --- a/packages/mcp-server/tests/server/utils/message-handlers.test.ts +++ b/packages/mcp-server/src/server/utils/__tests__/message-handlers.test.ts @@ -3,13 +3,13 @@ */ import { describe, expect, it } from 'vitest'; -import type { ServerInfo } from '../../../src/server/protocol/types'; +import type { ServerInfo } from '../../protocol/types'; import { createInitializeResult, extractToolCallParams, isSupportedMethod, validateRequest, -} from '../../../src/server/utils/message-handlers'; +} from '../message-handlers'; describe('messageHandlers', () => { describe('createInitializeResult', () => {