@cyanheads/git-mcp-server 2.0.8 → 2.0.10

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 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.11.0-green.svg)](https://modelcontextprotocol.io/)
5
- [![Version](https://img.shields.io/badge/Version-2.0.8-blue.svg)](./CHANGELOG.md)
5
+ [![Version](https://img.shields.io/badge/Version-2.0.10-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)
@@ -183,7 +183,7 @@ _Note: The `path` parameter for most tools defaults to the session's working dir
183
183
 
184
184
  ## Resources
185
185
 
186
- **MCP Resources are not implemented in this version (v2.0.2).**
186
+ **MCP Resources are not implemented in this version (v2.0.8).**
187
187
 
188
188
  This version focuses on the refactored Git tools implementation based on the latest `mcp-ts-template` and MCP SDK v1.11.0. Resource capabilities, previously available, have been temporarily removed during this major update.
189
189
 
@@ -191,7 +191,7 @@ If you require MCP Resource access (e.g., for reading file content directly via
191
191
 
192
192
  Future development may reintroduce resource capabilities in a subsequent release.
193
193
 
194
- > **Note:** This version (v2.0.2) focuses on refactoring and updating the core Git tools based on the latest MCP SDK. MCP Resource capabilities are not implemented in this version. For resource access, please use [v1.2.4](https://github.com/cyanheads/git-mcp-server/releases/tag/v1.2.4).
194
+ > **Note:** This version (v2.0.0) focuses on refactoring and updating the core Git tools based on the latest MCP SDK. MCP Resource capabilities are not implemented in this version. For resource access, please use [v1.2.4](https://github.com/cyanheads/git-mcp-server/releases/tag/v1.2.4).
195
195
 
196
196
  ## Development
197
197
 
@@ -204,6 +204,9 @@ npm run build
204
204
  # Test the server locally using the MCP inspector tool (stdio transport)
205
205
  npm run inspector
206
206
 
207
+ # Test the server locally using the MCP inspector tool (http transport)
208
+ npm run inspector:http
209
+
207
210
  # Clean build artifacts (runs scripts/clean.ts)
208
211
  npm run clean
209
212
 
@@ -44,9 +44,9 @@ export async function addGitFiles(input, context // Add getter to context
44
44
  logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
45
45
  }
46
46
  // Sanitize the resolved path
47
- const sanitizedPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
48
- logger.debug('Sanitized repository path', { ...context, operation, sanitizedPath });
49
- targetPath = sanitizedPath; // Use the sanitized path going forward
47
+ const sanitizedPathInfo = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
48
+ logger.debug('Sanitized repository path', { ...context, operation, sanitizedPathInfo });
49
+ targetPath = sanitizedPathInfo.sanitizedPath; // Use the sanitized path going forward
50
50
  }
51
51
  catch (error) {
52
52
  logger.error('Path resolution or sanitization failed', { ...context, operation, error });
@@ -56,7 +56,7 @@ export async function gitBranchLogic(input, context) {
56
56
  else {
57
57
  logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
58
58
  }
59
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
59
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
60
60
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
61
61
  }
62
62
  catch (error) {
@@ -41,7 +41,7 @@ export async function checkoutGit(input, context) {
41
41
  }
42
42
  targetPath = workingDir;
43
43
  }
44
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
44
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
45
45
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
46
46
  }
47
47
  catch (error) {
@@ -47,7 +47,7 @@ export async function gitCherryPickLogic(input, context) {
47
47
  else {
48
48
  logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
49
49
  }
50
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
50
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
51
51
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
52
52
  }
53
53
  catch (error) {
@@ -55,7 +55,7 @@ export async function gitCleanLogic(input, context) {
55
55
  else {
56
56
  logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
57
57
  }
58
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
58
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
59
59
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
60
60
  }
61
61
  catch (error) {
@@ -33,7 +33,7 @@ export async function gitCloneLogic(input, context) {
33
33
  let sanitizedRepoUrl;
34
34
  try {
35
35
  // Sanitize the target path (must be absolute)
36
- sanitizedTargetPath = sanitization.sanitizePath(input.targetPath, { allowAbsolute: true });
36
+ sanitizedTargetPath = sanitization.sanitizePath(input.targetPath, { allowAbsolute: true }).sanitizedPath;
37
37
  logger.debug('Sanitized target path', { ...context, operation, sanitizedTargetPath });
38
38
  // Basic sanitization/validation for URL (Zod already checks format)
39
39
  // Further sanitization might be needed depending on how it's used in the shell command
@@ -50,9 +50,9 @@ export async function commitGitChanges(input, context // Add getter to context
50
50
  logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
51
51
  }
52
52
  // Sanitize the resolved path
53
- const sanitizedPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
54
- logger.debug('Sanitized path', { ...context, operation, sanitizedPath });
55
- targetPath = sanitizedPath; // Use the sanitized path going forward
53
+ const sanitizedPathInfo = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
54
+ logger.debug('Sanitized path', { ...context, operation, sanitizedPathInfo });
55
+ targetPath = sanitizedPathInfo.sanitizedPath; // Use the sanitized path going forward
56
56
  }
57
57
  catch (error) {
58
58
  logger.error('Path resolution or sanitization failed', { ...context, operation, error });
@@ -67,7 +67,7 @@ export async function commitGitChanges(input, context // Add getter to context
67
67
  logger.debug(`Attempting to stage specific files: ${input.filesToStage.join(', ')}`, { ...context, operation });
68
68
  try {
69
69
  // Correctly pass targetPath as rootDir in options object
70
- const sanitizedFiles = input.filesToStage.map(file => sanitization.sanitizePath(file, { rootDir: targetPath })); // Sanitize relative to repo root
70
+ const sanitizedFiles = input.filesToStage.map(file => sanitization.sanitizePath(file, { rootDir: targetPath }).sanitizedPath); // Sanitize relative to repo root
71
71
  const filesToAddString = sanitizedFiles.map(file => `"${file}"`).join(' '); // Quote paths for safety
72
72
  const addCommand = `git -C "${targetPath}" add -- ${filesToAddString}`;
73
73
  logger.debug(`Executing git add command: ${addCommand}`, { ...context, operation });
@@ -48,7 +48,7 @@ export async function diffGitChanges(input, context) {
48
48
  }
49
49
  targetPath = workingDir;
50
50
  }
51
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
51
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
52
52
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
53
53
  }
54
54
  catch (error) {
@@ -88,10 +88,12 @@ export async function diffGitChanges(input, context) {
88
88
  // Log stderr as warning, as it might contain non-fatal info
89
89
  logger.warning(`Git diff stderr: ${stderr}`, { ...context, operation });
90
90
  }
91
- const diffOutput = stdout;
92
- const message = diffOutput.trim() === '' ? 'No changes found.' : 'Diff generated successfully.';
91
+ const rawDiffOutput = stdout;
92
+ const isNoChanges = rawDiffOutput.trim() === '';
93
+ const finalDiffOutput = isNoChanges ? 'No changes found.' : rawDiffOutput;
94
+ const message = isNoChanges ? 'No changes found.' : 'Diff generated successfully.';
93
95
  logger.info(`${operation} completed successfully. ${message}`, { ...context, operation, path: targetPath });
94
- return { success: true, diff: diffOutput, message };
96
+ return { success: true, diff: finalDiffOutput, message };
95
97
  }
96
98
  catch (error) {
97
99
  logger.error(`Failed to execute git diff command`, { ...context, operation, path: targetPath, error: error.message, stderr: error.stderr, stdout: error.stdout });
@@ -41,7 +41,7 @@ export async function fetchGitRemote(input, context) {
41
41
  }
42
42
  targetPath = workingDir;
43
43
  }
44
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
44
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
45
45
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
46
46
  }
47
47
  catch (error) {
@@ -31,7 +31,7 @@ export async function gitInitLogic(input, context) {
31
31
  let targetPath;
32
32
  try {
33
33
  // Sanitize the provided absolute path
34
- targetPath = sanitization.sanitizePath(input.path, { allowAbsolute: true });
34
+ targetPath = sanitization.sanitizePath(input.path, { allowAbsolute: true }).sanitizedPath;
35
35
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
36
36
  // Ensure the target directory exists before trying to init inside it
37
37
  // git init creates the directory if it doesn't exist, but we might want to ensure the parent exists
@@ -55,11 +55,11 @@ export const registerGitInitTool = async (server) => {
55
55
  let resolvedPath;
56
56
  try {
57
57
  if (path.isAbsolute(inputPath)) {
58
- resolvedPath = sanitization.sanitizePath(inputPath, { allowAbsolute: true });
58
+ resolvedPath = sanitization.sanitizePath(inputPath, { allowAbsolute: true }).sanitizedPath;
59
59
  logger.debug(`Using absolute path: ${resolvedPath}`, requestContext);
60
60
  }
61
61
  else if (sessionWorkingDirectory) {
62
- resolvedPath = sanitization.sanitizePath(path.resolve(sessionWorkingDirectory, inputPath), { allowAbsolute: true });
62
+ resolvedPath = sanitization.sanitizePath(path.resolve(sessionWorkingDirectory, inputPath), { allowAbsolute: true }).sanitizedPath;
63
63
  logger.debug(`Resolved relative path '${inputPath}' to absolute path: ${resolvedPath} using session CWD`, requestContext);
64
64
  }
65
65
  else {
@@ -37,7 +37,7 @@ const GIT_LOG_FORMAT = `--pretty=format:%H${FIELD_SEP}%an${FIELD_SEP}%ae${FIELD_
37
37
  *
38
38
  * @param {GitLogInput} input - The validated input object.
39
39
  * @param {RequestContext} context - The request context for logging and error handling.
40
- * @returns {Promise<GitLogResult>} A promise that resolves with the structured log result.
40
+ * @returns {Promise<GitLogResult>} A promise that resolves with the structured log result (either flat or grouped).
41
41
  * @throws {McpError} Throws an McpError if path resolution, validation, or the git command fails unexpectedly.
42
42
  */
43
43
  export async function logGitHistory(input, context) {
@@ -56,7 +56,7 @@ export async function logGitHistory(input, context) {
56
56
  }
57
57
  targetPath = workingDir;
58
58
  }
59
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
59
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
60
60
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
61
61
  }
62
62
  catch (error) {
@@ -124,11 +124,11 @@ export async function logGitHistory(input, context) {
124
124
  if (isRawOutput) {
125
125
  const message = `Raw log output (showSignature=true):\n${stdout}`;
126
126
  logger.info(`${operation} completed successfully (raw output).`, { ...context, operation, path: targetPath });
127
- // Return without the 'commits' field
127
+ // Return without the 'commits' or 'groupedCommits' field
128
128
  return { success: true, message: message };
129
129
  }
130
- // Otherwise, parse the structured output
131
- const commits = [];
130
+ // --- Parse the structured output into a flat list first ---
131
+ const flatCommits = [];
132
132
  const commitRecords = stdout.split(RECORD_SEP).filter(record => record.trim() !== ''); // Split records and remove empty ones
133
133
  for (const record of commitRecords) {
134
134
  const trimmedRecord = record.trim(); // Trim leading/trailing whitespace (like newlines)
@@ -145,9 +145,9 @@ export async function logGitHistory(input, context) {
145
145
  subject: fields[4],
146
146
  body: fields[5] || undefined, // Body might be empty
147
147
  };
148
- // Validate parsed entry (optional but recommended)
148
+ // Validate parsed entry
149
149
  CommitEntrySchema.parse(commitEntry);
150
- commits.push(commitEntry);
150
+ flatCommits.push(commitEntry);
151
151
  }
152
152
  catch (parseError) {
153
153
  logger.warning(`Failed to parse commit record field`, { ...context, operation, fieldIndex: fields.findIndex((_, i) => i > 5), recordFragment: record.substring(0, 100), parseError });
@@ -158,9 +158,33 @@ export async function logGitHistory(input, context) {
158
158
  logger.warning(`Skipping commit record due to unexpected number of fields (${fields.length})`, { ...context, operation, recordFragment: record.substring(0, 100) });
159
159
  }
160
160
  }
161
- const message = commits.length > 0 ? `${commits.length} commit(s) found.` : 'No commits found matching criteria.';
162
- logger.info(`${operation} completed successfully. ${message}`, { ...context, operation, path: targetPath, commitCount: commits.length });
163
- return { success: true, commits, message };
161
+ // --- Group the flat list by author ---
162
+ const groupedCommitsMap = new Map();
163
+ for (const commit of flatCommits) {
164
+ const authorKey = `${commit.authorName} <${commit.authorEmail}>`;
165
+ const groupedInfo = {
166
+ hash: commit.hash,
167
+ timestamp: commit.timestamp,
168
+ subject: commit.subject,
169
+ body: commit.body,
170
+ };
171
+ if (groupedCommitsMap.has(authorKey)) {
172
+ groupedCommitsMap.get(authorKey).commits.push(groupedInfo);
173
+ }
174
+ else {
175
+ groupedCommitsMap.set(authorKey, {
176
+ authorName: commit.authorName,
177
+ authorEmail: commit.authorEmail,
178
+ commits: [groupedInfo],
179
+ });
180
+ }
181
+ }
182
+ const groupedCommits = Array.from(groupedCommitsMap.values());
183
+ // --- Prepare final result ---
184
+ const commitCount = flatCommits.length;
185
+ const message = commitCount > 0 ? `${commitCount} commit(s) found.` : 'No commits found matching criteria.';
186
+ logger.info(`${operation} completed successfully. ${message}`, { ...context, operation, path: targetPath, commitCount: commitCount, authorGroupCount: groupedCommits.length });
187
+ return { success: true, groupedCommits, message }; // Return the grouped structure
164
188
  }
165
189
  catch (error) {
166
190
  logger.error(`Failed to execute git log command`, { ...context, operation, path: targetPath, error: error.message, stderr: error.stderr, stdout: error.stdout });
@@ -175,10 +199,11 @@ export async function logGitHistory(input, context) {
175
199
  if (errorMessage.includes('fatal: ambiguous argument')) {
176
200
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Ambiguous argument provided (e.g., branch/tag/file conflict): '${input.branchOrFile}'. Error: ${errorMessage}`, { context, operation, originalError: error });
177
201
  }
178
- // Check if it's just that no commits were found (which git might treat as an error in some cases, though usually not for log)
202
+ // Check if it's just that no commits were found
179
203
  if (errorMessage.includes('does not have any commits yet')) {
180
204
  logger.info('Repository has no commits yet.', { ...context, operation, path: targetPath });
181
- return { success: true, commits: [], message: 'Repository has no commits yet.' };
205
+ // Return the grouped structure even for no commits
206
+ return { success: true, groupedCommits: [], message: 'Repository has no commits yet.' };
182
207
  }
183
208
  // Generic internal error for other failures
184
209
  throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to get git log for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
@@ -63,7 +63,7 @@ export async function gitMergeLogic(input, context) {
63
63
  // Sanitize the resolved path
64
64
  // We assume the resolved path should be absolute for git commands.
65
65
  // sanitizePath checks for traversal and normalizes.
66
- targetPath = sanitization.sanitizePath(resolvedPath, { allowAbsolute: true });
66
+ targetPath = sanitization.sanitizePath(resolvedPath, { allowAbsolute: true }).sanitizedPath;
67
67
  logger.debug(`Sanitized path: ${targetPath}`, { ...context, operation });
68
68
  }
69
69
  catch (error) {
@@ -44,7 +44,7 @@ export async function pullGitChanges(input, context) {
44
44
  logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
45
45
  }
46
46
  // Sanitize the resolved path
47
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
47
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
48
48
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
49
49
  }
50
50
  catch (error) {
@@ -45,7 +45,7 @@ export async function pushGitChanges(input, context) {
45
45
  }
46
46
  targetPath = workingDir;
47
47
  }
48
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
48
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
49
49
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
50
50
  }
51
51
  catch (error) {
@@ -55,7 +55,7 @@ export async function gitRebaseLogic(input, context) {
55
55
  else {
56
56
  logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
57
57
  }
58
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
58
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
59
59
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
60
60
  }
61
61
  catch (error) {
@@ -42,7 +42,7 @@ export async function gitRemoteLogic(input, context) {
42
42
  else {
43
43
  logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
44
44
  }
45
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }); // Sanitize the final resolved path
45
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath; // Sanitize the final resolved path
46
46
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
47
47
  }
48
48
  catch (error) {
@@ -44,7 +44,7 @@ export async function resetGitState(input, context) {
44
44
  }
45
45
  targetPath = workingDir;
46
46
  }
47
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
47
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
48
48
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
49
49
  }
50
50
  catch (error) {
@@ -28,7 +28,7 @@ export async function gitSetWorkingDirLogic(input, context // Assuming context p
28
28
  try {
29
29
  // Sanitize the path. Must explicitly allow absolute paths for this tool.
30
30
  // It normalizes and checks for traversal issues.
31
- sanitizedPath = sanitization.sanitizePath(input.path, { allowAbsolute: true });
31
+ sanitizedPath = sanitization.sanitizePath(input.path, { allowAbsolute: true }).sanitizedPath;
32
32
  logger.debug(`Sanitized path: ${sanitizedPath}`, { ...context, operation });
33
33
  }
34
34
  catch (error) {
@@ -41,7 +41,7 @@ export async function gitShowLogic(input, context) {
41
41
  else {
42
42
  logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
43
43
  }
44
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
44
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
45
45
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
46
46
  }
47
47
  catch (error) {
@@ -47,7 +47,7 @@ export async function gitStashLogic(input, context) {
47
47
  else {
48
48
  logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
49
49
  }
50
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
50
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
51
51
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
52
52
  }
53
53
  catch (error) {
@@ -160,9 +160,9 @@ export async function getGitStatus(input, context // Add getter to context
160
160
  logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
161
161
  }
162
162
  // Sanitize the resolved path
163
- const sanitizedPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
164
- logger.debug('Sanitized path', { ...context, operation, sanitizedPath });
165
- targetPath = sanitizedPath; // Use the sanitized path going forward
163
+ const sanitizedPathInfo = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
164
+ logger.debug('Sanitized path', { ...context, operation, sanitizedPathInfo });
165
+ targetPath = sanitizedPathInfo.sanitizedPath; // Use the sanitized path going forward
166
166
  }
167
167
  catch (error) {
168
168
  logger.error('Path resolution or sanitization failed', { ...context, operation, error });
@@ -55,7 +55,7 @@ export async function gitTagLogic(input, context) {
55
55
  else {
56
56
  logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
57
57
  }
58
- targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
58
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
59
59
  logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
60
60
  }
61
61
  catch (error) {
@@ -5,7 +5,8 @@ import { BaseErrorCode, McpError } from '../../types-global/errors.js';
5
5
  // Import utils from the main barrel file (logger from ../internal/logger.js)
6
6
  import { logger } from '../index.js';
7
7
  /**
8
- * Sanitization class for handling various input sanitization tasks
8
+ * Sanitization class for handling various input sanitization tasks.
9
+ * Provides methods to clean and validate strings, HTML, URLs, paths, JSON, and numbers.
9
10
  */
10
11
  export class Sanitization {
11
12
  static instance;
@@ -29,14 +30,14 @@ export class Sanitization {
29
30
  preserveComments: false
30
31
  };
31
32
  /**
32
- * Private constructor to enforce singleton pattern
33
+ * Private constructor to enforce singleton pattern.
33
34
  */
34
35
  constructor() {
35
- // Removed logger call from constructor to prevent logging before initialization
36
+ // Constructor intentionally left blank for singleton.
36
37
  }
37
38
  /**
38
- * Get the singleton Sanitization instance
39
- * @returns Sanitization instance
39
+ * Get the singleton Sanitization instance.
40
+ * @returns {Sanitization} The singleton instance.
40
41
  */
41
42
  static getInstance() {
42
43
  if (!Sanitization.instance) {
@@ -45,37 +46,38 @@ export class Sanitization {
45
46
  return Sanitization.instance;
46
47
  }
47
48
  /**
48
- * Set sensitive fields for log sanitization
49
- * @param fields Array of field names to consider sensitive
49
+ * Set sensitive fields for log sanitization. These fields will be redacted when
50
+ * `sanitizeForLogging` is called.
51
+ * @param {string[]} fields - Array of field names to consider sensitive.
50
52
  */
51
53
  setSensitiveFields(fields) {
52
54
  this.sensitiveFields = [...new Set([...this.sensitiveFields, ...fields])]; // Ensure uniqueness
53
55
  logger.debug('Updated sensitive fields list', { count: this.sensitiveFields.length });
54
56
  }
55
57
  /**
56
- * Get the current list of sensitive fields
57
- * @returns Array of sensitive field names
58
+ * Get the current list of sensitive fields used for log sanitization.
59
+ * @returns {string[]} Array of sensitive field names.
58
60
  */
59
61
  getSensitiveFields() {
60
62
  return [...this.sensitiveFields];
61
63
  }
62
64
  /**
63
- * Sanitize HTML content using sanitize-html library
64
- * @param input HTML string to sanitize
65
- * @param config Optional custom sanitization config
66
- * @returns Sanitized HTML
65
+ * Sanitize HTML content using the `sanitize-html` library.
66
+ * Removes potentially malicious tags and attributes.
67
+ * @param {string} input - HTML string to sanitize.
68
+ * @param {HtmlSanitizeConfig} [config] - Optional custom sanitization configuration.
69
+ * @returns {string} Sanitized HTML string.
67
70
  */
68
71
  sanitizeHtml(input, config) {
69
72
  if (!input)
70
73
  return '';
71
- // Create sanitize-html options from our config
74
+ const effectiveConfig = { ...this.defaultHtmlSanitizeConfig, ...config };
72
75
  const options = {
73
- allowedTags: config?.allowedTags || this.defaultHtmlSanitizeConfig.allowedTags,
74
- allowedAttributes: config?.allowedAttributes || this.defaultHtmlSanitizeConfig.allowedAttributes,
75
- transformTags: config?.transformTags
76
+ allowedTags: effectiveConfig.allowedTags,
77
+ allowedAttributes: effectiveConfig.allowedAttributes,
78
+ transformTags: effectiveConfig.transformTags
76
79
  };
77
- // Handle comments - if preserveComments is true, add '!--' to allowedTags
78
- if (config?.preserveComments || this.defaultHtmlSanitizeConfig.preserveComments) {
80
+ if (effectiveConfig.preserveComments) {
79
81
  options.allowedTags = [...(options.allowedTags || []), '!--'];
80
82
  }
81
83
  return sanitizeHtml(input, options);
@@ -86,18 +88,16 @@ export class Sanitization {
86
88
  * **Important:** Using `context: 'javascript'` is explicitly disallowed and will throw an `McpError`.
87
89
  * This is a security measure to prevent accidental execution or ineffective sanitization of JavaScript code.
88
90
  *
89
- * @param input String to sanitize
90
- * @param options Sanitization options
91
- * @returns Sanitized string
91
+ * @param {string} input - String to sanitize.
92
+ * @param {SanitizeStringOptions} [options={}] - Sanitization options.
93
+ * @returns {string} Sanitized string.
92
94
  * @throws {McpError} If `context: 'javascript'` is used.
93
95
  */
94
96
  sanitizeString(input, options = {}) {
95
97
  if (!input)
96
98
  return '';
97
- // Handle based on context
98
99
  switch (options.context) {
99
100
  case 'html':
100
- // Use sanitize-html with custom options
101
101
  return this.sanitizeHtml(input, {
102
102
  allowedTags: options.allowedTags,
103
103
  allowedAttributes: options.allowedAttributes ?
@@ -105,51 +105,38 @@ export class Sanitization {
105
105
  undefined
106
106
  });
107
107
  case 'attribute':
108
- // Strip HTML tags for attribute context
109
108
  return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} });
110
109
  case 'url':
111
- // Validate and sanitize URL
112
- if (!validator.isURL(input, {
113
- protocols: ['http', 'https'],
114
- require_protocol: true
115
- })) {
116
- // Return empty string for invalid URLs in this context
110
+ if (!validator.isURL(input, { protocols: ['http', 'https'], require_protocol: true })) {
117
111
  logger.warning('Invalid URL detected during string sanitization', { input });
118
112
  return '';
119
113
  }
120
114
  return validator.trim(input);
121
115
  case 'javascript':
122
- // Reject any attempt to sanitize JavaScript
123
116
  logger.error('Attempted JavaScript sanitization via sanitizeString', { input: input.substring(0, 50) });
124
117
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'JavaScript sanitization not supported through string sanitizer');
125
118
  case 'text':
126
119
  default:
127
- // Strip HTML tags for basic text context
128
120
  return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} });
129
121
  }
130
122
  }
131
123
  /**
132
- * Sanitize URL with robust validation and sanitization
133
- * @param input URL to sanitize
134
- * @param allowedProtocols Allowed URL protocols
135
- * @returns Sanitized URL
136
- * @throws {McpError} If URL is invalid
124
+ * Sanitize URL with robust validation.
125
+ * Ensures the URL uses allowed protocols and is well-formed.
126
+ * @param {string} input - URL to sanitize.
127
+ * @param {string[]} [allowedProtocols=['http', 'https']] - Allowed URL protocols.
128
+ * @returns {string} Sanitized URL.
129
+ * @throws {McpError} If URL is invalid or uses a disallowed protocol.
137
130
  */
138
131
  sanitizeUrl(input, allowedProtocols = ['http', 'https']) {
139
132
  try {
140
- // First validate the URL format
141
- if (!validator.isURL(input, {
142
- protocols: allowedProtocols,
143
- require_protocol: true
144
- })) {
133
+ if (!validator.isURL(input, { protocols: allowedProtocols, require_protocol: true })) {
145
134
  throw new Error('Invalid URL format or protocol');
146
135
  }
147
- // Double-check no javascript: protocol sneaked in
148
136
  const lowerInput = input.toLowerCase().trim();
149
- if (lowerInput.startsWith('javascript:')) {
137
+ if (lowerInput.startsWith('javascript:')) { // Double-check against javascript:
150
138
  throw new Error('JavaScript protocol not allowed');
151
139
  }
152
- // Return the trimmed, validated URL
153
140
  return validator.trim(input);
154
141
  }
155
142
  catch (error) {
@@ -157,106 +144,136 @@ export class Sanitization {
157
144
  }
158
145
  }
159
146
  /**
160
- * Sanitize file paths to prevent path traversal attacks
161
- * @param input Path to sanitize
162
- * @param options Options for path sanitization
163
- * @returns Sanitized and normalized path
164
- * @throws {McpError} If path is invalid or unsafe
147
+ * Sanitizes a file path to prevent path traversal and other common attacks.
148
+ * Normalizes the path, optionally converts to POSIX style, and can restrict
149
+ * the path to a root directory.
150
+ *
151
+ * @param {string} input - The file path to sanitize.
152
+ * @param {PathSanitizeOptions} [options={}] - Options to control sanitization behavior.
153
+ * @returns {SanitizedPathInfo} An object containing the sanitized path and metadata about the sanitization process.
154
+ * @throws {McpError} If the path is invalid, unsafe (e.g., contains null bytes, attempts traversal).
165
155
  */
166
156
  sanitizePath(input, options = {}) {
157
+ const originalInput = input;
158
+ const effectiveOptions = {
159
+ toPosix: options.toPosix ?? false,
160
+ allowAbsolute: options.allowAbsolute ?? false,
161
+ rootDir: options.rootDir
162
+ };
163
+ let wasAbsoluteInitially = false;
164
+ let convertedToRelative = false;
167
165
  try {
168
166
  if (!input || typeof input !== 'string') {
169
167
  throw new Error('Invalid path input: must be a non-empty string');
170
168
  }
171
- // Apply path normalization using built-in path module
172
169
  let normalized = path.normalize(input);
173
- // Prevent null byte injection
170
+ wasAbsoluteInitially = path.isAbsolute(normalized);
174
171
  if (normalized.includes('\0')) {
175
172
  throw new Error('Path contains null byte');
176
173
  }
177
- // Convert backslashes to forward slashes if toPosix is true
178
- if (options.toPosix) {
174
+ if (effectiveOptions.toPosix) {
179
175
  normalized = normalized.replace(/\\/g, '/');
180
176
  }
181
- // Handle absolute paths based on allowAbsolute option
182
- if (!options.allowAbsolute && path.isAbsolute(normalized)) {
183
- // Remove leading slash or drive letter to make it relative
184
- normalized = normalized.replace(/^(?:[A-Za-z]:)?[/\\]/, '');
177
+ if (!effectiveOptions.allowAbsolute && path.isAbsolute(normalized)) {
178
+ // Original path was absolute, but absolute paths are not allowed.
179
+ // Convert to relative by stripping leading slash or drive letter.
180
+ normalized = normalized.replace(/^(?:[A-Za-z]:)?[/\\]+/, '');
181
+ convertedToRelative = true;
185
182
  }
186
- // If rootDir is specified, ensure the path doesn't escape it
187
- if (options.rootDir) {
188
- const rootDir = path.resolve(options.rootDir);
189
- // Resolve the normalized path against the root dir
190
- const fullPath = path.resolve(rootDir, normalized);
191
- // More robust check for path traversal: ensure fullPath starts with rootDir + separator
192
- // or is exactly rootDir
193
- if (!fullPath.startsWith(rootDir + path.sep) && fullPath !== rootDir) {
194
- throw new Error('Path traversal detected');
183
+ let finalSanitizedPath;
184
+ if (effectiveOptions.rootDir) {
185
+ const rootDirResolved = path.resolve(effectiveOptions.rootDir);
186
+ // If 'normalized' is absolute (and allowed), path.resolve uses it as the base.
187
+ // If 'normalized' is relative, it's resolved against 'rootDirResolved'.
188
+ const fullPath = path.resolve(rootDirResolved, normalized);
189
+ if (!fullPath.startsWith(rootDirResolved + path.sep) && fullPath !== rootDirResolved) {
190
+ throw new Error('Path traversal detected (escapes rootDir)');
195
191
  }
196
- // Return the path relative to the root
197
- return path.relative(rootDir, fullPath);
192
+ // Path is within rootDir, return it relative to rootDir.
193
+ finalSanitizedPath = path.relative(rootDirResolved, fullPath);
194
+ // Ensure empty string result from path.relative (if fullPath equals rootDirResolved) becomes '.'
195
+ finalSanitizedPath = finalSanitizedPath === '' ? '.' : finalSanitizedPath;
198
196
  }
199
- // Final validation - check for relative path traversal attempts if not rooted
200
- if (normalized.includes('..')) {
201
- // Resolve the path to see if it escapes the current working directory conceptually
202
- const resolvedPath = path.resolve(normalized);
203
- const currentWorkingDir = path.resolve('.'); // Or use a safer base if needed
204
- if (!resolvedPath.startsWith(currentWorkingDir)) {
205
- throw new Error('Relative path traversal detected');
197
+ else { // No rootDir specified
198
+ if (path.isAbsolute(normalized)) {
199
+ if (effectiveOptions.allowAbsolute) {
200
+ // Absolute path is allowed and no rootDir to constrain it.
201
+ finalSanitizedPath = normalized;
202
+ }
203
+ else {
204
+ // Should not happen if logic above is correct (already made relative or was originally relative)
205
+ // but as a safeguard:
206
+ throw new Error('Absolute path encountered when not allowed and not rooted');
207
+ }
208
+ }
209
+ else { // Path is relative and no rootDir
210
+ if (normalized.includes('..')) {
211
+ const resolvedPath = path.resolve(normalized); // Resolves relative to CWD
212
+ const currentWorkingDir = path.resolve('.');
213
+ if (!resolvedPath.startsWith(currentWorkingDir)) {
214
+ throw new Error('Relative path traversal detected (escapes CWD)');
215
+ }
216
+ }
217
+ finalSanitizedPath = normalized;
206
218
  }
207
219
  }
208
- return normalized;
220
+ return {
221
+ sanitizedPath: finalSanitizedPath,
222
+ originalInput,
223
+ wasAbsolute: wasAbsoluteInitially,
224
+ convertedToRelative,
225
+ optionsUsed: effectiveOptions
226
+ };
209
227
  }
210
228
  catch (error) {
211
229
  logger.warning('Path sanitization error', {
212
- input,
230
+ input: originalInput,
231
+ options: effectiveOptions,
213
232
  error: error instanceof Error ? error.message : String(error)
214
233
  });
215
- throw new McpError(BaseErrorCode.VALIDATION_ERROR, error instanceof Error ? error.message : 'Invalid or unsafe path', { input });
234
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, error instanceof Error ? error.message : 'Invalid or unsafe path', { input: originalInput } // Provide original input in error details
235
+ );
216
236
  }
217
237
  }
218
238
  /**
219
- * Sanitize a JSON string
220
- * @param input JSON string to sanitize
221
- * @param maxSize Maximum allowed size in bytes
222
- * @returns Parsed and sanitized object
223
- * @throws {McpError} If JSON is invalid or too large
239
+ * Sanitize a JSON string. Validates format and optionally checks size.
240
+ * @template T - The expected type of the parsed JSON object.
241
+ * @param {string} input - JSON string to sanitize.
242
+ * @param {number} [maxSize] - Maximum allowed size in bytes.
243
+ * @returns {T} Parsed and sanitized object.
244
+ * @throws {McpError} If JSON is invalid, too large, or input is not a string.
224
245
  */
225
246
  sanitizeJson(input, maxSize) {
226
247
  try {
227
248
  if (typeof input !== 'string') {
228
249
  throw new Error('Invalid input: expected a JSON string');
229
250
  }
230
- // Check size limit if specified
231
251
  if (maxSize !== undefined && Buffer.byteLength(input, 'utf8') > maxSize) {
232
252
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, `JSON exceeds maximum allowed size of ${maxSize} bytes`, { size: Buffer.byteLength(input, 'utf8'), maxSize });
233
253
  }
234
- // Validate JSON format using JSON.parse for stricter validation than validator.isJSON
235
254
  const parsed = JSON.parse(input);
236
255
  // Optional: Add recursive sanitization of parsed object values if needed
237
256
  // this.sanitizeObjectRecursively(parsed);
238
257
  return parsed;
239
258
  }
240
259
  catch (error) {
241
- if (error instanceof McpError) {
260
+ if (error instanceof McpError)
242
261
  throw error;
243
- }
244
262
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, error instanceof Error ? error.message : 'Invalid JSON format', { input: input.length > 100 ? `${input.substring(0, 100)}...` : input });
245
263
  }
246
264
  }
247
265
  /**
248
- * Ensure input is within a numeric range
249
- * @param input Number or string to validate
250
- * @param min Minimum allowed value (inclusive)
251
- * @param max Maximum allowed value (inclusive)
252
- * @returns Sanitized number within range
253
- * @throws {McpError} If input is not a valid number
266
+ * Ensure input is a valid number and optionally within a numeric range.
267
+ * Clamps the number to the range if min/max are provided and value is outside.
268
+ * @param {number | string} input - Number or string to validate.
269
+ * @param {number} [min] - Minimum allowed value (inclusive).
270
+ * @param {number} [max] - Maximum allowed value (inclusive).
271
+ * @returns {number} Sanitized number.
272
+ * @throws {McpError} If input is not a valid number or parsable string.
254
273
  */
255
274
  sanitizeNumber(input, min, max) {
256
275
  let value;
257
- // Handle string input
258
276
  if (typeof input === 'string') {
259
- // Use validator for initial check, but rely on parseFloat for conversion
260
277
  if (!validator.isNumeric(input.trim())) {
261
278
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid number format', { input });
262
279
  }
@@ -268,38 +285,37 @@ export class Sanitization {
268
285
  else {
269
286
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid input type: expected number or string', { input: String(input) });
270
287
  }
271
- // Check if parsing resulted in NaN
272
288
  if (isNaN(value) || !isFinite(value)) {
273
289
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid number value (NaN or Infinity)', { input });
274
290
  }
275
- // Clamp the value to the specified range
291
+ let clamped = false;
276
292
  if (min !== undefined && value < min) {
277
293
  value = min;
278
- logger.debug('Number clamped to minimum value', { input, min, value });
294
+ clamped = true;
279
295
  }
280
296
  if (max !== undefined && value > max) {
281
297
  value = max;
282
- logger.debug('Number clamped to maximum value', { input, max, value });
298
+ clamped = true;
299
+ }
300
+ if (clamped) {
301
+ logger.debug('Number clamped to range', { input, min, max, finalValue: value });
283
302
  }
284
303
  return value;
285
304
  }
286
305
  /**
287
- * Sanitize input for logging to protect sensitive information
288
- * @param input Input to sanitize
289
- * @returns Sanitized input safe for logging
306
+ * Sanitize input for logging to protect sensitive information.
307
+ * Deep clones the input and redacts fields matching `this.sensitiveFields`.
308
+ * @param {unknown} input - Input to sanitize.
309
+ * @returns {unknown} Sanitized input safe for logging.
290
310
  */
291
311
  sanitizeForLogging(input) {
292
312
  try {
293
- // Handle non-objects and null directly
294
313
  if (!input || typeof input !== 'object') {
295
314
  return input;
296
315
  }
297
- // Use structuredClone for deep copy if available (Node.js >= 17)
298
- // Fallback to JSON stringify/parse for older versions
299
316
  const clonedInput = typeof structuredClone === 'function'
300
317
  ? structuredClone(input)
301
- : JSON.parse(JSON.stringify(input));
302
- // Recursively sanitize the cloned object
318
+ : JSON.parse(JSON.stringify(input)); // Fallback for older Node versions
303
319
  this.redactSensitiveFields(clonedInput);
304
320
  return clonedInput;
305
321
  }
@@ -307,66 +323,51 @@ export class Sanitization {
307
323
  logger.error('Error during log sanitization', {
308
324
  error: error instanceof Error ? error.message : String(error)
309
325
  });
310
- // Return a placeholder if sanitization fails
311
326
  return '[Log Sanitization Failed]';
312
327
  }
313
328
  }
314
329
  /**
315
- * Private helper to convert attribute format from record to sanitize-html format
330
+ * Private helper to convert attribute format for sanitize-html.
316
331
  */
317
332
  convertAttributesFormat(attrs) {
318
- // sanitize-html directly supports Record<string, string[]> for allowedAttributes per tag
319
333
  return attrs;
320
334
  }
321
335
  /**
322
- * Recursively redact sensitive fields in an object or array
336
+ * Recursively redact sensitive fields in an object or array.
337
+ * Modifies the object in place.
338
+ * @param {unknown} obj - The object or array to redact.
323
339
  */
324
340
  redactSensitiveFields(obj) {
325
341
  if (!obj || typeof obj !== 'object') {
326
342
  return;
327
343
  }
328
- // Handle arrays: iterate and recurse
329
344
  if (Array.isArray(obj)) {
330
- obj.forEach((item, index) => {
331
- // If the item is an object/array, recurse. Otherwise, leave primitive values.
345
+ obj.forEach(item => {
332
346
  if (item && typeof item === 'object') {
333
347
  this.redactSensitiveFields(item);
334
348
  }
335
349
  });
336
350
  return;
337
351
  }
338
- // Handle regular objects: iterate through keys
339
352
  for (const key in obj) {
340
- // Use hasOwnProperty to avoid iterating over prototype properties
341
353
  if (Object.prototype.hasOwnProperty.call(obj, key)) {
342
354
  const value = obj[key];
343
- // Check if this key matches any sensitive field pattern (case-insensitive)
344
355
  const isSensitive = this.sensitiveFields.some(field => key.toLowerCase().includes(field.toLowerCase()));
345
356
  if (isSensitive) {
346
- // Mask sensitive value
347
357
  obj[key] = '[REDACTED]';
348
358
  }
349
359
  else if (value && typeof value === 'object') {
350
- // Recursively process nested objects/arrays
351
360
  this.redactSensitiveFields(value);
352
361
  }
353
- // Primitive values are left as is if not sensitive
354
362
  }
355
363
  }
356
364
  }
357
365
  }
358
366
  // Create and export singleton instance
359
367
  export const sanitization = Sanitization.getInstance();
360
- // Removed the `sanitizeInput` object export for simplicity.
361
- // Users should import `sanitization` and call methods directly.
362
- // e.g., import { sanitization } from './sanitization.js';
363
- // sanitization.sanitizeHtml(input);
364
- // sanitization.sanitizePath(input);
365
368
  /**
366
- * Sanitize input for logging to protect sensitive information.
367
- * Kept as a separate export for convenience.
368
- * @param input Input to sanitize
369
- * @returns Sanitized input safe for logging
369
+ * Convenience function to sanitize input for logging.
370
+ * @param {unknown} input - Input to sanitize.
371
+ * @returns {unknown} Sanitized input safe for logging.
370
372
  */
371
373
  export const sanitizeInputForLogging = (input) => sanitization.sanitizeForLogging(input);
372
- // Removed default export
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanheads/git-mcp-server",
3
- "version": "2.0.8",
3
+ "version": "2.0.10",
4
4
  "description": "An MCP (Model Context Protocol) server providing tools to interact with Git repositories. Enables LLMs and AI agents to perform Git operations like clone, commit, push, pull, branch, diff, log, status, and more via the MCP standard.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -29,13 +29,15 @@
29
29
  "start:http": "MCP_LOG_LEVEL=debug MCP_TRANSPORT_TYPE=http node dist/index.js",
30
30
  "rebuild": "ts-node --esm scripts/clean.ts && npm run build",
31
31
  "tree": "ts-node --esm scripts/tree.ts",
32
- "inspector": "npx @modelcontextprotocol/inspector dist/index.js",
32
+ "inspector": "npx @modelcontextprotocol/inspector --config mcp.json --server git-mcp-server",
33
+ "inspector:http": "npx @modelcontextprotocol/inspector --config mcp.json --server git-mcp-server-http",
33
34
  "clean": "ts-node --esm scripts/clean.ts"
34
35
  },
35
36
  "publishConfig": {
36
37
  "access": "public"
37
38
  },
38
39
  "dependencies": {
40
+ "@modelcontextprotocol/inspector": "^0.11.0",
39
41
  "@modelcontextprotocol/sdk": "^1.11.0",
40
42
  "@types/jsonwebtoken": "^9.0.9",
41
43
  "@types/node": "^22.15.15",