MCP (Model Context Protocol) определяет, как AI agent взаимодействует с external tools. Это простой способ расширить Copilot своими workflows.
Для этого поста я написал MCP server, который запускается локально. Пример на TypeScript, поддержка .NET уже находится в preview, и следующие samples я планирую писать на C#. Server сравнивает две Git branches и помогает запускать early code review прямо внутри GitHub Copilot.
Почему это важно
- Добавляйте собственные commands, rules и data sources.
- Создавайте AI-ready skills, которые работодатели уже ожидают.
- Работайте из привычного Copilot interface.
Clone the repo
GitHub - sigmade/Git-MCP-Server
Core Git operations нашего MCP server обрабатываются в файле gitUtils.ts.
import { execSync } from 'child_process';
export interface MergeInfo {
sourceBranch: string;
targetBranch: string;
filesChanged: string[];
insertions: number;
deletions: number;
commits: number;
summary: string;
}
export class GitUtils {
static executeGit(command: string, repoPath: string): string {
try {
return execSync(command, { cwd: repoPath, encoding: 'utf8' }).toString().trim();
} catch (error) {
throw new Error(`Git error: ${(error as Error).message}`);
}
}
static getCurrentBranch(repoPath: string): string {
return this.executeGit('git branch --show-current', repoPath);
}
static getMainBranch(repoPath: string): string {
try {
this.executeGit('git show-ref --verify refs/heads/master', repoPath);
return 'master';
} catch {
return 'main';
}
}
static getMergeInfo(repoPath: string, fromBranch: string, toBranch: string): MergeInfo {
const filesOutput = this.executeGit(
`git diff --name-only ${fromBranch}..${toBranch}`,
repoPath
);
const filesChanged = filesOutput ? filesOutput.split('\n').filter(f => f.trim()) : [];
const statsOutput = this.executeGit(
`git diff --numstat ${fromBranch}..${toBranch}`,
repoPath
);
let insertions = 0;
let deletions = 0;
if (statsOutput) {
statsOutput.split('\n').forEach(line => {
const parts = line.split('\t');
if (parts.length >= 2) {
insertions += parseInt(parts[0]) || 0;
deletions += parseInt(parts[1]) || 0;
}
});
}
const commitsOutput = this.executeGit(
`git rev-list --count ${fromBranch}..${toBranch}`,
repoPath
);
const commits = parseInt(commitsOutput) || 0;
const summary = commits === 0
? 'No new commits for merge'
: `${commits} commits, ${filesChanged.length} files, +${insertions}/-${deletions} lines`;
return {
sourceBranch: fromBranch,
targetBranch: toBranch,
filesChanged,
insertions,
deletions,
commits,
summary,
};
}
static getQuickStats(repoPath: string, baseBranch: string, currentBranch: string) {
if (baseBranch === currentBranch) {
return {
message: 'Already on the base branch',
needsMerge: false,
};
}
try {
const ahead = this.executeGit(
`git rev-list --count ${baseBranch}..${currentBranch}`,
repoPath
);
const behind = this.executeGit(
`git rev-list --count ${currentBranch}..${baseBranch}`,
repoPath
);
const aheadCount = parseInt(ahead) || 0;
const behindCount = parseInt(behind) || 0;
let message = '';
if (aheadCount === 0 && behindCount === 0) {
message = 'Branches are synchronized';
} else if (aheadCount > 0 && behindCount === 0) {
message = `Ahead by ${aheadCount} commits`;
} else if (aheadCount === 0 && behindCount > 0) {
message = `Behind by ${behindCount} commits`;
} else {
message = `Ahead by ${aheadCount}, behind by ${behindCount} commits`;
}
return {
message,
aheadBy: aheadCount,
behindBy: behindCount,
needsMerge: aheadCount > 0 || behindCount > 0,
};
} catch (error) {
return {
message: 'Failed to determine status',
error: (error as Error).message,
};
}
}
static getFileDiff(repoPath: string, filename: string, fromBranch: string, toBranch: string): string {
try {
return this.executeGit(
`git diff ${fromBranch}..${toBranch} -- "${filename}"`,
repoPath
);
} catch (error) {
throw new Error(`Failed to get diff for file ${filename}: ${(error as Error).message}`);
}
}
}
Entry point MCP server и tool handlers находятся в index.ts.
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 * as fs from 'fs';
import { GitUtils } from './gitUtils.js';
class SimpleMergeReviewMCP {
private server: Server;
constructor() {
this.server = new Server({
name: 'simple-merge-review',
version: '1.0.0',
});
this.setupToolHandlers();
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'show_merge_diff',
description: 'Show changes between branches before merge',
inputSchema: {
type: 'object',
properties: {
repoPath: {
type: 'string',
description: 'Path to the git repository',
},
fromBranch: {
type: 'string',
description: 'Branch to merge from (default: main)',
default: 'main',
},
toBranch: {
type: 'string',
description: 'Branch to merge into (default: current)',
},
},
required: ['repoPath'],
},
},
{
name: 'quick_merge_summary',
description: 'Quick summary of changes for merge',
inputSchema: {
type: 'object',
properties: {
repoPath: {
type: 'string',
description: 'Path to the git repository',
},
branch: {
type: 'string',
description: 'Branch to analyze (default: current)',
},
},
required: ['repoPath'],
},
},
{
name: 'show_file_diff',
description: 'Show specific changes in a file between branches',
inputSchema: {
type: 'object',
properties: {
repoPath: {
type: 'string',
description: 'Path to the git repository',
},
filename: {
type: 'string',
description: 'Path to the file relative to the repository root',
},
fromBranch: {
type: 'string',
description: 'Branch to compare from (default: main/master)',
},
toBranch: {
type: 'string',
description: 'Branch to compare to (default: current)',
},
},
required: ['repoPath', 'filename'],
},
},
],
};
});
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'show_merge_diff':
return await this.showMergeDiff(args);
case 'quick_merge_summary':
return await this.quickMergeSummary(args);
case 'show_file_diff':
return await this.showFileDiff(args);
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
} catch (error) {
throw new McpError(ErrorCode.InternalError, `Error: ${(error as Error).message}`);
}
});
}
private async showMergeDiff(args: any) {
const { repoPath, fromBranch = 'main', toBranch } = args;
if (!fs.existsSync(repoPath)) {
throw new Error(`Repository not found: ${repoPath}`);
}
const currentBranch = toBranch || GitUtils.getCurrentBranch(repoPath);
const mergeInfo = GitUtils.getMergeInfo(repoPath, fromBranch, currentBranch);
return {
content: [
{
type: 'text',
text: JSON.stringify(mergeInfo, null, 2),
},
],
};
}
private async quickMergeSummary(args: any) {
const { repoPath, branch } = args;
if (!fs.existsSync(repoPath)) {
throw new Error(`Repository not found: ${repoPath}`);
}
const currentBranch = branch || GitUtils.getCurrentBranch(repoPath);
const mainBranch = GitUtils.getMainBranch(repoPath);
// Simple summary of changes
const summary = {
currentBranch,
baseBranch: mainBranch,
...GitUtils.getQuickStats(repoPath, mainBranch, currentBranch),
};
return {
content: [
{
type: 'text',
text: JSON.stringify(summary, null, 2),
},
],
};
}
private async showFileDiff(args: any) {
const { repoPath, filename, fromBranch, toBranch } = args;
if (!fs.existsSync(repoPath)) {
throw new Error(`Repository not found: ${repoPath}`);
}
// Determine branches
const sourceBranch = fromBranch || GitUtils.getMainBranch(repoPath);
const targetBranch = toBranch || GitUtils.getCurrentBranch(repoPath);
// Get diff for the specific file
const diffOutput = GitUtils.getFileDiff(repoPath, filename, sourceBranch, targetBranch);
// Parse diff for more readable output
const parsedDiff = this.parseFileDiff(diffOutput);
return {
content: [
{
type: 'text',
text: JSON.stringify({
filename,
fromBranch: sourceBranch,
toBranch: targetBranch,
...parsedDiff,
rawDiff: diffOutput.split('\n').slice(0, 100), // Limit the number of lines
}, null, 2),
},
],
};
}
private parseFileDiff(diffOutput: string): { hasChanges: boolean; additions: string[]; deletions: string[]; summary: string } {
const lines = diffOutput.split('\n');
const additions: string[] = [];
const deletions: string[] = [];
let addedLines = 0;
let deletedLines = 0;
for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
additions.push(line.substring(1)); // Remove the + sign
addedLines++;
} else if (line.startsWith('-') && !line.startsWith('---')) {
deletions.push(line.substring(1)); // Remove the - sign
deletedLines++;
}
}
const hasChanges = addedLines > 0 || deletedLines > 0;
const summary = hasChanges
? `+${addedLines} lines, -${deletedLines} lines`
: 'No changes in the file';
return {
hasChanges,
additions: additions.slice(0, 50), // Limit output
deletions: deletions.slice(0, 50),
summary,
};
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Simple Merge Review MCP running');
}
}
// Start
const server = new SimpleMergeReviewMCP();
server.run().catch(console.error);
Теперь нужно build project и запустить его.
npm install
npm run build
npm run start
Это создает files build/index.js.

В C:\\Users\\YourUserName\\AppData\\Roaming\\Code\\User добавьте mcp.json и укажите путь к server.
Нужно указать path до файла index.js.

В VS Code откройте project с двумя branches. В моем случае это master и new-feature.
Затем убедитесь, что Copilot обнаружил ваш MCP. Для этого переключитесь в Agent mode, нажмите Tools button и найдите ваш MCP в списке.

Отправьте prompt:
A PR from new-feature to master will be created. Do an early code review
Как видно, Copilot распознал functions нашего MCP и показывает interaction log с ними прямо в chat.

И наконец мы получаем review наших code changes.

MCP позволяет любому developer подключать scripts, APIs или internal services к AI agent и использовать их внутри Copilot для автоматизации daily work.