@cyanheads/git-mcp-server 1.2.4 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +172 -285
  2. package/dist/config/index.js +69 -0
  3. package/dist/index.js +135 -0
  4. package/dist/mcp-server/server.js +572 -0
  5. package/dist/mcp-server/tools/gitAdd/index.js +7 -0
  6. package/dist/mcp-server/tools/gitAdd/logic.js +118 -0
  7. package/dist/mcp-server/tools/gitAdd/registration.js +73 -0
  8. package/dist/mcp-server/tools/gitBranch/index.js +7 -0
  9. package/dist/mcp-server/tools/gitBranch/logic.js +180 -0
  10. package/dist/mcp-server/tools/gitBranch/registration.js +72 -0
  11. package/dist/mcp-server/tools/gitCheckout/index.js +6 -0
  12. package/dist/mcp-server/tools/gitCheckout/logic.js +165 -0
  13. package/dist/mcp-server/tools/gitCheckout/registration.js +78 -0
  14. package/dist/mcp-server/tools/gitCherryPick/index.js +7 -0
  15. package/dist/mcp-server/tools/gitCherryPick/logic.js +115 -0
  16. package/dist/mcp-server/tools/gitCherryPick/registration.js +69 -0
  17. package/dist/mcp-server/tools/gitClean/index.js +7 -0
  18. package/dist/mcp-server/tools/gitClean/logic.js +110 -0
  19. package/dist/mcp-server/tools/gitClean/registration.js +98 -0
  20. package/dist/mcp-server/tools/gitClearWorkingDir/index.js +7 -0
  21. package/dist/mcp-server/tools/gitClearWorkingDir/logic.js +35 -0
  22. package/dist/mcp-server/tools/gitClearWorkingDir/registration.js +73 -0
  23. package/dist/mcp-server/tools/gitClone/index.js +7 -0
  24. package/dist/mcp-server/tools/gitClone/logic.js +136 -0
  25. package/dist/mcp-server/tools/gitClone/registration.js +44 -0
  26. package/dist/mcp-server/tools/gitCommit/index.js +7 -0
  27. package/dist/mcp-server/tools/gitCommit/logic.js +129 -0
  28. package/dist/mcp-server/tools/gitCommit/registration.js +100 -0
  29. package/dist/mcp-server/tools/gitDiff/index.js +6 -0
  30. package/dist/mcp-server/tools/gitDiff/logic.js +114 -0
  31. package/dist/mcp-server/tools/gitDiff/registration.js +74 -0
  32. package/dist/mcp-server/tools/gitFetch/index.js +6 -0
  33. package/dist/mcp-server/tools/gitFetch/logic.js +116 -0
  34. package/dist/mcp-server/tools/gitFetch/registration.js +71 -0
  35. package/dist/mcp-server/tools/gitInit/index.js +7 -0
  36. package/dist/mcp-server/tools/gitInit/logic.js +117 -0
  37. package/dist/mcp-server/tools/gitInit/registration.js +44 -0
  38. package/dist/mcp-server/tools/gitLog/index.js +6 -0
  39. package/dist/mcp-server/tools/gitLog/logic.js +148 -0
  40. package/dist/mcp-server/tools/gitLog/registration.js +71 -0
  41. package/dist/mcp-server/tools/gitMerge/index.js +7 -0
  42. package/dist/mcp-server/tools/gitMerge/logic.js +160 -0
  43. package/dist/mcp-server/tools/gitMerge/registration.js +77 -0
  44. package/dist/mcp-server/tools/gitPull/index.js +6 -0
  45. package/dist/mcp-server/tools/gitPull/logic.js +144 -0
  46. package/dist/mcp-server/tools/gitPull/registration.js +81 -0
  47. package/dist/mcp-server/tools/gitPush/index.js +6 -0
  48. package/dist/mcp-server/tools/gitPush/logic.js +188 -0
  49. package/dist/mcp-server/tools/gitPush/registration.js +81 -0
  50. package/dist/mcp-server/tools/gitRebase/index.js +7 -0
  51. package/dist/mcp-server/tools/gitRebase/logic.js +171 -0
  52. package/dist/mcp-server/tools/gitRebase/registration.js +72 -0
  53. package/dist/mcp-server/tools/gitRemote/index.js +7 -0
  54. package/dist/mcp-server/tools/gitRemote/logic.js +158 -0
  55. package/dist/mcp-server/tools/gitRemote/registration.js +76 -0
  56. package/dist/mcp-server/tools/gitReset/index.js +6 -0
  57. package/dist/mcp-server/tools/gitReset/logic.js +116 -0
  58. package/dist/mcp-server/tools/gitReset/registration.js +71 -0
  59. package/dist/mcp-server/tools/gitSetWorkingDir/index.js +7 -0
  60. package/dist/mcp-server/tools/gitSetWorkingDir/logic.js +91 -0
  61. package/dist/mcp-server/tools/gitSetWorkingDir/registration.js +78 -0
  62. package/dist/mcp-server/tools/gitShow/index.js +7 -0
  63. package/dist/mcp-server/tools/gitShow/logic.js +99 -0
  64. package/dist/mcp-server/tools/gitShow/registration.js +83 -0
  65. package/dist/mcp-server/tools/gitStash/index.js +7 -0
  66. package/dist/mcp-server/tools/gitStash/logic.js +161 -0
  67. package/dist/mcp-server/tools/gitStash/registration.js +84 -0
  68. package/dist/mcp-server/tools/gitStatus/index.js +7 -0
  69. package/dist/mcp-server/tools/gitStatus/logic.js +215 -0
  70. package/dist/mcp-server/tools/gitStatus/registration.js +77 -0
  71. package/dist/mcp-server/tools/gitTag/index.js +7 -0
  72. package/dist/mcp-server/tools/gitTag/logic.js +142 -0
  73. package/dist/mcp-server/tools/gitTag/registration.js +84 -0
  74. package/dist/types-global/errors.js +68 -0
  75. package/dist/types-global/mcp.js +59 -0
  76. package/dist/types-global/tool.js +1 -0
  77. package/dist/utils/errorHandler.js +237 -0
  78. package/dist/utils/idGenerator.js +148 -0
  79. package/dist/utils/index.js +11 -0
  80. package/dist/utils/jsonParser.js +78 -0
  81. package/dist/utils/logger.js +266 -0
  82. package/dist/utils/rateLimiter.js +177 -0
  83. package/dist/utils/requestContext.js +49 -0
  84. package/dist/utils/sanitization.js +371 -0
  85. package/dist/utils/tokenCounter.js +124 -0
  86. package/package.json +62 -17
  87. package/build/index.js +0 -54
  88. package/build/resources/descriptors.js +0 -77
  89. package/build/resources/diff.js +0 -241
  90. package/build/resources/file.js +0 -222
  91. package/build/resources/history.js +0 -242
  92. package/build/resources/index.js +0 -99
  93. package/build/resources/repository.js +0 -286
  94. package/build/server.js +0 -120
  95. package/build/services/error-service.js +0 -73
  96. package/build/services/git-service.js +0 -965
  97. package/build/tools/advanced.js +0 -526
  98. package/build/tools/branch.js +0 -296
  99. package/build/tools/index.js +0 -29
  100. package/build/tools/remote.js +0 -279
  101. package/build/tools/repository.js +0 -170
  102. package/build/tools/workdir.js +0 -445
  103. package/build/types/git.js +0 -7
  104. package/build/utils/global-settings.js +0 -64
  105. package/build/utils/validation.js +0 -108
