@nvhien20vn/git-mcp-server
Version:
158 lines (157 loc) • 9.32 kB
JavaScript
import { exec } from 'child_process';
import { promisify } from 'util';
import { z } from 'zod';
import { BaseErrorCode, McpError } from '../../../types-global/errors.js'; // Direct import for types-global
import { logger, sanitization } from '../../../utils/index.js'; // Logger (./utils/internal/logger.js) & RequestContext (./utils/internal/requestContext.js) & sanitization (./utils/security/sanitization.js)
const execAsync = promisify(exec);
// Define the input schema for the git_remote tool using Zod
export const GitRemoteInputSchema = 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."),
mode: z.enum(['list', 'add', 'remove', 'show']).describe("Operation mode: 'list', 'add', 'remove', 'show'"),
name: z.string().min(1).optional().describe("Remote name (required for 'add', 'remove', 'show')"),
url: z.string().optional().describe("Remote URL (required for 'add')"), // Removed .url() validation
});
/**
* Executes git remote commands based on the specified mode.
*
* @param {GitRemoteInput} input - The validated input object.
* @param {RequestContext} context - The request context for logging and error handling.
* @returns {Promise<GitRemoteResult>} A promise that resolves with the structured result.
* @throws {McpError} Throws an McpError for path resolution/validation failures or unexpected errors.
*/
export async function gitRemoteLogic(input, context) {
const operation = `gitRemoteLogic:${input.mode}`;
logger.debug(`Executing ${operation}`, { ...context, input });
let targetPath;
try {
// Resolve and sanitize the target path
const workingDir = context.getWorkingDirectory();
targetPath = (input.path && input.path !== '.')
? input.path
: workingDir ?? '.'; // Default to '.' if no working dir set and no path provided
if (targetPath === '.' && !workingDir) {
logger.warning("Executing git remote in server's CWD as no path provided and no session WD set.", { ...context, operation });
// Allow execution in CWD but log it clearly. Consider if an error is more appropriate.
// For now, let's proceed but be aware.
targetPath = process.cwd(); // Use actual CWD if '.' was the default
}
else if (targetPath === '.' && workingDir) {
targetPath = workingDir;
logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
}
else {
logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
}
targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath; // Sanitize the final resolved path
logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
}
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 });
}
try {
let command;
let result;
switch (input.mode) {
case 'list':
command = `git -C "${targetPath}" remote -v`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
const { stdout: listStdout } = await execAsync(command);
const remotes = [];
const lines = listStdout.trim().split('\n');
const remoteMap = new Map();
lines.forEach(line => {
const parts = line.split(/\s+/);
if (parts.length >= 3) {
const name = parts[0];
const url = parts[1];
const type = parts[2].replace(/[()]/g, ''); // Remove parentheses around (fetch) or (push)
if (!remoteMap.has(name)) {
remoteMap.set(name, {});
}
if (type === 'fetch') {
remoteMap.get(name).fetchUrl = url;
}
else if (type === 'push') {
remoteMap.get(name).pushUrl = url;
}
}
});
remoteMap.forEach((urls, name) => {
// Ensure both URLs are present, defaulting to fetch URL if push is missing (common case)
remotes.push({
name,
fetchUrl: urls.fetchUrl || 'N/A',
pushUrl: urls.pushUrl || urls.fetchUrl || 'N/A',
});
});
result = { success: true, mode: 'list', remotes };
break;
case 'add':
if (!input.name || !input.url) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Remote 'name' and 'url' are required for 'add' mode.", { context, operation });
}
// Basic validation for remote name (avoiding shell injection characters)
if (!/^[a-zA-Z0-9_.-]+$/.test(input.name)) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid remote name: ${input.name}`, { context, operation });
}
command = `git -C "${targetPath}" remote add "${input.name}" "${input.url}"`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
await execAsync(command);
result = { success: true, mode: 'add', message: `Remote '${input.name}' added successfully.` };
break;
case 'remove':
if (!input.name) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Remote 'name' is required for 'remove' mode.", { context, operation });
}
if (!/^[a-zA-Z0-9_.-]+$/.test(input.name)) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid remote name: ${input.name}`, { context, operation });
}
command = `git -C "${targetPath}" remote remove "${input.name}"`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
await execAsync(command);
result = { success: true, mode: 'remove', message: `Remote '${input.name}' removed successfully.` };
break;
case 'show':
if (!input.name) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Remote 'name' is required for 'show' mode.", { context, operation });
}
if (!/^[a-zA-Z0-9_.-]+$/.test(input.name)) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid remote name: ${input.name}`, { context, operation });
}
command = `git -C "${targetPath}" remote show "${input.name}"`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
const { stdout: showStdout } = await execAsync(command);
result = { success: true, mode: 'show', details: showStdout.trim() };
break;
default:
// Should not happen due to Zod validation, but good practice
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid mode: ${input.mode}`, { context, operation });
}
logger.info(`${operation} executed successfully`, { ...context, operation, path: targetPath });
return result;
}
catch (error) {
const errorMessage = error.stderr || error.message || '';
logger.error(`Failed to execute git remote command`, { ...context, operation, path: targetPath, error: errorMessage, stderr: error.stderr, stdout: error.stdout });
// Specific error handling
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 (input.mode === 'add' && errorMessage.toLowerCase().includes('already exists')) {
return { success: false, mode: 'add', message: `Failed to add remote: Remote '${input.name}' already exists.`, error: errorMessage };
}
if ((input.mode === 'remove' || input.mode === 'show') && errorMessage.toLowerCase().includes('no such remote')) {
return { success: false, mode: input.mode, message: `Failed to ${input.mode} remote: Remote '${input.name}' does not exist.`, error: errorMessage };
}
// Return structured failure for other git errors
return {
success: false,
mode: input.mode,
message: `Git remote ${input.mode} failed for path: ${targetPath}.`,
error: errorMessage
};
}
}