UNPKG

@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
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 }); } }