@mseep/git-mcp-server
Version:
An MCP (Model Context Protocol) server enabling LLMs and AI agents to interact with Git repositories. Provides tools for comprehensive Git operations including clone, commit, branch, diff, log, status, push, pull, merge, rebase, worktree, tag management,
199 lines (198 loc) • 9.65 kB
JavaScript
import { exec } from "child_process";
import fs from "fs/promises";
import { promisify } from "util";
import { z } from "zod";
// Import utils from barrel (logger from ../utils/internal/logger.js)
import { logger } from "../../../utils/index.js";
// Import utils from barrel (RequestContext from ../utils/internal/requestContext.js)
import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Keep direct import for types-global
// Import utils from barrel (sanitization from ../utils/security/sanitization.js)
import { sanitization } from "../../../utils/index.js";
const execAsync = promisify(exec);
// Define the input schema for the git_clone tool using Zod
export const GitCloneInputSchema = z.object({
repositoryUrl: z
.string()
.url("Invalid repository URL format.")
.describe("The URL of the repository to clone (e.g., https://github.com/cyanheads/git-mcp-server, git@github.com:cyanheads/git-mcp-server.git)."),
targetPath: z
.string()
.min(1)
.describe("The absolute path to the directory where the repository should be cloned."),
branch: z
.string()
.optional()
.describe("Specify a specific branch to checkout after cloning."),
depth: z
.number()
.int()
.positive()
.optional()
.describe("Create a shallow clone with a history truncated to the specified number of commits."),
// recursive: z.boolean().default(false).describe("After the clone is created, initialize all submodules within, using their default settings."), // Consider adding later
quiet: z
.boolean()
.default(false)
.describe("Operate quietly. Progress is not reported to the standard error stream."),
});
/**
* Executes the 'git clone' command to clone a repository.
*
* @param {GitCloneInput} input - The validated input object.
* @param {RequestContext} context - The request context for logging and error handling.
* @returns {Promise<GitCloneResult>} A promise that resolves with the structured clone result.
* @throws {McpError} Throws an McpError if path/URL validation fails or the git command fails unexpectedly.
*/
export async function gitCloneLogic(input, context) {
const operation = "gitCloneLogic";
logger.debug(`Executing ${operation}`, { ...context, input });
let sanitizedTargetPath;
let sanitizedRepoUrl;
try {
// Sanitize the target path (must be absolute)
sanitizedTargetPath = sanitization.sanitizePath(input.targetPath, {
allowAbsolute: true,
}).sanitizedPath;
logger.debug("Sanitized target path", {
...context,
operation,
sanitizedTargetPath,
});
// Basic sanitization/validation for URL (Zod already checks format)
// Further sanitization might be needed depending on how it's used in the shell command
// For now, rely on Zod's URL validation and careful command construction.
sanitizedRepoUrl = input.repositoryUrl; // Assume Zod validation is sufficient for now
logger.debug("Validated repository URL", {
...context,
operation,
sanitizedRepoUrl,
});
// Check if target directory already exists and is not empty
try {
const stats = await fs.stat(sanitizedTargetPath);
if (stats.isDirectory()) {
const files = await fs.readdir(sanitizedTargetPath);
if (files.length > 0) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Target directory already exists and is not empty: ${sanitizedTargetPath}`, { context, operation });
}
}
else {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Target path exists but is not a directory: ${sanitizedTargetPath}`, { context, operation });
}
}
catch (error) {
if (error instanceof McpError)
throw error; // Re-throw our specific validation errors
if (error.code !== "ENOENT") {
// If error is not "does not exist", it's unexpected
logger.error(`Error checking target directory ${sanitizedTargetPath}`, {
...context,
operation,
error: error.message,
});
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to check target directory: ${error.message}`, { context, operation });
}
// ENOENT is expected - directory doesn't exist, which is fine for clone
logger.debug(`Target directory ${sanitizedTargetPath} does not exist, proceeding with clone.`, { ...context, operation });
}
}
catch (error) {
logger.error("Path/URL validation or sanitization failed", {
...context,
operation,
error,
});
if (error instanceof McpError)
throw error;
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid input: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
}
try {
// Construct the git clone command
// Use placeholders and pass args safely if possible, but exec requires string command. Be careful with quoting.
let command = `git clone`;
if (input.quiet) {
command += " --quiet";
}
if (input.branch) {
command += ` --branch "${input.branch.replace(/"/g, '\\"')}"`;
}
if (input.depth) {
command += ` --depth ${input.depth}`;
}
// Add repo URL and target path (ensure they are quoted)
command += ` "${sanitizedRepoUrl}" "${sanitizedTargetPath}"`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
// Increase timeout for clone operations as they can take time
const { stdout, stderr } = await execAsync(command, { timeout: 300000 }); // 5 minutes timeout
if (stderr && !input.quiet) {
// Stderr often contains progress info, log as info if quiet is false
logger.info(`Git clone command produced stderr (progress/info)`, {
...context,
operation,
stderr,
});
}
if (stdout && !input.quiet) {
logger.info(`Git clone command produced stdout`, {
...context,
operation,
stdout,
});
}
// Verify the target directory exists after clone
let repoDirExists = false;
try {
await fs.access(sanitizedTargetPath);
repoDirExists = true;
}
catch (e) {
logger.error(`Could not verify existence of target directory ${sanitizedTargetPath} after git clone`, { ...context, operation });
// This indicates a potential failure despite exec not throwing
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Clone command finished but target directory ${sanitizedTargetPath} not found.`, { context, operation });
}
const successMessage = `Repository cloned successfully into ${sanitizedTargetPath}`;
logger.info(successMessage, {
...context,
operation,
path: sanitizedTargetPath,
});
return {
success: true,
message: successMessage,
path: sanitizedTargetPath,
repoDirExists: repoDirExists,
};
}
catch (error) {
const errorMessage = error.stderr || error.message || "";
logger.error(`Failed to execute git clone command`, {
...context,
operation,
path: sanitizedTargetPath,
error: errorMessage,
stderr: error.stderr,
stdout: error.stdout,
});
// Handle specific error cases
if (errorMessage.toLowerCase().includes("repository not found") ||
errorMessage
.toLowerCase()
.includes("could not read from remote repository")) {
throw new McpError(BaseErrorCode.NOT_FOUND, `Repository not found or access denied: ${sanitizedRepoUrl}. Error: ${errorMessage}`, { context, operation, originalError: error });
}
if (errorMessage
.toLowerCase()
.includes("already exists and is not an empty directory")) {
// This should have been caught by our pre-check, but handle defensively
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Target directory already exists and is not empty: ${sanitizedTargetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
}
if (errorMessage.toLowerCase().includes("permission denied")) {
throw new McpError(BaseErrorCode.FORBIDDEN, `Permission denied during clone operation for path: ${sanitizedTargetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
}
if (errorMessage.toLowerCase().includes("timeout")) {
throw new McpError(BaseErrorCode.TIMEOUT, `Git clone operation timed out for repository: ${sanitizedRepoUrl}. Error: ${errorMessage}`, { context, operation, originalError: error });
}
// Generic internal error for other failures
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to clone repository ${sanitizedRepoUrl} to ${sanitizedTargetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
}
}