#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ErrorCode,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import simpleGit, { SimpleGit } from 'simple-git';
import { readFileSync } from 'fs';
import { resolve } from 'path';
interface GitServerOptions {
workingDirectory?: string;
}
class GitMCPServer {
private server: Server;
private git: SimpleGit;
private workingDirectory: string;
constructor(options: GitServerOptions = {}) {
this.server = new Server(
{
name: 'git-mcp-server',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
// Use provided working directory or current working directory
this.workingDirectory = options.workingDirectory || process.cwd();
this.git = simpleGit(this.workingDirectory);
this.setupToolHandlers();
this.setupErrorHandling();
}
private setupErrorHandling(): void {
this.server.onerror = (error) => console.error('[MCP Error]', error);
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
private setupToolHandlers(): void {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'git_status',
description: 'Get the current git status of the repository',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
{
name: 'git_current_branch',
description: 'Get the current branch name',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
{
name: 'git_staged_changes',
description: 'Get the currently staged changes',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
{
name: 'git_diff',
description: 'Show diff between branches or commits',
inputSchema: {
type: 'object',
properties: {
target: {
type: 'string',
description: 'Target branch or commit to diff against (e.g., "main", "HEAD~1")',
},
staged: {
type: 'boolean',
description: 'Show staged changes only',
default: false,
},
},
additionalProperties: false,
},
},
{
name: 'git_log',
description: 'Get commit history',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'number',
description: 'Limit number of commits to show',
default: 10,
},
oneline: {
type: 'boolean',
description: 'Show one line per commit',
default: true,
},
},
additionalProperties: false,
},
},
{
name: 'git_branches',
description: 'List all branches',
inputSchema: {
type: 'object',
properties: {
remote: {
type: 'boolean',
description: 'Include remote branches',
default: false,
},
},
additionalProperties: false,
},
},
{
name: 'git_show_file',
description: 'Show contents of a file at a specific commit',
inputSchema: {
type: 'object',
properties: {
file: {
type: 'string',
description: 'Path to the file',
},
commit: {
type: 'string',
description: 'Commit hash or branch name (default: HEAD)',
default: 'HEAD',
},
},
required: ['file'],
additionalProperties: false,
},
},
{
name: 'git_working_directory',
description: 'Get the current working directory of the git server',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
{
name: 'git_commit',
description: 'Create a new commit with the specified message and files',
inputSchema: {
type: 'object',
properties: {
message: {
type: 'string',
description: 'Commit message',
},
files: {
type: 'array',
items: {
type: 'string',
},
description: 'Files to add and commit (optional - if not provided, commits all staged files)',
},
},
required: ['message'],
additionalProperties: false,
},
},
{
name: 'git_checkout',
description: 'Switch to a different branch or create a new branch',
inputSchema: {
type: 'object',
properties: {
branch: {
type: 'string',
description: 'Branch name to checkout',
},
create: {
type: 'boolean',
description: 'Create new branch if it does not exist',
default: false,
},
},
required: ['branch'],
additionalProperties: false,
},
},
{
name: 'git_pull',
description: 'Pull changes from remote repository',
inputSchema: {
type: 'object',
properties: {
remote: {
type: 'string',
description: 'Remote name (default: origin)',
default: 'origin',
},
branch: {
type: 'string',
description: 'Branch name (default: current branch)',
},
},
additionalProperties: false,
},
},
{
name: 'git_fetch',
description: 'Fetch changes from remote repository without merging',
inputSchema: {
type: 'object',
properties: {
remote: {
type: 'string',
description: 'Remote name (default: origin)',
default: 'origin',
},
},
additionalProperties: false,
},
},
{
name: 'git_init',
description: 'Initialize a new git repository',
inputSchema: {
type: 'object',
properties: {
bare: {
type: 'boolean',
description: 'Create a bare repository',
default: false,
},
initialBranch: {
type: 'string',
description: 'Set the initial branch name',
},
},
additionalProperties: false,
},
},
{
name: 'git_add',
description: 'Add files to the staging area',
inputSchema: {
type: 'object',
properties: {
files: {
type: 'array',
items: {
type: 'string',
},
description: 'Files to add (use "." for all files, or specify individual files)',
},
all: {
type: 'boolean',
description: 'Add all tracked and untracked files (-A flag)',
default: false,
},
},
additionalProperties: false,
},
},
{
name: 'git_create_branch',
description: 'Create a new branch and switch to it (git checkout -b)',
inputSchema: {
type: 'object',
properties: {
branchName: {
type: 'string',
description: 'Name of the new branch to create',
},
startPoint: {
type: 'string',
description: 'Starting point for the new branch (branch name or commit hash)',
default: 'HEAD',
},
},
required: ['branchName'],
additionalProperties: false,
},
},
{
name: 'git_push',
description: 'Push changes to remote repository',
inputSchema: {
type: 'object',
properties: {
remote: {
type: 'string',
description: 'Remote name (default: origin)',
default: 'origin',
},
branch: {
type: 'string',
description: 'Branch name to push (default: current branch)',
},
setUpstream: {
type: 'boolean',
description: 'Set upstream tracking branch (-u flag)',
default: false,
},
},
additionalProperties: false,
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'git_status':
return await this.handleGitStatus();
case 'git_current_branch':
return await this.handleCurrentBranch();
case 'git_staged_changes':
return await this.handleStagedChanges();
case 'git_diff':
return await this.handleGitDiff(args as { target?: string; staged?: boolean });
case 'git_log':
return await this.handleGitLog(args as { limit?: number; oneline?: boolean });
case 'git_branches':
return await this.handleGitBranches(args as { remote?: boolean });
case 'git_show_file':
return await this.handleShowFile(args as { file: string; commit?: string });
case 'git_working_directory':
return await this.handleWorkingDirectory();
case 'git_commit':
return await this.handleGitCommit(args as { message: string; files?: string[] });
case 'git_checkout':
return await this.handleGitCheckout(args as { branch: string; create?: boolean });
case 'git_pull':
return await this.handleGitPull(args as { remote?: string; branch?: string });
case 'git_fetch':
return await this.handleGitFetch(args as { remote?: string });
case 'git_init':
return await this.handleGitInit(args as { bare?: boolean; initialBranch?: string });
case 'git_add':
return await this.handleGitAdd(args as { files?: string[]; all?: boolean });
case 'git_create_branch':
return await this.handleGitCreateBranch(args as { branchName: string; startPoint?: string });
case 'git_push':
return await this.handleGitPush(args as { remote?: string; branch?: string; setUpstream?: boolean });
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new McpError(ErrorCode.InternalError, `Git operation failed: ${errorMessage}`);
}
});
}
private async handleGitStatus() {
const status = await this.git.status();
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
current: status.current,
ahead: status.ahead,
behind: status.behind,
staged: status.staged,
modified: status.modified,
created: status.created,
deleted: status.deleted,
renamed: status.renamed,
conflicted: status.conflicted,
isClean: status.isClean(),
}, null, 2),
},
],
};
}
private async handleCurrentBranch() {
const branch = await this.git.branch();
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
currentBranch: branch.current || 'No current branch',
}, null, 2),
},
],
};
}
private async handleStagedChanges() {
const diff = await this.git.diff(['--cached']);
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
stagedChanges: diff || 'No staged changes',
}, null, 2),
},
],
};
}
private async handleGitDiff(args: { target?: string; staged?: boolean }) {
const diffArgs: string[] = [];
if (args.staged) {
diffArgs.push('--cached');
} else if (args.target) {
diffArgs.push(args.target);
}
const diff = await this.git.diff(diffArgs);
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
diff: diff || 'No differences found',
target: args.target || (args.staged ? 'staged' : 'working directory'),
}, null, 2),
},
],
};
}
private async handleGitLog(args: { limit?: number; oneline?: boolean }) {
const logOptions: any = {
maxCount: args.limit || 10,
};
if (args.oneline) {
logOptions.format = { hash: '%H', message: '%s', author: '%an', date: '%ad' };
}
const log = await this.git.log(logOptions);
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
log: log,
}, null, 2),
},
],
};
}
private async handleGitBranches(args: { remote?: boolean }) {
const branches = await this.git.branch(args.remote ? ['-a'] : []);
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
current: branches.current,
all: branches.all,
branches: Object.fromEntries(
Object.entries(branches.branches).map(([name, branch]) => [
name,
{
current: branch.current,
name: branch.name,
commit: branch.commit,
label: branch.label,
},
])
),
}, null, 2),
},
],
};
}
private async handleShowFile(args: { file: string; commit?: string }) {
const commit = args.commit || 'HEAD';
const content = await this.git.show([`${commit}:${args.file}`]);
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
file: args.file,
commit: commit,
content: content,
}, null, 2),
},
],
};
}
private async handleWorkingDirectory() {
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
projectName: this.workingDirectory.split('/').pop() || 'unknown',
}, null, 2),
},
],
};
}
private async handleGitCommit(args: { message: string; files?: string[] }) {
if (args.files && args.files.length > 0) {
await this.git.add(args.files);
}
const result = await this.git.commit(args.message);
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
commit: result.commit,
summary: result.summary,
author: result.author,
filesChanged: args.files || 'staged files',
}, null, 2),
},
],
};
}
private async handleGitCheckout(args: { branch: string; create?: boolean }) {
const checkoutArgs: string[] = [];
if (args.create) {
checkoutArgs.push('-b');
}
checkoutArgs.push(args.branch);
const result = await this.git.checkout(checkoutArgs);
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
branch: args.branch,
created: args.create || false,
result: result,
}, null, 2),
},
],
};
}
private async handleGitPull(args: { remote?: string; branch?: string }) {
const remote = args.remote || 'origin';
const pullArgs: string[] = [remote];
if (args.branch) {
pullArgs.push(args.branch);
}
const result = await this.git.pull(...pullArgs);
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
remote: remote,
branch: args.branch || 'current branch',
summary: result.summary,
files: result.files,
insertions: result.insertions,
deletions: result.deletions,
}, null, 2),
},
],
};
}
private async handleGitFetch(args: { remote?: string }) {
const remote = args.remote || 'origin';
const result = await this.git.fetch(remote);
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
remote: remote,
result: result,
}, null, 2),
},
],
};
}
private async handleGitInit(args: { bare?: boolean; initialBranch?: string }) {
const initOptions: string[] = [];
if (args.bare) {
initOptions.push('--bare');
}
if (args.initialBranch) {
initOptions.push('--initial-branch', args.initialBranch);
}
const result = await this.git.init(args.bare || false, initOptions.length > 0 ? { config: initOptions } : undefined);
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
initialized: true,
bare: args.bare || false,
initialBranch: args.initialBranch || 'default',
result: result,
}, null, 2),
},
],
};
}
private async handleGitAdd(args: { files?: string[]; all?: boolean }) {
let result;
if (args.all) {
result = await this.git.add('-A');
} else if (args.files && args.files.length > 0) {
result = await this.git.add(args.files);
} else {
throw new Error('Must specify either files to add or use the "all" flag');
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
filesAdded: args.files || (args.all ? 'all files' : 'none'),
all: args.all || false,
result: result,
}, null, 2),
},
],
};
}
private async handleGitCreateBranch(args: { branchName: string; startPoint?: string }) {
const checkoutArgs = ['-b', args.branchName];
if (args.startPoint && args.startPoint !== 'HEAD') {
checkoutArgs.push(args.startPoint);
}
const result = await this.git.checkout(checkoutArgs);
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
branchName: args.branchName,
startPoint: args.startPoint || 'HEAD',
created: true,
result: result,
}, null, 2),
},
],
};
}
private async handleGitPush(args: { remote?: string; branch?: string; setUpstream?: boolean }) {
const remote = args.remote || 'origin';
const pushArgs: string[] = [remote];
if (args.branch) {
pushArgs.push(args.branch);
}
if (args.setUpstream) {
pushArgs.unshift('-u');
}
const result = await this.git.push(pushArgs);
return {
content: [
{
type: 'text',
text: JSON.stringify({
workingDirectory: this.workingDirectory,
remote: remote,
branch: args.branch || 'current branch',
setUpstream: args.setUpstream || false,
result: result,
}, null, 2),
},
],
};
}
async run(): Promise<void> {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error(`Git MCP Server running on stdio for directory: ${this.workingDirectory}`);
}
}
// Main execution
async function main() {
// You can pass working directory as command line argument
const workingDirectory = process.argv[2];
const server = new GitMCPServer({ workingDirectory });
await server.run();
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
}