Git Forensics MCP
by davidorex
#!/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 BranchOverviewArgs {
repoPath: string;
branches: string[];
outputPath: string;
}
interface TimePeriodArgs {
repoPath: string;
branches: string[];
timeRange: {
start: string;
end: string;
};
outputPath: string;
}
interface FileChangesArgs {
repoPath: string;
branches: string[];
files: string[];
outputPath: string;
}
interface MergeRecommendationsArgs {
repoPath: string;
branches: string[];
outputPath: string;
}
class GitAnalysisServer {
private server: Server;
constructor() {
this.server = new Server(
{
name: 'git-analysis-server',
version: '0.1.0',
},
{
capabilities: {
tools: {},
},
}
);
this.setupToolHandlers();
this.server.onerror = (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: 'get_branch_overview',
description: 'Get high-level overview of branch states and relationships',
inputSchema: {
type: 'object',
properties: {
repoPath: {
type: 'string',
description: 'Path to git repository',
},
branches: {
type: 'array',
items: { type: 'string' },
description: 'Branches to analyze',
},
outputPath: {
type: 'string',
description: 'Path to write analysis output',
},
},
required: ['repoPath', 'branches', 'outputPath'],
},
},
{
name: 'analyze_time_period',
description: 'Analyze detailed development activity in a specific time period',
inputSchema: {
type: 'object',
properties: {
repoPath: {
type: 'string',
description: 'Path to git repository',
},
branches: {
type: 'array',
items: { type: 'string' },
description: 'Branches to analyze',
},
timeRange: {
type: 'object',
properties: {
start: { type: 'string' },
end: { type: 'string' },
},
required: ['start', 'end'],
},
outputPath: {
type: 'string',
description: 'Path to write analysis output',
},
},
required: ['repoPath', 'branches', 'timeRange', 'outputPath'],
},
},
{
name: 'analyze_file_changes',
description: 'Analyze changes to specific files across branches',
inputSchema: {
type: 'object',
properties: {
repoPath: {
type: 'string',
description: 'Path to git repository',
},
branches: {
type: 'array',
items: { type: 'string' },
description: 'Branches to analyze',
},
files: {
type: 'array',
items: { type: 'string' },
description: 'Files to analyze',
},
outputPath: {
type: 'string',
description: 'Path to write analysis output',
},
},
required: ['repoPath', 'branches', 'files', 'outputPath'],
},
},
{
name: 'get_merge_recommendations',
description: 'Get detailed merge strategy recommendations',
inputSchema: {
type: 'object',
properties: {
repoPath: {
type: 'string',
description: 'Path to git repository',
},
branches: {
type: 'array',
items: { type: 'string' },
description: 'Branches to analyze',
},
outputPath: {
type: 'string',
description: 'Path to write analysis output',
},
},
required: ['repoPath', 'branches', 'outputPath'],
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
switch (request.params.name) {
case 'get_branch_overview': {
const args = request.params.arguments as BranchOverviewArgs;
if (!args?.repoPath || !args?.branches || !args?.outputPath) {
throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters');
}
return await this.handleBranchOverview(args);
}
case 'analyze_time_period': {
const args = request.params.arguments as TimePeriodArgs;
if (!args?.repoPath || !args?.branches || !args?.timeRange || !args?.outputPath) {
throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters');
}
return await this.handleTimePeriodAnalysis(args);
}
case 'analyze_file_changes': {
const args = request.params.arguments as FileChangesArgs;
if (!args?.repoPath || !args?.branches || !args?.files || !args?.outputPath) {
throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters');
}
return await this.handleFileChangesAnalysis(args);
}
case 'get_merge_recommendations': {
const args = request.params.arguments as MergeRecommendationsArgs;
if (!args?.repoPath || !args?.branches || !args?.outputPath) {
throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters');
}
return await this.handleMergeRecommendations(args);
}
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
} catch (error) {
return {
content: [
{
type: 'text',
text: `Git analysis error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
}
private async handleBranchOverview(args: BranchOverviewArgs) {
const overview = args.branches.map(branch => {
const lastCommit = this.getLastCommit(args.repoPath, branch);
const commitCount = this.getCommitCount(args.repoPath, branch);
const mergeBase = args.branches.map(otherBranch => {
if (otherBranch === branch) return null;
return {
branch: otherBranch,
base: this.getMergeBase(args.repoPath, branch, otherBranch),
};
}).filter((base): base is NonNullable<typeof base> => base !== null);
return {
branch,
lastCommit,
commitCount,
mergeBase,
};
});
const analysis = {
overview,
summary: this.generateOverviewSummary(overview),
};
writeFileSync(args.outputPath, JSON.stringify(analysis, null, 2));
return {
content: [
{
type: 'text',
text: `Branch analysis written to ${args.outputPath}`,
},
],
};
}
private async handleTimePeriodAnalysis(args: TimePeriodArgs) {
const analysis = args.branches.map(branch => {
const commits = this.getCommitsInRange(args.repoPath, branch, args.timeRange);
return {
branch,
commits,
activitySummary: this.summarizeActivity(commits),
};
});
const result = {
analysis,
summary: this.generateTimePeriodSummary(analysis),
};
writeFileSync(args.outputPath, JSON.stringify(result, null, 2));
return {
content: [
{
type: 'text',
text: `Time period analysis written to ${args.outputPath}`,
},
],
};
}
private async handleFileChangesAnalysis(args: FileChangesArgs) {
const analysis = args.files.map(file => {
const changes = args.branches.map(branch => ({
branch,
history: this.getFileHistory(args.repoPath, branch, file),
}));
return {
file,
changes,
conflicts: this.analyzeConflicts(changes),
};
});
const result = {
analysis,
summary: this.generateFileChangesSummary(analysis),
};
writeFileSync(args.outputPath, JSON.stringify(result, null, 2));
return {
content: [
{
type: 'text',
text: `File changes analysis written to ${args.outputPath}`,
},
],
};
}
private async handleMergeRecommendations(args: MergeRecommendationsArgs) {
const recommendations = {
strategy: this.determineMergeStrategy(args.repoPath, args.branches),
conflictRisks: this.assessConflictRisks(args.repoPath, args.branches),
steps: this.generateMergeSteps(args.repoPath, args.branches),
};
writeFileSync(args.outputPath, JSON.stringify(recommendations, null, 2));
return {
content: [
{
type: 'text',
text: `Merge recommendations written to ${args.outputPath}`,
},
],
};
}
private getLastCommit(repoPath: string, branch: string) {
const output = execSync(
`cd "${repoPath}" && git log -1 --format="%H|%aI|%s" ${branch}`,
{ encoding: 'utf8' }
).trim();
const [hash, date, message] = output.split('|');
return { hash, date, message, branch };
}
private getCommitCount(repoPath: string, branch: string): number {
return parseInt(
execSync(
`cd "${repoPath}" && git rev-list --count ${branch}`,
{ encoding: 'utf8' }
).trim(),
10
);
}
private getMergeBase(repoPath: string, branch1: string, branch2: string): string {
return execSync(
`cd "${repoPath}" && git merge-base ${branch1} ${branch2}`,
{ encoding: 'utf8' }
).trim();
}
private getCommitsInRange(
repoPath: string,
branch: string,
timeRange: { start: string; end: string }
) {
const output = execSync(
`cd "${repoPath}" && git log --format="%H|%aI|%s" ` +
`--after="${timeRange.start}" --before="${timeRange.end}" ${branch}`,
{ encoding: 'utf8' }
);
return output.trim().split('\n').filter(Boolean).map(line => {
const [hash, date, message] = line.split('|');
return { hash, date, message, branch };
});
}
private getFileHistory(repoPath: string, branch: string, file: string) {
const output = execSync(
`cd "${repoPath}" && git log --format="%H|%aI|%s" ${branch} -- ${file}`,
{ encoding: 'utf8' }
);
return output.trim().split('\n').filter(Boolean).map(line => {
const [hash, date, message] = line.split('|');
return { hash, date, message, branch };
});
}
private summarizeActivity(commits: Array<{ hash: string; date: string; message: string; branch: string }>) {
return {
totalCommits: commits.length,
firstCommit: commits[commits.length - 1],
lastCommit: commits[0],
commitTypes: this.categorizeCommits(commits),
};
}
private categorizeCommits(commits: Array<{ message: string }>) {
const categories = {
feature: 0,
fix: 0,
refactor: 0,
docs: 0,
other: 0,
};
commits.forEach(({ message }) => {
if (message.match(/^feat|^add/i)) categories.feature++;
else if (message.match(/^fix|^bug/i)) categories.fix++;
else if (message.match(/^refactor|^style|^chore/i)) categories.refactor++;
else if (message.match(/^docs/i)) categories.docs++;
else categories.other++;
});
return categories;
}
private analyzeConflicts(branchChanges: Array<{ branch: string; history: Array<{ hash: string; date: string; message: string }> }>) {
const overlaps = this.findOverlappingChanges(branchChanges);
return {
riskLevel: this.assessRiskLevel(overlaps),
reasons: this.generateConflictReasons(overlaps),
};
}
private findOverlappingChanges(branchChanges: Array<{ branch: string; history: Array<{ date: string }> }>) {
const timeRanges = branchChanges.map(({ branch, history }) => ({
branch,
start: history[history.length - 1]?.date,
end: history[0]?.date,
}));
return timeRanges.flatMap((range1, i) =>
timeRanges.slice(i + 1).map(range2 => ({
branches: [range1.branch, range2.branch],
overlaps: this.datesOverlap(
new Date(range1.start),
new Date(range1.end),
new Date(range2.start),
new Date(range2.end)
),
}))
).filter(({ overlaps }) => overlaps);
}
private datesOverlap(start1: Date, end1: Date, start2: Date, end2: Date): boolean {
return start1 <= end2 && start2 <= end1;
}
private assessRiskLevel(overlaps: Array<{ branches: string[] }>) {
if (overlaps.length === 0) return 'low';
if (overlaps.length <= 2) return 'medium';
return 'high';
}
private generateConflictReasons(overlaps: Array<{ branches: string[] }>) {
return overlaps.map(({ branches }) =>
`Parallel development detected between ${branches.join(' and ')}`
);
}
private determineMergeStrategy(repoPath: string, branches: string[]) {
const commitCounts = branches.map(branch => ({
branch,
count: this.getCommitCount(repoPath, branch),
}));
const baseChoice = commitCounts.reduce((a, b) =>
a.count > b.count ? a : b
);
return {
recommendedBase: baseChoice.branch,
approach: 'cherry-pick',
reasoning: [
`${baseChoice.branch} has the most changes (${baseChoice.count} commits)`,
'Cherry-pick approach allows for selective integration',
],
};
}
private assessConflictRisks(repoPath: string, branches: string[]) {
const changedFiles = new Map<string, string[]>();
branches.forEach(branch => {
const files = execSync(
`cd "${repoPath}" && git diff --name-only $(git merge-base ${branches[0]} ${branch})..${branch}`,
{ encoding: 'utf8' }
)
.trim()
.split('\n')
.filter(Boolean);
files.forEach(file => {
const current = changedFiles.get(file) || [];
changedFiles.set(file, [...current, branch]);
});
});
const hotspots = Array.from(changedFiles.entries())
.filter(([_, branches]) => branches.length > 1)
.map(([file]) => file);
return {
overallRisk: hotspots.length > 5 ? 'high' : hotspots.length > 0 ? 'medium' : 'low',
hotspots,
recommendations: [
'Review changes in hotspots first',
'Consider creating integration tests for modified components',
],
};
}
private generateMergeSteps(repoPath: string, branches: string[]) {
return [
'Create backup branches',
'Create integration branch from recommended base',
'Cherry-pick non-conflicting changes',
'Resolve conflicts in hotspots',
'Run test suite after each significant change',
'Verify functionality of modified components',
'Update documentation to reflect changes',
];
}
private generateOverviewSummary(overview: Array<{ branch: string; commitCount: number }>) {
const totalCommits = overview.reduce((sum, { commitCount }) => sum + commitCount, 0);
const avgCommits = totalCommits / overview.length;
return {
totalBranches: overview.length,
totalCommits,
averageCommitsPerBranch: Math.round(avgCommits),
mostActiveBranch: overview.reduce((a, b) =>
a.commitCount > b.commitCount ? a : b
).branch,
};
}
private generateTimePeriodSummary(
analysis: Array<{
branch: string;
commits: Array<{ message: string }>;
activitySummary: { totalCommits: number };
}>
) {
const totalCommits = analysis.reduce(
(sum, { activitySummary }) => sum + activitySummary.totalCommits,
0
);
return {
totalCommits,
branchesWithActivity: analysis.filter(
({ activitySummary }) => activitySummary.totalCommits > 0
).length,
mostActiveBy: {
commits: analysis.reduce((a, b) =>
a.activitySummary.totalCommits > b.activitySummary.totalCommits ? a : b
).branch,
},
};
}
private generateFileChangesSummary(
analysis: Array<{
file: string;
changes: Array<{ branch: string; history: Array<unknown> }>;
conflicts: { riskLevel: string };
}>
) {
const riskLevels = analysis.map(({ conflicts }) => conflicts.riskLevel);
return {
totalFiles: analysis.length,
filesWithConflicts: riskLevels.filter(level => level !== 'low').length,
highRiskFiles: riskLevels.filter(level => level === 'high').length,
recommendedReviewOrder: analysis
.sort((a, b) => this.riskToNumber(b.conflicts.riskLevel) - this.riskToNumber(a.conflicts.riskLevel))
.map(({ file }) => file),
};
}
private riskToNumber(risk: string): number {
switch (risk) {
case 'high': return 3;
case 'medium': return 2;
case 'low': return 1;
default: return 0;
}
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Git Analysis MCP server running on stdio');
}
}
const server = new GitAnalysisServer();
server.run().catch(console.error);