@@ -0,0 +1,136 @@
1
+ import { z } from 'zod';
2
+ import { promisify } from 'util';
3
+ import { exec } from 'child_process';
4
+ import fs from 'fs/promises';
5
+ import { logger } from '../../../utils/logger.js';
6
+ import { McpError, BaseErrorCode } from '../../../types-global/errors.js';
7
+ import { sanitization } from '../../../utils/sanitization.js';
8
+ const execAsync = promisify(exec);
9
+ // Define the input schema for the git_clone tool using Zod
10
+ export const GitCloneInputSchema = z.object({
11
+ 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)."),
12
+ targetPath: z.string().min(1).describe("The absolute path to the directory where the repository should be cloned."),
13
+ branch: z.string().optional().describe("Specify a specific branch to checkout after cloning."),
14
+ depth: z.number().int().positive().optional().describe("Create a shallow clone with a history truncated to the specified number of commits."),
15
+ // recursive: z.boolean().default(false).describe("After the clone is created, initialize all submodules within, using their default settings."), // Consider adding later
16
+ quiet: z.boolean().default(false).describe("Operate quietly. Progress is not reported to the standard error stream."),
17
+ });
18
+ /**
19
+ * Executes the 'git clone' command to clone a repository.
20
+ *
21
+ * @param {GitCloneInput} input - The validated input object.
22
+ * @param {RequestContext} context - The request context for logging and error handling.
23
+ * @returns {Promise<GitCloneResult>} A promise that resolves with the structured clone result.
24
+ * @throws {McpError} Throws an McpError if path/URL validation fails or the git command fails unexpectedly.
25
+ */
26
+ export async function gitCloneLogic(input, context) {
27
+ const operation = 'gitCloneLogic';
28
+ logger.debug(`Executing ${operation}`, { ...context, input });
29
+ let sanitizedTargetPath;
30
+ let sanitizedRepoUrl;
31
+ try {
32
+ // Sanitize the target path (must be absolute)
33
+ sanitizedTargetPath = sanitization.sanitizePath(input.targetPath);
34
+ logger.debug('Sanitized target path', { ...context, operation, sanitizedTargetPath });
35
+ // Basic sanitization/validation for URL (Zod already checks format)
36
+ // Further sanitization might be needed depending on how it's used in the shell command
37
+ // For now, rely on Zod's URL validation and careful command construction.
38
+ sanitizedRepoUrl = input.repositoryUrl; // Assume Zod validation is sufficient for now
39
+ logger.debug('Validated repository URL', { ...context, operation, sanitizedRepoUrl });
40
+ // Check if target directory already exists and is not empty
41
+ try {
42
+ const stats = await fs.stat(sanitizedTargetPath);
43
+ if (stats.isDirectory()) {
44
+ const files = await fs.readdir(sanitizedTargetPath);
45
+ if (files.length > 0) {
46
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Target directory already exists and is not empty: ${sanitizedTargetPath}`, { context, operation });
47
+ }
48
+ }
49
+ else {
50
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Target path exists but is not a directory: ${sanitizedTargetPath}`, { context, operation });
51
+ }
52
+ }
53
+ catch (error) {
54
+ if (error instanceof McpError)
55
+ throw error; // Re-throw our specific validation errors
56
+ if (error.code !== 'ENOENT') {
57
+ // If error is not "does not exist", it's unexpected
58
+ logger.error(`Error checking target directory ${sanitizedTargetPath}`, { ...context, operation, error: error.message });
59
+ throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to check target directory: ${error.message}`, { context, operation });
60
+ }
61
+ // ENOENT is expected - directory doesn't exist, which is fine for clone
62
+ logger.debug(`Target directory ${sanitizedTargetPath} does not exist, proceeding with clone.`, { ...context, operation });
63
+ }
64
+ }
65
+ catch (error) {
66
+ logger.error('Path/URL validation or sanitization failed', { ...context, operation, error });
67
+ if (error instanceof McpError)
68
+ throw error;
69
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid input: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
70
+ }
71
+ try {
72
+ // Construct the git clone command
73
+ // Use placeholders and pass args safely if possible, but exec requires string command. Be careful with quoting.
74
+ let command = `git clone`;
75
+ if (input.quiet) {
76
+ command += ' --quiet';
77
+ }
78
+ if (input.branch) {
79
+ command += ` --branch "${input.branch.replace(/"/g, '\\"')}"`;
80
+ }
81
+ if (input.depth) {
82
+ command += ` --depth ${input.depth}`;
83
+ }
84
+ // Add repo URL and target path (ensure they are quoted)
85
+ command += ` "${sanitizedRepoUrl}" "${sanitizedTargetPath}"`;
86
+ logger.debug(`Executing command: ${command}`, { ...context, operation });
87
+ // Increase timeout for clone operations as they can take time
88
+ const { stdout, stderr } = await execAsync(command, { timeout: 300000 }); // 5 minutes timeout
89
+ if (stderr && !input.quiet) {
90
+ // Stderr often contains progress info, log as info if quiet is false
91
+ logger.info(`Git clone command produced stderr (progress/info)`, { ...context, operation, stderr });
92
+ }
93
+ if (stdout && !input.quiet) {
94
+ logger.info(`Git clone command produced stdout`, { ...context, operation, stdout });
95
+ }
96
+ // Verify the target directory exists after clone
97
+ let repoDirExists = false;
98
+ try {
99
+ await fs.access(sanitizedTargetPath);
100
+ repoDirExists = true;
101
+ }
102
+ catch (e) {
103
+ logger.error(`Could not verify existence of target directory ${sanitizedTargetPath} after git clone`, { ...context, operation });
104
+ // This indicates a potential failure despite exec not throwing
105
+ throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Clone command finished but target directory ${sanitizedTargetPath} not found.`, { context, operation });
106
+ }
107
+ const successMessage = `Repository cloned successfully into ${sanitizedTargetPath}`;
108
+ logger.info(`${operation} executed successfully`, { ...context, operation, path: sanitizedTargetPath });
109
+ return {
110
+ success: true,
111
+ message: successMessage,
112
+ path: sanitizedTargetPath,
113
+ repoDirExists: repoDirExists
114
+ };
115
+ }
116
+ catch (error) {
117
+ const errorMessage = error.stderr || error.message || '';
118
+ logger.error(`Failed to execute git clone command`, { ...context, operation, path: sanitizedTargetPath, error: errorMessage, stderr: error.stderr, stdout: error.stdout });
119
+ // Handle specific error cases
120
+ if (errorMessage.toLowerCase().includes('repository not found') || errorMessage.toLowerCase().includes('could not read from remote repository')) {
121
+ throw new McpError(BaseErrorCode.NOT_FOUND, `Repository not found or access denied: ${sanitizedRepoUrl}. Error: ${errorMessage}`, { context, operation, originalError: error });
122
+ }
123
+ if (errorMessage.toLowerCase().includes('already exists and is not an empty directory')) {
124
+ // This should have been caught by our pre-check, but handle defensively
125
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Target directory already exists and is not empty: ${sanitizedTargetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
126
+ }
127
+ if (errorMessage.toLowerCase().includes('permission denied')) {
128
+ throw new McpError(BaseErrorCode.FORBIDDEN, `Permission denied during clone operation for path: ${sanitizedTargetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
129
+ }
130
+ if (errorMessage.toLowerCase().includes('timeout')) {
131
+ throw new McpError(BaseErrorCode.TIMEOUT, `Git clone operation timed out for repository: ${sanitizedRepoUrl}. Error: ${errorMessage}`, { context, operation, originalError: error });
132
+ }
133
+ // Generic internal error for other failures
134
+ throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to clone repository ${sanitizedRepoUrl} to ${sanitizedTargetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
135
+ }
136
+ }
@@ -0,0 +1,44 @@
1
+ import { ErrorHandler } from '../../../utils/errorHandler.js';
2
+ import { logger } from '../../../utils/logger.js';
3
+ import { requestContextService } from '../../../utils/requestContext.js';
4
+ import { GitCloneInputSchema, gitCloneLogic } from './logic.js';
5
+ import { BaseErrorCode } from '../../../types-global/errors.js';
6
+ const TOOL_NAME = 'git_clone';
7
+ const TOOL_DESCRIPTION = 'Clones a Git repository from a given URL into a specified absolute directory path. Supports cloning specific branches and setting clone depth.';
8
+ /**
9
+ * Registers the git_clone tool with the MCP server.
10
+ *
11
+ * @param {McpServer} server - The McpServer instance to register the tool with.
12
+ * @returns {Promise<void>}
13
+ * @throws {Error} If registration fails.
14
+ */
15
+ export const registerGitCloneTool = async (server) => {
16
+ const operation = 'registerGitCloneTool';
17
+ const context = requestContextService.createRequestContext({ operation });
18
+ await ErrorHandler.tryCatch(async () => {
19
+ server.tool(TOOL_NAME, TOOL_DESCRIPTION, GitCloneInputSchema.shape, // Provide the Zod schema shape
20
+ async (validatedArgs, callContext) => {
21
+ const toolOperation = 'tool:git_clone';
22
+ const requestContext = requestContextService.createRequestContext({ operation: toolOperation, parentContext: callContext });
23
+ logger.info(`Executing tool: ${TOOL_NAME}`, requestContext);
24
+ return await ErrorHandler.tryCatch(async () => {
25
+ // Call the core logic function
26
+ const cloneResult = await gitCloneLogic(validatedArgs, requestContext);
27
+ // Format the result as a JSON string within TextContent
28
+ const resultContent = {
29
+ type: 'text',
30
+ text: JSON.stringify(cloneResult, null, 2), // Pretty-print JSON
31
+ contentType: 'application/json',
32
+ };
33
+ logger.info(`Tool ${TOOL_NAME} executed successfully, returning JSON`, requestContext);
34
+ return { content: [resultContent] };
35
+ }, {
36
+ operation: toolOperation,
37
+ context: requestContext,
38
+ input: validatedArgs, // Log sanitized input
39
+ errorCode: BaseErrorCode.INTERNAL_ERROR, // Default if unexpected error occurs
40
+ });
41
+ });
42
+ logger.info(`Tool registered: ${TOOL_NAME}`, context);
43
+ }, { operation, context, critical: true }); // Mark registration as critical
44
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @fileoverview Barrel file for the gitCommit tool.
3
+ * Exports the registration function and state accessor initialization function.
4
+ */
5
+ export { initializeGitCommitStateAccessors, registerGitCommitTool } from './registration.js';
6
+ // Export types if needed elsewhere, e.g.:
7
+ // export type { GitCommitInput, GitCommitResult } from './logic.js';
@@ -0,0 +1,129 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { z } from 'zod';
4
+ import { BaseErrorCode, McpError } from '../../../types-global/errors.js';
5
+ import { logger } from '../../../utils/logger.js';
6
+ import { sanitization } from '../../../utils/sanitization.js';
7
+ const execAsync = promisify(exec);
8
+ // Define the input schema for the git_commit tool using Zod
9
+ export const GitCommitInputSchema = z.object({
10
+ path: z.string().min(1).optional().default('.').describe("Path to the Git repository. Defaults to the session's working directory if set via `git_set_working_dir`, otherwise defaults to the server's current working directory (`.`)."),
11
+ message: z.string().min(1).describe('Commit message. Follow Conventional Commits format: `type(scope): subject`. Example: `feat(api): add user signup endpoint`'),
12
+ author: z.object({
13
+ name: z.string().describe('Author name for the commit'),
14
+ email: z.string().email().describe('Author email for the commit'),
15
+ }).optional().describe('Overrides the commit author information (name and email). Use only when necessary (e.g., applying external patches).'),
16
+ allowEmpty: z.boolean().default(false).describe('Allow creating empty commits'),
17
+ amend: z.boolean().default(false).describe('Amend the previous commit instead of creating a new one'),
18
+ });
19
+ /**
20
+ * Executes the 'git commit' command and returns structured JSON output.
21
+ *
22
+ * @param {GitCommitInput} input - The validated input object.
23
+ * @param {RequestContext} context - The request context for logging and error handling.
24
+ * @returns {Promise<GitCommitResult>} A promise that resolves with the structured commit result.
25
+ * @throws {McpError} Throws an McpError if path resolution or validation fails, or if the git command fails unexpectedly.
26
+ */
27
+ export async function commitGitChanges(input, context // Add getter to context
28
+ ) {
29
+ const operation = 'commitGitChanges';
30
+ logger.debug(`Executing ${operation}`, { ...context, input });
31
+ let targetPath;
32
+ try {
33
+ // Resolve the target path
34
+ if (input.path && input.path !== '.') {
35
+ targetPath = input.path;
36
+ logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
37
+ }
38
+ else {
39
+ const workingDir = context.getWorkingDirectory();
40
+ if (!workingDir) {
41
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, "No path provided and no working directory set for the session.", { context, operation });
42
+ }
43
+ targetPath = workingDir;
44
+ logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
45
+ }
46
+ // Sanitize the resolved path
47
+ const sanitizedPath = sanitization.sanitizePath(targetPath);
48
+ logger.debug('Sanitized path', { ...context, operation, sanitizedPath });
49
+ targetPath = sanitizedPath; // Use the sanitized path going forward
50
+ }
51
+ catch (error) {
52
+ logger.error('Path resolution or sanitization failed', { ...context, operation, error });
53
+ if (error instanceof McpError) {
54
+ throw error;
55
+ }
56
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
57
+ }
58
+ try {
59
+ // Construct the git commit command using the resolved targetPath
60
+ let command = `git -C "${targetPath}" commit -m "${input.message.replace(/"/g, '\\"')}"`; // Escape double quotes
61
+ if (input.allowEmpty) {
62
+ command += ' --allow-empty';
63
+ }
64
+ if (input.amend) {
65
+ command += ' --amend --no-edit';
66
+ }
67
+ if (input.author) {
68
+ // Ensure author details are properly escaped if needed, though exec usually handles this
69
+ command = `git -C "${targetPath}" -c user.name="${input.author.name.replace(/"/g, '\\"')}" -c user.email="${input.author.email.replace(/"/g, '\\"')}" commit -m "${input.message.replace(/"/g, '\\"')}"`;
70
+ if (input.allowEmpty)
71
+ command += ' --allow-empty';
72
+ if (input.amend)
73
+ command += ' --amend --no-edit';
74
+ }
75
+ logger.debug(`Executing command: ${command}`, { ...context, operation });
76
+ const { stdout, stderr } = await execAsync(command);
77
+ // Check stderr first for common non-error messages
78
+ if (stderr) {
79
+ if (stderr.includes('nothing to commit, working tree clean') || stderr.includes('no changes added to commit')) {
80
+ const msg = stderr.includes('nothing to commit') ? 'Nothing to commit, working tree clean.' : 'No changes added to commit.';
81
+ logger.info(msg, { ...context, operation, path: targetPath });
82
+ // Use statusMessage
83
+ return { success: true, statusMessage: msg, nothingToCommit: true };
84
+ }
85
+ // Log other stderr as warning but continue, as commit might still succeed
86
+ logger.warning(`Git commit command produced stderr`, { ...context, operation, stderr });
87
+ }
88
+ // Extract commit hash (more robustly)
89
+ let commitHash = undefined;
90
+ const hashMatch = stdout.match(/([a-f0-9]{7,40})/); // Look for typical short or long hash
91
+ if (hashMatch) {
92
+ commitHash = hashMatch[1];
93
+ }
94
+ else {
95
+ // Fallback parsing if needed, or rely on success message
96
+ logger.warning('Could not parse commit hash from stdout', { ...context, operation, stdout });
97
+ }
98
+ // Use statusMessage
99
+ const statusMsg = commitHash
100
+ ? `Commit successful: ${commitHash}`
101
+ : `Commit successful (stdout: ${stdout.trim()})`;
102
+ logger.info(`${operation} executed successfully`, { ...context, operation, path: targetPath, commitHash });
103
+ return {
104
+ success: true,
105
+ statusMessage: statusMsg,
106
+ commitHash: commitHash
107
+ };
108
+ }
109
+ catch (error) {
110
+ logger.error(`Failed to execute git commit command`, { ...context, operation, path: targetPath, error: error.message, stderr: error.stderr });
111
+ const errorMessage = error.stderr || error.message || '';
112
+ // Handle specific error cases first
113
+ if (errorMessage.toLowerCase().includes('not a git repository')) {
114
+ throw new McpError(BaseErrorCode.NOT_FOUND, `Path is not a Git repository: ${targetPath}`, { context, operation, originalError: error });
115
+ }
116
+ if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes added to commit')) {
117
+ // This might happen if git exits with error despite these messages
118
+ const msg = errorMessage.includes('nothing to commit') ? 'Nothing to commit, working tree clean.' : 'No changes added to commit.';
119
+ logger.info(msg + ' (caught as error)', { ...context, operation, path: targetPath, errorMessage });
120
+ // Return success=false but indicate the reason using statusMessage
121
+ return { success: false, statusMessage: msg, nothingToCommit: true };
122
+ }
123
+ if (errorMessage.includes('conflicts')) {
124
+ throw new McpError(BaseErrorCode.CONFLICT, `Commit failed due to unresolved conflicts in ${targetPath}`, { context, operation, originalError: error });
125
+ }
126
+ // Generic internal error for other failures
127
+ throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to commit changes for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
128
+ }
129
+ }
@@ -0,0 +1,100 @@
1
+ import { ErrorHandler } from '../../../utils/errorHandler.js';
2
+ import { logger } from '../../../utils/logger.js';
3
+ import { requestContextService } from '../../../utils/requestContext.js';
4
+ // Import the result type along with the function and input schema
5
+ import { BaseErrorCode } from '../../../types-global/errors.js'; // Import BaseErrorCode
6
+ import { commitGitChanges, GitCommitInputSchema } from './logic.js';
7
+ let _getWorkingDirectory;
8
+ let _getSessionId;
9
+ /**
10
+ * Initializes the state accessors needed by the tool registration.
11
+ * This should be called once during server setup.
12
+ * @param getWdFn - Function to get the working directory for a session.
13
+ * @param getSidFn - Function to get the session ID from context.
14
+ */
15
+ export function initializeGitCommitStateAccessors(getWdFn, getSidFn) {
16
+ _getWorkingDirectory = getWdFn;
17
+ _getSessionId = getSidFn;
18
+ logger.info('State accessors initialized for git_commit tool registration.');
19
+ }
20
+ const TOOL_NAME = 'git_commit';
21
+ const TOOL_DESCRIPTION = `Commits staged changes to the Git repository index with a descriptive message. Supports author override, amending, and empty commits. Returns a JSON result.
22
+
23
+ **Commit Message Guidance:**
24
+ Write clear, concise commit messages using the Conventional Commits format: \`type(scope): subject\`.
25
+ - \`type\`: feat, fix, docs, style, refactor, test, chore, etc.
26
+ - \`(scope)\`: Optional context (e.g., \`auth\`, \`ui\`, filename).
27
+ - \`subject\`: Imperative, present tense description (e.g., "add login button", not "added login button").
28
+
29
+ **Example Commit Message:**
30
+ \`\`\`
31
+ feat(auth): implement password reset endpoint
32
+
33
+ Adds the /api/auth/reset-password endpoint to allow users
34
+ to reset their password via an email link. Includes input
35
+ validation and rate limiting.
36
+
37
+ Closes #123 (if applicable).
38
+ \`\`\`
39
+
40
+ **Best Practice:** Commit related changes together in logical units. If you've modified multiple files for a single feature or fix, stage and commit them together with a message that describes the overall change.
41
+
42
+ **Path Handling:** If the 'path' parameter is omitted or set to '.', the tool uses the working directory set by 'git_set_working_dir'. Providing a full, absolute path overrides this default and ensures explicitness.`;
43
+ /**
44
+ * Registers the git_commit tool with the MCP server.
45
+ * Uses the high-level server.tool() method for registration, schema validation, and routing.
46
+ *
47
+ * @param {McpServer} server - The McpServer instance to register the tool with.
48
+ * @returns {Promise<void>}
49
+ * @throws {Error} If registration fails or state accessors are not initialized.
50
+ */
51
+ export const registerGitCommitTool = async (server) => {
52
+ if (!_getWorkingDirectory || !_getSessionId) {
53
+ throw new Error('State accessors for git_commit must be initialized before registration.');
54
+ }
55
+ const operation = 'registerGitCommitTool';
56
+ const context = requestContextService.createRequestContext({ operation });
57
+ await ErrorHandler.tryCatch(async () => {
58
+ server.tool(TOOL_NAME, TOOL_DESCRIPTION, GitCommitInputSchema.shape, // Provide the Zod schema shape
59
+ async (validatedArgs, callContext) => {
60
+ const toolOperation = 'tool:git_commit';
61
+ const requestContext = requestContextService.createRequestContext({ operation: toolOperation, parentContext: callContext });
62
+ const sessionId = _getSessionId(requestContext);
63
+ const getWorkingDirectoryForSession = () => {
64
+ return _getWorkingDirectory(sessionId);
65
+ };
66
+ const logicContext = {
67
+ ...requestContext,
68
+ sessionId: sessionId,
69
+ getWorkingDirectory: getWorkingDirectoryForSession,
70
+ };
71
+ logger.info(`Executing tool: ${TOOL_NAME}`, logicContext);
72
+ return await ErrorHandler.tryCatch(async () => {
73
+ // Call the core logic function which now returns a GitCommitResult object
74
+ const commitResult = await commitGitChanges(validatedArgs, logicContext);
75
+ // Format the result as a JSON string within TextContent
76
+ const resultContent = {
77
+ type: 'text',
78
+ // Stringify the JSON object for the response content
79
+ text: JSON.stringify(commitResult, null, 2), // Pretty-print JSON
80
+ contentType: 'application/json',
81
+ };
82
+ // Log based on the success flag in the result
83
+ if (commitResult.success) {
84
+ logger.info(`Tool ${TOOL_NAME} executed successfully, returning JSON`, logicContext);
85
+ }
86
+ else {
87
+ logger.info(`Tool ${TOOL_NAME} completed with non-fatal condition (e.g., nothing to commit), returning JSON`, logicContext);
88
+ }
89
+ // Even if success is false (e.g., nothing to commit), it's not a tool execution error
90
+ return { content: [resultContent] };
91
+ }, {
92
+ operation: toolOperation,
93
+ context: logicContext,
94
+ input: validatedArgs,
95
+ errorCode: BaseErrorCode.INTERNAL_ERROR,
96
+ });
97
+ });
98
+ logger.info(`Tool registered: ${TOOL_NAME}`, context);
99
+ }, { operation, context, critical: true });
100
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @fileoverview Barrel file for the gitDiff tool.
3
+ */
4
+ export { registerGitDiffTool, initializeGitDiffStateAccessors } from './registration.js';
5
+ // Export types if needed elsewhere, e.g.:
6
+ // export type { GitDiffInput, GitDiffResult } from './logic.js';
@@ -0,0 +1,114 @@
1
+ import { z } from 'zod';
2
+ import { promisify } from 'util';
3
+ import { exec } from 'child_process';
4
+ import { logger } from '../../../utils/logger.js';
5
+ import { McpError, BaseErrorCode } from '../../../types-global/errors.js';
6
+ import { sanitization } from '../../../utils/sanitization.js';
7
+ const execAsync = promisify(exec);
8
+ // Define the base input schema without refinement
9
+ const GitDiffInputBaseSchema = z.object({
10
+ path: z.string().min(1).optional().default('.').describe("Path to the Git repository. Defaults to the session's working directory if set."),
11
+ commit1: z.string().optional().describe("First commit, branch, or ref for comparison. If omitted, compares against the working tree or index (depending on 'staged')."),
12
+ commit2: z.string().optional().describe("Second commit, branch, or ref for comparison. If omitted, compares commit1 against the working tree or index."),
13
+ staged: z.boolean().optional().default(false).describe("Show diff of changes staged for the next commit (compares index against HEAD). Overrides commit1/commit2 if true."),
14
+ file: z.string().optional().describe("Limit the diff output to a specific file path."),
15
+ // Add options like --name-only, --stat, context lines (-U<n>) if needed
16
+ });
17
+ // Export the shape for registration
18
+ export const GitDiffInputShape = GitDiffInputBaseSchema.shape;
19
+ // Define the final schema with refinement for validation during execution
20
+ export const GitDiffInputSchema = GitDiffInputBaseSchema.refine(data => !(data.staged && (data.commit1 || data.commit2)), {
21
+ message: "Cannot use 'staged' option with specific commit references (commit1 or commit2).",
22
+ path: ["staged", "commit1", "commit2"], // Indicate related fields
23
+ });
24
+ /**
25
+ * Executes the 'git diff' command and returns the diff output.
26
+ *
27
+ * @param {GitDiffInput} input - The validated input object.
28
+ * @param {RequestContext} context - The request context for logging and error handling.
29
+ * @returns {Promise<GitDiffResult>} A promise that resolves with the structured diff result.
30
+ * @throws {McpError} Throws an McpError if path resolution, validation, or the git command fails unexpectedly.
31
+ */
32
+ export async function diffGitChanges(input, context) {
33
+ const operation = 'diffGitChanges';
34
+ logger.debug(`Executing ${operation}`, { ...context, input });
35
+ let targetPath;
36
+ try {
37
+ // Resolve and sanitize the target path
38
+ if (input.path && input.path !== '.') {
39
+ targetPath = input.path;
40
+ }
41
+ else {
42
+ const workingDir = context.getWorkingDirectory();
43
+ if (!workingDir) {
44
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, "No path provided and no working directory set for the session.", { context, operation });
45
+ }
46
+ targetPath = workingDir;
47
+ }
48
+ targetPath = sanitization.sanitizePath(targetPath);
49
+ logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
50
+ }
51
+ catch (error) {
52
+ logger.error('Path resolution or sanitization failed', { ...context, operation, error });
53
+ if (error instanceof McpError)
54
+ throw error;
55
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
56
+ }
57
+ // Basic sanitization for refs and file path
58
+ const safeCommit1 = input.commit1?.replace(/[`$&;*()|<>]/g, '');
59
+ const safeCommit2 = input.commit2?.replace(/[`$&;*()|<>]/g, '');
60
+ const safeFile = input.file?.replace(/[`$&;*()|<>]/g, '');
61
+ try {
62
+ // Construct the git diff command
63
+ let command = `git -C "${targetPath}" diff`;
64
+ if (input.staged) {
65
+ command += ' --staged'; // Or --cached
66
+ }
67
+ else {
68
+ // Add commit references if not doing staged diff
69
+ if (safeCommit1) {
70
+ command += ` ${safeCommit1}`;
71
+ }
72
+ if (safeCommit2) {
73
+ command += ` ${safeCommit2}`;
74
+ }
75
+ }
76
+ // Add file path limiter if provided
77
+ if (safeFile) {
78
+ command += ` -- "${safeFile}"`; // Use '--' to separate paths from revisions
79
+ }
80
+ logger.debug(`Executing command: ${command}`, { ...context, operation });
81
+ // Execute command. Diff output is primarily on stdout.
82
+ // Increase maxBuffer as diffs can be large.
83
+ const { stdout, stderr } = await execAsync(command, { maxBuffer: 1024 * 1024 * 20 }); // 20MB buffer
84
+ if (stderr) {
85
+ // Log stderr as warning, as it might contain non-fatal info
86
+ logger.warning(`Git diff stderr: ${stderr}`, { ...context, operation });
87
+ }
88
+ const diffOutput = stdout;
89
+ const message = diffOutput.trim() === '' ? 'No changes found.' : 'Diff generated successfully.';
90
+ logger.info(`${operation} completed successfully. ${message}`, { ...context, operation, path: targetPath });
91
+ return { success: true, diff: diffOutput, message };
92
+ }
93
+ catch (error) {
94
+ logger.error(`Failed to execute git diff command`, { ...context, operation, path: targetPath, error: error.message, stderr: error.stderr, stdout: error.stdout });
95
+ const errorMessage = error.stderr || error.stdout || error.message || '';
96
+ // Handle specific error cases
97
+ if (errorMessage.toLowerCase().includes('not a git repository')) {
98
+ throw new McpError(BaseErrorCode.NOT_FOUND, `Path is not a Git repository: ${targetPath}`, { context, operation, originalError: error });
99
+ }
100
+ if (errorMessage.includes('fatal: bad object') || errorMessage.includes('unknown revision or path not in the working tree')) {
101
+ const invalidRef = input.commit1 || input.commit2 || input.file;
102
+ throw new McpError(BaseErrorCode.NOT_FOUND, `Invalid commit reference or file path specified: '${invalidRef}'. Error: ${errorMessage}`, { context, operation, originalError: error });
103
+ }
104
+ if (errorMessage.includes('ambiguous argument')) {
105
+ const ambiguousArg = input.commit1 || input.commit2 || input.file;
106
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Ambiguous argument provided: '${ambiguousArg}'. Error: ${errorMessage}`, { context, operation, originalError: error });
107
+ }
108
+ // If the command exits with an error but stdout has content, it might still be useful (e.g., diff with conflicts)
109
+ // However, standard 'git diff' usually exits 0 even with differences. Errors typically mean invalid input/repo state.
110
+ // We'll treat most exec errors as failures.
111
+ // Generic internal error for other failures
112
+ throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to get git diff for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
113
+ }
114
+ }
@@ -0,0 +1,74 @@
1
+ import { ErrorHandler } from '../../../utils/errorHandler.js';
2
+ import { logger } from '../../../utils/logger.js';
3
+ import { requestContextService } from '../../../utils/requestContext.js';
4
+ // Import the shape and the final schema/types
5
+ import { GitDiffInputShape, diffGitChanges } from './logic.js';
6
+ import { BaseErrorCode } from '../../../types-global/errors.js';
7
+ let _getWorkingDirectory;
8
+ let _getSessionId;
9
+ /**
10
+ * Initializes the state accessors needed by the tool registration.
11
+ * This should be called once during server setup.
12
+ * @param getWdFn - Function to get the working directory for a session.
13
+ * @param getSidFn - Function to get the session ID from context.
14
+ */
15
+ export function initializeGitDiffStateAccessors(getWdFn, getSidFn) {
16
+ _getWorkingDirectory = getWdFn;
17
+ _getSessionId = getSidFn;
18
+ logger.info('State accessors initialized for git_diff tool registration.');
19
+ }
20
+ const TOOL_NAME = 'git_diff';
21
+ const TOOL_DESCRIPTION = "Shows changes between commits, commit and working tree, etc. Can show staged changes or diff specific files. Returns the diff output as plain text.";
22
+ /**
23
+ * Registers the git_diff tool with the MCP server.
24
+ *
25
+ * @param {McpServer} server - The MCP server instance.
26
+ * @throws {Error} If state accessors are not initialized.
27
+ */
28
+ export async function registerGitDiffTool(server) {
29
+ if (!_getWorkingDirectory || !_getSessionId) {
30
+ throw new Error('State accessors for git_diff must be initialized before registration.');
31
+ }
32
+ const operation = 'registerGitDiffTool';
33
+ const context = requestContextService.createRequestContext({ operation });
34
+ await ErrorHandler.tryCatch(async () => {
35
+ // Use the exported shape for registration
36
+ server.tool(TOOL_NAME, TOOL_DESCRIPTION, GitDiffInputShape, // Provide the Zod base schema shape
37
+ async (validatedArgs, callContext) => {
38
+ const toolOperation = 'tool:git_diff';
39
+ const requestContext = requestContextService.createRequestContext({ operation: toolOperation, parentContext: callContext });
40
+ const sessionId = _getSessionId(requestContext);
41
+ const getWorkingDirectoryForSession = () => {
42
+ return _getWorkingDirectory(sessionId);
43
+ };
44
+ const logicContext = {
45
+ ...requestContext,
46
+ sessionId: sessionId,
47
+ getWorkingDirectory: getWorkingDirectoryForSession,
48
+ };
49
+ logger.info(`Executing tool: ${TOOL_NAME}`, logicContext);
50
+ return await ErrorHandler.tryCatch(async () => {
51
+ // Call the core logic function
52
+ const diffResult = await diffGitChanges(validatedArgs, logicContext);
53
+ // Format the result (the diff string) as plain text within TextContent
54
+ const resultContent = {
55
+ type: 'text',
56
+ // Return the raw diff output directly
57
+ text: diffResult.diff,
58
+ // Indicate the content type is plain text diff
59
+ contentType: 'text/plain; charset=utf-8', // Or 'text/x-diff'
60
+ };
61
+ logger.info(`Tool ${TOOL_NAME} executed successfully: ${diffResult.message}`, logicContext);
62
+ // Success is determined by the logic function
63
+ return { content: [resultContent] };
64
+ }, {
65
+ operation: toolOperation,
66
+ context: logicContext,
67
+ input: validatedArgs,
68
+ errorCode: BaseErrorCode.INTERNAL_ERROR, // Default if unexpected error in logic
69
+ });
70
+ });
71
+ logger.info(`Tool registered: ${TOOL_NAME}`, context);
72
+ }, { operation, context, critical: true });
73
+ }
74
+ ;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @fileoverview Barrel file for the gitFetch tool.
3
+ */
4
+ export { registerGitFetchTool, initializeGitFetchStateAccessors } from './registration.js';
5
+ // Export types if needed elsewhere, e.g.:
6
+ // export type { GitFetchInput, GitFetchResult } from './logic.js';