@nvhien20vn/git-mcp-server
Version:
140 lines (139 loc) • 8.97 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(`${operation} executed successfully`, { ...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 });
}
}