@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.
- package/README.md +172 -285
- package/dist/config/index.js +69 -0
- package/dist/index.js +135 -0
- package/dist/mcp-server/server.js +572 -0
- package/dist/mcp-server/tools/gitAdd/index.js +7 -0
- package/dist/mcp-server/tools/gitAdd/logic.js +118 -0
- package/dist/mcp-server/tools/gitAdd/registration.js +73 -0
- package/dist/mcp-server/tools/gitBranch/index.js +7 -0
- package/dist/mcp-server/tools/gitBranch/logic.js +180 -0
- package/dist/mcp-server/tools/gitBranch/registration.js +72 -0
- package/dist/mcp-server/tools/gitCheckout/index.js +6 -0
- package/dist/mcp-server/tools/gitCheckout/logic.js +165 -0
- package/dist/mcp-server/tools/gitCheckout/registration.js +78 -0
- package/dist/mcp-server/tools/gitCherryPick/index.js +7 -0
- package/dist/mcp-server/tools/gitCherryPick/logic.js +115 -0
- package/dist/mcp-server/tools/gitCherryPick/registration.js +69 -0
- package/dist/mcp-server/tools/gitClean/index.js +7 -0
- package/dist/mcp-server/tools/gitClean/logic.js +110 -0
- package/dist/mcp-server/tools/gitClean/registration.js +98 -0
- package/dist/mcp-server/tools/gitClearWorkingDir/index.js +7 -0
- package/dist/mcp-server/tools/gitClearWorkingDir/logic.js +35 -0
- package/dist/mcp-server/tools/gitClearWorkingDir/registration.js +73 -0
- package/dist/mcp-server/tools/gitClone/index.js +7 -0
- package/dist/mcp-server/tools/gitClone/logic.js +136 -0
- package/dist/mcp-server/tools/gitClone/registration.js +44 -0
- package/dist/mcp-server/tools/gitCommit/index.js +7 -0
- package/dist/mcp-server/tools/gitCommit/logic.js +129 -0
- package/dist/mcp-server/tools/gitCommit/registration.js +100 -0
- package/dist/mcp-server/tools/gitDiff/index.js +6 -0
- package/dist/mcp-server/tools/gitDiff/logic.js +114 -0
- package/dist/mcp-server/tools/gitDiff/registration.js +74 -0
- package/dist/mcp-server/tools/gitFetch/index.js +6 -0
- package/dist/mcp-server/tools/gitFetch/logic.js +116 -0
- package/dist/mcp-server/tools/gitFetch/registration.js +71 -0
- package/dist/mcp-server/tools/gitInit/index.js +7 -0
- package/dist/mcp-server/tools/gitInit/logic.js +117 -0
- package/dist/mcp-server/tools/gitInit/registration.js +44 -0
- package/dist/mcp-server/tools/gitLog/index.js +6 -0
- package/dist/mcp-server/tools/gitLog/logic.js +148 -0
- package/dist/mcp-server/tools/gitLog/registration.js +71 -0
- package/dist/mcp-server/tools/gitMerge/index.js +7 -0
- package/dist/mcp-server/tools/gitMerge/logic.js +160 -0
- package/dist/mcp-server/tools/gitMerge/registration.js +77 -0
- package/dist/mcp-server/tools/gitPull/index.js +6 -0
- package/dist/mcp-server/tools/gitPull/logic.js +144 -0
- package/dist/mcp-server/tools/gitPull/registration.js +81 -0
- package/dist/mcp-server/tools/gitPush/index.js +6 -0
- package/dist/mcp-server/tools/gitPush/logic.js +188 -0
- package/dist/mcp-server/tools/gitPush/registration.js +81 -0
- package/dist/mcp-server/tools/gitRebase/index.js +7 -0
- package/dist/mcp-server/tools/gitRebase/logic.js +171 -0
- package/dist/mcp-server/tools/gitRebase/registration.js +72 -0
- package/dist/mcp-server/tools/gitRemote/index.js +7 -0
- package/dist/mcp-server/tools/gitRemote/logic.js +158 -0
- package/dist/mcp-server/tools/gitRemote/registration.js +76 -0
- package/dist/mcp-server/tools/gitReset/index.js +6 -0
- package/dist/mcp-server/tools/gitReset/logic.js +116 -0
- package/dist/mcp-server/tools/gitReset/registration.js +71 -0
- package/dist/mcp-server/tools/gitSetWorkingDir/index.js +7 -0
- package/dist/mcp-server/tools/gitSetWorkingDir/logic.js +91 -0
- package/dist/mcp-server/tools/gitSetWorkingDir/registration.js +78 -0
- package/dist/mcp-server/tools/gitShow/index.js +7 -0
- package/dist/mcp-server/tools/gitShow/logic.js +99 -0
- package/dist/mcp-server/tools/gitShow/registration.js +83 -0
- package/dist/mcp-server/tools/gitStash/index.js +7 -0
- package/dist/mcp-server/tools/gitStash/logic.js +161 -0
- package/dist/mcp-server/tools/gitStash/registration.js +84 -0
- package/dist/mcp-server/tools/gitStatus/index.js +7 -0
- package/dist/mcp-server/tools/gitStatus/logic.js +215 -0
- package/dist/mcp-server/tools/gitStatus/registration.js +77 -0
- package/dist/mcp-server/tools/gitTag/index.js +7 -0
- package/dist/mcp-server/tools/gitTag/logic.js +142 -0
- package/dist/mcp-server/tools/gitTag/registration.js +84 -0
- package/dist/types-global/errors.js +68 -0
- package/dist/types-global/mcp.js +59 -0
- package/dist/types-global/tool.js +1 -0
- package/dist/utils/errorHandler.js +237 -0
- package/dist/utils/idGenerator.js +148 -0
- package/dist/utils/index.js +11 -0
- package/dist/utils/jsonParser.js +78 -0
- package/dist/utils/logger.js +266 -0
- package/dist/utils/rateLimiter.js +177 -0
- package/dist/utils/requestContext.js +49 -0
- package/dist/utils/sanitization.js +371 -0
- package/dist/utils/tokenCounter.js +124 -0
- package/package.json +62 -17
- package/build/index.js +0 -54
- package/build/resources/descriptors.js +0 -77
- package/build/resources/diff.js +0 -241
- package/build/resources/file.js +0 -222
- package/build/resources/history.js +0 -242
- package/build/resources/index.js +0 -99
- package/build/resources/repository.js +0 -286
- package/build/server.js +0 -120
- package/build/services/error-service.js +0 -73
- package/build/services/git-service.js +0 -965
- package/build/tools/advanced.js +0 -526
- package/build/tools/branch.js +0 -296
- package/build/tools/index.js +0 -29
- package/build/tools/remote.js +0 -279
- package/build/tools/repository.js +0 -170
- package/build/tools/workdir.js +0 -445
- package/build/types/git.js +0 -7
- package/build/utils/global-settings.js +0 -64
- package/build/utils/validation.js +0 -108
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
import path from 'path'; // Import path module
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
// Define the input schema for the git_merge tool
|
|
10
|
+
export const GitMergeInputSchema = z.object({
|
|
11
|
+
path: z.string().min(1).optional().default('.').describe("Path to the Git repository. Defaults to the session's working directory if set."),
|
|
12
|
+
branch: z.string().min(1).describe('The name of the branch to merge into the current branch.'),
|
|
13
|
+
commitMessage: z.string().optional().describe('Commit message to use for the merge commit (if required, e.g., not fast-forward).'),
|
|
14
|
+
noFf: z.boolean().default(false).describe('Create a merge commit even when the merge resolves as a fast-forward (`--no-ff`).'),
|
|
15
|
+
squash: z.boolean().default(false).describe('Combine merged changes into a single commit (`--squash`). Requires manual commit afterwards.'),
|
|
16
|
+
abort: z.boolean().default(false).describe('Abort the current merge process (resolves conflicts).'),
|
|
17
|
+
// 'continue' might be too complex for initial implementation due to requiring index manipulation
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* Executes the 'git merge' command.
|
|
21
|
+
*
|
|
22
|
+
* @param {GitMergeInput} input - The validated input object.
|
|
23
|
+
* @param {RequestContext} context - The request context.
|
|
24
|
+
* @returns {Promise<GitMergeResult>} A promise that resolves with the structured merge result.
|
|
25
|
+
* @throws {McpError} Throws an McpError for path issues, command failures, or unexpected errors.
|
|
26
|
+
*/
|
|
27
|
+
export async function gitMergeLogic(input, context) {
|
|
28
|
+
const operation = 'gitMergeLogic';
|
|
29
|
+
logger.debug(`Executing ${operation}`, { ...context, input });
|
|
30
|
+
let targetPath;
|
|
31
|
+
try {
|
|
32
|
+
// Resolve the target path
|
|
33
|
+
let resolvedPath;
|
|
34
|
+
if (input.path && input.path !== '.') {
|
|
35
|
+
// If a specific path is given, resolve it absolutely first
|
|
36
|
+
// Assuming input.path could be relative *to the server's CWD* if no session WD is set,
|
|
37
|
+
// but it's safer to require absolute paths or rely on session WD.
|
|
38
|
+
// For simplicity, let's assume input.path is intended relative to session WD if set, or absolute otherwise.
|
|
39
|
+
const workingDir = context.getWorkingDirectory();
|
|
40
|
+
if (workingDir) {
|
|
41
|
+
resolvedPath = path.resolve(workingDir, input.path); // Resolve relative to session WD
|
|
42
|
+
}
|
|
43
|
+
else if (path.isAbsolute(input.path)) {
|
|
44
|
+
resolvedPath = input.path; // Use absolute path directly
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
// If relative path given without session WD, it's ambiguous. Error out.
|
|
48
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Relative path provided but no working directory set for the session.", { context, operation });
|
|
49
|
+
}
|
|
50
|
+
logger.debug(`Resolved provided path: ${resolvedPath}`, { ...context, operation });
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const workingDir = context.getWorkingDirectory();
|
|
54
|
+
if (!workingDir) {
|
|
55
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "No path provided and no working directory set for the session.", { context, operation });
|
|
56
|
+
}
|
|
57
|
+
resolvedPath = workingDir; // Use session working directory
|
|
58
|
+
logger.debug(`Using session working directory: ${resolvedPath}`, { ...context, operation, sessionId: context.sessionId });
|
|
59
|
+
}
|
|
60
|
+
// Sanitize the resolved path
|
|
61
|
+
// We assume the resolved path should be absolute for git commands.
|
|
62
|
+
// sanitizePath checks for traversal and normalizes.
|
|
63
|
+
targetPath = sanitization.sanitizePath(resolvedPath, { allowAbsolute: true });
|
|
64
|
+
logger.debug(`Sanitized path: ${targetPath}`, { ...context, operation });
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
logger.error('Path resolution or sanitization failed', { ...context, operation, error });
|
|
68
|
+
if (error instanceof McpError) {
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
|
|
72
|
+
}
|
|
73
|
+
// --- Construct the git merge command ---
|
|
74
|
+
let command = `git -C "${targetPath}" merge`;
|
|
75
|
+
if (input.abort) {
|
|
76
|
+
command += ' --abort';
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
// Standard merge options
|
|
80
|
+
if (input.noFf)
|
|
81
|
+
command += ' --no-ff';
|
|
82
|
+
if (input.squash)
|
|
83
|
+
command += ' --squash';
|
|
84
|
+
if (input.commitMessage && !input.squash) { // Commit message only relevant if not squashing (squash requires separate commit)
|
|
85
|
+
command += ` -m "${input.commitMessage.replace(/"/g, '\\"')}"`;
|
|
86
|
+
}
|
|
87
|
+
else if (input.squash && input.commitMessage) {
|
|
88
|
+
logger.warning("Commit message provided with --squash, but it will be ignored. Squash requires a separate commit.", { ...context, operation });
|
|
89
|
+
}
|
|
90
|
+
command += ` "${input.branch.replace(/"/g, '\\"')}"`; // Add branch to merge
|
|
91
|
+
}
|
|
92
|
+
logger.debug(`Executing command: ${command}`, { ...context, operation });
|
|
93
|
+
// --- Execute and Parse ---
|
|
94
|
+
try {
|
|
95
|
+
const { stdout, stderr } = await execAsync(command);
|
|
96
|
+
logger.debug(`Command stdout: ${stdout}`, { ...context, operation });
|
|
97
|
+
if (stderr)
|
|
98
|
+
logger.debug(`Command stderr: ${stderr}`, { ...context, operation }); // Log stderr even on success
|
|
99
|
+
if (input.abort) {
|
|
100
|
+
return { success: true, message: 'Merge aborted successfully.', aborted: true };
|
|
101
|
+
}
|
|
102
|
+
// Check stdout/stderr for specific success messages
|
|
103
|
+
if (stdout.includes('Fast-forward')) {
|
|
104
|
+
return { success: true, message: `Merge successful (fast-forward): ${stdout.trim()}`, fastForward: true };
|
|
105
|
+
}
|
|
106
|
+
if (stdout.includes('Merge made by') || stdout.includes('merging')) { // Check for recursive strategy message etc.
|
|
107
|
+
const match = stdout.match(/Merge commit '([a-f0-9]+)'/); // Try to get merge commit hash
|
|
108
|
+
return {
|
|
109
|
+
success: true,
|
|
110
|
+
message: `Merge successful: ${stdout.trim()}`,
|
|
111
|
+
mergedCommitHash: match ? match[1] : undefined,
|
|
112
|
+
fastForward: false, // Explicitly not FF
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (stdout.includes('Squash commit -- not updating HEAD')) {
|
|
116
|
+
return {
|
|
117
|
+
success: true,
|
|
118
|
+
message: `Merge successful (--squash): Changes staged. Manual commit required. ${stdout.trim()}`,
|
|
119
|
+
needsManualCommit: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (stdout.includes('Already up to date')) {
|
|
123
|
+
return { success: true, message: `Already up to date.`, fastForward: true }; // Treat as success/FF
|
|
124
|
+
}
|
|
125
|
+
// If none of the above, return generic success based on exit code 0
|
|
126
|
+
return { success: true, message: `Merge command completed: ${stdout.trim()}` };
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
const errorMessage = error.stderr || error.stdout || error.message || ''; // Git often puts errors in stdout/stderr
|
|
130
|
+
logger.error(`Git merge command failed`, { ...context, operation, path: targetPath, error: error.message, output: errorMessage });
|
|
131
|
+
if (input.abort) {
|
|
132
|
+
// If abort failed, it's likely there was no merge in progress
|
|
133
|
+
if (errorMessage.includes('fatal: There is no merge to abort')) {
|
|
134
|
+
return { success: false, message: 'No merge in progress to abort.', aborted: false };
|
|
135
|
+
}
|
|
136
|
+
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to abort merge: ${errorMessage}`, { context, operation, originalError: error });
|
|
137
|
+
}
|
|
138
|
+
// Check for specific failure scenarios
|
|
139
|
+
if (errorMessage.includes('CONFLICT')) {
|
|
140
|
+
return { success: false, message: `Merge failed due to conflicts. Please resolve conflicts and commit. Output: ${errorMessage}`, conflict: true };
|
|
141
|
+
}
|
|
142
|
+
if (errorMessage.includes('refusing to merge unrelated histories')) {
|
|
143
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Merge failed: Refusing to merge unrelated histories. Consider using '--allow-unrelated-histories'.`, { context, operation, originalError: error });
|
|
144
|
+
}
|
|
145
|
+
if (errorMessage.includes('fatal: Not possible to fast-forward, aborting.')) {
|
|
146
|
+
throw new McpError(BaseErrorCode.CONFLICT, `Merge failed: Not possible to fast-forward. Merge required.`, { context, operation, originalError: error });
|
|
147
|
+
}
|
|
148
|
+
if (errorMessage.match(/fatal: '.*?' does not point to a commit/)) {
|
|
149
|
+
throw new McpError(BaseErrorCode.NOT_FOUND, `Merge failed: Branch '${input.branch}' not found or does not point to a commit.`, { context, operation, originalError: error });
|
|
150
|
+
}
|
|
151
|
+
if (errorMessage.includes('fatal: You have not concluded your merge')) {
|
|
152
|
+
return { success: false, message: `Merge failed: Conflicts still exist from a previous merge. Resolve conflicts or abort. Output: ${errorMessage}`, conflict: true };
|
|
153
|
+
}
|
|
154
|
+
if (errorMessage.includes('error: Your local changes to the following files would be overwritten by merge')) {
|
|
155
|
+
throw new McpError(BaseErrorCode.CONFLICT, `Merge failed: Local changes would be overwritten. Please commit or stash them.`, { context, operation, originalError: error });
|
|
156
|
+
}
|
|
157
|
+
// Generic error
|
|
158
|
+
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Git merge command failed for path ${targetPath}: ${errorMessage}`, { context, operation, originalError: error });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { logger } from '../../../utils/logger.js';
|
|
2
|
+
import { ErrorHandler } from '../../../utils/errorHandler.js';
|
|
3
|
+
import { requestContextService } from '../../../utils/requestContext.js';
|
|
4
|
+
import { gitMergeLogic, GitMergeInputSchema } from './logic.js';
|
|
5
|
+
import { BaseErrorCode } from '../../../types-global/errors.js';
|
|
6
|
+
let _getWorkingDirectory;
|
|
7
|
+
let _getSessionId;
|
|
8
|
+
/**
|
|
9
|
+
* Initializes the state accessors needed by the gitMerge tool registration.
|
|
10
|
+
* @param getWdFn - Function to get the working directory for a session.
|
|
11
|
+
* @param getSidFn - Function to get the session ID from context.
|
|
12
|
+
*/
|
|
13
|
+
export function initializeGitMergeStateAccessors(getWdFn, getSidFn) {
|
|
14
|
+
_getWorkingDirectory = getWdFn;
|
|
15
|
+
_getSessionId = getSidFn;
|
|
16
|
+
logger.info('State accessors initialized for git_merge tool registration.');
|
|
17
|
+
}
|
|
18
|
+
const TOOL_NAME = 'git_merge';
|
|
19
|
+
const TOOL_DESCRIPTION = 'Merges the specified branch into the current branch. Supports options like --no-ff, --squash, and --abort. Returns the merge result as a JSON object.';
|
|
20
|
+
/**
|
|
21
|
+
* Registers the git_merge tool with the MCP server.
|
|
22
|
+
*
|
|
23
|
+
* @param {McpServer} server - The McpServer instance.
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
* @throws {Error} If registration fails or state accessors are not initialized.
|
|
26
|
+
*/
|
|
27
|
+
export const registerGitMergeTool = async (server) => {
|
|
28
|
+
if (!_getWorkingDirectory || !_getSessionId) {
|
|
29
|
+
throw new Error('State accessors for git_merge must be initialized before registration.');
|
|
30
|
+
}
|
|
31
|
+
const operation = 'registerGitMergeTool';
|
|
32
|
+
const context = requestContextService.createRequestContext({ operation });
|
|
33
|
+
await ErrorHandler.tryCatch(async () => {
|
|
34
|
+
server.tool(TOOL_NAME, TOOL_DESCRIPTION, GitMergeInputSchema.shape, // Provide the Zod schema shape
|
|
35
|
+
async (validatedArgs, callContext) => {
|
|
36
|
+
const toolOperation = 'tool:git_merge';
|
|
37
|
+
const requestContext = requestContextService.createRequestContext({ operation: toolOperation, parentContext: callContext });
|
|
38
|
+
const sessionId = _getSessionId(requestContext);
|
|
39
|
+
const getWorkingDirectoryForSession = () => {
|
|
40
|
+
return _getWorkingDirectory(sessionId);
|
|
41
|
+
};
|
|
42
|
+
const logicContext = {
|
|
43
|
+
...requestContext,
|
|
44
|
+
sessionId: sessionId,
|
|
45
|
+
getWorkingDirectory: getWorkingDirectoryForSession,
|
|
46
|
+
};
|
|
47
|
+
logger.info(`Executing tool: ${TOOL_NAME}`, logicContext);
|
|
48
|
+
return await ErrorHandler.tryCatch(async () => {
|
|
49
|
+
// Call the core logic function
|
|
50
|
+
const mergeResult = await gitMergeLogic(validatedArgs, logicContext);
|
|
51
|
+
// Format the result as a JSON string within TextContent
|
|
52
|
+
const resultContent = {
|
|
53
|
+
type: 'text',
|
|
54
|
+
text: JSON.stringify(mergeResult, null, 2), // Pretty-print JSON
|
|
55
|
+
contentType: 'application/json',
|
|
56
|
+
};
|
|
57
|
+
// Log based on the success flag in the result
|
|
58
|
+
if (mergeResult.success) {
|
|
59
|
+
logger.info(`Tool ${TOOL_NAME} executed successfully, returning JSON`, logicContext);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// Log non-fatal conditions (like conflicts) differently from execution errors
|
|
63
|
+
logger.info(`Tool ${TOOL_NAME} completed with specific condition (e.g., conflict, no merge to abort), returning JSON`, logicContext);
|
|
64
|
+
}
|
|
65
|
+
// Even if success is false (e.g., conflicts), it's not a tool execution *error* in the MCP sense,
|
|
66
|
+
// the tool ran, but the git operation failed predictably. Return the structured result.
|
|
67
|
+
return { content: [resultContent] };
|
|
68
|
+
}, {
|
|
69
|
+
operation: toolOperation,
|
|
70
|
+
context: logicContext,
|
|
71
|
+
input: validatedArgs,
|
|
72
|
+
errorCode: BaseErrorCode.INTERNAL_ERROR, // Default for unexpected logic errors
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
logger.info(`Tool registered: ${TOOL_NAME}`, context);
|
|
76
|
+
}, { operation, context, critical: true });
|
|
77
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
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 input schema for the git_pull tool using Zod
|
|
9
|
+
export const GitPullInputSchema = 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
|
+
remote: z.string().optional().describe("The remote repository to pull from (e.g., 'origin'). Defaults to the tracked upstream or 'origin'."),
|
|
12
|
+
branch: z.string().optional().describe("The remote branch to pull. Defaults to the current branch's upstream."),
|
|
13
|
+
rebase: z.boolean().optional().default(false).describe("Use 'git pull --rebase' instead of merge."),
|
|
14
|
+
ffOnly: z.boolean().optional().default(false).describe("Use '--ff-only' to only allow fast-forward merges."),
|
|
15
|
+
// Add other relevant git pull options as needed (e.g., --prune, --tags, --depth)
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* Executes the 'git pull' command and returns structured JSON output.
|
|
19
|
+
*
|
|
20
|
+
* @param {GitPullInput} input - The validated input object.
|
|
21
|
+
* @param {RequestContext} context - The request context for logging and error handling, including session info and working dir getter.
|
|
22
|
+
* @returns {Promise<GitPullResult>} A promise that resolves with the structured pull result.
|
|
23
|
+
* @throws {McpError} Throws an McpError if path resolution, validation, or the git command fails unexpectedly.
|
|
24
|
+
*/
|
|
25
|
+
export async function pullGitChanges(input, context) {
|
|
26
|
+
const operation = 'pullGitChanges';
|
|
27
|
+
logger.debug(`Executing ${operation}`, { ...context, input });
|
|
28
|
+
let targetPath;
|
|
29
|
+
try {
|
|
30
|
+
// Resolve the target path
|
|
31
|
+
if (input.path && input.path !== '.') {
|
|
32
|
+
targetPath = input.path;
|
|
33
|
+
logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const workingDir = context.getWorkingDirectory();
|
|
37
|
+
if (!workingDir) {
|
|
38
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "No path provided and no working directory set for the session.", { context, operation });
|
|
39
|
+
}
|
|
40
|
+
targetPath = workingDir;
|
|
41
|
+
logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
|
|
42
|
+
}
|
|
43
|
+
// Sanitize the resolved path
|
|
44
|
+
targetPath = sanitization.sanitizePath(targetPath);
|
|
45
|
+
logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
logger.error('Path resolution or sanitization failed', { ...context, operation, error });
|
|
49
|
+
if (error instanceof McpError)
|
|
50
|
+
throw error;
|
|
51
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
// Construct the git pull command
|
|
55
|
+
let command = `git -C "${targetPath}" pull`;
|
|
56
|
+
if (input.rebase) {
|
|
57
|
+
command += ' --rebase';
|
|
58
|
+
}
|
|
59
|
+
if (input.ffOnly) {
|
|
60
|
+
command += ' --ff-only';
|
|
61
|
+
}
|
|
62
|
+
if (input.remote) {
|
|
63
|
+
// Sanitize remote and branch names - basic alphanumeric + common chars
|
|
64
|
+
const safeRemote = input.remote.replace(/[^a-zA-Z0-9_.\-/]/g, '');
|
|
65
|
+
command += ` ${safeRemote}`;
|
|
66
|
+
if (input.branch) {
|
|
67
|
+
const safeBranch = input.branch.replace(/[^a-zA-Z0-9_.\-/]/g, '');
|
|
68
|
+
command += ` ${safeBranch}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else if (input.branch) {
|
|
72
|
+
// If only branch is specified, assume 'origin' or tracked remote
|
|
73
|
+
const safeBranch = input.branch.replace(/[^a-zA-Z0-9_.\-/]/g, '');
|
|
74
|
+
command += ` origin ${safeBranch}`; // Defaulting to origin if remote not specified but branch is
|
|
75
|
+
logger.warning(`Remote not specified, defaulting to 'origin' for branch pull`, { ...context, operation });
|
|
76
|
+
}
|
|
77
|
+
logger.debug(`Executing command: ${command}`, { ...context, operation });
|
|
78
|
+
const { stdout, stderr } = await execAsync(command);
|
|
79
|
+
logger.info(`Git pull stdout: ${stdout}`, { ...context, operation });
|
|
80
|
+
if (stderr) {
|
|
81
|
+
// Stderr might contain progress or non-error info, log as warning unless it indicates a clear failure handled below
|
|
82
|
+
logger.warning(`Git pull stderr: ${stderr}`, { ...context, operation });
|
|
83
|
+
}
|
|
84
|
+
// Analyze stdout/stderr to determine the outcome
|
|
85
|
+
let message = stdout.trim() || stderr.trim(); // Use stdout first, fallback to stderr for message
|
|
86
|
+
let success = true;
|
|
87
|
+
let conflict = false;
|
|
88
|
+
let summary = undefined;
|
|
89
|
+
if (message.includes('Already up to date')) {
|
|
90
|
+
message = 'Already up to date.';
|
|
91
|
+
}
|
|
92
|
+
else if (message.includes('Fast-forward')) {
|
|
93
|
+
message = 'Pull successful (fast-forward).';
|
|
94
|
+
// Try to extract summary
|
|
95
|
+
const summaryMatch = stdout.match(/(\d+ files? changed.*)/);
|
|
96
|
+
if (summaryMatch)
|
|
97
|
+
summary = summaryMatch[1];
|
|
98
|
+
}
|
|
99
|
+
else if (message.includes('Merge made by the') || message.includes('merging')) { // Covers recursive and octopus
|
|
100
|
+
message = 'Pull successful (merge).';
|
|
101
|
+
const summaryMatch = stdout.match(/(\d+ files? changed.*)/);
|
|
102
|
+
if (summaryMatch)
|
|
103
|
+
summary = summaryMatch[1];
|
|
104
|
+
}
|
|
105
|
+
else if (message.includes('Automatic merge failed; fix conflicts and then commit the result.')) {
|
|
106
|
+
message = 'Pull resulted in merge conflicts.';
|
|
107
|
+
success = false; // Indicate failure due to conflicts
|
|
108
|
+
conflict = true;
|
|
109
|
+
}
|
|
110
|
+
else if (message.includes('fatal:')) {
|
|
111
|
+
// If a fatal error wasn't caught by the execAsync catch block but is in stdout/stderr
|
|
112
|
+
success = false;
|
|
113
|
+
message = `Pull failed: ${message}`;
|
|
114
|
+
logger.error(`Git pull command indicated failure: ${message}`, { ...context, operation, stdout, stderr });
|
|
115
|
+
}
|
|
116
|
+
// Add more specific checks based on git pull output variations if needed
|
|
117
|
+
logger.info(`${operation} completed`, { ...context, operation, path: targetPath, success, message, conflict });
|
|
118
|
+
return { success, message, summary, conflict };
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
logger.error(`Failed to execute git pull command`, { ...context, operation, path: targetPath, error: error.message, stderr: error.stderr, stdout: error.stdout });
|
|
122
|
+
const errorMessage = error.stderr || error.stdout || error.message || ''; // Check stdout too for errors
|
|
123
|
+
// Handle specific error cases
|
|
124
|
+
if (errorMessage.toLowerCase().includes('not a git repository')) {
|
|
125
|
+
throw new McpError(BaseErrorCode.NOT_FOUND, `Path is not a Git repository: ${targetPath}`, { context, operation, originalError: error });
|
|
126
|
+
}
|
|
127
|
+
if (errorMessage.includes('resolve host') || errorMessage.includes('Could not read from remote repository')) {
|
|
128
|
+
throw new McpError(BaseErrorCode.NETWORK_ERROR, `Failed to connect to remote repository. Error: ${errorMessage}`, { context, operation, originalError: error });
|
|
129
|
+
}
|
|
130
|
+
if (errorMessage.includes('merge conflict') || errorMessage.includes('fix conflicts')) {
|
|
131
|
+
// This might be caught here if execAsync throws due to non-zero exit code during conflict
|
|
132
|
+
logger.warning('Pull resulted in merge conflicts (caught as error)', { ...context, operation, path: targetPath, errorMessage });
|
|
133
|
+
return { success: false, message: 'Pull resulted in merge conflicts.', conflict: true };
|
|
134
|
+
}
|
|
135
|
+
if (errorMessage.includes('You have unstaged changes') || errorMessage.includes('Your local changes to the following files would be overwritten by merge')) {
|
|
136
|
+
throw new McpError(BaseErrorCode.CONFLICT, `Pull failed due to uncommitted local changes. Please commit or stash them first. Error: ${errorMessage}`, { context, operation, originalError: error });
|
|
137
|
+
}
|
|
138
|
+
if (errorMessage.includes('refusing to merge unrelated histories')) {
|
|
139
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Pull failed: Refusing to merge unrelated histories. Use '--allow-unrelated-histories' if intended.`, { context, operation, originalError: error });
|
|
140
|
+
}
|
|
141
|
+
// Generic internal error for other failures
|
|
142
|
+
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to pull changes for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ErrorHandler } from '../../../utils/errorHandler.js';
|
|
2
|
+
import { logger } from '../../../utils/logger.js';
|
|
3
|
+
import { requestContextService } from '../../../utils/requestContext.js';
|
|
4
|
+
import { GitPullInputSchema, pullGitChanges } from './logic.js';
|
|
5
|
+
import { BaseErrorCode } from '../../../types-global/errors.js';
|
|
6
|
+
let _getWorkingDirectory;
|
|
7
|
+
let _getSessionId;
|
|
8
|
+
/**
|
|
9
|
+
* Initializes the state accessors needed by the tool registration.
|
|
10
|
+
* This should be called once during server setup.
|
|
11
|
+
* @param getWdFn - Function to get the working directory for a session.
|
|
12
|
+
* @param getSidFn - Function to get the session ID from context.
|
|
13
|
+
*/
|
|
14
|
+
export function initializeGitPullStateAccessors(getWdFn, getSidFn) {
|
|
15
|
+
_getWorkingDirectory = getWdFn;
|
|
16
|
+
_getSessionId = getSidFn;
|
|
17
|
+
logger.info('State accessors initialized for git_pull tool registration.');
|
|
18
|
+
}
|
|
19
|
+
const TOOL_NAME = 'git_pull';
|
|
20
|
+
const TOOL_DESCRIPTION = "Fetches from and integrates with another repository or a local branch (e.g., 'git pull origin main'). Supports rebase and fast-forward only options. Returns the pull result as a JSON object.";
|
|
21
|
+
/**
|
|
22
|
+
* Registers the git_pull tool with the MCP server.
|
|
23
|
+
*
|
|
24
|
+
* @param {McpServer} server - The MCP server instance.
|
|
25
|
+
* @throws {Error} If state accessors are not initialized.
|
|
26
|
+
*/
|
|
27
|
+
export async function registerGitPullTool(server) {
|
|
28
|
+
if (!_getWorkingDirectory || !_getSessionId) {
|
|
29
|
+
throw new Error('State accessors for git_pull must be initialized before registration.');
|
|
30
|
+
}
|
|
31
|
+
const operation = 'registerGitPullTool';
|
|
32
|
+
const context = requestContextService.createRequestContext({ operation });
|
|
33
|
+
await ErrorHandler.tryCatch(async () => {
|
|
34
|
+
server.tool(TOOL_NAME, TOOL_DESCRIPTION, GitPullInputSchema.shape, // Provide the Zod schema shape
|
|
35
|
+
async (validatedArgs, callContext) => {
|
|
36
|
+
const toolOperation = 'tool:git_pull';
|
|
37
|
+
const requestContext = requestContextService.createRequestContext({ operation: toolOperation, parentContext: callContext });
|
|
38
|
+
const sessionId = _getSessionId(requestContext);
|
|
39
|
+
const getWorkingDirectoryForSession = () => {
|
|
40
|
+
return _getWorkingDirectory(sessionId);
|
|
41
|
+
};
|
|
42
|
+
const logicContext = {
|
|
43
|
+
...requestContext,
|
|
44
|
+
sessionId: sessionId,
|
|
45
|
+
getWorkingDirectory: getWorkingDirectoryForSession,
|
|
46
|
+
};
|
|
47
|
+
logger.info(`Executing tool: ${TOOL_NAME}`, logicContext);
|
|
48
|
+
return await ErrorHandler.tryCatch(async () => {
|
|
49
|
+
// Call the core logic function
|
|
50
|
+
const pullResult = await pullGitChanges(validatedArgs, logicContext);
|
|
51
|
+
// Format the result as a JSON string within TextContent
|
|
52
|
+
const resultContent = {
|
|
53
|
+
type: 'text',
|
|
54
|
+
text: JSON.stringify(pullResult, null, 2), // Pretty-print JSON
|
|
55
|
+
contentType: 'application/json',
|
|
56
|
+
};
|
|
57
|
+
// Log based on the success flag in the result
|
|
58
|
+
if (pullResult.success) {
|
|
59
|
+
logger.info(`Tool ${TOOL_NAME} executed successfully, returning JSON`, logicContext);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// Log non-fatal conditions like conflicts or already up-to-date differently
|
|
63
|
+
const logMessage = pullResult.conflict
|
|
64
|
+
? `Tool ${TOOL_NAME} completed with merge conflicts, returning JSON`
|
|
65
|
+
: `Tool ${TOOL_NAME} completed with status: ${pullResult.message}, returning JSON`;
|
|
66
|
+
logger.info(logMessage, logicContext);
|
|
67
|
+
}
|
|
68
|
+
// Even if success is false (e.g., conflicts), it's not necessarily a tool execution *error*
|
|
69
|
+
// unless the logic threw an McpError. The success flag in the JSON indicates the Git outcome.
|
|
70
|
+
return { content: [resultContent] };
|
|
71
|
+
}, {
|
|
72
|
+
operation: toolOperation,
|
|
73
|
+
context: logicContext,
|
|
74
|
+
input: validatedArgs,
|
|
75
|
+
errorCode: BaseErrorCode.INTERNAL_ERROR, // Default if unexpected error in logic
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
logger.info(`Tool registered: ${TOOL_NAME}`, context);
|
|
79
|
+
}, { operation, context, critical: true });
|
|
80
|
+
}
|
|
81
|
+
;
|