@cyanheads/git-mcp-server 2.0.7 → 2.0.9
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 +3 -3
- package/dist/mcp-server/tools/gitAdd/logic.js +3 -3
- package/dist/mcp-server/tools/gitBranch/logic.js +1 -1
- package/dist/mcp-server/tools/gitCheckout/logic.js +1 -1
- package/dist/mcp-server/tools/gitCherryPick/logic.js +1 -1
- package/dist/mcp-server/tools/gitClean/logic.js +1 -1
- package/dist/mcp-server/tools/gitClone/logic.js +1 -1
- package/dist/mcp-server/tools/gitCommit/logic.js +4 -4
- package/dist/mcp-server/tools/gitDiff/logic.js +6 -4
- package/dist/mcp-server/tools/gitFetch/logic.js +1 -1
- package/dist/mcp-server/tools/gitInit/logic.js +1 -1
- package/dist/mcp-server/tools/gitInit/registration.js +2 -2
- package/dist/mcp-server/tools/gitLog/logic.js +37 -12
- package/dist/mcp-server/tools/gitMerge/logic.js +1 -1
- package/dist/mcp-server/tools/gitPull/logic.js +1 -1
- package/dist/mcp-server/tools/gitPush/logic.js +1 -1
- package/dist/mcp-server/tools/gitRebase/logic.js +1 -1
- package/dist/mcp-server/tools/gitRemote/logic.js +1 -1
- package/dist/mcp-server/tools/gitReset/logic.js +1 -1
- package/dist/mcp-server/tools/gitSetWorkingDir/logic.js +2 -2
- package/dist/mcp-server/tools/gitShow/logic.js +1 -1
- package/dist/mcp-server/tools/gitStash/logic.js +1 -1
- package/dist/mcp-server/tools/gitStatus/logic.js +3 -3
- package/dist/mcp-server/tools/gitTag/logic.js +1 -1
- package/dist/utils/security/sanitization.js +133 -132
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.typescriptlang.org/)
|
|
4
4
|
[](https://modelcontextprotocol.io/)
|
|
5
|
-
[](./CHANGELOG.md)
|
|
6
6
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
7
7
|
[](https://github.com/cyanheads/git-mcp-server/issues)
|
|
8
8
|
[](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.
|
|
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.
|
|
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
|
|
|
@@ -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
|
|
48
|
-
logger.debug('Sanitized repository path', { ...context, operation,
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
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
|
|
54
|
-
logger.debug('Sanitized path', { ...context, operation,
|
|
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);
|
|
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
|
|
92
|
-
const
|
|
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:
|
|
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);
|
|
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);
|
|
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);
|
|
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));
|
|
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);
|
|
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
|
-
//
|
|
131
|
-
const
|
|
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
|
|
148
|
+
// Validate parsed entry
|
|
149
149
|
CommitEntrySchema.parse(commitEntry);
|
|
150
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
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
|
-
|
|
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);
|
|
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);
|
|
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);
|
|
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); // 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);
|
|
47
|
+
targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
|
|
48
48
|
logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
|
|
49
49
|
}
|
|
50
50
|
catch (error) {
|
|
@@ -26,9 +26,9 @@ export async function gitSetWorkingDirLogic(input, context // Assuming context p
|
|
|
26
26
|
logger.info('Executing git_set_working_dir logic', { ...context, operation, inputPath: input.path });
|
|
27
27
|
let sanitizedPath;
|
|
28
28
|
try {
|
|
29
|
-
// Sanitize the path.
|
|
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);
|
|
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);
|
|
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);
|
|
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
|
|
164
|
-
logger.debug('Sanitized path', { ...context, operation,
|
|
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);
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
65
|
-
* @param
|
|
66
|
-
* @
|
|
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
|
-
|
|
74
|
+
const effectiveConfig = { ...this.defaultHtmlSanitizeConfig, ...config };
|
|
72
75
|
const options = {
|
|
73
|
-
allowedTags:
|
|
74
|
-
allowedAttributes:
|
|
75
|
-
transformTags:
|
|
76
|
+
allowedTags: effectiveConfig.allowedTags,
|
|
77
|
+
allowedAttributes: effectiveConfig.allowedAttributes,
|
|
78
|
+
transformTags: effectiveConfig.transformTags
|
|
76
79
|
};
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
133
|
-
*
|
|
134
|
-
* @param
|
|
135
|
-
* @
|
|
136
|
-
* @
|
|
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
|
-
|
|
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
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* @
|
|
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
|
-
|
|
170
|
+
wasAbsoluteInitially = path.isAbsolute(normalized);
|
|
174
171
|
if (normalized.includes('\0')) {
|
|
175
172
|
throw new Error('Path contains null byte');
|
|
176
173
|
}
|
|
177
|
-
|
|
178
|
-
if (options.toPosix) {
|
|
174
|
+
if (effectiveOptions.toPosix) {
|
|
179
175
|
normalized = normalized.replace(/\\/g, '/');
|
|
180
176
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
//
|
|
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
|
-
|
|
187
|
-
if (
|
|
188
|
-
const
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
//
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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
|
-
* @
|
|
221
|
-
* @param
|
|
222
|
-
* @
|
|
223
|
-
* @
|
|
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
|
-
*
|
|
250
|
-
* @param
|
|
251
|
-
* @param
|
|
252
|
-
* @
|
|
253
|
-
* @
|
|
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
|
-
|
|
291
|
+
let clamped = false;
|
|
276
292
|
if (min !== undefined && value < min) {
|
|
277
293
|
value = min;
|
|
278
|
-
|
|
294
|
+
clamped = true;
|
|
279
295
|
}
|
|
280
296
|
if (max !== undefined && value > max) {
|
|
281
297
|
value = max;
|
|
282
|
-
|
|
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
|
-
*
|
|
289
|
-
* @
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
367
|
-
*
|
|
368
|
-
* @
|
|
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.
|
|
3
|
+
"version": "2.0.9",
|
|
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",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
|
40
40
|
"@types/jsonwebtoken": "^9.0.9",
|
|
41
|
-
"@types/node": "^22.15.
|
|
41
|
+
"@types/node": "^22.15.15",
|
|
42
42
|
"@types/sanitize-html": "^2.16.0",
|
|
43
43
|
"@types/validator": "^13.15.0",
|
|
44
44
|
"chrono-node": "^2.8.0",
|