diff --git a/packages/mcp-server/bin/dev-agent-mcp.ts b/packages/mcp-server/bin/dev-agent-mcp.ts index d6c9271..6130073 100644 --- a/packages/mcp-server/bin/dev-agent-mcp.ts +++ b/packages/mcp-server/bin/dev-agent-mcp.ts @@ -6,10 +6,13 @@ import { ensureStorageDirectory, + GitIndexer, getStorageFilePaths, getStoragePath, + LocalGitExtractor, RepositoryIndexer, saveMetadata, + VectorStorage, } from '@lytics/dev-agent-core'; import { ExplorerAgent, @@ -21,6 +24,7 @@ import { ExploreAdapter, GitHubAdapter, HealthAdapter, + HistoryAdapter, MapAdapter, PlanAdapter, RefsAdapter, @@ -193,6 +197,26 @@ async function main() { defaultTokenBudget: 2000, }); + // Create git extractor and indexer for history adapter + // Note: GitIndexer uses the same vector storage for commit embeddings + const gitExtractor = new LocalGitExtractor(repositoryPath); + const gitVectorStorage = new VectorStorage({ + storePath: `${filePaths.vectors}-git`, + }); + await gitVectorStorage.initialize(); + + const gitIndexer = new GitIndexer({ + extractor: gitExtractor, + vectorStorage: gitVectorStorage, + }); + + const historyAdapter = new HistoryAdapter({ + gitIndexer, + gitExtractor, + defaultLimit: 10, + defaultTokenBudget: 2000, + }); + // Create MCP server with coordinator const server = new MCPServer({ serverInfo: { @@ -213,6 +237,7 @@ async function main() { healthAdapter, refsAdapter, mapAdapter, + historyAdapter, ], coordinator, }); @@ -221,6 +246,7 @@ async function main() { const shutdown = async () => { await server.stop(); await indexer.close(); + await gitVectorStorage.close(); // Close GitHub adapter if initialized if (githubAdapter.githubIndexer) { await githubAdapter.githubIndexer.close(); diff --git a/packages/mcp-server/src/adapters/__tests__/history-adapter.test.ts b/packages/mcp-server/src/adapters/__tests__/history-adapter.test.ts new file mode 100644 index 0000000..c1b410c --- /dev/null +++ b/packages/mcp-server/src/adapters/__tests__/history-adapter.test.ts @@ -0,0 +1,281 @@ +import type { GitCommit, GitIndexer, LocalGitExtractor } from '@lytics/dev-agent-core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HistoryAdapter } from '../built-in/history-adapter'; +import type { ToolExecutionContext } from '../types'; + +// Mock commit data +const createMockCommit = (overrides: Partial = {}): GitCommit => ({ + hash: 'abc123def456789012345678901234567890abcd', + shortHash: 'abc123d', + message: 'feat: add authentication token handling\n\nThis adds token refresh logic.', + subject: 'feat: add authentication token handling', + body: 'This adds token refresh logic.', + author: { + name: 'Test User', + email: 'test@example.com', + date: '2025-01-15T10:00:00Z', + }, + committer: { + name: 'Test User', + email: 'test@example.com', + date: '2025-01-15T10:00:00Z', + }, + files: [ + { path: 'src/auth/token.ts', status: 'modified', additions: 50, deletions: 10 }, + { path: 'src/auth/index.ts', status: 'modified', additions: 5, deletions: 2 }, + ], + stats: { + additions: 55, + deletions: 12, + filesChanged: 2, + }, + refs: { + branches: [], + tags: [], + issueRefs: [123], + prRefs: [456], + }, + parents: ['parent123'], + ...overrides, +}); + +describe('HistoryAdapter', () => { + let mockGitIndexer: GitIndexer; + let mockGitExtractor: LocalGitExtractor; + let adapter: HistoryAdapter; + let mockContext: ToolExecutionContext; + + beforeEach(() => { + // Create mock git indexer + mockGitIndexer = { + index: vi.fn().mockResolvedValue({ commitsIndexed: 10, durationMs: 100, errors: [] }), + search: vi.fn().mockResolvedValue([createMockCommit()]), + getFileHistory: vi.fn().mockResolvedValue([createMockCommit()]), + getIndexedCommitCount: vi.fn().mockResolvedValue(100), + } as unknown as GitIndexer; + + // Create mock git extractor + mockGitExtractor = { + getCommits: vi.fn().mockResolvedValue([createMockCommit()]), + getCommit: vi.fn(), + getBlame: vi.fn(), + getRepositoryInfo: vi.fn(), + } as unknown as LocalGitExtractor; + + adapter = new HistoryAdapter({ + gitIndexer: mockGitIndexer, + gitExtractor: mockGitExtractor, + defaultLimit: 10, + defaultTokenBudget: 2000, + }); + + mockContext = { + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + requestId: 'test-request', + } as unknown as ToolExecutionContext; + }); + + describe('getToolDefinition', () => { + it('should return correct tool definition', () => { + const definition = adapter.getToolDefinition(); + + expect(definition.name).toBe('dev_history'); + expect(definition.description).toContain('git commit history'); + expect(definition.inputSchema.properties).toHaveProperty('query'); + expect(definition.inputSchema.properties).toHaveProperty('file'); + expect(definition.inputSchema.properties).toHaveProperty('limit'); + expect(definition.inputSchema.properties).toHaveProperty('since'); + expect(definition.inputSchema.properties).toHaveProperty('author'); + expect(definition.inputSchema.properties).toHaveProperty('tokenBudget'); + }); + + it('should require either query or file', () => { + const definition = adapter.getToolDefinition(); + + expect(definition.inputSchema.anyOf).toEqual([ + { required: ['query'] }, + { required: ['file'] }, + ]); + }); + }); + + describe('execute', () => { + describe('semantic search (query)', () => { + it('should search commits by semantic query', async () => { + const result = await adapter.execute({ query: 'authentication token' }, mockContext); + + expect(result.success).toBe(true); + expect(mockGitIndexer.search).toHaveBeenCalledWith('authentication token', { limit: 10 }); + expect(result.data).toMatchObject({ + searchType: 'semantic', + query: 'authentication token', + }); + }); + + it('should respect limit option', async () => { + await adapter.execute({ query: 'test', limit: 5 }, mockContext); + + expect(mockGitIndexer.search).toHaveBeenCalledWith('test', { limit: 5 }); + }); + + it('should include commit summaries in data', async () => { + const result = await adapter.execute({ query: 'test' }, mockContext); + + expect(result.data?.commits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + hash: 'abc123d', + subject: 'feat: add authentication token handling', + author: 'Test User', + }), + ]) + ); + }); + }); + + describe('file history', () => { + it('should get history for a specific file', async () => { + const result = await adapter.execute({ file: 'src/auth/token.ts' }, mockContext); + + expect(result.success).toBe(true); + expect(mockGitExtractor.getCommits).toHaveBeenCalledWith({ + path: 'src/auth/token.ts', + limit: 10, + since: undefined, + author: undefined, + follow: true, + noMerges: true, + }); + expect(result.data).toMatchObject({ + searchType: 'file', + file: 'src/auth/token.ts', + }); + }); + + it('should pass since and author filters', async () => { + await adapter.execute( + { + file: 'src/file.ts', + since: '2025-01-01', + author: 'test@example.com', + }, + mockContext + ); + + expect(mockGitExtractor.getCommits).toHaveBeenCalledWith( + expect.objectContaining({ + since: '2025-01-01', + author: 'test@example.com', + }) + ); + }); + }); + + describe('validation', () => { + it('should require query or file', async () => { + const result = await adapter.execute({}, mockContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('MISSING_INPUT'); + }); + + it('should validate limit range', async () => { + const result = await adapter.execute({ query: 'test', limit: 100 }, mockContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_LIMIT'); + }); + }); + + describe('output formatting', () => { + it('should include formatted content', async () => { + const result = await adapter.execute({ query: 'test' }, mockContext); + + expect(result.data?.content).toContain('# Git History'); + expect(result.data?.content).toContain('abc123d'); + expect(result.data?.content).toContain('feat: add authentication token handling'); + }); + + it('should include file changes in output', async () => { + const result = await adapter.execute({ query: 'test' }, mockContext); + + expect(result.data?.content).toContain('src/auth/token.ts'); + }); + + it('should include issue/PR refs in output', async () => { + const result = await adapter.execute({ query: 'test' }, mockContext); + + expect(result.data?.content).toContain('#123'); + expect(result.data?.content).toContain('#456'); + }); + }); + + describe('token budgeting', () => { + it('should respect token budget', async () => { + // Create many commits + const manyCommits = Array.from({ length: 20 }, (_, i) => + createMockCommit({ + hash: `hash${i.toString().padStart(38, '0')}`, + shortHash: `h${i.toString().padStart(6, '0')}`, + subject: `Commit ${i}: ${Array(100).fill('word').join(' ')}`, + }) + ); + vi.mocked(mockGitIndexer.search).mockResolvedValue(manyCommits); + + const result = await adapter.execute({ query: 'test', tokenBudget: 500 }, mockContext); + + expect(result.success).toBe(true); + // Should truncate due to token budget + expect(result.data?.content).toContain('token budget reached'); + }); + }); + + describe('metadata', () => { + it('should include metadata in result', async () => { + const result = await adapter.execute({ query: 'test' }, mockContext); + + expect(result.metadata).toMatchObject({ + tokens: expect.any(Number), + duration_ms: expect.any(Number), + timestamp: expect.any(String), + cached: false, + }); + }); + }); + + describe('error handling', () => { + it('should handle search errors', async () => { + vi.mocked(mockGitIndexer.search).mockRejectedValue(new Error('Search failed')); + + const result = await adapter.execute({ query: 'test' }, mockContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('HISTORY_FAILED'); + expect(result.error?.message).toContain('Search failed'); + }); + + it('should handle extractor errors', async () => { + vi.mocked(mockGitExtractor.getCommits).mockRejectedValue(new Error('Git error')); + + const result = await adapter.execute({ file: 'src/file.ts' }, mockContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('HISTORY_FAILED'); + }); + }); + }); + + describe('estimateTokens', () => { + it('should estimate tokens based on limit and budget', () => { + const estimate = adapter.estimateTokens({ limit: 10, tokenBudget: 2000 }); + + expect(estimate).toBeLessThanOrEqual(2000); + expect(estimate).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/mcp-server/src/adapters/built-in/history-adapter.ts b/packages/mcp-server/src/adapters/built-in/history-adapter.ts new file mode 100644 index 0000000..3bdd0fc --- /dev/null +++ b/packages/mcp-server/src/adapters/built-in/history-adapter.ts @@ -0,0 +1,352 @@ +/** + * History Adapter + * Provides semantic search over git commit history via the dev_history tool + */ + +import type { GitCommit, GitIndexer, LocalGitExtractor } from '@lytics/dev-agent-core'; +import { estimateTokensForText, startTimer } from '../../formatters/utils'; +import { ToolAdapter } from '../tool-adapter'; +import type { AdapterContext, ToolDefinition, ToolExecutionContext, ToolResult } from '../types'; + +/** + * History adapter configuration + */ +export interface HistoryAdapterConfig { + /** + * Git indexer instance for semantic search + */ + gitIndexer: GitIndexer; + + /** + * Git extractor for direct file history + */ + gitExtractor: LocalGitExtractor; + + /** + * Default result limit + */ + defaultLimit?: number; + + /** + * Default token budget + */ + defaultTokenBudget?: number; +} + +/** + * History Adapter + * Implements the dev_history tool for querying git commit history + */ +export class HistoryAdapter extends ToolAdapter { + readonly metadata = { + name: 'history-adapter', + version: '1.0.0', + description: 'Git history semantic search adapter', + author: 'Dev-Agent Team', + }; + + private gitIndexer: GitIndexer; + private gitExtractor: LocalGitExtractor; + private config: Required; + + constructor(config: HistoryAdapterConfig) { + super(); + this.gitIndexer = config.gitIndexer; + this.gitExtractor = config.gitExtractor; + this.config = { + gitIndexer: config.gitIndexer, + gitExtractor: config.gitExtractor, + defaultLimit: config.defaultLimit ?? 10, + defaultTokenBudget: config.defaultTokenBudget ?? 2000, + }; + } + + async initialize(context: AdapterContext): Promise { + context.logger.info('HistoryAdapter initialized', { + defaultLimit: this.config.defaultLimit, + defaultTokenBudget: this.config.defaultTokenBudget, + }); + } + + getToolDefinition(): ToolDefinition { + return { + name: 'dev_history', + description: + 'Search git commit history semantically or get history for a specific file. Use this to understand what changed and why.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'Semantic search query over commit messages (e.g., "authentication token expiry fix")', + }, + file: { + type: 'string', + description: 'Get history for a specific file path (e.g., "src/auth/token.ts")', + }, + limit: { + type: 'number', + description: `Maximum number of commits to return (default: ${this.config.defaultLimit})`, + minimum: 1, + maximum: 50, + default: this.config.defaultLimit, + }, + since: { + type: 'string', + description: + 'Only show commits after this date (ISO format or relative like "2 weeks ago")', + }, + author: { + type: 'string', + description: 'Filter by author email', + }, + tokenBudget: { + type: 'number', + description: `Maximum tokens for output (default: ${this.config.defaultTokenBudget})`, + minimum: 100, + maximum: 10000, + default: this.config.defaultTokenBudget, + }, + }, + // At least one of query or file is required + anyOf: [{ required: ['query'] }, { required: ['file'] }], + }, + }; + } + + async execute(args: Record, context: ToolExecutionContext): Promise { + const { + query, + file, + limit = this.config.defaultLimit, + since, + author, + tokenBudget = this.config.defaultTokenBudget, + } = args as { + query?: string; + file?: string; + limit?: number; + since?: string; + author?: string; + tokenBudget?: number; + }; + + // Validate inputs + if (!query && !file) { + return { + success: false, + error: { + code: 'MISSING_INPUT', + message: 'Either "query" or "file" must be provided', + }, + }; + } + + 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', + }, + }; + } + + try { + const timer = startTimer(); + context.logger.debug('Executing history query', { query, file, limit, since, author }); + + let commits: GitCommit[]; + let searchType: 'semantic' | 'file'; + + if (query) { + // Semantic search over commit messages + searchType = 'semantic'; + commits = await this.gitIndexer.search(query, { limit }); + } else { + // File-specific history + searchType = 'file'; + commits = await this.gitExtractor.getCommits({ + path: file, + limit, + since, + author, + follow: true, + noMerges: true, + }); + } + + // Format output with token budget + const content = this.formatCommits(commits, tokenBudget, searchType, query || file || ''); + const duration_ms = timer.elapsed(); + + context.logger.info('History query completed', { + searchType, + commitsFound: commits.length, + duration_ms, + }); + + const tokens = estimateTokensForText(content); + + return { + success: true, + data: { + searchType, + query: query || undefined, + file: file || undefined, + commits: commits.map((c) => ({ + hash: c.shortHash, + subject: c.subject, + author: c.author.name, + date: c.author.date, + filesChanged: c.stats.filesChanged, + })), + content, + }, + metadata: { + tokens, + duration_ms, + timestamp: new Date().toISOString(), + cached: false, + }, + }; + } catch (error) { + context.logger.error('History query failed', { error }); + return { + success: false, + error: { + code: 'HISTORY_FAILED', + message: error instanceof Error ? error.message : 'Unknown error', + details: error, + }, + }; + } + } + + /** + * Format commits into readable output with token budget + */ + private formatCommits( + commits: GitCommit[], + tokenBudget: number, + searchType: 'semantic' | 'file', + searchTerm: string + ): string { + const lines: string[] = []; + + // Header + if (searchType === 'semantic') { + lines.push(`# Git History: "${searchTerm}"`); + lines.push(`Found ${commits.length} relevant commits`); + } else { + lines.push(`# File History: ${searchTerm}`); + lines.push(`Showing ${commits.length} commits`); + } + lines.push(''); + + if (commits.length === 0) { + lines.push('*No commits found*'); + return lines.join('\n'); + } + + // Track token usage + let tokensUsed = estimateTokensForText(lines.join('\n')); + const reserveTokens = 50; // For footer + + for (let i = 0; i < commits.length; i++) { + const commit = commits[i]; + const commitLines = this.formatSingleCommit(commit, i === 0); + + const commitText = commitLines.join('\n'); + const commitTokens = estimateTokensForText(commitText); + + // Check if we can fit this commit + if (tokensUsed + commitTokens + reserveTokens > tokenBudget && i > 0) { + lines.push(''); + lines.push(`*... ${commits.length - i} more commits (token budget reached)*`); + break; + } + + lines.push(...commitLines); + tokensUsed += commitTokens; + } + + return lines.join('\n'); + } + + /** + * Format a single commit + */ + private formatSingleCommit(commit: GitCommit, includeBody: boolean): string[] { + const lines: string[] = []; + + // Commit header + const date = new Date(commit.author.date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + + lines.push(`## ${commit.shortHash} - ${commit.subject}`); + lines.push(`**Author:** ${commit.author.name} | **Date:** ${date}`); + + // Stats + const stats = []; + if (commit.stats.filesChanged > 0) { + stats.push(`${commit.stats.filesChanged} files`); + } + if (commit.stats.additions > 0) { + stats.push(`+${commit.stats.additions}`); + } + if (commit.stats.deletions > 0) { + stats.push(`-${commit.stats.deletions}`); + } + if (stats.length > 0) { + lines.push(`**Changes:** ${stats.join(', ')}`); + } + + // Issue/PR references + const refs = []; + if (commit.refs.issueRefs.length > 0) { + refs.push(`Issues: ${commit.refs.issueRefs.map((n: number) => `#${n}`).join(', ')}`); + } + if (commit.refs.prRefs.length > 0) { + refs.push(`PRs: ${commit.refs.prRefs.map((n: number) => `#${n}`).join(', ')}`); + } + if (refs.length > 0) { + lines.push(`**Refs:** ${refs.join(' | ')}`); + } + + // Body (for first commit only to save tokens) + if (includeBody && commit.body) { + lines.push(''); + // Truncate body if too long + const body = commit.body.length > 200 ? `${commit.body.slice(0, 200)}...` : commit.body; + lines.push(body); + } + + // Files changed (abbreviated) + if (commit.files.length > 0) { + lines.push(''); + lines.push('**Files:**'); + const filesToShow = commit.files.slice(0, 5); + for (const file of filesToShow) { + const status = file.status === 'added' ? '+' : file.status === 'deleted' ? '-' : '~'; + lines.push(`- ${status} ${file.path}`); + } + if (commit.files.length > 5) { + lines.push(` *... and ${commit.files.length - 5} more files*`); + } + } + + lines.push(''); + return lines; + } + + estimateTokens(args: Record): number { + const { limit = this.config.defaultLimit, tokenBudget = this.config.defaultTokenBudget } = args; + // Estimate based on limit and token budget + return Math.min((limit as number) * 100, tokenBudget as number); + } +} diff --git a/packages/mcp-server/src/adapters/built-in/index.ts b/packages/mcp-server/src/adapters/built-in/index.ts index a96e339..a1bf7d3 100644 --- a/packages/mcp-server/src/adapters/built-in/index.ts +++ b/packages/mcp-server/src/adapters/built-in/index.ts @@ -6,6 +6,7 @@ export { ExploreAdapter, type ExploreAdapterConfig } from './explore-adapter'; export { GitHubAdapter, type GitHubAdapterConfig } from './github-adapter'; export { HealthAdapter, type HealthCheckConfig } from './health-adapter'; +export { HistoryAdapter, type HistoryAdapterConfig } from './history-adapter'; export { MapAdapter, type MapAdapterConfig } from './map-adapter'; export { PlanAdapter, type PlanAdapterConfig } from './plan-adapter'; export { RefsAdapter, type RefsAdapterConfig } from './refs-adapter';