@nvhien20vn/git-mcp-server
Version:
142 lines (141 loc) • 8.95 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 base input schema for the git_tag tool using Zod
// We export this separately to access its .shape for registration
export const GitTagBaseSchema = z.object({
path: z.string().min(1).optional().default('.').describe("Path to the local 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', 'create', 'delete']).describe("The tag operation to perform: 'list' (show all tags), 'create' (add a new tag), 'delete' (remove a local tag)."),
tagName: z.string().min(1).optional().describe("The name for the tag. Required for 'create' and 'delete' modes. e.g., 'v2.3.0'."),
message: z.string().optional().describe("The annotation message for the tag. Required and used only when 'mode' is 'create' and 'annotate' is true."),
commitRef: z.string().optional().describe("The commit hash, branch name, or other reference to tag. Used only in 'create' mode. Defaults to the current HEAD if omitted."),
annotate: z.boolean().default(false).describe("If true, creates an annotated tag (-a flag) instead of a lightweight tag. Requires 'message' to be provided. Used only in 'create' mode."),
// force: z.boolean().default(false).describe("Force tag creation/update (-f flag). Use with caution as it can overwrite existing tags."), // Consider adding later
});
// Apply refinements for conditional validation and export the final schema
export const GitTagInputSchema = GitTagBaseSchema.refine(data => !(data.mode === 'create' && data.annotate && !data.message), {
message: "An annotation 'message' is required when creating an annotated tag (annotate=true).",
path: ["message"], // Point Zod error to the message field
}).refine(data => !(data.mode === 'create' && !data.tagName), {
message: "A 'tagName' is required for 'create' mode.",
path: ["tagName"], // Point Zod error to the tagName field
}).refine(data => !(data.mode === 'delete' && !data.tagName), {
message: "A 'tagName' is required for 'delete' mode.",
path: ["tagName"], // Point Zod error to the tagName field
});
/**
* Executes git tag commands based on the specified mode.
*
* @param {GitTagInput} input - The validated input object.
* @param {RequestContext} context - The request context for logging and error handling.
* @returns {Promise<GitTagResult>} A promise that resolves with the structured result.
* @throws {McpError} Throws an McpError for path resolution/validation failures or unexpected errors.
*/
export async function gitTagLogic(input, context) {
const operation = `gitTagLogic:${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 ?? '.';
if (targetPath === '.' && !workingDir) {
logger.warning("Executing git tag in server's CWD as no path provided and no session WD set.", { ...context, operation });
targetPath = process.cwd();
}
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;
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 });
}
// Validate tag name format (simple validation)
if (input.tagName && !/^[a-zA-Z0-9_./-]+$/.test(input.tagName)) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid tag name format: ${input.tagName}`, { context, operation });
}
// Validate commit ref format (simple validation - allows hashes, HEAD, branches, etc.)
if (input.commitRef && !/^[a-zA-Z0-9_./~^-]+$/.test(input.commitRef)) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid commit reference format: ${input.commitRef}`, { context, operation });
}
try {
let command;
let result;
switch (input.mode) {
case 'list':
command = `git -C "${targetPath}" tag --list`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
const { stdout: listStdout } = await execAsync(command);
const tags = listStdout.trim().split('\n').filter(tag => tag); // Filter out empty lines
result = { success: true, mode: 'list', tags };
break;
case 'create':
// TagName is validated by Zod refine
const tagNameCreate = input.tagName;
command = `git -C "${targetPath}" tag`;
if (input.annotate) {
// Message is validated by Zod refine
command += ` -a -m "${input.message.replace(/"/g, '\\"')}"`;
}
command += ` "${tagNameCreate}"`;
if (input.commitRef) {
command += ` "${input.commitRef}"`;
}
logger.debug(`Executing command: ${command}`, { ...context, operation });
await execAsync(command);
result = { success: true, mode: 'create', message: `Tag '${tagNameCreate}' created successfully.`, tagName: tagNameCreate };
break;
case 'delete':
// TagName is validated by Zod refine
const tagNameDelete = input.tagName;
command = `git -C "${targetPath}" tag -d "${tagNameDelete}"`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
await execAsync(command);
result = { success: true, mode: 'delete', message: `Tag '${tagNameDelete}' deleted successfully.`, tagName: tagNameDelete };
break;
default:
// Should not happen due to Zod validation
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 tag 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 === 'create' && errorMessage.toLowerCase().includes('already exists')) {
return { success: false, mode: 'create', message: `Failed to create tag: Tag '${input.tagName}' already exists.`, error: errorMessage };
}
if (input.mode === 'delete' && errorMessage.toLowerCase().includes('not found')) {
return { success: false, mode: 'delete', message: `Failed to delete tag: Tag '${input.tagName}' not found.`, error: errorMessage };
}
if (input.mode === 'create' && input.commitRef && errorMessage.toLowerCase().includes('unknown revision or path not in the working tree')) {
return { success: false, mode: 'create', message: `Failed to create tag: Commit reference '${input.commitRef}' not found.`, error: errorMessage };
}
// Return structured failure for other git errors
return {
success: false,
mode: input.mode,
message: `Git tag ${input.mode} failed for path: ${targetPath}.`,
error: errorMessage
};
}
}