UNPKG

@cyanheads/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,

171 lines (170 loc) 7.8 kB
import { exec } from "child_process"; 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_add tool using Zod export const GitAddInputSchema = z.object({ path: z .string() .min(1) .optional() .default(".") .describe("Path to the Git repository. Defaults to the directory set via `git_set_working_dir` for the session; set 'git_set_working_dir' if not set."), files: z .union([z.string().min(1), z.array(z.string().min(1))]) .default(".") .describe("Files or patterns to stage, defaults to all changes ('.')"), }); /** * Executes the 'git add' command and returns structured JSON output. * * @param {GitAddInput} input - The validated input object. * @param {RequestContext} context - The request context for logging and error handling. * @returns {Promise<GitAddResult>} A promise that resolves with the structured add result. * @throws {McpError} Throws an McpError if path resolution or validation fails, or if the git command fails unexpectedly. */ export async function addGitFiles(input, context) { const operation = "addGitFiles"; logger.debug(`Executing ${operation}`, { ...context, input }); let targetPath; try { // Resolve the target path if (input.path && input.path !== ".") { targetPath = input.path; logger.debug(`Using provided path: ${targetPath}`, { ...context, operation, }); } else { const workingDir = context.getWorkingDirectory(); if (!workingDir) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, "No path provided and no working directory set for the session.", { context, operation }); } targetPath = workingDir; logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId, }); } // Sanitize the resolved path const sanitizedPathInfo = sanitization.sanitizePath(targetPath, { allowAbsolute: true, }); logger.debug("Sanitized repository path", { ...context, operation, sanitizedPathInfo, }); targetPath = sanitizedPathInfo.sanitizedPath; // Use the sanitized path going forward } catch (error) { logger.error("Path resolution or sanitization failed", { ...context, operation, error, }); if (error instanceof McpError) { throw error; } throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error }); } // Prepare the files argument for the command, ensuring proper quoting let filesArg; const filesToStage = input.files; // Keep original for reporting try { if (Array.isArray(filesToStage)) { if (filesToStage.length === 0) { logger.warning("Empty array provided for files, defaulting to staging all changes.", { ...context, operation }); filesArg = "."; // Default to staging all if array is empty } else { // Quote each file path individually filesArg = filesToStage .map((file) => { const sanitizedFile = file.startsWith("-") ? `./${file}` : file; // Prefix with './' if it starts with a dash return `"${sanitizedFile.replace(/"/g, '\\"')}"`; // Escape quotes within path }) .join(" "); } } else { // Single string case const sanitizedFile = filesToStage.startsWith("-") ? `./${filesToStage}` : filesToStage; // Prefix with './' if it starts with a dash filesArg = `"${sanitizedFile.replace(/"/g, '\\"')}"`; } } catch (err) { logger.error("File path validation/quoting failed", { ...context, operation, files: filesToStage, error: err, }); throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid file path/pattern provided: ${err instanceof Error ? err.message : String(err)}`, { context, operation, originalError: err }); } // This check should ideally not be needed now due to the logic above if (!filesArg) { logger.error("Internal error: filesArg is unexpectedly empty after processing.", { ...context, operation }); throw new McpError(BaseErrorCode.INTERNAL_ERROR, "Internal error preparing git add command.", { context, operation }); } try { // Use the resolved targetPath const command = `git -C "${targetPath}" add -- ${filesArg}`; logger.debug(`Executing command: ${command}`, { ...context, operation }); const { stdout, stderr } = await execAsync(command); if (stderr) { // Log stderr as warning, as 'git add' can produce warnings but still succeed. logger.warning(`Git add command produced stderr`, { ...context, operation, stderr, }); } const filesAddedDesc = Array.isArray(filesToStage) ? filesToStage.join(", ") : filesToStage; const successMessage = `Successfully staged: ${filesAddedDesc}`; logger.info(successMessage, { ...context, operation, path: targetPath, files: filesToStage, }); const reminder = "Remember to write clear, concise commit messages using the Conventional Commits format (e.g., 'feat(scope): subject')."; // Use statusMessage and add reminder return { success: true, statusMessage: `${successMessage}. ${reminder}`, filesStaged: filesToStage, }; } catch (error) { logger.error(`Failed to execute git add command`, { ...context, operation, path: targetPath, error: error.message, stderr: error.stderr, }); const errorMessage = error.stderr || error.message || ""; if (errorMessage.toLowerCase().includes("not a git repository")) { throw new McpError(BaseErrorCode.NOT_FOUND, `Path is not a Git repository: ${targetPath}`, { context, operation, originalError: error }); } if (errorMessage.toLowerCase().includes("did not match any files")) { // Still throw an error, but return structured info in the catch block of the registration throw new McpError(BaseErrorCode.NOT_FOUND, `Specified files/patterns did not match any files in ${targetPath}: ${filesArg}`, { context, operation, originalError: error, filesStaged: filesToStage }); } // Throw generic error for other cases throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to stage files for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error, filesStaged: filesToStage }); } }