@cyanheads/git-mcp-server 2.1.3 → 2.1.5

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 (37) hide show
  1. package/README.md +10 -8
  2. package/dist/config/index.js +10 -2
  3. package/dist/mcp-server/server.js +33 -32
  4. package/dist/mcp-server/tools/gitAdd/logic.js +18 -45
  5. package/dist/mcp-server/tools/gitBranch/logic.js +39 -34
  6. package/dist/mcp-server/tools/gitCheckout/logic.js +17 -12
  7. package/dist/mcp-server/tools/gitCherryPick/logic.js +22 -15
  8. package/dist/mcp-server/tools/gitClean/logic.js +11 -8
  9. package/dist/mcp-server/tools/gitClone/logic.js +15 -11
  10. package/dist/mcp-server/tools/gitCommit/logic.js +29 -65
  11. package/dist/mcp-server/tools/gitDiff/logic.js +29 -14
  12. package/dist/mcp-server/tools/gitFetch/logic.js +13 -12
  13. package/dist/mcp-server/tools/gitInit/logic.js +12 -9
  14. package/dist/mcp-server/tools/gitLog/logic.js +17 -30
  15. package/dist/mcp-server/tools/gitMerge/logic.js +17 -12
  16. package/dist/mcp-server/tools/gitPull/logic.js +13 -14
  17. package/dist/mcp-server/tools/gitPush/logic.js +19 -21
  18. package/dist/mcp-server/tools/gitRebase/logic.js +29 -20
  19. package/dist/mcp-server/tools/gitRemote/logic.js +15 -15
  20. package/dist/mcp-server/tools/gitReset/logic.js +11 -10
  21. package/dist/mcp-server/tools/gitSetWorkingDir/logic.js +6 -4
  22. package/dist/mcp-server/tools/gitShow/logic.js +9 -8
  23. package/dist/mcp-server/tools/gitStash/logic.js +16 -17
  24. package/dist/mcp-server/tools/gitStatus/logic.js +10 -8
  25. package/dist/mcp-server/tools/gitTag/logic.js +15 -15
  26. package/dist/mcp-server/tools/gitWorktree/logic.js +54 -38
  27. package/dist/mcp-server/transports/auth/core/authContext.js +24 -0
  28. package/dist/mcp-server/transports/auth/core/authTypes.js +5 -0
  29. package/dist/mcp-server/transports/auth/core/authUtils.js +45 -0
  30. package/dist/mcp-server/transports/auth/index.js +9 -0
  31. package/dist/mcp-server/transports/auth/strategies/jwt/jwtMiddleware.js +149 -0
  32. package/dist/mcp-server/transports/auth/strategies/oauth/oauthMiddleware.js +127 -0
  33. package/dist/mcp-server/transports/httpErrorHandler.js +73 -0
  34. package/dist/mcp-server/transports/httpTransport.js +149 -495
  35. package/dist/mcp-server/transports/stdioTransport.js +18 -48
  36. package/package.json +4 -13
  37. package/dist/mcp-server/transports/authentication/authMiddleware.js +0 -167
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![TypeScript](https://img.shields.io/badge/TypeScript-^5.8.3-blue.svg)](https://www.typescriptlang.org/)
4
4
  [![Model Context Protocol](https://img.shields.io/badge/MCP%20SDK-^1.13.0-green.svg)](https://modelcontextprotocol.io/)
5
- [![Version](https://img.shields.io/badge/Version-2.1.3-blue.svg)](./CHANGELOG.md)
5
+ [![Version](https://img.shields.io/badge/Version-2.1.4-blue.svg)](./CHANGELOG.md)
6
6
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
7
7
  [![Status](https://img.shields.io/badge/Status-Stable-green.svg)](https://github.com/cyanheads/git-mcp-server/issues)
8
8
  [![GitHub](https://img.shields.io/github/stars/cyanheads/git-mcp-server?style=social)](https://github.com/cyanheads/git-mcp-server)
@@ -29,10 +29,9 @@ This server equips your AI with a comprehensive suite of tools to interact with
29
29
 
30
30
  ## Table of Contents
31
31
 
32
- | [Overview](#overview) | [Features](#features) | [Installation](#installation) |
33
- | :------------------------------ | :-------------------------------------- | :---------------------------- | ------------------- |
32
+ | [Overview](#overview) | [Features](#features) | [Installation](#installation) |
34
33
  | [Configuration](#configuration) | [Project Structure](#project-structure) |
35
- | [Tools](#tools) | [Resources](#resources) | [Development](#development) | [License](#license) |
34
+ | [Tools](#tools) | [Resources](#resources) | [Development](#development) | [License](#license) |
36
35
 
37
36
  ## Overview
38
37
 
@@ -61,7 +60,7 @@ Leverages the robust utilities provided by the `mcp-ts-template`:
61
60
  - **Input Validation/Sanitization**: Uses `zod` for schema validation and custom sanitization logic (crucial for paths).
62
61
  - **Request Context**: Tracking and correlation of operations via unique request IDs using `AsyncLocalStorage`.
63
62
  - **Type Safety**: Strong typing enforced by TypeScript and Zod schemas.
64
- - **HTTP Transport**: High-performance HTTP server using **Hono**, featuring session management with garbage collection, CORS, and IP-based rate limiting.
63
+ - **HTTP Transport**: High-performance HTTP server using **Hono**, featuring session management, CORS, and authentication support.
65
64
  - **Deployment**: Multi-stage `Dockerfile` for creating small, secure production images with native dependency support.
66
65
 
67
66
  ### Git Integration
@@ -100,7 +99,7 @@ Add the following to your MCP client's configuration file (e.g., `cline_mcp_sett
100
99
  }
101
100
  ```
102
101
 
103
- ### If running manually (not via MCP client for development or testing)
102
+ ### If running manually (not via MCP client) for development or testing
104
103
 
105
104
  #### Install via npm
106
105
 
@@ -143,7 +142,10 @@ Configure the server using environment variables. These environmental variables
143
142
  | `MCP_ALLOWED_ORIGINS` | Comma-separated list of allowed origins for CORS (if `MCP_TRANSPORT_TYPE=http`). | (none) |
144
143
  | `MCP_LOG_LEVEL` | Logging level (`debug`, `info`, `notice`, `warning`, `error`, `crit`, `alert`, `emerg`). Inherited from template. | `info` |
145
144
  | `GIT_SIGN_COMMITS` | Set to `"true"` to enable signing attempts for commits made by the `git_commit` tool. Requires server-side Git/key setup (see below). | `false` |
146
- | `MCP_AUTH_SECRET_KEY` | Secret key for signing/verifying authentication tokens (required if auth is enabled in the future). | `''` |
145
+ | `MCP_AUTH_MODE` | Authentication mode: `jwt`, `oauth`, or `none`. | `none` |
146
+ | `MCP_AUTH_SECRET_KEY` | Secret key for JWT validation (if `MCP_AUTH_MODE=jwt`). | `''` |
147
+ | `OAUTH_ISSUER_URL` | OIDC issuer URL for OAuth validation (if `MCP_AUTH_MODE=oauth`). | `''` |
148
+ | `OAUTH_AUDIENCE` | Audience claim for OAuth validation (if `MCP_AUTH_MODE=oauth`). | `''` |
147
149
 
148
150
  ## Project Structure
149
151
 
@@ -201,7 +203,7 @@ _Note: The `path` parameter for most tools defaults to the session's working dir
201
203
 
202
204
  ## Resources
203
205
 
204
- **MCP Resources are not implemented in this version (v2.1.2).**
206
+ **MCP Resources are not implemented in this version (v2.1.4).**
205
207
 
206
208
  This version focuses on the refactored Git tools implementation based on the latest `mcp-ts-template` and MCP SDK v1.13.0. Resource capabilities, previously available, have been temporarily removed during this major update.
207
209
 
@@ -40,14 +40,22 @@ export const config = {
40
40
  mcpAllowedOrigins: process.env.MCP_ALLOWED_ORIGINS?.split(",") || [],
41
41
  /** Flag to enable GPG signing for commits made by the git_commit tool. Requires server-side GPG setup. */
42
42
  gitSignCommits: process.env.GIT_SIGN_COMMITS === "true",
43
+ /** The authentication mode ('jwt', 'oauth', or 'none'). Defaults to 'none'. */
44
+ mcpAuthMode: process.env.MCP_AUTH_MODE || "none",
45
+ /** Secret key for signing/verifying JWTs. Required if mcpAuthMode is 'jwt'. */
46
+ mcpAuthSecretKey: process.env.MCP_AUTH_SECRET_KEY,
47
+ /** The OIDC issuer URL for OAuth token validation. Required if mcpAuthMode is 'oauth'. */
48
+ oauthIssuerUrl: process.env.OAUTH_ISSUER_URL,
49
+ /** The audience claim for OAuth token validation. Required if mcpAuthMode is 'oauth'. */
50
+ oauthAudience: process.env.OAUTH_AUDIENCE,
51
+ /** The JWKS URI for fetching public keys for OAuth. Optional, can be derived from issuer URL. */
52
+ oauthJwksUri: process.env.OAUTH_JWKS_URI,
43
53
  /** Security-related configurations. */
44
54
  security: {
45
55
  // Placeholder for security settings
46
56
  // Example: authRequired: process.env.AUTH_REQUIRED === 'true'
47
57
  /** Indicates if authentication is required for server operations. */
48
58
  authRequired: false,
49
- /** Secret key for signing/verifying authentication tokens (required if authRequired is true). */
50
- mcpAuthSecretKey: process.env.MCP_AUTH_SECRET_KEY || "", // Default to empty string, validation should happen elsewhere
51
59
  },
52
60
  // Note: mcpClient configuration is now loaded separately from mcp-config.json
53
61
  };
@@ -43,9 +43,9 @@ import { initializeGitStatusStateAccessors, registerGitStatusTool, } from "./too
43
43
  import { initializeGitTagStateAccessors, registerGitTagTool, } from "./tools/gitTag/index.js";
44
44
  import { initializeGitWorktreeStateAccessors, registerGitWorktreeTool, } from "./tools/gitWorktree/index.js";
45
45
  import { initializeGitWrapupInstructionsStateAccessors, registerGitWrapupInstructionsTool, } from "./tools/gitWrapupInstructions/index.js";
46
- // Import transport setup functions AND state accessors
47
- import { getHttpSessionWorkingDirectory, setHttpSessionWorkingDirectory, startHttpTransport, } from "./transports/httpTransport.js";
48
- import { connectStdioTransport, getStdioWorkingDirectory, setStdioWorkingDirectory, } from "./transports/stdioTransport.js";
46
+ // Import transport setup functions
47
+ import { startHttpTransport } from "./transports/httpTransport.js";
48
+ import { connectStdioTransport } from "./transports/stdioTransport.js";
49
49
  /**
50
50
  * Creates and configures a new instance of the McpServer.
51
51
  *
@@ -79,7 +79,9 @@ import { connectStdioTransport, getStdioWorkingDirectory, setStdioWorkingDirecto
79
79
  */
80
80
  // Removed sessionId parameter, it will be retrieved from context within tool handlers
81
81
  async function createMcpServerInstance() {
82
- const context = { operation: "createMcpServerInstance" };
82
+ const context = requestContextService.createRequestContext({
83
+ operation: "createMcpServerInstance",
84
+ });
83
85
  logger.info("Initializing MCP server instance", context);
84
86
  // Configure the request context service (used for correlating logs/errors).
85
87
  requestContextService.configure({
@@ -110,6 +112,9 @@ async function createMcpServerInstance() {
110
112
  tools: { listChanged: true },
111
113
  },
112
114
  });
115
+ // Each server instance is isolated per session. This variable will hold the
116
+ // working directory for the duration of this session.
117
+ let sessionWorkingDirectory = undefined;
113
118
  // --- Define Unified State Accessor Functions ---
114
119
  // These functions abstract away the transport type to get/set session state.
115
120
  /** Gets the session ID from the tool's execution context. */
@@ -117,34 +122,21 @@ async function createMcpServerInstance() {
117
122
  // The RequestContext created by the tool registration wrapper should contain the sessionId.
118
123
  return toolContext?.sessionId;
119
124
  };
120
- /** Gets the working directory based on transport type and session ID. */
125
+ /** Gets the working directory for the current session. */
121
126
  const getWorkingDirectory = (sessionId) => {
122
- if (config.mcpTransportType === "http") {
123
- if (!sessionId) {
124
- logger.warning("Attempted to get HTTP working directory without session ID", { ...context, caller: "getWorkingDirectory" });
125
- return undefined;
126
- }
127
- return getHttpSessionWorkingDirectory(sessionId);
128
- }
129
- else {
130
- // For stdio, there's only one implicit session, ID is not needed.
131
- return getStdioWorkingDirectory();
132
- }
127
+ // The working directory is now stored in a variable scoped to this server instance.
128
+ // The sessionId is kept for potential logging or more complex future state management.
129
+ return sessionWorkingDirectory;
133
130
  };
134
- /** Sets the working directory based on transport type and session ID. */
131
+ /** Sets the working directory for the current session. */
135
132
  const setWorkingDirectory = (sessionId, dir) => {
136
- if (config.mcpTransportType === "http") {
137
- if (!sessionId) {
138
- logger.error("Attempted to set HTTP working directory without session ID", { ...context, caller: "setWorkingDirectory", dir });
139
- // Optionally throw an error or just log
140
- return;
141
- }
142
- setHttpSessionWorkingDirectory(sessionId, dir);
143
- }
144
- else {
145
- // For stdio, set the single session's directory.
146
- setStdioWorkingDirectory(dir);
147
- }
133
+ // The working directory is now stored in a variable scoped to this server instance.
134
+ logger.debug("Setting session working directory", {
135
+ ...context,
136
+ sessionId,
137
+ newDirectory: dir,
138
+ });
139
+ sessionWorkingDirectory = dir;
148
140
  };
149
141
  // --- Initialize Tool State Accessors BEFORE Registration ---
150
142
  // Pass the defined unified accessor functions to the initializers.
@@ -254,7 +246,10 @@ async function createMcpServerInstance() {
254
246
  async function startTransport() {
255
247
  // Determine the transport type from the validated configuration.
256
248
  const transportType = config.mcpTransportType;
257
- const context = { operation: "startTransport", transport: transportType };
249
+ const context = requestContextService.createRequestContext({
250
+ operation: "startTransport",
251
+ transport: transportType,
252
+ });
258
253
  logger.info(`Starting transport: ${transportType}`, context);
259
254
  // --- HTTP Transport Setup ---
260
255
  if (transportType === "http") {
@@ -294,7 +289,9 @@ async function startTransport() {
294
289
  * @returns {Promise<void | McpServer>} Resolves upon successful startup (void for http, McpServer for stdio). Rejects on critical failure.
295
290
  */
296
291
  export async function initializeAndStartServer() {
297
- const context = { operation: "initializeAndStartServer" };
292
+ const context = requestContextService.createRequestContext({
293
+ operation: "initializeAndStartServer",
294
+ });
298
295
  logger.info("MCP Server initialization sequence started.", context);
299
296
  try {
300
297
  // Initiate the transport setup based on configuration.
@@ -310,7 +307,11 @@ export async function initializeAndStartServer() {
310
307
  stack: err instanceof Error ? err.stack : undefined,
311
308
  });
312
309
  // Use the centralized error handler for consistent critical error reporting.
313
- ErrorHandler.handleError(err, { ...context, critical: true });
310
+ ErrorHandler.handleError(err, {
311
+ ...context,
312
+ operation: "initializeAndStartServer_Catch",
313
+ critical: true,
314
+ });
314
315
  // Exit the process with a non-zero code to indicate failure.
315
316
  logger.info("Exiting process due to critical initialization error.", context);
316
317
  process.exit(1);
@@ -1,4 +1,4 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { z } from "zod";
4
4
  // Import utils from barrel (logger from ../utils/internal/logger.js)
@@ -7,7 +7,7 @@ import { logger } from "../../../utils/index.js";
7
7
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Keep direct import for types-global
8
8
  // Import utils from barrel (sanitization from ../utils/security/sanitization.js)
9
9
  import { sanitization } from "../../../utils/index.js";
10
- const execAsync = promisify(exec);
10
+ const execFileAsync = promisify(execFile);
11
11
  // Define the input schema for the git_add tool using Zod
12
12
  export const GitAddInputSchema = z.object({
13
13
  path: z
@@ -76,52 +76,25 @@ export async function addGitFiles(input, context) {
76
76
  }
77
77
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
78
78
  }
79
- // Prepare the files argument for the command, ensuring proper quoting
80
- let filesArg;
81
- const filesToStage = input.files; // Keep original for reporting
82
- try {
83
- if (Array.isArray(filesToStage)) {
84
- if (filesToStage.length === 0) {
85
- logger.warning("Empty array provided for files, defaulting to staging all changes.", { ...context, operation });
86
- filesArg = "."; // Default to staging all if array is empty
87
- }
88
- else {
89
- // Quote each file path individually
90
- filesArg = filesToStage
91
- .map((file) => {
92
- const sanitizedFile = file.startsWith("-") ? `./${file}` : file; // Prefix with './' if it starts with a dash
93
- return `"${sanitizedFile.replace(/"/g, '\\"')}"`; // Escape quotes within path
94
- })
95
- .join(" ");
96
- }
97
- }
98
- else {
99
- // Single string case
100
- const sanitizedFile = filesToStage.startsWith("-")
101
- ? `./${filesToStage}`
102
- : filesToStage; // Prefix with './' if it starts with a dash
103
- filesArg = `"${sanitizedFile.replace(/"/g, '\\"')}"`;
104
- }
79
+ const filesToStage = Array.isArray(input.files)
80
+ ? input.files
81
+ : [input.files];
82
+ if (filesToStage.length === 0) {
83
+ filesToStage.push("."); // Default to staging all if array is empty
105
84
  }
106
- catch (err) {
107
- logger.error("File path validation/quoting failed", {
85
+ try {
86
+ const args = ["-C", targetPath, "add", "--"];
87
+ filesToStage.forEach((file) => {
88
+ // Sanitize each file path. Although execFile is safer,
89
+ // this prevents arguments like "-v" from being treated as flags by git.
90
+ const sanitizedFile = file.startsWith("-") ? `./${file}` : file;
91
+ args.push(sanitizedFile);
92
+ });
93
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
108
94
  ...context,
109
95
  operation,
110
- files: filesToStage,
111
- error: err,
112
96
  });
113
- throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid file path/pattern provided: ${err instanceof Error ? err.message : String(err)}`, { context, operation, originalError: err });
114
- }
115
- // This check should ideally not be needed now due to the logic above
116
- if (!filesArg) {
117
- logger.error("Internal error: filesArg is unexpectedly empty after processing.", { ...context, operation });
118
- throw new McpError(BaseErrorCode.INTERNAL_ERROR, "Internal error preparing git add command.", { context, operation });
119
- }
120
- try {
121
- // Use the resolved targetPath
122
- const command = `git -C "${targetPath}" add -- ${filesArg}`;
123
- logger.debug(`Executing command: ${command}`, { ...context, operation });
124
- const { stdout, stderr } = await execAsync(command);
97
+ const { stdout, stderr } = await execFileAsync("git", args);
125
98
  if (stderr) {
126
99
  // Log stderr as warning, as 'git add' can produce warnings but still succeed.
127
100
  logger.warning(`Git add command produced stderr`, {
@@ -162,7 +135,7 @@ export async function addGitFiles(input, context) {
162
135
  }
163
136
  if (errorMessage.toLowerCase().includes("did not match any files")) {
164
137
  // Still throw an error, but return structured info in the catch block of the registration
165
- throw new McpError(BaseErrorCode.NOT_FOUND, `Specified files/patterns did not match any files in ${targetPath}: ${filesArg}`, { context, operation, originalError: error, filesStaged: filesToStage });
138
+ throw new McpError(BaseErrorCode.NOT_FOUND, `Specified files/patterns did not match any files in ${targetPath}: ${filesToStage.join(", ")}`, { context, operation, originalError: error, filesStaged: filesToStage });
166
139
  }
167
140
  // Throw generic error for other cases
168
141
  throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to stage files for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error, filesStaged: filesToStage });
@@ -1,4 +1,4 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { z } from "zod";
4
4
  // Import utils from barrel (logger from ../utils/internal/logger.js)
@@ -7,7 +7,7 @@ import { logger } from "../../../utils/index.js";
7
7
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Keep direct import for types-global
8
8
  // Import utils from barrel (sanitization from ../utils/security/sanitization.js)
9
9
  import { sanitization } from "../../../utils/index.js";
10
- const execAsync = promisify(exec);
10
+ const execFileAsync = promisify(execFile);
11
11
  // Define the BASE input schema for the git_branch tool using Zod
12
12
  export const GitBranchBaseSchema = z.object({
13
13
  path: z
@@ -115,21 +115,23 @@ export async function gitBranchLogic(input, context) {
115
115
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
116
116
  }
117
117
  try {
118
- let command;
118
+ let args;
119
119
  let result;
120
120
  switch (input.mode) {
121
121
  case "list":
122
- command = `git -C "${targetPath}" branch --list --no-color`; // Start with basic list
123
- if (input.all)
124
- command += " -a"; // Add -a if requested
125
- else if (input.remote)
126
- command += " -r"; // Add -r if requested (exclusive with -a)
127
- command += " --verbose"; // Add verbose for commit info
128
- logger.debug(`Executing command: ${command}`, {
122
+ args = ["-C", targetPath, "branch", "--list", "--no-color"]; // Start with basic list
123
+ if (input.all) {
124
+ args.push("-a"); // Add -a if requested
125
+ }
126
+ else if (input.remote) {
127
+ args.push("-r"); // Add -r if requested (exclusive with -a)
128
+ }
129
+ args.push("--verbose"); // Add verbose for commit info
130
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
129
131
  ...context,
130
132
  operation,
131
133
  });
132
- const { stdout: listStdout } = await execAsync(command);
134
+ const { stdout: listStdout } = await execFileAsync("git", args);
133
135
  const branches = listStdout
134
136
  .trim()
135
137
  .split("\n")
@@ -157,17 +159,19 @@ export async function gitBranchLogic(input, context) {
157
159
  break;
158
160
  case "create":
159
161
  // branchName is validated by Zod refine
160
- command = `git -C "${targetPath}" branch `;
161
- if (input.force)
162
- command += "-f ";
163
- command += `"${input.branchName}"`; // branchName is guaranteed by refine
164
- if (input.startPoint)
165
- command += ` "${input.startPoint}"`;
166
- logger.debug(`Executing command: ${command}`, {
162
+ args = ["-C", targetPath, "branch"];
163
+ if (input.force) {
164
+ args.push("-f");
165
+ }
166
+ args.push(input.branchName); // branchName is guaranteed by refine
167
+ if (input.startPoint) {
168
+ args.push(input.startPoint);
169
+ }
170
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
167
171
  ...context,
168
172
  operation,
169
173
  });
170
- await execAsync(command);
174
+ await execFileAsync("git", args);
171
175
  result = {
172
176
  success: true,
173
177
  mode: "create",
@@ -177,16 +181,17 @@ export async function gitBranchLogic(input, context) {
177
181
  break;
178
182
  case "delete":
179
183
  // branchName is validated by Zod refine
180
- command = `git -C "${targetPath}" branch `;
181
- if (input.remote)
182
- command += "-r ";
183
- command += input.force ? "-D " : "-d ";
184
- command += `"${input.branchName}"`; // branchName is guaranteed by refine
185
- logger.debug(`Executing command: ${command}`, {
184
+ args = ["-C", targetPath, "branch"];
185
+ if (input.remote) {
186
+ args.push("-r");
187
+ }
188
+ args.push(input.force ? "-D" : "-d");
189
+ args.push(input.branchName); // branchName is guaranteed by refine
190
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
186
191
  ...context,
187
192
  operation,
188
193
  });
189
- const { stdout: deleteStdout } = await execAsync(command);
194
+ const { stdout: deleteStdout } = await execFileAsync("git", args);
190
195
  result = {
191
196
  success: true,
192
197
  mode: "delete",
@@ -198,14 +203,14 @@ export async function gitBranchLogic(input, context) {
198
203
  break;
199
204
  case "rename":
200
205
  // branchName and newBranchName validated by Zod refine
201
- command = `git -C "${targetPath}" branch `;
202
- command += input.force ? "-M " : "-m ";
203
- command += `"${input.branchName}" "${input.newBranchName}"`;
204
- logger.debug(`Executing command: ${command}`, {
206
+ args = ["-C", targetPath, "branch"];
207
+ args.push(input.force ? "-M" : "-m");
208
+ args.push(input.branchName, input.newBranchName);
209
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
205
210
  ...context,
206
211
  operation,
207
212
  });
208
- await execAsync(command);
213
+ await execFileAsync("git", args);
209
214
  result = {
210
215
  success: true,
211
216
  mode: "rename",
@@ -215,13 +220,13 @@ export async function gitBranchLogic(input, context) {
215
220
  };
216
221
  break;
217
222
  case "show-current":
218
- command = `git -C "${targetPath}" branch --show-current`;
219
- logger.debug(`Executing command: ${command}`, {
223
+ args = ["-C", targetPath, "branch", "--show-current"];
224
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
220
225
  ...context,
221
226
  operation,
222
227
  });
223
228
  try {
224
- const { stdout: currentStdout } = await execAsync(command);
229
+ const { stdout: currentStdout } = await execFileAsync("git", args);
225
230
  const currentBranchName = currentStdout.trim();
226
231
  result = {
227
232
  success: true,
@@ -1,4 +1,4 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { z } from "zod";
4
4
  // Import utils from barrel (logger from ../utils/internal/logger.js)
@@ -7,7 +7,7 @@ import { logger } from "../../../utils/index.js";
7
7
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Keep direct import for types-global
8
8
  // Import utils from barrel (sanitization from ../utils/security/sanitization.js)
9
9
  import { sanitization } from "../../../utils/index.js";
10
- const execAsync = promisify(exec);
10
+ const execFileAsync = promisify(execFile);
11
11
  // Define the input schema for the git_checkout tool using Zod
12
12
  export const GitCheckoutInputSchema = z.object({
13
13
  path: z
@@ -75,22 +75,22 @@ export async function checkoutGit(input, context) {
75
75
  throw error;
76
76
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
77
77
  }
78
- // Basic sanitization for branch/path argument
79
- const safeBranchOrPath = input.branchOrPath.replace(/[`$&;*()|<>]/g, ""); // Remove potentially dangerous characters
80
78
  try {
81
79
  // Construct the git checkout command
82
- let command = `git -C "${targetPath}" checkout`;
80
+ const args = ["-C", targetPath, "checkout"];
83
81
  if (input.force) {
84
- command += " --force";
82
+ args.push("--force");
85
83
  }
86
84
  if (input.newBranch) {
87
- const safeNewBranch = input.newBranch.replace(/[^a-zA-Z0-9_.\-/]/g, ""); // Sanitize new branch name
88
- command += ` -b ${safeNewBranch}`;
85
+ args.push("-b", input.newBranch);
89
86
  }
90
- command += ` ${safeBranchOrPath}`; // Add the target branch/path
91
- logger.debug(`Executing command: ${command}`, { ...context, operation });
87
+ args.push(input.branchOrPath); // Add the target branch/path
88
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
89
+ ...context,
90
+ operation,
91
+ });
92
92
  // Execute command. Checkout often uses stderr for status messages.
93
- const { stdout, stderr } = await execAsync(command);
93
+ const { stdout, stderr } = await execFileAsync("git", args);
94
94
  const message = stderr.trim() || stdout.trim();
95
95
  logger.debug(`Git checkout stdout: ${stdout}`, { ...context, operation });
96
96
  if (stderr) {
@@ -99,7 +99,12 @@ export async function checkoutGit(input, context) {
99
99
  // Get the current branch name after the checkout operation
100
100
  let currentBranch;
101
101
  try {
102
- const { stdout: branchStdout } = await execAsync(`git -C "${targetPath}" branch --show-current`);
102
+ const { stdout: branchStdout } = await execFileAsync("git", [
103
+ "-C",
104
+ targetPath,
105
+ "branch",
106
+ "--show-current",
107
+ ]);
103
108
  currentBranch = branchStdout.trim();
104
109
  }
105
110
  catch (e) {
@@ -1,4 +1,4 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { z } from "zod";
4
4
  // Import utils from barrel (logger from ../utils/internal/logger.js)
@@ -7,7 +7,7 @@ import { logger } from "../../../utils/index.js";
7
7
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Keep direct import for types-global
8
8
  // Import utils from barrel (sanitization from ../utils/security/sanitization.js)
9
9
  import { sanitization } from "../../../utils/index.js";
10
- const execAsync = promisify(exec);
10
+ const execFileAsync = promisify(execFile);
11
11
  // Define the input schema for the git_cherry-pick tool using Zod
12
12
  export const GitCherryPickInputSchema = z.object({
13
13
  path: z
@@ -95,20 +95,27 @@ export async function gitCherryPickLogic(input, context) {
95
95
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
96
96
  }
97
97
  try {
98
- let command = `git -C "${targetPath}" cherry-pick`;
99
- if (input.mainline)
100
- command += ` -m ${input.mainline}`;
101
- if (input.strategy)
102
- command += ` -X${input.strategy}`; // Note: -X for strategy options
103
- if (input.noCommit)
104
- command += " --no-commit";
105
- if (input.signoff)
106
- command += " --signoff";
107
- // Add the commit reference(s) - ensure it's treated as a single argument potentially containing special chars like '..'
108
- command += ` "${input.commitRef.replace(/"/g, '\\"')}"`;
109
- logger.debug(`Executing command: ${command}`, { ...context, operation });
98
+ const args = ["-C", targetPath, "cherry-pick"];
99
+ if (input.mainline) {
100
+ args.push("-m", String(input.mainline));
101
+ }
102
+ if (input.strategy) {
103
+ args.push(`-X${input.strategy}`);
104
+ } // Note: -X for strategy options
105
+ if (input.noCommit) {
106
+ args.push("--no-commit");
107
+ }
108
+ if (input.signoff) {
109
+ args.push("--signoff");
110
+ }
111
+ // Add the commit reference(s)
112
+ args.push(input.commitRef);
113
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
114
+ ...context,
115
+ operation,
116
+ });
110
117
  try {
111
- const { stdout, stderr } = await execAsync(command);
118
+ const { stdout, stderr } = await execFileAsync("git", args);
112
119
  // Check stdout/stderr for conflict messages, although exit code 0 usually means success
113
120
  const output = stdout + stderr;
114
121
  const conflicts = /conflict/i.test(output);
@@ -1,4 +1,4 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { z } from "zod";
4
4
  // Import utils from barrel (logger from ../utils/internal/logger.js)
@@ -7,7 +7,7 @@ import { logger } from "../../../utils/index.js";
7
7
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Keep direct import for types-global
8
8
  // Import utils from barrel (sanitization from ../utils/security/sanitization.js)
9
9
  import { sanitization } from "../../../utils/index.js";
10
- const execAsync = promisify(exec);
10
+ const execFileAsync = promisify(execFile);
11
11
  // Define the input schema for the git_clean tool using Zod
12
12
  // No refinements needed here, but the 'force' check is critical in the logic
13
13
  export const GitCleanInputSchema = z.object({
@@ -102,18 +102,21 @@ export async function gitCleanLogic(input, context) {
102
102
  try {
103
103
  // Construct the command
104
104
  // Force (-f) is always added because the logic checks input.force
105
- let command = `git -C "${targetPath}" clean -f`;
105
+ const args = ["-C", targetPath, "clean", "-f"];
106
106
  if (input.dryRun) {
107
- command += " -n";
107
+ args.push("-n");
108
108
  }
109
109
  if (input.directories) {
110
- command += " -d";
110
+ args.push("-d");
111
111
  }
112
112
  if (input.ignored) {
113
- command += " -x";
113
+ args.push("-x");
114
114
  }
115
- logger.debug(`Executing command: ${command}`, { ...context, operation });
116
- const { stdout, stderr } = await execAsync(command);
115
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
116
+ ...context,
117
+ operation,
118
+ });
119
+ const { stdout, stderr } = await execFileAsync("git", args);
117
120
  if (stderr) {
118
121
  // Log stderr as warning, as git clean might report non-fatal issues here
119
122
  logger.warning(`Git clean command produced stderr`, {