mcp-fs-shell-windows 0.2.7 → 0.2.19
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 +41 -12
- package/dist/append_files/handler.js +2 -2
- package/dist/checksum_files/handler.js +2 -2
- package/dist/checksum_files_verif/handler.js +2 -2
- package/dist/convert_encoding/handler.js +40 -0
- package/dist/convert_encoding/schema.js +19 -0
- package/dist/copy_files/handler.js +2 -2
- package/dist/count_lines/handler.js +3 -3
- package/dist/create_directories/handler.js +2 -2
- package/dist/create_link/handler.js +90 -0
- package/dist/create_link/schema.js +25 -0
- package/dist/delete_files/handler.js +205 -14
- package/dist/delete_files/schema.js +4 -0
- package/dist/delete_files_by_pattern/handler.js +7 -7
- package/dist/dir_diff/handler.js +155 -0
- package/dist/dir_diff/schema.js +7 -0
- package/dist/directory_tree/helpers.js +5 -5
- package/dist/edit_files/handler.js +2 -2
- package/dist/file_diff/handler.js +2 -2
- package/dist/file_info/handler.js +39 -3
- package/dist/fuzzy_find_files/handler.js +2 -2
- package/dist/helpers/encoding.js +154 -0
- package/dist/helpers/path.js +133 -6
- package/dist/launch_file/handler.js +6 -4
- package/dist/list_directory/handler.js +16 -9
- package/dist/move_files/handler.js +2 -2
- package/dist/patch_files/handler.js +2 -2
- package/dist/patch_files/helpers.js +24 -4
- package/dist/read_bytes/handler.js +43 -0
- package/dist/read_bytes/schema.js +6 -0
- package/dist/read_files/handler.js +18 -9
- package/dist/read_files/schema.js +37 -3
- package/dist/replace_regex/handler.js +105 -0
- package/dist/replace_regex/schema.js +23 -0
- package/dist/search_files/handler.js +2 -2
- package/dist/search_glob/handler.js +2 -2
- package/dist/search_regex/handler.js +63 -40
- package/dist/search_regex/schema.js +1 -0
- package/dist/server.js +183 -9
- package/dist/wait_for_file/handler.js +91 -0
- package/dist/wait_for_file/schema.js +7 -0
- package/dist/write_bytes/handler.js +49 -0
- package/dist/write_bytes/schema.js +7 -0
- package/dist/write_new_files/handler.js +6 -3
- package/dist/write_new_files/schema.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,14 +12,14 @@ Heavily extended fork of [fabienvauchelles/mcp-filesystem-extended](https://gith
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| Official base | `@modelcontextprotocol/server-filesystem` (Anthropic, PBC; MIT) |
|
|
14
14
|
| Base fork | [fabienvauchelles/mcp-filesystem-extended](https://github.com/fabienvauchelles/mcp-filesystem-extended) @ `b6c317a` (MIT) |
|
|
15
|
-
| This fork | v0.2.1 tool parity + `transfer_files` rename, robustness patches, 8 shell tools (v0.2.2), verbatim argv (v0.2.4), `launch_file` launcher (v0.2.7) — see [Differences](#differences-from-the-base-fork) |
|
|
15
|
+
| This fork | v0.2.1 tool parity + `transfer_files` rename, robustness patches, 8 shell tools (v0.2.2), verbatim argv (v0.2.4), `launch_file` launcher (v0.2.7), raw-byte access + `dir_diff` + text encodings (v0.2.9–v0.2.11), `wait_for_file` (v0.2.12), `replace_regex` (v0.2.14), reparse-point & long-path hardening (v0.2.15–v0.2.16), Recycle-Bin deletes (v0.2.17), `create_link` (v0.2.18) — see [Differences](#differences-from-the-base-fork) |
|
|
16
16
|
|
|
17
17
|
## Features
|
|
18
18
|
|
|
19
|
-
- **
|
|
19
|
+
- **30 filesystem tools** — batched read/write/append/delete (including raw-byte access, per-file text encodings, Recycle-Bin deletes, and link creation), move/copy, line-number patching, search (name / glob / regex / fuzzy / wait-for-file), in-place regex replacement, tree, counts, checksums, file and directory diffs, exact-match text editing. Confined to the allowed directories passed on the command line.
|
|
20
20
|
- **8 shell tools** — synchronous, background, and interactive command execution implemented *inside the MCP server process*: no client-side permission toggles, survives client restarts, works with any MCP client.
|
|
21
21
|
- **1 launcher tool** (`launch_file`, Windows-only) — opens files, folders, and http(s) links in their OS default app, fully detached: returns the moment the opener is spawned (never waits for the app), no console window, no stdio inheritance; filesystem targets are confined to the allowed directories.
|
|
22
|
-
- **Windows-first** — drive roots, UNC roots, `cmd.exe /d /c` with verbatim argv, whole-process-tree kills (`taskkill /T /F`), detached interactive console windows.
|
|
22
|
+
- **Windows-first** — drive roots, UNC roots, long paths beyond the 260-char `MAX_PATH` limit, `cmd.exe /d /c` with verbatim argv, whole-process-tree kills (`taskkill /T /F`), detached interactive console windows.
|
|
23
23
|
- **NAS-safe startup** — an allowed directory that is missing or offline (e.g., a powered-down network share) logs a warning and the server continues instead of exiting.
|
|
24
24
|
|
|
25
25
|
## Requirements
|
|
@@ -65,22 +65,22 @@ node dist\index.js <allowed-dir> [more-dirs...]
|
|
|
65
65
|
|
|
66
66
|
Then point your MCP config at `node <path-to-repo>\dist\index.js` with the allowed directories as the remaining args (same shape as the npx example above).
|
|
67
67
|
|
|
68
|
-
### Filesystem tools (
|
|
68
|
+
### Filesystem tools (30)
|
|
69
69
|
|
|
70
70
|
| Tool | Purpose |
|
|
71
71
|
|---|---|
|
|
72
|
-
| `read_files` | Read one or more files, line-numbered; `offset`/`limit` paging |
|
|
73
|
-
| `write_new_files` | Create files (per-file `overwrite`, optional `base64`
|
|
72
|
+
| `read_files` | Read one or more files, line-numbered; `offset`/`limit` paging; per-file `encoding` (utf8/utf16le/utf16be/cp1252, auto-detected when omitted) |
|
|
73
|
+
| `write_new_files` | Create files (per-file `overwrite`, `textEncoding` utf8/utf16le/utf16be/cp1252, optional `base64` for binary) |
|
|
74
74
|
| `append_files` | Append to files (creates them if missing) |
|
|
75
|
-
| `delete_files` | Delete files/directories (`recursive`) |
|
|
75
|
+
| `delete_files` | Delete files/directories (`recursive`; junctions/symlinks are removed as links, never followed; `toRecycleBin` - Windows: send to Recycle Bin instead of permanent delete) |
|
|
76
76
|
| `copy_file` | Copy files/directories (`recursive`, `overwrite`) |
|
|
77
77
|
| `transfer_files` | Move/rename items (`overwrite`, creates parent dirs) |
|
|
78
78
|
| `patch_files` | Patch by line ranges (`replace`/`insertBefore`/`delete`, `dryRun`, git-diff output) |
|
|
79
79
|
| `create_directories` | Create directory paths (nested chains) |
|
|
80
|
-
| `list_directory` | Single-level listing (sizes, pagination, type filter, ignore globs) |
|
|
81
|
-
| `directory_tree` | Recursive tree view (JSON) with exclusion patterns |
|
|
80
|
+
| `list_directory` | Single-level listing ([FILE]/[DIR]/[LINK] prefixes, sizes, pagination, type filter, ignore globs) |
|
|
81
|
+
| `directory_tree` | Recursive tree view (JSON) with exclusion patterns; includes empty dirs and link nodes |
|
|
82
82
|
| `search_files` | Recursive case-insensitive name-substring search |
|
|
83
|
-
| `search_regex` | Content grep over a single file or directory tree (paged) |
|
|
83
|
+
| `search_regex` | Content grep over a single file or directory tree (paged; total + per-file match counts, `countOnly` mode) |
|
|
84
84
|
| `search_glob` | Glob-based file search (e.g. `**/*.ts`, paged) |
|
|
85
85
|
| `fuzzy_find_files` | Typo-tolerant name/path search (Levenshtein) |
|
|
86
86
|
| `delete_files_by_pattern` | Regex delete within a single directory |
|
|
@@ -89,8 +89,15 @@ Then point your MCP config at `node <path-to-repo>\dist\index.js` with the allow
|
|
|
89
89
|
| `content_diff` | Unified diff of two strings |
|
|
90
90
|
| `checksum_files` | md5/sha1/sha256/sha512 for files |
|
|
91
91
|
| `checksum_files_verif` | Verify files against expected hashes |
|
|
92
|
-
| `get_file_info` | Size, timestamps, type, permissions |
|
|
93
|
-
| `edit_files` | Exact-match text replacement, EOL-tolerant |
|
|
92
|
+
| `get_file_info` | Size, timestamps, type, permissions, `isReparsePoint`, (Windows) NTFS attributes |
|
|
93
|
+
| `edit_files` | Exact-match text replacement, EOL-tolerant (dominant line ending preserved) |
|
|
94
|
+
| `read_bytes` | Read raw bytes at (offset, length) as base64 JSON — any binary file, any size |
|
|
95
|
+
| `write_bytes` | Write raw bytes (base64) into an existing file at an offset — in place, or `extend` past EOF (gap zero-filled) |
|
|
96
|
+
| `replace_regex` | In-place regex replacement across one or more files; `dryRun` defaults to true (unified-diff preview); preserves dominant line endings |
|
|
97
|
+
| `dir_diff` | Recursive two-directory comparison (added/removed/changed/identical; unified diff for changed text files; `ignore` globs, `maxFiles` cap) |
|
|
98
|
+
| `convert_encoding` | Convert a file's text encoding (utf8/utf16le/utf16be/cp1252, source auto-detected) to a new file |
|
|
99
|
+
| `wait_for_file` | Poll until at least one file matching a glob appears under a directory (configurable timeout/interval) |
|
|
100
|
+
| `create_link` | Create a hardlink, directory junction, or file/directory symlink to an existing target (`auto`/`symlink`/`junction`/`hardlink`) |
|
|
94
101
|
| `list_allowed_directories` | List the allowed root directories |
|
|
95
102
|
|
|
96
103
|
### Shell tools (8, Windows-only)
|
|
@@ -149,6 +156,28 @@ The filesystem tools work in the image; the shell tools require a Windows host w
|
|
|
149
156
|
|
|
150
157
|
**v0.2.7 — `launch_file`:** the detached OS launcher (see above). Renamed from a local `open_file` prototype so it cannot collide with other servers' `open_file` tools. Hardened: spawn-failure returns an error (the handler awaits the child's `spawn`/`error` events — it never reports success for a process Windows refused to create), URLs are restricted to http(s) so `file:` URIs cannot bypass the allowed-root policy, and non-Windows platforms fail with a clear error.
|
|
151
158
|
|
|
159
|
+
**v0.2.8 — line-ending preservation:** `patch_files` and `edit_files` preserve the file's dominant line ending (CRLF or LF) on write.
|
|
160
|
+
|
|
161
|
+
**v0.2.9 — `read_bytes` / `write_bytes`:** raw binary access at (offset, length) — base64 reads from any part of a file (any size, e.g. GGUFs and blobs) and in-place byte writes into existing files (`extend: true` grows the file past EOF, zero-filling the gap).
|
|
162
|
+
|
|
163
|
+
**v0.2.10 — `dir_diff`:** recursive comparison of two directory trees — added/removed/changed/identical, unified diffs for changed text files, `ignore` glob patterns, and a `maxFiles` cap.
|
|
164
|
+
|
|
165
|
+
**v0.2.11 — text encoding support:** per-file `encoding` in `read_files` (utf8/utf16le/utf16be/cp1252, auto-detected from BOMs when omitted), `textEncoding` in `write_new_files`, and `convert_encoding` to transcode a file into a new encoding.
|
|
166
|
+
|
|
167
|
+
**v0.2.12 — `wait_for_file`:** polling file watcher — waits (up to `timeoutMs`, default 30 s, max 300 s) until at least one file matching a glob appears under a directory.
|
|
168
|
+
|
|
169
|
+
**v0.2.13 — `search_regex` match totals:** every pattern occurrence is counted (a line matching N times counts N); responses end with the total and per-file counts, and `countOnly: true` returns counts without the matching line text.
|
|
170
|
+
|
|
171
|
+
**v0.2.14 — `replace_regex`:** in-place regex replacement across one or more files (directories are rejected). `dryRun` defaults to `true` with a unified-diff preview; applied replacements preserve each file's dominant line ending; a missing path or invalid regex errors without writing anything.
|
|
172
|
+
|
|
173
|
+
**v0.2.15 — reparse-point safety:** junctions/symlinks are never followed. `delete_files` removes them as links (including nested links inside recursive deletes) without touching their targets; `list_directory` shows `[LINK]` entries; `directory_tree` reports link nodes and includes empty directories; `get_file_info` adds `isReparsePoint` and (on Windows) NTFS attributes.
|
|
174
|
+
|
|
175
|
+
**v0.2.16 — long-path support:** paths beyond the 260-char `MAX_PATH` limit work on Windows via `\\?\` extended-length form (drive and UNC), transparent to callers.
|
|
176
|
+
|
|
177
|
+
**v0.2.17 — `delete_files` `toRecycleBin`:** send deletes to the Windows Recycle Bin instead of permanent removal. Reparse points are still removed as links (the bin cannot hold them), and any path that cannot be recycled is reported as an error — never silently hard-deleted.
|
|
178
|
+
|
|
179
|
+
**v0.2.18 — `create_link`:** create hardlinks, directory junctions, and file/directory symlinks (`type: auto | symlink | junction | hardlink`) with strict checks: the target must exist, the link path must not, and the link path's parent must exist.
|
|
180
|
+
|
|
152
181
|
## Security
|
|
153
182
|
|
|
154
183
|
- The filesystem tools are confined to the allowed directories passed at startup (and their subtrees).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { validatePath, ensureDirectoryExists } from "../helpers/path.js";
|
|
3
|
+
import { validatePath, ensureDirectoryExists, stripLongPathAll } from "../helpers/path.js";
|
|
4
4
|
export async function handleAppendFiles(files, allowedDirectories) {
|
|
5
5
|
const results = [];
|
|
6
6
|
const errors = [];
|
|
@@ -15,7 +15,7 @@ export async function handleAppendFiles(files, allowedDirectories) {
|
|
|
15
15
|
results.push(`Successfully appended to ${file.path}`);
|
|
16
16
|
}
|
|
17
17
|
catch (error) {
|
|
18
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
18
|
+
const errorMessage = stripLongPathAll(error instanceof Error ? error.message : String(error));
|
|
19
19
|
errors.push(`Failed to append to file ${file.path}: ${errorMessage}`);
|
|
20
20
|
}
|
|
21
21
|
}));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validatePath } from "../helpers/path.js";
|
|
1
|
+
import { validatePath, stripLongPathAll } from "../helpers/path.js";
|
|
2
2
|
import { calculateFileHash } from "../helpers/checksum.js";
|
|
3
3
|
export async function handleChecksumFiles(filePaths, algorithm, allowedDirectories) {
|
|
4
4
|
const results = [];
|
|
@@ -12,7 +12,7 @@ export async function handleChecksumFiles(filePaths, algorithm, allowedDirectori
|
|
|
12
12
|
results.push({ path: filePath, hash });
|
|
13
13
|
}
|
|
14
14
|
catch (error) {
|
|
15
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
15
|
+
const errorMessage = stripLongPathAll(error instanceof Error ? error.message : String(error));
|
|
16
16
|
errors.push({ path: filePath, error: errorMessage });
|
|
17
17
|
}
|
|
18
18
|
}));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validatePath } from "../helpers/path.js";
|
|
1
|
+
import { validatePath, stripLongPathAll } from "../helpers/path.js";
|
|
2
2
|
import { calculateFileHash } from "../helpers/checksum.js";
|
|
3
3
|
import { validateHash } from "./helpers.js";
|
|
4
4
|
export async function handleChecksumFilesVerif(files, algorithm, allowedDirectories) {
|
|
@@ -20,7 +20,7 @@ export async function handleChecksumFilesVerif(files, algorithm, allowedDirector
|
|
|
20
20
|
});
|
|
21
21
|
}
|
|
22
22
|
catch (error) {
|
|
23
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
23
|
+
const errorMessage = stripLongPathAll(error instanceof Error ? error.message : String(error));
|
|
24
24
|
errors.push({
|
|
25
25
|
path: file.path,
|
|
26
26
|
expectedHash: file.expectedHash,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { validatePath, ensureDirectoryExists } from "../helpers/path.js";
|
|
4
|
+
import { decodeText, detectEncoding, encodeText, } from "../helpers/encoding.js";
|
|
5
|
+
export async function handleConvertEncoding(filePath, to, from, outPath, overwrite, allowedDirectories) {
|
|
6
|
+
const validPath = await validatePath(filePath, allowedDirectories);
|
|
7
|
+
const validOutPath = await validatePath(outPath, allowedDirectories);
|
|
8
|
+
if (path.normalize(validPath) === path.normalize(validOutPath)) {
|
|
9
|
+
throw new Error("outPath must differ from path");
|
|
10
|
+
}
|
|
11
|
+
// Check that outPath does not already exist (unless overwrite).
|
|
12
|
+
if (!overwrite) {
|
|
13
|
+
try {
|
|
14
|
+
await fs.access(validOutPath);
|
|
15
|
+
// If we get here, the file exists
|
|
16
|
+
throw new Error("Output file already exists. Pass overwrite: true to replace it.");
|
|
17
|
+
}
|
|
18
|
+
catch (accessError) {
|
|
19
|
+
if (accessError.code !== "ENOENT") {
|
|
20
|
+
// Either the "already exists" error above, or some other access error
|
|
21
|
+
throw accessError;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const raw = await fs.readFile(validPath);
|
|
26
|
+
const sourceEncoding = from ?? detectEncoding(raw);
|
|
27
|
+
const text = decodeText(raw, sourceEncoding);
|
|
28
|
+
const out = encodeText(text, to);
|
|
29
|
+
const directory = path.dirname(validOutPath);
|
|
30
|
+
await ensureDirectoryExists(directory);
|
|
31
|
+
await fs.writeFile(validOutPath, out);
|
|
32
|
+
return JSON.stringify({
|
|
33
|
+
path: filePath,
|
|
34
|
+
outPath: outPath,
|
|
35
|
+
from: sourceEncoding,
|
|
36
|
+
to,
|
|
37
|
+
bytesIn: raw.length,
|
|
38
|
+
bytesOut: out.length,
|
|
39
|
+
}, null, 2);
|
|
40
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const ConvertEncodingArgsSchema = z.object({
|
|
3
|
+
path: z.string().describe("Path to the source file to read"),
|
|
4
|
+
to: z
|
|
5
|
+
.enum(["utf8", "utf16le", "utf16be", "cp1252"])
|
|
6
|
+
.describe("Target encoding (utf16le/utf16be output includes a BOM; utf8/cp1252 do not)"),
|
|
7
|
+
from: z
|
|
8
|
+
.enum(["utf8", "utf16le", "utf16be", "cp1252"])
|
|
9
|
+
.optional()
|
|
10
|
+
.describe("Source encoding. Omit to auto-detect from the file's bytes (BOM FF FE -> utf16le, FE FF -> utf16be, EF BB BF -> utf8, otherwise strict UTF-8 -> utf8, else cp1252)"),
|
|
11
|
+
outPath: z
|
|
12
|
+
.string()
|
|
13
|
+
.describe("Path of the file to write. Must differ from path and must not already exist unless overwrite=true"),
|
|
14
|
+
overwrite: z
|
|
15
|
+
.boolean()
|
|
16
|
+
.optional()
|
|
17
|
+
.default(false)
|
|
18
|
+
.describe("Replace outPath if it already exists. Default: false (fail if it exists)"),
|
|
19
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import { copyDir } from "./helpers.js";
|
|
3
|
-
import { validatePath, ensureDirectoryExists } from "../helpers/path.js";
|
|
3
|
+
import { validatePath, ensureDirectoryExists, stripLongPathAll } from "../helpers/path.js";
|
|
4
4
|
export async function handleCopyFile(sourcePath, destinationPath, recursive, overwrite, allowedDirectories) {
|
|
5
5
|
const validSourcePath = await validatePath(sourcePath, allowedDirectories);
|
|
6
6
|
const validDestPath = await validatePath(destinationPath, allowedDirectories);
|
|
@@ -37,7 +37,7 @@ export async function handleCopyFile(sourcePath, destinationPath, recursive, ove
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
catch (error) {
|
|
40
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
40
|
+
const errorMessage = stripLongPathAll(error instanceof Error ? error.message : String(error));
|
|
41
41
|
throw new Error(`Error copying ${sourcePath} to ${destinationPath}: ${errorMessage}`);
|
|
42
42
|
}
|
|
43
43
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { validatePath } from "../helpers/path.js";
|
|
3
|
+
import { validatePath, stripLongPath } from "../helpers/path.js";
|
|
4
4
|
import { minimatch } from "minimatch";
|
|
5
5
|
export async function handleCountLines(filePath, recursive, pattern, filePattern, excludePatterns, ignoreEmptyLines, allowedDirectories) {
|
|
6
6
|
// Validate the path
|
|
@@ -50,10 +50,10 @@ export async function handleCountLines(filePath, recursive, pattern, filePattern
|
|
|
50
50
|
totalMatchingLines += file.matchingCount;
|
|
51
51
|
}
|
|
52
52
|
if (pattern) {
|
|
53
|
-
output += `${file.file}: ${file.count} lines total, ${file.matchingCount} matching lines\n`;
|
|
53
|
+
output += `${stripLongPath(file.file)}: ${file.count} lines total, ${file.matchingCount} matching lines\n`;
|
|
54
54
|
}
|
|
55
55
|
else {
|
|
56
|
-
output += `${file.file}: ${file.count} lines\n`;
|
|
56
|
+
output += `${stripLongPath(file.file)}: ${file.count} lines\n`;
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
output += "\n";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
|
-
import { validatePath } from "../helpers/path.js";
|
|
2
|
+
import { validatePath, stripLongPathAll } from "../helpers/path.js";
|
|
3
3
|
export async function handleCreateDirectories(paths, allowedDirectories) {
|
|
4
4
|
const results = [];
|
|
5
5
|
const errors = [];
|
|
@@ -12,7 +12,7 @@ export async function handleCreateDirectories(paths, allowedDirectories) {
|
|
|
12
12
|
results.push(`Successfully created directory: ${dirPath}`);
|
|
13
13
|
}
|
|
14
14
|
catch (error) {
|
|
15
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
15
|
+
const errorMessage = stripLongPathAll(error instanceof Error ? error.message : String(error));
|
|
16
16
|
errors.push(`Failed to create directory ${dirPath}: ${errorMessage}`);
|
|
17
17
|
}
|
|
18
18
|
}));
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { validatePath, stripLongPathAll } from "../helpers/path.js";
|
|
4
|
+
export async function handleCreateLink(target, linkPath, type, allowedDirectories) {
|
|
5
|
+
// Both paths go through the repo's standard path validation (allow-root
|
|
6
|
+
// checks, nearest-existing-ancestor handling for not-yet-existing paths,
|
|
7
|
+
// long-path normalization at the single choke point).
|
|
8
|
+
const linkAbs = await validatePath(linkPath, allowedDirectories);
|
|
9
|
+
const targetAbs = await validatePath(target, allowedDirectories);
|
|
10
|
+
// The target must exist. lstat so a link target is classified as itself.
|
|
11
|
+
let targetStats;
|
|
12
|
+
try {
|
|
13
|
+
targetStats = await fs.lstat(targetAbs);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
const code = error.code;
|
|
17
|
+
if (code === "ENOENT") {
|
|
18
|
+
throw new Error(`Target does not exist: ${target}`);
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
// The link path must NOT already exist (lstat: an existing reparse point
|
|
23
|
+
// is caught here too, not followed past).
|
|
24
|
+
let linkExists = false;
|
|
25
|
+
try {
|
|
26
|
+
await fs.lstat(linkAbs);
|
|
27
|
+
linkExists = true;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
const code = error.code;
|
|
31
|
+
if (code !== "ENOENT") {
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (linkExists) {
|
|
36
|
+
throw new Error(`Link path already exists: ${linkPath}`);
|
|
37
|
+
}
|
|
38
|
+
// The link path's parent directory must exist (NOT auto-created).
|
|
39
|
+
const linkParentAbs = path.dirname(linkAbs);
|
|
40
|
+
let parentStats;
|
|
41
|
+
try {
|
|
42
|
+
parentStats = await fs.stat(linkParentAbs);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const code = error.code;
|
|
46
|
+
if (code === "ENOENT") {
|
|
47
|
+
throw new Error(`Parent directory does not exist: ${path.dirname(linkPath)} (create_link does not auto-create parent directories)`);
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
if (!parentStats.isDirectory()) {
|
|
52
|
+
throw new Error(`Parent path is not a directory: ${path.dirname(linkPath)}`);
|
|
53
|
+
}
|
|
54
|
+
let createdKind;
|
|
55
|
+
if (type === "hardlink") {
|
|
56
|
+
if (targetStats.isDirectory()) {
|
|
57
|
+
throw new Error(`hardlink target must be a file, but target is a directory: ${target}`);
|
|
58
|
+
}
|
|
59
|
+
// Second name for the same file. Same volume required - a cross-volume
|
|
60
|
+
// attempt surfaces the natural EXDEV error.
|
|
61
|
+
await fs.link(targetAbs, linkAbs);
|
|
62
|
+
createdKind = "hardlink";
|
|
63
|
+
}
|
|
64
|
+
else if (type === "junction") {
|
|
65
|
+
if (process.platform !== "win32") {
|
|
66
|
+
throw new Error("junction links are only supported on Windows (win32)");
|
|
67
|
+
}
|
|
68
|
+
if (!targetStats.isDirectory()) {
|
|
69
|
+
throw new Error(`junction target must be a directory, but target is a file: ${target}`);
|
|
70
|
+
}
|
|
71
|
+
await fs.symlink(targetAbs, linkAbs, "junction");
|
|
72
|
+
createdKind = "junction";
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// 'auto' (default) and 'symlink': real symlink, mode chosen by the
|
|
76
|
+
// target's type. 'auto' NEVER creates a junction - junctions must be
|
|
77
|
+
// explicit.
|
|
78
|
+
if (targetStats.isDirectory()) {
|
|
79
|
+
await fs.symlink(targetAbs, linkAbs, "dir");
|
|
80
|
+
createdKind = "symlink (directory)";
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
await fs.symlink(targetAbs, linkAbs, "file");
|
|
84
|
+
createdKind = "symlink (file)";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// User-supplied strings are echoed back verbatim; stripLongPathAll guards
|
|
88
|
+
// against any extended-length form leaking into the response.
|
|
89
|
+
return stripLongPathAll(`Created ${createdKind} link: ${linkPath} -> ${target}`);
|
|
90
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const CreateLinkArgsSchema = z.object({
|
|
3
|
+
target: z
|
|
4
|
+
.string()
|
|
5
|
+
.describe("Existing file or directory the new link will point to. " +
|
|
6
|
+
"Must exist (lstat-checked; clear error otherwise). " +
|
|
7
|
+
"Must be within allowed directories."),
|
|
8
|
+
linkPath: z
|
|
9
|
+
.string()
|
|
10
|
+
.describe("Path at which the link object is created. " +
|
|
11
|
+
"Must NOT already exist (clear error); its parent directory must already exist " +
|
|
12
|
+
"(clear error - it is NOT auto-created). " +
|
|
13
|
+
"Must be within allowed directories."),
|
|
14
|
+
type: z
|
|
15
|
+
.enum(["auto", "symlink", "junction", "hardlink"])
|
|
16
|
+
.optional()
|
|
17
|
+
.default("auto")
|
|
18
|
+
.describe("Link type to create. " +
|
|
19
|
+
"'auto' (default) and 'symlink' create a real symlink based on the target's type: " +
|
|
20
|
+
"directory target -> directory symlink, file target -> file symlink " +
|
|
21
|
+
"('auto' never creates a junction - junctions must be explicit). " +
|
|
22
|
+
"'junction' creates a directory junction (Windows only; target must be a directory). " +
|
|
23
|
+
"'hardlink' creates a second name for an existing file " +
|
|
24
|
+
"(target must be a file; same volume - cross-volume errors surface naturally)."),
|
|
25
|
+
});
|
|
@@ -1,39 +1,230 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { execFile } from "child_process";
|
|
3
|
+
import { promisify } from "util";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { validatePathPreservingLinks, stripLongPath, stripLongPathAll } from "../helpers/path.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
// Best-effort unlink that retries after clearing read-only flags, so plain
|
|
9
|
+
// directory trees delete as completely as fs.rm(..., { force: true }) did.
|
|
10
|
+
async function unlinkEntry(fullPath) {
|
|
11
|
+
try {
|
|
12
|
+
await fs.unlink(fullPath);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
try {
|
|
16
|
+
await fs.chmod(fullPath, 0o666);
|
|
17
|
+
await fs.unlink(fullPath);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Recursively delete a real directory without following reparse points
|
|
25
|
+
// (junctions/symlinks): every entry is classified with lstat, links are
|
|
26
|
+
// removed as leaves, real directories are walked, files are unlinked.
|
|
27
|
+
async function rmRealDirectory(dir, errors) {
|
|
28
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
const fullPath = path.join(dir, entry.name);
|
|
31
|
+
try {
|
|
32
|
+
const st = await fs.lstat(fullPath);
|
|
33
|
+
if (st.isSymbolicLink()) {
|
|
34
|
+
await fs.unlink(fullPath);
|
|
35
|
+
}
|
|
36
|
+
else if (st.isDirectory()) {
|
|
37
|
+
// The recursive call removes the directory itself
|
|
38
|
+
await rmRealDirectory(fullPath, errors);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
await unlinkEntry(fullPath);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
errors.push(stripLongPath(fullPath) +
|
|
46
|
+
": " +
|
|
47
|
+
stripLongPathAll(error instanceof Error ? error.message : String(error)));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
await fs.rmdir(dir);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
errors.push(stripLongPath(dir) + ": " + stripLongPathAll(error instanceof Error ? error.message : String(error)));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function recycleBatch(items) {
|
|
58
|
+
const jobDir = path.join(os.tmpdir(), "mcp-fs-shell-windows");
|
|
59
|
+
await fs.mkdir(jobDir, { recursive: true });
|
|
60
|
+
const listFile = path.join(jobDir, `delete_files_recycle_${Date.now()}_${process.pid}_${Math.floor(Math.random() * 1e9).toString(36)}.txt`);
|
|
61
|
+
await fs.writeFile(listFile, items.map((i) => i.resolved).join("\n"), "utf8");
|
|
62
|
+
try {
|
|
63
|
+
const ps = [
|
|
64
|
+
"$ErrorActionPreference='Stop'",
|
|
65
|
+
`$items = Get-Content -LiteralPath '${listFile}' -Encoding UTF8`,
|
|
66
|
+
"Add-Type -AssemblyName Microsoft.VisualBasic",
|
|
67
|
+
"$fileio = [Microsoft.VisualBasic.FileIO.FileSystem]",
|
|
68
|
+
"foreach ($p in $items) {",
|
|
69
|
+
" try {",
|
|
70
|
+
" if (Test-Path -LiteralPath $p -PathType Container) {",
|
|
71
|
+
" $fileio::DeleteDirectory($p, 'OnlyErrorDialogs', 'SendToRecycleBin')",
|
|
72
|
+
" Write-Output ('OK' + [char]9 + $p)",
|
|
73
|
+
" } elseif (Test-Path -LiteralPath $p -PathType Leaf) {",
|
|
74
|
+
" $fileio::DeleteFile($p, 'OnlyErrorDialogs', 'SendToRecycleBin')",
|
|
75
|
+
" Write-Output ('OK' + [char]9 + $p)",
|
|
76
|
+
" } else {",
|
|
77
|
+
" Write-Output ('MISSING' + [char]9 + $p)",
|
|
78
|
+
" }",
|
|
79
|
+
" } catch {",
|
|
80
|
+
" Write-Output ('ERR' + [char]9 + $p + [char]9 + $_.Exception.Message)",
|
|
81
|
+
" }",
|
|
82
|
+
"}",
|
|
83
|
+
].join("\n");
|
|
84
|
+
const encoded = Buffer.from(ps, "utf16le").toString("base64");
|
|
85
|
+
const { stdout } = await execFileAsync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], { timeout: 300000, maxBuffer: 16 * 1024 * 1024, windowsHide: true });
|
|
86
|
+
const byResolved = new Map(items.map((i) => [i.resolved, i]));
|
|
87
|
+
const ok = [];
|
|
88
|
+
const missing = [];
|
|
89
|
+
const err = [];
|
|
90
|
+
const reported = new Set();
|
|
91
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
92
|
+
const parts = line.split("\t");
|
|
93
|
+
if (parts.length < 2)
|
|
94
|
+
continue;
|
|
95
|
+
const [status, resolved, ...msgParts] = parts;
|
|
96
|
+
const item = byResolved.get(resolved);
|
|
97
|
+
if (!item || reported.has(item.resolved))
|
|
98
|
+
continue;
|
|
99
|
+
reported.add(item.resolved);
|
|
100
|
+
if (status === "OK") {
|
|
101
|
+
ok.push(item);
|
|
102
|
+
}
|
|
103
|
+
else if (status === "MISSING") {
|
|
104
|
+
missing.push(item);
|
|
105
|
+
}
|
|
106
|
+
else if (status === "ERR") {
|
|
107
|
+
err.push({ item, message: msgParts.join("\t") });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// A path with no result line at all is an error - never assume success.
|
|
111
|
+
for (const item of items) {
|
|
112
|
+
if (!reported.has(item.resolved)) {
|
|
113
|
+
err.push({ item, message: "no result from recycle batch" });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { ok, missing, err };
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
await fs.rm(listFile, { force: true }).catch(() => undefined);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Volume roots (drive letters like C:\ or UNC share roots like \\server\share)
|
|
123
|
+
// cannot be sent to the Recycle Bin as a unit; reject them so the shell never
|
|
124
|
+
// starts moving a whole volume's contents.
|
|
125
|
+
function isVolumeRoot(p) {
|
|
126
|
+
return /^[A-Za-z]:[\\/]?$/.test(p) || /^[\\/][^\\/]+[\\/][^\\/]+$/.test(p) || p === "/";
|
|
127
|
+
}
|
|
128
|
+
export async function handleDeleteFiles(paths, recursive, allowedDirectories, toRecycleBin) {
|
|
4
129
|
const results = [];
|
|
5
130
|
const errors = [];
|
|
131
|
+
const linkResults = [];
|
|
132
|
+
const recycleItems = [];
|
|
133
|
+
const isWin32 = process.platform === "win32";
|
|
134
|
+
const useRecycle = toRecycleBin && isWin32;
|
|
135
|
+
if (toRecycleBin && !isWin32) {
|
|
136
|
+
// Non-Windows: nothing is deleted, each path is reported as an error.
|
|
137
|
+
for (const filePath of paths) {
|
|
138
|
+
errors.push(`Failed to delete ${filePath}: toRecycleBin is supported only on Windows`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
6
141
|
await Promise.all(paths.map(async (filePath) => {
|
|
142
|
+
if (toRecycleBin && !isWin32)
|
|
143
|
+
return; // already reported above
|
|
7
144
|
try {
|
|
8
|
-
// Validate path is within allowed directories
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
145
|
+
// Validate path is within allowed directories without following
|
|
146
|
+
// reparse points (the link itself must be what gets classified)
|
|
147
|
+
const validPath = await validatePathPreservingLinks(filePath, allowedDirectories);
|
|
148
|
+
// Classify with lstat so reparse points are never followed
|
|
149
|
+
const stats = await fs.lstat(validPath);
|
|
150
|
+
if (stats.isSymbolicLink()) {
|
|
151
|
+
// Junction/symlink: always removed as a leaf, with or without the
|
|
152
|
+
// recursive flag. The link target is never touched.
|
|
153
|
+
await fs.unlink(validPath);
|
|
154
|
+
if (useRecycle) {
|
|
155
|
+
results.push(`Successfully deleted link: ${filePath} (link removed permanently; the recycle bin cannot hold links)`);
|
|
16
156
|
}
|
|
17
157
|
else {
|
|
158
|
+
results.push(`Successfully deleted link: ${filePath} (link removed; target untouched)`);
|
|
159
|
+
}
|
|
160
|
+
linkResults.push(filePath);
|
|
161
|
+
}
|
|
162
|
+
else if (stats.isDirectory()) {
|
|
163
|
+
if (!recursive) {
|
|
18
164
|
throw new Error("Cannot delete directory without recursive flag");
|
|
19
165
|
}
|
|
166
|
+
if (useRecycle) {
|
|
167
|
+
const plain = stripLongPath(validPath);
|
|
168
|
+
if (isVolumeRoot(plain)) {
|
|
169
|
+
throw new Error("Cannot send a volume root to the Recycle Bin");
|
|
170
|
+
}
|
|
171
|
+
recycleItems.push({ requested: filePath, resolved: plain, kind: "directory" });
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
const dirErrors = [];
|
|
175
|
+
await rmRealDirectory(validPath, dirErrors);
|
|
176
|
+
if (dirErrors.length > 0) {
|
|
177
|
+
throw new Error(dirErrors.join("; "));
|
|
178
|
+
}
|
|
179
|
+
results.push(`Successfully deleted directory: ${filePath}`);
|
|
180
|
+
}
|
|
20
181
|
}
|
|
21
182
|
else {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
183
|
+
if (useRecycle) {
|
|
184
|
+
recycleItems.push({ requested: filePath, resolved: stripLongPath(validPath), kind: "file" });
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
// Delete the file
|
|
188
|
+
await unlinkEntry(validPath);
|
|
189
|
+
results.push(`Successfully deleted file: ${filePath}`);
|
|
190
|
+
}
|
|
25
191
|
}
|
|
26
192
|
}
|
|
27
193
|
catch (error) {
|
|
28
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
194
|
+
const errorMessage = stripLongPathAll(error instanceof Error ? error.message : String(error));
|
|
29
195
|
errors.push(`Failed to delete ${filePath}: ${errorMessage}`);
|
|
30
196
|
}
|
|
31
197
|
}));
|
|
198
|
+
if (useRecycle && recycleItems.length > 0) {
|
|
199
|
+
const { ok, missing, err } = await recycleBatch(recycleItems);
|
|
200
|
+
for (const item of ok) {
|
|
201
|
+
const noun = item.kind === "directory" ? "directory" : "file";
|
|
202
|
+
results.push(`Successfully deleted ${noun}: ${item.requested} (recycled)`);
|
|
203
|
+
}
|
|
204
|
+
for (const item of missing) {
|
|
205
|
+
errors.push(`Failed to delete ${item.requested}: path no longer exists (recycle bin operation not performed)`);
|
|
206
|
+
}
|
|
207
|
+
for (const { item, message } of err) {
|
|
208
|
+
errors.push(`Failed to delete ${item.requested}: ${stripLongPathAll(message)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
32
211
|
// Format the results
|
|
33
212
|
const successCount = results.length;
|
|
34
213
|
const errorCount = errors.length;
|
|
35
214
|
let output = `Processed ${successCount + errorCount} paths:\n`;
|
|
215
|
+
if (useRecycle && results.length > 0) {
|
|
216
|
+
// Recycle mode: print the per-item success lines so the (recycled) /
|
|
217
|
+
// link-note suffixes are visible. Default mode keeps the historical
|
|
218
|
+
// count-only output (byte-identical when the flag is omitted).
|
|
219
|
+
output += results.join("\n") + "\n";
|
|
220
|
+
}
|
|
36
221
|
output += `- ${successCount} items deleted successfully\n`;
|
|
222
|
+
if (linkResults.length > 0) {
|
|
223
|
+
const linkNote = useRecycle
|
|
224
|
+
? "link removed permanently; the recycle bin cannot hold links"
|
|
225
|
+
: "link removed; target untouched";
|
|
226
|
+
output += `- ${linkResults.length} reparse point(s) removed (${linkNote}): ${linkResults.join(", ")}\n`;
|
|
227
|
+
}
|
|
37
228
|
if (errorCount > 0) {
|
|
38
229
|
output += `- ${errorCount} items failed\n\n`;
|
|
39
230
|
output += "Errors:\n" + errors.join("\n");
|
|
@@ -2,4 +2,8 @@ import { z } from "zod";
|
|
|
2
2
|
export const DeleteFilesArgsSchema = z.object({
|
|
3
3
|
paths: z.array(z.string()).describe("Paths to files or directories to delete"),
|
|
4
4
|
recursive: z.boolean().default(false).describe("Whether to recursively delete directories"),
|
|
5
|
+
toRecycleBin: z.boolean().default(false).describe("Windows only. When true, files and directories are sent to the Recycle Bin instead of being permanently deleted. " +
|
|
6
|
+
"Reparse points (junctions/symlinks) are still removed as links, because the recycle bin cannot hold links. " +
|
|
7
|
+
"Any path that cannot be recycled (or no longer exists) is reported as an error and is never permanently deleted as a fallback. " +
|
|
8
|
+
"On non-Windows platforms this returns a clear error and deletes nothing."),
|
|
5
9
|
});
|