Git File Forensics MCP

  • src
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import { execSync } from 'child_process'; import { writeFileSync } from 'fs'; import { join } from 'path'; interface FileVersionArgs { repoPath: string; file: string; outputPath: string; } interface FileDiffArgs { repoPath: string; file: string; versions: { from: string; to: string; }; outputPath: string; } interface FileContextArgs { repoPath: string; file: string; commit: string; outputPath: string; } interface FileSemanticArgs { repoPath: string; file: string; outputPath: string; } interface SemanticChange { type: 'addition' | 'deletion' | 'modification'; content: string; context?: string; impact: 'high' | 'medium' | 'low'; } interface ChangePattern { pattern: string; frequency: number; significance: 'high' | 'medium' | 'low'; context: string; } class GitFileForensicsServer { private server: Server; private isFileVersionArgs(args: unknown): args is FileVersionArgs { return ( typeof args === 'object' && args !== null && 'repoPath' in args && 'file' in args && 'outputPath' in args && typeof (args as FileVersionArgs).repoPath === 'string' && typeof (args as FileVersionArgs).file === 'string' && typeof (args as FileVersionArgs).outputPath === 'string' ); } private isFileDiffArgs(args: unknown): args is FileDiffArgs { return ( typeof args === 'object' && args !== null && 'repoPath' in args && 'file' in args && 'versions' in args && 'outputPath' in args && typeof (args as FileDiffArgs).repoPath === 'string' && typeof (args as FileDiffArgs).file === 'string' && typeof (args as FileDiffArgs).outputPath === 'string' && typeof (args as FileDiffArgs).versions === 'object' && (args as FileDiffArgs).versions !== null && 'from' in (args as FileDiffArgs).versions && 'to' in (args as FileDiffArgs).versions && typeof (args as FileDiffArgs).versions.from === 'string' && typeof (args as FileDiffArgs).versions.to === 'string' ); } private isFileContextArgs(args: unknown): args is FileContextArgs { return ( typeof args === 'object' && args !== null && 'repoPath' in args && 'file' in args && 'commit' in args && 'outputPath' in args && typeof (args as FileContextArgs).repoPath === 'string' && typeof (args as FileContextArgs).file === 'string' && typeof (args as FileContextArgs).commit === 'string' && typeof (args as FileContextArgs).outputPath === 'string' ); } private isFileSemanticArgs(args: unknown): args is FileSemanticArgs { return ( typeof args === 'object' && args !== null && 'repoPath' in args && 'file' in args && 'outputPath' in args && typeof (args as FileSemanticArgs).repoPath === 'string' && typeof (args as FileSemanticArgs).file === 'string' && typeof (args as FileSemanticArgs).outputPath === 'string' ); } constructor() { this.server = new Server( { name: 'git-file-forensics-mcp', version: '0.1.0', }, { capabilities: { tools: {}, }, } ); this.setupToolHandlers(); this.server.onerror = (error: Error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } private setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'track_file_versions', description: 'Track complete version history of a specific file, including renames and moves', inputSchema: { type: 'object', properties: { repoPath: { type: 'string', description: 'Path to git repository', }, file: { type: 'string', description: 'File to analyze', }, outputPath: { type: 'string', description: 'Path to write analysis output', }, }, required: ['repoPath', 'file', 'outputPath'], }, }, { name: 'analyze_file_diff', description: 'Analyze specific changes between any two versions of a file', inputSchema: { type: 'object', properties: { repoPath: { type: 'string', description: 'Path to git repository', }, file: { type: 'string', description: 'File to analyze', }, versions: { type: 'object', properties: { from: { type: 'string' }, to: { type: 'string' }, }, required: ['from', 'to'], }, outputPath: { type: 'string', description: 'Path to write analysis output', }, }, required: ['repoPath', 'file', 'versions', 'outputPath'], }, }, { name: 'analyze_file_context', description: 'Analyze broader context of file changes in a specific commit', inputSchema: { type: 'object', properties: { repoPath: { type: 'string', description: 'Path to git repository', }, file: { type: 'string', description: 'File to analyze', }, commit: { type: 'string', description: 'Commit hash to analyze', }, outputPath: { type: 'string', description: 'Path to write analysis output', }, }, required: ['repoPath', 'file', 'commit', 'outputPath'], }, }, { name: 'analyze_file_semantics', description: 'Analyze semantic changes and patterns in file history', inputSchema: { type: 'object', properties: { repoPath: { type: 'string', description: 'Path to git repository', }, file: { type: 'string', description: 'File to analyze', }, outputPath: { type: 'string', description: 'Path to write analysis output', }, }, required: ['repoPath', 'file', 'outputPath'], }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const args = request.params.arguments; try { switch (request.params.name) { case 'track_file_versions': { const args = request.params.arguments as unknown; if (!this.isFileVersionArgs(args)) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters'); } return await this.handleFileVersions(args); } case 'analyze_file_diff': { const args = request.params.arguments as unknown; if (!this.isFileDiffArgs(args)) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters'); } return await this.handleFileDiff(args); } case 'analyze_file_context': { const args = request.params.arguments as unknown; if (!this.isFileContextArgs(args)) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters'); } return await this.handleFileContext(args); } case 'analyze_file_semantics': { const args = request.params.arguments as unknown; if (!this.isFileSemanticArgs(args)) { throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters'); } return await this.handleFileSemantics(args); } default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } } catch (error) { return { content: [ { type: 'text', text: `Git file forensics error: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); } private async handleFileVersions(args: FileVersionArgs) { const history = this.getCompleteFileHistory(args.repoPath, args.file); const renames = this.getFileRenames(args.repoPath, args.file); const analysis = { history, renames, summary: this.generateVersionSummary(history), }; writeFileSync(args.outputPath, JSON.stringify(analysis, null, 2)); return { content: [ { type: 'text', text: `File version analysis written to ${args.outputPath}`, }, ], }; } private async handleFileDiff(args: FileDiffArgs) { const diff = this.getFileDiff(args.repoPath, args.file, args.versions); const movedBlocks = this.findMovedBlocks(diff); const analysis = { diff, movedBlocks, summary: this.generateDiffSummary(diff), }; writeFileSync(args.outputPath, JSON.stringify(analysis, null, 2)); return { content: [ { type: 'text', text: `File diff analysis written to ${args.outputPath}`, }, ], }; } private async handleFileContext(args: FileContextArgs) { const relatedFiles = this.getRelatedFiles(args.repoPath, args.file, args.commit); const commitInfo = this.getCommitContext(args.repoPath, args.commit); const analysis = { relatedFiles, commitInfo, summary: this.generateContextSummary(relatedFiles, commitInfo), }; writeFileSync(args.outputPath, JSON.stringify(analysis, null, 2)); return { content: [ { type: 'text', text: `File context analysis written to ${args.outputPath}`, }, ], }; } private async handleFileSemantics(args: FileSemanticArgs) { const changes = this.getSemanticChanges(args.repoPath, args.file); const patterns = this.analyzeChangePatterns(changes); const analysis = { changes, patterns, summary: this.generateSemanticSummary(changes, patterns), }; writeFileSync(args.outputPath, JSON.stringify(analysis, null, 2)); return { content: [ { type: 'text', text: `File semantic analysis written to ${args.outputPath}`, }, ], }; } private getCompleteFileHistory(repoPath: string, file: string) { const output = execSync( `cd "${repoPath}" && git log --follow --format="%H|%aI|%an|%s" -- "${file}"`, { encoding: 'utf8' } ); return output.trim().split('\n').filter(Boolean).map(line => { const [hash, date, author, message] = line.split('|'); return { hash, date, author, message }; }); } private getFileRenames(repoPath: string, file: string) { const output = execSync( `cd "${repoPath}" && git log --follow --name-status --format="%H" -- "${file}"`, { encoding: 'utf8' } ); const renames: Array<{ commit: string; oldPath: string; newPath: string }> = []; let currentCommit = ''; output.trim().split('\n').forEach(line => { if (line.match(/^[0-9a-f]{40}$/)) { currentCommit = line; } else if (line.startsWith('R')) { const [_, oldPath, newPath] = line.split(/\t/); renames.push({ commit: currentCommit, oldPath, newPath }); } }); return renames; } private getFileDiff( repoPath: string, file: string, versions: { from: string; to: string } ) { return execSync( `cd "${repoPath}" && git diff ${versions.from} ${versions.to} -- "${file}"`, { encoding: 'utf8' } ); } private findMovedBlocks(diff: string) { // Implement sophisticated code block movement detection return []; } private getRelatedFiles(repoPath: string, file: string, commit: string) { const output = execSync( `cd "${repoPath}" && git show --name-only --format="" ${commit}`, { encoding: 'utf8' } ); return output.trim().split('\n').filter(f => f !== file); } private getCommitContext(repoPath: string, commit: string) { const output = execSync( `cd "${repoPath}" && git show --format="%H|%aI|%an|%s|%b" ${commit}`, { encoding: 'utf8' } ); const [hash, date, author, subject, body] = output.split('|'); return { hash, date, author, subject, body }; } private getSemanticChanges(repoPath: string, file: string) { const output = execSync( `cd "${repoPath}" && git log --patch --format="%H|%aI|%s" -- "${file}"`, { encoding: 'utf8' } ); // Implement semantic change analysis return []; } private analyzeChangePatterns(changes: SemanticChange[]): ChangePattern[] { // Implement pattern analysis return []; } private generateVersionSummary(history: Array<{ date: string; message: string }>) { return { totalVersions: history.length, firstChange: history[history.length - 1], lastChange: history[0], changeFrequency: this.calculateChangeFrequency(history), }; } private generateDiffSummary(diff: string) { const lines = diff.split('\n'); const additions = lines.filter(l => l.startsWith('+')).length; const deletions = lines.filter(l => l.startsWith('-')).length; return { additions, deletions, changeSize: additions + deletions, impactLevel: this.assessImpactLevel(additions + deletions), }; } private generateContextSummary( relatedFiles: string[], commitInfo: { subject: string } ) { return { relatedFileCount: relatedFiles.length, changeType: this.categorizeChange(commitInfo.subject), impactScope: this.assessImpactScope(relatedFiles), }; } private generateSemanticSummary(changes: SemanticChange[], patterns: ChangePattern[]) { return { dominantPatterns: this.identifyDominantPatterns(patterns), changeTypes: this.categorizeChanges(changes), stability: this.assessFileStability(changes), }; } private calculateChangeFrequency( history: Array<{ date: string }> ): 'high' | 'medium' | 'low' { if (history.length < 2) return 'low'; const dates = history.map(h => new Date(h.date)); const totalDays = (dates[0].getTime() - dates[dates.length - 1].getTime()) / (1000 * 60 * 60 * 24); const changesPerDay = history.length / totalDays; if (changesPerDay > 0.5) return 'high'; if (changesPerDay > 0.1) return 'medium'; return 'low'; } private assessImpactLevel(changeSize: number): 'high' | 'medium' | 'low' { if (changeSize > 100) return 'high'; if (changeSize > 30) return 'medium'; return 'low'; } private categorizeChange(message: string): string { if (message.match(/^feat|^add/i)) return 'feature'; if (message.match(/^fix|^bug/i)) return 'bugfix'; if (message.match(/^refactor/i)) return 'refactor'; if (message.match(/^docs/i)) return 'documentation'; return 'other'; } private assessImpactScope(relatedFiles: string[]): 'high' | 'medium' | 'low' { if (relatedFiles.length > 5) return 'high'; if (relatedFiles.length > 2) return 'medium'; return 'low'; } private identifyDominantPatterns(patterns: ChangePattern[]): Array<{ pattern: string; significance: string }> { // Implement pattern identification return []; } private categorizeChanges(changes: SemanticChange[]): Record<string, number> { // Implement change categorization return {}; } private assessFileStability(changes: SemanticChange[]): 'stable' | 'evolving' | 'volatile' { // Implement stability assessment return 'stable'; } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Git File Forensics MCP server running on stdio'); } } const server = new GitFileForensicsServer(); server.run().catch(console.error);