index.ts•7.58 kB
import Fastify, { FastifyInstance } from "fastify";
import { execFile } from "node:child_process";
import { access } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const allowedRoots: readonly string[] = (process.env.MCP_GIT_ROOTS ?? process.cwd())
.split(":")
.map((value) => value.trim())
.filter(Boolean);
interface GitStatusResponse {
branch: string | null;
upstream: string | null;
ahead: number;
behind: number;
changes: Array<{
path: string;
statusCode: string;
}>;
}
interface GitCommitRequest {
message: string;
paths?: string[];
allowEmpty?: boolean;
signOff?: boolean;
path?: string;
}
type StatusQuery = {
path?: string;
};
type DiffQuery = {
path?: string;
ref?: string;
};
function ensureAllowed(targetPath: string): string {
const resolved = path.resolve(targetPath);
if (allowedRoots.length === 0) {
return resolved;
}
const isAllowed = allowedRoots.some((root) => {
const resolvedRoot = path.resolve(root);
return resolved === resolvedRoot || resolved.startsWith(`${resolvedRoot}${path.sep}`);
});
if (!isAllowed) {
throw new Error(`Path '${resolved}' is outside the configured MCP_GIT_ROOTS`);
}
return resolved;
}
async function detectRepository(cwd: string): Promise<string> {
const resolved = ensureAllowed(cwd);
await access(resolved);
const { stdout } = await runGit(["rev-parse", "--show-toplevel"], resolved);
return stdout.trim();
}
async function runGit(args: readonly string[], cwd: string): Promise<{ stdout: string; stderr: string }> {
return execFileAsync("git", args, { cwd }) as Promise<{ stdout: string; stderr: string }>;
}
function parseStatus(output: string): GitStatusResponse {
const lines = output.split("\n");
const changes: GitStatusResponse["changes"] = [];
let branch: string | null = null;
let upstream: string | null = null;
let ahead = 0;
let behind = 0;
for (const line of lines) {
if (line.startsWith("# branch.head")) {
branch = line.split(" ")[2] ?? null;
}
if (line.startsWith("# branch.upstream")) {
upstream = line.split(" ")[2] ?? null;
}
if (line.startsWith("# branch.ab")) {
const [, aheadStr, behindStr] = line.match(/\+(\d+) -(\d+)/) ?? [];
if (aheadStr) {
ahead = Number(aheadStr);
}
if (behindStr) {
behind = Number(behindStr);
}
}
if (line.startsWith("1 ") || line.startsWith("2 ")) {
const parts = line.split(" ");
const statusCode = parts[1];
const filePath = parts.at(-1) ?? "";
changes.push({ path: filePath, statusCode });
}
}
return { branch, upstream, ahead, behind, changes };
}
export function createApp(): FastifyInstance {
const app = Fastify({
logger: true,
});
app.get("/health", {
schema: {
response: {
200: {
type: "object",
properties: {
status: { type: "string" },
allowedRoots: { type: "array", items: { type: "string" } },
},
required: ["status", "allowedRoots"],
},
},
},
}, () => ({ status: "ok", allowedRoots }));
app.get<{ Querystring: StatusQuery }>(
"/v1/git/status",
{
schema: {
querystring: {
type: "object",
properties: {
path: { type: "string" },
},
},
response: {
200: {
type: "object",
properties: {
repository: { type: "string" },
status: {
type: "object",
properties: {
branch: { type: ["string", "null"] },
upstream: { type: ["string", "null"] },
ahead: { type: "integer" },
behind: { type: "integer" },
changes: {
type: "array",
items: {
type: "object",
properties: {
path: { type: "string" },
statusCode: { type: "string" },
},
required: ["path", "statusCode"],
},
},
},
required: ["branch", "upstream", "ahead", "behind", "changes"],
},
},
required: ["repository", "status"],
},
},
},
},
async (request, reply) => {
const repoPath = await detectRepository(request.query.path ?? process.cwd());
const { stdout } = await runGit(["status", "--branch", "--porcelain=v2"], repoPath);
const statusResponse = parseStatus(stdout);
return reply.status(200).send({ repository: repoPath, status: statusResponse });
}
);
app.get<{ Querystring: DiffQuery }>(
"/v1/git/diff",
{
schema: {
querystring: {
type: "object",
properties: {
path: { type: "string" },
ref: { type: "string", default: "HEAD" },
},
},
response: {
200: {
type: "object",
properties: {
repository: { type: "string" },
ref: { type: "string" },
diff: { type: "string" },
},
required: ["repository", "ref", "diff"],
},
},
},
},
async (request, reply) => {
const repoPath = await detectRepository(request.query.path ?? process.cwd());
const ref = request.query.ref ?? "HEAD";
const { stdout } = await runGit(["diff", ref], repoPath);
return reply.status(200).send({ repository: repoPath, ref, diff: stdout });
}
);
app.post<{ Body: GitCommitRequest }>(
"/v1/git/commit",
{
schema: {
body: {
type: "object",
required: ["message"],
properties: {
message: { type: "string", minLength: 1 },
paths: {
type: "array",
items: { type: "string" },
},
allowEmpty: { type: "boolean", default: false },
signOff: { type: "boolean", default: false },
path: { type: "string" },
},
additionalProperties: false,
},
response: {
200: {
type: "object",
properties: {
repository: { type: "string" },
commit: { type: "string" },
},
required: ["repository", "commit"],
},
},
},
},
async (request, reply) => {
const {
message,
paths = [] as string[],
allowEmpty = false,
signOff = false,
path: repoInput,
} = request.body;
const repoPath = await detectRepository(repoInput ?? process.cwd());
if (paths.length > 0) {
await runGit(["add", ...paths], repoPath);
}
const args = ["commit", "-m", message];
if (allowEmpty) {
args.push("--allow-empty");
}
if (signOff) {
args.push("--signoff");
}
const { stdout } = await runGit(args, repoPath);
return reply.status(200).send({ repository: repoPath, commit: stdout.trim() });
}
);
return app;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const port = Number(process.env.PORT ?? 8082);
const app = createApp();
app
.listen({ host: "127.0.0.1", port })
.catch((error) => {
app.log.error(error);
process.exit(1);
});
}