Skip to content

Commit 4d3b6b6

Browse files
committed
feat(mcp): add dev_history adapter for git history search (#93)
- Add HistoryAdapter for semantic search over git commits - Support both query-based (semantic) and file-based history - Include date/author filtering options - Token budget management for output - Rich formatting with commit details, stats, and refs - Register adapter in MCP server entry point - Add separate vector storage for git commits - Add 17 comprehensive unit tests Part of Epic: Intelligent Git History (v0.4.0) #90
1 parent 7576454 commit 4d3b6b6

File tree

4 files changed

+660
-0
lines changed

4 files changed

+660
-0
lines changed

packages/mcp-server/bin/dev-agent-mcp.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66

77
import {
88
ensureStorageDirectory,
9+
GitIndexer,
910
getStorageFilePaths,
1011
getStoragePath,
12+
LocalGitExtractor,
1113
RepositoryIndexer,
1214
saveMetadata,
15+
VectorStorage,
1316
} from '@lytics/dev-agent-core';
1417
import {
1518
ExplorerAgent,
@@ -21,6 +24,7 @@ import {
2124
ExploreAdapter,
2225
GitHubAdapter,
2326
HealthAdapter,
27+
HistoryAdapter,
2428
MapAdapter,
2529
PlanAdapter,
2630
RefsAdapter,
@@ -193,6 +197,26 @@ async function main() {
193197
defaultTokenBudget: 2000,
194198
});
195199

200+
// Create git extractor and indexer for history adapter
201+
// Note: GitIndexer uses the same vector storage for commit embeddings
202+
const gitExtractor = new LocalGitExtractor(repositoryPath);
203+
const gitVectorStorage = new VectorStorage({
204+
storePath: `${filePaths.vectors}-git`,
205+
});
206+
await gitVectorStorage.initialize();
207+
208+
const gitIndexer = new GitIndexer({
209+
extractor: gitExtractor,
210+
vectorStorage: gitVectorStorage,
211+
});
212+
213+
const historyAdapter = new HistoryAdapter({
214+
gitIndexer,
215+
gitExtractor,
216+
defaultLimit: 10,
217+
defaultTokenBudget: 2000,
218+
});
219+
196220
// Create MCP server with coordinator
197221
const server = new MCPServer({
198222
serverInfo: {
@@ -213,6 +237,7 @@ async function main() {
213237
healthAdapter,
214238
refsAdapter,
215239
mapAdapter,
240+
historyAdapter,
216241
],
217242
coordinator,
218243
});
@@ -221,6 +246,7 @@ async function main() {
221246
const shutdown = async () => {
222247
await server.stop();
223248
await indexer.close();
249+
await gitVectorStorage.close();
224250
// Close GitHub adapter if initialized
225251
if (githubAdapter.githubIndexer) {
226252
await githubAdapter.githubIndexer.close();
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import type { GitCommit, GitIndexer, LocalGitExtractor } from '@lytics/dev-agent-core';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { HistoryAdapter } from '../built-in/history-adapter';
4+
import type { ToolExecutionContext } from '../types';
5+
6+
// Mock commit data
7+
const createMockCommit = (overrides: Partial<GitCommit> = {}): GitCommit => ({
8+
hash: 'abc123def456789012345678901234567890abcd',
9+
shortHash: 'abc123d',
10+
message: 'feat: add authentication token handling\n\nThis adds token refresh logic.',
11+
subject: 'feat: add authentication token handling',
12+
body: 'This adds token refresh logic.',
13+
author: {
14+
name: 'Test User',
15+
16+
date: '2025-01-15T10:00:00Z',
17+
},
18+
committer: {
19+
name: 'Test User',
20+
21+
date: '2025-01-15T10:00:00Z',
22+
},
23+
files: [
24+
{ path: 'src/auth/token.ts', status: 'modified', additions: 50, deletions: 10 },
25+
{ path: 'src/auth/index.ts', status: 'modified', additions: 5, deletions: 2 },
26+
],
27+
stats: {
28+
additions: 55,
29+
deletions: 12,
30+
filesChanged: 2,
31+
},
32+
refs: {
33+
branches: [],
34+
tags: [],
35+
issueRefs: [123],
36+
prRefs: [456],
37+
},
38+
parents: ['parent123'],
39+
...overrides,
40+
});
41+
42+
describe('HistoryAdapter', () => {
43+
let mockGitIndexer: GitIndexer;
44+
let mockGitExtractor: LocalGitExtractor;
45+
let adapter: HistoryAdapter;
46+
let mockContext: ToolExecutionContext;
47+
48+
beforeEach(() => {
49+
// Create mock git indexer
50+
mockGitIndexer = {
51+
index: vi.fn().mockResolvedValue({ commitsIndexed: 10, durationMs: 100, errors: [] }),
52+
search: vi.fn().mockResolvedValue([createMockCommit()]),
53+
getFileHistory: vi.fn().mockResolvedValue([createMockCommit()]),
54+
getIndexedCommitCount: vi.fn().mockResolvedValue(100),
55+
} as unknown as GitIndexer;
56+
57+
// Create mock git extractor
58+
mockGitExtractor = {
59+
getCommits: vi.fn().mockResolvedValue([createMockCommit()]),
60+
getCommit: vi.fn(),
61+
getBlame: vi.fn(),
62+
getRepositoryInfo: vi.fn(),
63+
} as unknown as LocalGitExtractor;
64+
65+
adapter = new HistoryAdapter({
66+
gitIndexer: mockGitIndexer,
67+
gitExtractor: mockGitExtractor,
68+
defaultLimit: 10,
69+
defaultTokenBudget: 2000,
70+
});
71+
72+
mockContext = {
73+
logger: {
74+
debug: vi.fn(),
75+
info: vi.fn(),
76+
warn: vi.fn(),
77+
error: vi.fn(),
78+
},
79+
requestId: 'test-request',
80+
} as unknown as ToolExecutionContext;
81+
});
82+
83+
describe('getToolDefinition', () => {
84+
it('should return correct tool definition', () => {
85+
const definition = adapter.getToolDefinition();
86+
87+
expect(definition.name).toBe('dev_history');
88+
expect(definition.description).toContain('git commit history');
89+
expect(definition.inputSchema.properties).toHaveProperty('query');
90+
expect(definition.inputSchema.properties).toHaveProperty('file');
91+
expect(definition.inputSchema.properties).toHaveProperty('limit');
92+
expect(definition.inputSchema.properties).toHaveProperty('since');
93+
expect(definition.inputSchema.properties).toHaveProperty('author');
94+
expect(definition.inputSchema.properties).toHaveProperty('tokenBudget');
95+
});
96+
97+
it('should require either query or file', () => {
98+
const definition = adapter.getToolDefinition();
99+
100+
expect(definition.inputSchema.anyOf).toEqual([
101+
{ required: ['query'] },
102+
{ required: ['file'] },
103+
]);
104+
});
105+
});
106+
107+
describe('execute', () => {
108+
describe('semantic search (query)', () => {
109+
it('should search commits by semantic query', async () => {
110+
const result = await adapter.execute({ query: 'authentication token' }, mockContext);
111+
112+
expect(result.success).toBe(true);
113+
expect(mockGitIndexer.search).toHaveBeenCalledWith('authentication token', { limit: 10 });
114+
expect(result.data).toMatchObject({
115+
searchType: 'semantic',
116+
query: 'authentication token',
117+
});
118+
});
119+
120+
it('should respect limit option', async () => {
121+
await adapter.execute({ query: 'test', limit: 5 }, mockContext);
122+
123+
expect(mockGitIndexer.search).toHaveBeenCalledWith('test', { limit: 5 });
124+
});
125+
126+
it('should include commit summaries in data', async () => {
127+
const result = await adapter.execute({ query: 'test' }, mockContext);
128+
129+
expect(result.data?.commits).toEqual(
130+
expect.arrayContaining([
131+
expect.objectContaining({
132+
hash: 'abc123d',
133+
subject: 'feat: add authentication token handling',
134+
author: 'Test User',
135+
}),
136+
])
137+
);
138+
});
139+
});
140+
141+
describe('file history', () => {
142+
it('should get history for a specific file', async () => {
143+
const result = await adapter.execute({ file: 'src/auth/token.ts' }, mockContext);
144+
145+
expect(result.success).toBe(true);
146+
expect(mockGitExtractor.getCommits).toHaveBeenCalledWith({
147+
path: 'src/auth/token.ts',
148+
limit: 10,
149+
since: undefined,
150+
author: undefined,
151+
follow: true,
152+
noMerges: true,
153+
});
154+
expect(result.data).toMatchObject({
155+
searchType: 'file',
156+
file: 'src/auth/token.ts',
157+
});
158+
});
159+
160+
it('should pass since and author filters', async () => {
161+
await adapter.execute(
162+
{
163+
file: 'src/file.ts',
164+
since: '2025-01-01',
165+
author: '[email protected]',
166+
},
167+
mockContext
168+
);
169+
170+
expect(mockGitExtractor.getCommits).toHaveBeenCalledWith(
171+
expect.objectContaining({
172+
since: '2025-01-01',
173+
author: '[email protected]',
174+
})
175+
);
176+
});
177+
});
178+
179+
describe('validation', () => {
180+
it('should require query or file', async () => {
181+
const result = await adapter.execute({}, mockContext);
182+
183+
expect(result.success).toBe(false);
184+
expect(result.error?.code).toBe('MISSING_INPUT');
185+
});
186+
187+
it('should validate limit range', async () => {
188+
const result = await adapter.execute({ query: 'test', limit: 100 }, mockContext);
189+
190+
expect(result.success).toBe(false);
191+
expect(result.error?.code).toBe('INVALID_LIMIT');
192+
});
193+
});
194+
195+
describe('output formatting', () => {
196+
it('should include formatted content', async () => {
197+
const result = await adapter.execute({ query: 'test' }, mockContext);
198+
199+
expect(result.data?.content).toContain('# Git History');
200+
expect(result.data?.content).toContain('abc123d');
201+
expect(result.data?.content).toContain('feat: add authentication token handling');
202+
});
203+
204+
it('should include file changes in output', async () => {
205+
const result = await adapter.execute({ query: 'test' }, mockContext);
206+
207+
expect(result.data?.content).toContain('src/auth/token.ts');
208+
});
209+
210+
it('should include issue/PR refs in output', async () => {
211+
const result = await adapter.execute({ query: 'test' }, mockContext);
212+
213+
expect(result.data?.content).toContain('#123');
214+
expect(result.data?.content).toContain('#456');
215+
});
216+
});
217+
218+
describe('token budgeting', () => {
219+
it('should respect token budget', async () => {
220+
// Create many commits
221+
const manyCommits = Array.from({ length: 20 }, (_, i) =>
222+
createMockCommit({
223+
hash: `hash${i.toString().padStart(38, '0')}`,
224+
shortHash: `h${i.toString().padStart(6, '0')}`,
225+
subject: `Commit ${i}: ${Array(100).fill('word').join(' ')}`,
226+
})
227+
);
228+
vi.mocked(mockGitIndexer.search).mockResolvedValue(manyCommits);
229+
230+
const result = await adapter.execute({ query: 'test', tokenBudget: 500 }, mockContext);
231+
232+
expect(result.success).toBe(true);
233+
// Should truncate due to token budget
234+
expect(result.data?.content).toContain('token budget reached');
235+
});
236+
});
237+
238+
describe('metadata', () => {
239+
it('should include metadata in result', async () => {
240+
const result = await adapter.execute({ query: 'test' }, mockContext);
241+
242+
expect(result.metadata).toMatchObject({
243+
tokens: expect.any(Number),
244+
duration_ms: expect.any(Number),
245+
timestamp: expect.any(String),
246+
cached: false,
247+
});
248+
});
249+
});
250+
251+
describe('error handling', () => {
252+
it('should handle search errors', async () => {
253+
vi.mocked(mockGitIndexer.search).mockRejectedValue(new Error('Search failed'));
254+
255+
const result = await adapter.execute({ query: 'test' }, mockContext);
256+
257+
expect(result.success).toBe(false);
258+
expect(result.error?.code).toBe('HISTORY_FAILED');
259+
expect(result.error?.message).toContain('Search failed');
260+
});
261+
262+
it('should handle extractor errors', async () => {
263+
vi.mocked(mockGitExtractor.getCommits).mockRejectedValue(new Error('Git error'));
264+
265+
const result = await adapter.execute({ file: 'src/file.ts' }, mockContext);
266+
267+
expect(result.success).toBe(false);
268+
expect(result.error?.code).toBe('HISTORY_FAILED');
269+
});
270+
});
271+
});
272+
273+
describe('estimateTokens', () => {
274+
it('should estimate tokens based on limit and budget', () => {
275+
const estimate = adapter.estimateTokens({ limit: 10, tokenBudget: 2000 });
276+
277+
expect(estimate).toBeLessThanOrEqual(2000);
278+
expect(estimate).toBeGreaterThan(0);
279+
});
280+
});
281+
});

0 commit comments

Comments
 (0)