mcp-fs-shell-windows 0.2.4
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/LICENSE +23 -0
- package/README.md +147 -0
- package/dist/append_files/handler.js +32 -0
- package/dist/append_files/schema.js +7 -0
- package/dist/checksum_files/handler.js +33 -0
- package/dist/checksum_files/schema.js +5 -0
- package/dist/checksum_files_verif/handler.js +66 -0
- package/dist/checksum_files_verif/helpers.js +6 -0
- package/dist/checksum_files_verif/schema.js +8 -0
- package/dist/content_diff/handler.js +11 -0
- package/dist/content_diff/schema.js +7 -0
- package/dist/copy_files/handler.js +43 -0
- package/dist/copy_files/helpers.js +24 -0
- package/dist/copy_files/schema.js +7 -0
- package/dist/count_lines/handler.js +144 -0
- package/dist/count_lines/schema.js +9 -0
- package/dist/create_directories/handler.js +29 -0
- package/dist/create_directories/schema.js +4 -0
- package/dist/delete_files/handler.js +42 -0
- package/dist/delete_files/schema.js +5 -0
- package/dist/delete_files_by_pattern/handler.js +46 -0
- package/dist/delete_files_by_pattern/schema.js +6 -0
- package/dist/directory_tree/handler.js +7 -0
- package/dist/directory_tree/helpers.js +47 -0
- package/dist/directory_tree/schema.js +5 -0
- package/dist/edit_files/handler.js +62 -0
- package/dist/edit_files/schema.js +11 -0
- package/dist/file_diff/handler.js +28 -0
- package/dist/file_diff/schema.js +5 -0
- package/dist/file_info/handler.js +30 -0
- package/dist/file_info/schema.js +4 -0
- package/dist/fuzzy_find_files/handler.js +74 -0
- package/dist/fuzzy_find_files/schema.js +8 -0
- package/dist/helpers/checksum.js +10 -0
- package/dist/helpers/diff.js +10 -0
- package/dist/helpers/path.js +87 -0
- package/dist/index.js +36 -0
- package/dist/list_directory/handler.js +42 -0
- package/dist/list_directory/schema.js +9 -0
- package/dist/move_files/handler.js +68 -0
- package/dist/move_files/schema.js +8 -0
- package/dist/patch_files/handler.js +34 -0
- package/dist/patch_files/helpers.js +103 -0
- package/dist/patch_files/schema.js +18 -0
- package/dist/read_files/handler.js +36 -0
- package/dist/read_files/schema.js +6 -0
- package/dist/search_files/handler.js +15 -0
- package/dist/search_files/helpers.js +71 -0
- package/dist/search_files/schema.js +8 -0
- package/dist/search_glob/handler.js +77 -0
- package/dist/search_glob/schema.js +8 -0
- package/dist/search_regex/handler.js +159 -0
- package/dist/search_regex/schema.js +10 -0
- package/dist/server.js +698 -0
- package/dist/shell/handler.js +687 -0
- package/dist/shell/schema.js +69 -0
- package/dist/write_new_files/handler.js +55 -0
- package/dist/write_new_files/schema.js +9 -0
- package/package.json +37 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { validatePath } from "../helpers/path.js";
|
|
3
|
+
export async function handleDeleteFiles(paths, recursive, allowedDirectories) {
|
|
4
|
+
const results = [];
|
|
5
|
+
const errors = [];
|
|
6
|
+
await Promise.all(paths.map(async (filePath) => {
|
|
7
|
+
try {
|
|
8
|
+
// Validate path is within allowed directories
|
|
9
|
+
const validPath = await validatePath(filePath, allowedDirectories);
|
|
10
|
+
// Get file stats to determine if it's a file or directory
|
|
11
|
+
const stats = await fs.stat(validPath);
|
|
12
|
+
if (stats.isDirectory()) {
|
|
13
|
+
if (recursive) {
|
|
14
|
+
await fs.rm(validPath, { recursive: true, force: true });
|
|
15
|
+
results.push(`Successfully deleted directory: ${filePath}`);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
throw new Error("Cannot delete directory without recursive flag");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
// Delete the file
|
|
23
|
+
await fs.unlink(validPath);
|
|
24
|
+
results.push(`Successfully deleted file: ${filePath}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
29
|
+
errors.push(`Failed to delete ${filePath}: ${errorMessage}`);
|
|
30
|
+
}
|
|
31
|
+
}));
|
|
32
|
+
// Format the results
|
|
33
|
+
const successCount = results.length;
|
|
34
|
+
const errorCount = errors.length;
|
|
35
|
+
let output = `Processed ${successCount + errorCount} paths:\n`;
|
|
36
|
+
output += `- ${successCount} items deleted successfully\n`;
|
|
37
|
+
if (errorCount > 0) {
|
|
38
|
+
output += `- ${errorCount} items failed\n\n`;
|
|
39
|
+
output += "Errors:\n" + errors.join("\n");
|
|
40
|
+
}
|
|
41
|
+
return output;
|
|
42
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { validatePath } from "../helpers/path.js";
|
|
4
|
+
export async function handleDeleteFilesByPattern(directory, pattern, includeDirectories, allowedDirectories) {
|
|
5
|
+
const validPath = await validatePath(directory, allowedDirectories);
|
|
6
|
+
let regex;
|
|
7
|
+
try {
|
|
8
|
+
regex = new RegExp(pattern);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
throw new Error(`Invalid regular expression: ${pattern}`);
|
|
12
|
+
}
|
|
13
|
+
const entries = await fs.readdir(validPath, { withFileTypes: true });
|
|
14
|
+
const deleted = [];
|
|
15
|
+
const skipped = [];
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
if (!regex.test(entry.name))
|
|
18
|
+
continue;
|
|
19
|
+
const fullPath = path.join(validPath, entry.name);
|
|
20
|
+
try {
|
|
21
|
+
if (entry.isDirectory()) {
|
|
22
|
+
if (includeDirectories) {
|
|
23
|
+
await fs.rm(fullPath, { recursive: true, force: true });
|
|
24
|
+
deleted.push(`${fullPath} (directory)`);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
skipped.push(`${fullPath} (directory; set includeDirectories: true to delete)`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
await fs.unlink(fullPath);
|
|
32
|
+
deleted.push(fullPath);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
37
|
+
skipped.push(`${fullPath} (${errorMessage})`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
let output = `Pattern "${pattern}" in ${validPath}:\n`;
|
|
41
|
+
output += `- ${deleted.length} deleted\n`;
|
|
42
|
+
if (skipped.length > 0) {
|
|
43
|
+
output += `- ${skipped.length} skipped:\n` + skipped.map((s) => ` ${s}`).join("\n");
|
|
44
|
+
}
|
|
45
|
+
return output;
|
|
46
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const DeleteFilesByPatternArgsSchema = z.object({
|
|
3
|
+
directory: z.string().describe("Directory to scan (single level; subdirectories are not scanned)."),
|
|
4
|
+
pattern: z.string().describe("Regular expression matched against each entry name (e.g. '^temp_.*\\.txt$')."),
|
|
5
|
+
includeDirectories: z.boolean().optional().default(false).describe("Also delete matching directories (recursively). Default: false (files only)."),
|
|
6
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { validatePath } from "../helpers/path.js";
|
|
2
|
+
import { buildDirectoryTree } from "./helpers.js";
|
|
3
|
+
export async function handleDirectoryTree(directoryPath, excludePatterns, allowedDirectories) {
|
|
4
|
+
const validRootPath = await validatePath(directoryPath, allowedDirectories);
|
|
5
|
+
const treeData = await buildDirectoryTree(validRootPath, validRootPath, excludePatterns, allowedDirectories);
|
|
6
|
+
return JSON.stringify(treeData, null, 2);
|
|
7
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { minimatch } from 'minimatch';
|
|
4
|
+
import { validatePath } from "../helpers/path.js";
|
|
5
|
+
export async function buildDirectoryTree(currentPath, rootPath, excludePatterns = [], allowedDirectories) {
|
|
6
|
+
const validPath = await validatePath(currentPath, allowedDirectories);
|
|
7
|
+
const entries = await fs.readdir(validPath, { withFileTypes: true });
|
|
8
|
+
const result = [];
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
const entryPath = path.join(currentPath, entry.name);
|
|
11
|
+
// Check if path should be excluded based on patterns
|
|
12
|
+
const relativePath = path.relative(rootPath, entryPath);
|
|
13
|
+
const shouldExclude = excludePatterns.some(pattern => {
|
|
14
|
+
// Apply same pattern logic as in searchFiles
|
|
15
|
+
let globPattern = pattern;
|
|
16
|
+
if (!pattern.includes('*') && !pattern.includes('?')) {
|
|
17
|
+
if (pattern.includes('/')) {
|
|
18
|
+
globPattern = `**/${pattern}/**`;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
globPattern = `**/*${pattern}*/**`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return minimatch(relativePath, globPattern, {
|
|
25
|
+
dot: true,
|
|
26
|
+
nocase: true,
|
|
27
|
+
matchBase: true
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
if (shouldExclude) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const entryData = {
|
|
34
|
+
name: entry.name,
|
|
35
|
+
type: entry.isDirectory() ? 'directory' : 'file'
|
|
36
|
+
};
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
entryData.children = await buildDirectoryTree(entryPath, rootPath, excludePatterns, allowedDirectories);
|
|
39
|
+
// Skip empty directories after exclusion
|
|
40
|
+
if (entryData.children.length === 0) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
result.push(entryData);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { validatePath } from "../helpers/path.js";
|
|
3
|
+
// Detect the dominant line ending of a file so it can be preserved on write.
|
|
4
|
+
function detectEol(text) {
|
|
5
|
+
if (text.includes("\r\n"))
|
|
6
|
+
return "\r\n";
|
|
7
|
+
if (text.includes("\r"))
|
|
8
|
+
return "\r";
|
|
9
|
+
return "\n";
|
|
10
|
+
}
|
|
11
|
+
const toLf = (t) => t.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
12
|
+
const fromLf = (t, eol) => (eol === "\n" ? t : t.replace(/\n/g, eol));
|
|
13
|
+
export async function handleEditFiles(files, allowedDirectories) {
|
|
14
|
+
const results = [];
|
|
15
|
+
const errors = [];
|
|
16
|
+
for (const file of files) {
|
|
17
|
+
try {
|
|
18
|
+
const validPath = await validatePath(file.path, allowedDirectories);
|
|
19
|
+
const raw = await fs.readFile(validPath, "utf-8");
|
|
20
|
+
const eol = detectEol(raw);
|
|
21
|
+
let current = toLf(raw);
|
|
22
|
+
const editLog = [];
|
|
23
|
+
for (let i = 0; i < file.edits.length; i++) {
|
|
24
|
+
const { oldString, newString, replaceAll } = file.edits[i];
|
|
25
|
+
const oldN = toLf(oldString);
|
|
26
|
+
const newN = toLf(newString);
|
|
27
|
+
if (oldN.length === 0) {
|
|
28
|
+
throw new Error(`Edit ${i + 1}: oldString must not be empty`);
|
|
29
|
+
}
|
|
30
|
+
const count = current.split(oldN).length - 1;
|
|
31
|
+
if (count === 0) {
|
|
32
|
+
throw new Error(`Edit ${i + 1}: oldString not found`);
|
|
33
|
+
}
|
|
34
|
+
if (!replaceAll && count > 1) {
|
|
35
|
+
throw new Error(`Edit ${i + 1}: oldString matched ${count} times (expected exactly 1; set replaceAll: true to replace all)`);
|
|
36
|
+
}
|
|
37
|
+
current = current.split(oldN).join(newN);
|
|
38
|
+
editLog.push(`Edit ${i + 1}: replaced ${replaceAll ? count : 1} occurrence(s)`);
|
|
39
|
+
}
|
|
40
|
+
// Write back with the file's original dominant line ending.
|
|
41
|
+
await fs.writeFile(validPath, fromLf(current, eol), "utf-8");
|
|
42
|
+
results.push(`File: ${file.path}\n${editLog.join("\n")}`);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
46
|
+
errors.push(`Failed to edit ${file.path}: ${errorMessage}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const successCount = results.length;
|
|
50
|
+
const errorCount = errors.length;
|
|
51
|
+
let output = `Processed ${successCount + errorCount} files:\n`;
|
|
52
|
+
output += `- ${successCount} files edited successfully\n`;
|
|
53
|
+
if (errorCount > 0) {
|
|
54
|
+
output += `- ${errorCount} files failed\n\n`;
|
|
55
|
+
output += "Errors:\n" + errors.join("\n");
|
|
56
|
+
}
|
|
57
|
+
if (successCount > 0) {
|
|
58
|
+
output += "\n\nEdit details:\n" + "=".repeat(40) + "\n";
|
|
59
|
+
output += results.join("\n" + "=".repeat(40) + "\n");
|
|
60
|
+
}
|
|
61
|
+
return output;
|
|
62
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const EditFilesArgsSchema = z.object({
|
|
3
|
+
files: z.array(z.object({
|
|
4
|
+
path: z.string().describe("Path to the file to edit"),
|
|
5
|
+
edits: z.array(z.object({
|
|
6
|
+
oldString: z.string().describe("Exact literal text to find. Must not be empty."),
|
|
7
|
+
newString: z.string().describe("Replacement text. May be empty to delete the matched text."),
|
|
8
|
+
replaceAll: z.boolean().optional().default(false).describe("When true, replace every occurrence. Default: require exactly one match."),
|
|
9
|
+
})).describe("Array of edits applied in order"),
|
|
10
|
+
})).describe("Array of files to edit"),
|
|
11
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { validatePath } from "../helpers/path.js";
|
|
3
|
+
import { createUnifiedDiff } from "../helpers/diff.js";
|
|
4
|
+
export async function handleFileDiff(file1Path, file2Path, allowedDirectories) {
|
|
5
|
+
const validFile1Path = await validatePath(file1Path, allowedDirectories);
|
|
6
|
+
const validFile2Path = await validatePath(file2Path, allowedDirectories);
|
|
7
|
+
try {
|
|
8
|
+
// Read both files
|
|
9
|
+
const file1Content = await fs.readFile(validFile1Path, 'utf-8');
|
|
10
|
+
const file2Content = await fs.readFile(validFile2Path, 'utf-8');
|
|
11
|
+
// Create a unified diff
|
|
12
|
+
const diff = createUnifiedDiff(file1Content, file2Content, file1Path, file2Path);
|
|
13
|
+
// Format diff with appropriate number of backticks
|
|
14
|
+
let numBackticks = 3;
|
|
15
|
+
while (diff.includes('`'.repeat(numBackticks))) {
|
|
16
|
+
numBackticks++;
|
|
17
|
+
}
|
|
18
|
+
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`;
|
|
19
|
+
if (diff.trim() === '') {
|
|
20
|
+
return `Files are identical.`;
|
|
21
|
+
}
|
|
22
|
+
return formattedDiff;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
26
|
+
throw new Error(`Error comparing files: ${errorMessage}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { validatePath } from "../helpers/path.js";
|
|
3
|
+
export async function getFileStats(filePath) {
|
|
4
|
+
const stats = await fs.stat(filePath);
|
|
5
|
+
return {
|
|
6
|
+
size: stats.size,
|
|
7
|
+
created: stats.birthtime,
|
|
8
|
+
modified: stats.mtime,
|
|
9
|
+
accessed: stats.atime,
|
|
10
|
+
isDirectory: stats.isDirectory(),
|
|
11
|
+
isFile: stats.isFile(),
|
|
12
|
+
permissions: stats.mode.toString(8).slice(-3),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export async function handleGetFileInfo(filePath, allowedDirectories) {
|
|
16
|
+
const validPath = await validatePath(filePath, allowedDirectories);
|
|
17
|
+
const stats = await fs.stat(validPath);
|
|
18
|
+
const formattedInfo = {
|
|
19
|
+
path: filePath,
|
|
20
|
+
size: `${stats.size} bytes`,
|
|
21
|
+
type: stats.isDirectory() ? 'directory' : (stats.isFile() ? 'file' : 'other'),
|
|
22
|
+
created: stats.birthtime.toISOString(),
|
|
23
|
+
modified: stats.mtime.toISOString(),
|
|
24
|
+
accessed: stats.atime.toISOString(),
|
|
25
|
+
permissions: stats.mode.toString(8).slice(-3)
|
|
26
|
+
};
|
|
27
|
+
return Object.entries(formattedInfo)
|
|
28
|
+
.map(([key, value]) => `${key}: ${value}`)
|
|
29
|
+
.join("\n");
|
|
30
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { validatePath } from "../helpers/path.js";
|
|
4
|
+
function levenshtein(a, b) {
|
|
5
|
+
if (a === b)
|
|
6
|
+
return 0;
|
|
7
|
+
if (a.length === 0)
|
|
8
|
+
return b.length;
|
|
9
|
+
if (b.length === 0)
|
|
10
|
+
return a.length;
|
|
11
|
+
let prev = new Array(b.length + 1);
|
|
12
|
+
let curr = new Array(b.length + 1);
|
|
13
|
+
for (let j = 0; j <= b.length; j++)
|
|
14
|
+
prev[j] = j;
|
|
15
|
+
for (let i = 1; i <= a.length; i++) {
|
|
16
|
+
curr[0] = i;
|
|
17
|
+
for (let j = 1; j <= b.length; j++) {
|
|
18
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
19
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
20
|
+
}
|
|
21
|
+
[prev, curr] = [curr, prev];
|
|
22
|
+
}
|
|
23
|
+
return prev[b.length];
|
|
24
|
+
}
|
|
25
|
+
export async function handleFuzzyFindFiles(query, searchPath, recursive, maxResults, scanLimit, allowedDirectories) {
|
|
26
|
+
const validRoot = await validatePath(searchPath, allowedDirectories);
|
|
27
|
+
const q = query.toLowerCase();
|
|
28
|
+
const candidates = [];
|
|
29
|
+
let scanned = 0;
|
|
30
|
+
let truncated = false;
|
|
31
|
+
async function walk(dir) {
|
|
32
|
+
if (truncated)
|
|
33
|
+
return;
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (truncated)
|
|
43
|
+
break;
|
|
44
|
+
scanned++;
|
|
45
|
+
if (scanned > scanLimit) {
|
|
46
|
+
truncated = true;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
const fullPath = path.join(dir, entry.name);
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
if (recursive) {
|
|
52
|
+
await walk(fullPath);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else if (entry.isFile()) {
|
|
56
|
+
const rel = path.relative(validRoot, fullPath).toLowerCase();
|
|
57
|
+
const name = entry.name.toLowerCase();
|
|
58
|
+
const distance = Math.min(levenshtein(q, name), levenshtein(q, rel));
|
|
59
|
+
candidates.push({ path: fullPath, distance });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await walk(validRoot);
|
|
64
|
+
candidates.sort((a, b) => a.distance - b.distance || a.path.localeCompare(b.path));
|
|
65
|
+
const top = candidates.slice(0, maxResults);
|
|
66
|
+
if (top.length === 0) {
|
|
67
|
+
return `No files found for query: ${query} (scanned ${scanned} entries)`;
|
|
68
|
+
}
|
|
69
|
+
let output = `Top ${top.length} fuzzy matches for "${query}" (scanned ${scanned} entries${truncated ? ", stopped at scan limit" : ""}):\n`;
|
|
70
|
+
top.forEach((c, i) => {
|
|
71
|
+
output += `${i + 1}. (distance ${c.distance}) ${c.path}\n`;
|
|
72
|
+
});
|
|
73
|
+
return output;
|
|
74
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const FuzzyFindFilesArgsSchema = z.object({
|
|
3
|
+
query: z.string().describe("Search query to match against file names/paths (typo-tolerant)."),
|
|
4
|
+
path: z.string().describe("Root directory to search in."),
|
|
5
|
+
recursive: z.boolean().optional().default(true).describe("Recursively search subdirectories. Default: true."),
|
|
6
|
+
maxResults: z.number().int().min(1).max(50).optional().default(5).describe("Maximum number of results to return. Default: 5."),
|
|
7
|
+
scanLimit: z.number().int().min(1).optional().default(20000).describe("Maximum entries to scan before stopping (safety for huge trees/network shares). Default: 20000."),
|
|
8
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
export async function calculateFileHash(filePath, algorithm = "sha256") {
|
|
4
|
+
// Read the file
|
|
5
|
+
const data = await fs.readFile(filePath);
|
|
6
|
+
// Calculate hash
|
|
7
|
+
const hash = crypto.createHash(algorithm);
|
|
8
|
+
hash.update(data);
|
|
9
|
+
return hash.digest("hex");
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createTwoFilesPatch } from 'diff';
|
|
2
|
+
export function normalizeLineEndings(text) {
|
|
3
|
+
return text.replace(/\r\n/g, '\n');
|
|
4
|
+
}
|
|
5
|
+
export function createUnifiedDiff(originalContent, newContent, originalFilePath = 'file1', newFilePath = 'file2') {
|
|
6
|
+
// Ensure consistent line endings for diff
|
|
7
|
+
const normalizedOriginal = normalizeLineEndings(originalContent);
|
|
8
|
+
const normalizedNew = normalizeLineEndings(newContent);
|
|
9
|
+
return createTwoFilesPatch(originalFilePath, newFilePath, normalizedOriginal, normalizedNew, 'original', 'modified');
|
|
10
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from 'os';
|
|
4
|
+
// Normalize all paths consistently
|
|
5
|
+
export function normalizePath(p) {
|
|
6
|
+
return path.normalize(p);
|
|
7
|
+
}
|
|
8
|
+
export function expandHome(filepath) {
|
|
9
|
+
if (filepath.startsWith('~/') || filepath === '~') {
|
|
10
|
+
return path.join(os.homedir(), filepath.slice(1));
|
|
11
|
+
}
|
|
12
|
+
return filepath;
|
|
13
|
+
}
|
|
14
|
+
// Security utilities
|
|
15
|
+
export async function validatePath(requestedPath, allowedDirectories) {
|
|
16
|
+
const expandedPath = expandHome(requestedPath);
|
|
17
|
+
const absolute = path.isAbsolute(expandedPath)
|
|
18
|
+
? path.resolve(expandedPath)
|
|
19
|
+
: path.resolve(process.cwd(), expandedPath);
|
|
20
|
+
const normalizedRequested = normalizePath(absolute);
|
|
21
|
+
// Check if path is within allowed directories
|
|
22
|
+
const isAllowed = allowedDirectories.some(dir => normalizedRequested.startsWith(dir));
|
|
23
|
+
if (!isAllowed) {
|
|
24
|
+
throw new Error(`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`);
|
|
25
|
+
}
|
|
26
|
+
// Handle symlinks by checking their real path
|
|
27
|
+
try {
|
|
28
|
+
const realPath = await fs.realpath(absolute);
|
|
29
|
+
const normalizedReal = normalizePath(realPath);
|
|
30
|
+
const isRealPathAllowed = allowedDirectories.some(dir => normalizedReal.startsWith(dir));
|
|
31
|
+
if (!isRealPathAllowed) {
|
|
32
|
+
throw new Error("Access denied - symlink target outside allowed directories");
|
|
33
|
+
}
|
|
34
|
+
return realPath;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
// Target doesn't exist yet: walk up to the nearest existing ancestor
|
|
38
|
+
// and verify it is within an allowed directory
|
|
39
|
+
let ancestor = path.dirname(absolute);
|
|
40
|
+
let realAncestor = null;
|
|
41
|
+
while (true) {
|
|
42
|
+
try {
|
|
43
|
+
realAncestor = await fs.realpath(ancestor);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
const up = path.dirname(ancestor);
|
|
48
|
+
if (up === ancestor) {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
ancestor = up;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (realAncestor === null) {
|
|
55
|
+
throw new Error(`Parent directory does not exist: ${path.dirname(absolute)}`);
|
|
56
|
+
}
|
|
57
|
+
const normalizedAncestor = normalizePath(realAncestor);
|
|
58
|
+
const isAncestorAllowed = allowedDirectories.some(dir => normalizedAncestor.startsWith(dir));
|
|
59
|
+
if (!isAncestorAllowed) {
|
|
60
|
+
throw new Error("Access denied - parent directory outside allowed directories");
|
|
61
|
+
}
|
|
62
|
+
return absolute;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Create a directory (and parents) if needed. Tolerates volume roots,
|
|
66
|
+
// which Node's recursive mkdir can reject with EPERM on Windows.
|
|
67
|
+
export async function ensureDirectoryExists(dir) {
|
|
68
|
+
try {
|
|
69
|
+
const stats = await fs.stat(dir);
|
|
70
|
+
if (stats.isDirectory()) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Doesn't exist or is inaccessible - fall through and try to create
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await fs.mkdir(dir, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const code = error.code;
|
|
82
|
+
if (code === 'EPERM' || code === 'EEXIST') {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { normalizePath, expandHome } from "./helpers/path.js";
|
|
5
|
+
import { FilesystemServer } from "./server.js";
|
|
6
|
+
// Command line argument parsing
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
if (args.length === 0) {
|
|
9
|
+
console.error("Usage: mcp-fs-shell-windows <allowed-directory> [additional-directories...]");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
// Store allowed directories in normalized form
|
|
13
|
+
const allowedDirectories = args.map(dir => normalizePath(path.resolve(expandHome(dir))));
|
|
14
|
+
// Validate that all directories exist and are accessible
|
|
15
|
+
(async () => {
|
|
16
|
+
try {
|
|
17
|
+
await Promise.all(args.map(async (dir) => {
|
|
18
|
+
try {
|
|
19
|
+
const stats = await fs.stat(dir);
|
|
20
|
+
if (!stats.isDirectory()) {
|
|
21
|
+
console.error(`Warning: ${dir} is not a directory; continuing without it`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
console.error(`Warning: cannot access ${dir} (offline or invalid); continuing without it`);
|
|
26
|
+
}
|
|
27
|
+
}));
|
|
28
|
+
// Start server
|
|
29
|
+
const server = new FilesystemServer(allowedDirectories);
|
|
30
|
+
await server.connect();
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
console.error("Fatal error running server:", error);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
})();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { validatePath } from "../helpers/path.js";
|
|
4
|
+
import { minimatch } from "minimatch";
|
|
5
|
+
export async function handleListDirectory(directoryPath, allowedDirectories, type = "all", ignore = [], offset = 1, limit = 100, includeSizes = true) {
|
|
6
|
+
const validPath = await validatePath(directoryPath, allowedDirectories);
|
|
7
|
+
const entries = await fs.readdir(validPath, { withFileTypes: true });
|
|
8
|
+
let filtered = entries.filter((entry) => {
|
|
9
|
+
if (type === "files" && !entry.isFile())
|
|
10
|
+
return false;
|
|
11
|
+
if (type === "directories" && !entry.isDirectory())
|
|
12
|
+
return false;
|
|
13
|
+
return !ignore.some((pattern) => minimatch(entry.name, pattern, { dot: true }));
|
|
14
|
+
});
|
|
15
|
+
// Stable ordering for pagination
|
|
16
|
+
filtered = filtered.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
17
|
+
const total = filtered.length;
|
|
18
|
+
if (total === 0) {
|
|
19
|
+
return `No entries in ${validPath}`;
|
|
20
|
+
}
|
|
21
|
+
if (offset > total) {
|
|
22
|
+
return `No entries in ${validPath} (offset ${offset} is beyond ${total} entries)`;
|
|
23
|
+
}
|
|
24
|
+
const start = offset - 1;
|
|
25
|
+
const shown = filtered.slice(start, start + limit);
|
|
26
|
+
const lines = [];
|
|
27
|
+
for (const entry of shown) {
|
|
28
|
+
let sizeText = "";
|
|
29
|
+
if (includeSizes && entry.isFile()) {
|
|
30
|
+
try {
|
|
31
|
+
const stats = await fs.stat(path.join(validPath, entry.name));
|
|
32
|
+
sizeText = ` (${stats.size} bytes)`;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
sizeText = "";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
lines.push(`${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}${sizeText}`);
|
|
39
|
+
}
|
|
40
|
+
const end = start + shown.length;
|
|
41
|
+
return `Showing ${offset}-${end} of ${total} entries in ${validPath}\n` + lines.join("\n");
|
|
42
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const ListDirectoryArgsSchema = z.object({
|
|
3
|
+
path: z.string(),
|
|
4
|
+
type: z.enum(["files", "directories", "all"]).optional().default("all").describe("What to return: \"files\", \"directories\", or \"all\". Default: \"all\"."),
|
|
5
|
+
ignore: z.array(z.string()).optional().default([]).describe("Glob patterns to skip (e.g. [\"node_modules\", \"dist\", \"*.log\"])."),
|
|
6
|
+
offset: z.number().int().min(1).optional().default(1).describe("1-indexed result number to start from. Default: 1."),
|
|
7
|
+
limit: z.number().int().min(1).optional().default(100).describe("Maximum number of entries to return. Default: 100."),
|
|
8
|
+
includeSizes: z.boolean().optional().default(true).describe("Include file sizes in the listing. Default: true (one stat per shown file entry)."),
|
|
9
|
+
});
|