← Назад ко всем статьям

Подключаем свой MCP Server к GitHub Copilot

Опубликовано

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.

image

В C:\\Users\\YourUserName\\AppData\\Roaming\\Code\\User добавьте mcp.json и укажите путь к server.

Нужно указать path до файла index.js.

image

В VS Code откройте project с двумя branches. В моем случае это master и new-feature.

Затем убедитесь, что Copilot обнаружил ваш MCP. Для этого переключитесь в Agent mode, нажмите Tools button и найдите ваш MCP в списке.

image

Отправьте prompt:

A PR from new-feature to master will be created. Do an early code review

Как видно, Copilot распознал functions нашего MCP и показывает interaction log с ними прямо в chat.

image

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

image

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