mcp-fs-shell-windows 0.2.6 → 0.2.18
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 +12 -2
- 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 +154 -0
- package/dist/launch_file/schema.js +4 -0
- 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 +203 -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
|
@@ -1,14 +1,15 @@
|
|
|
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
|
-
export async function handleSearchRegex(searchPath, pattern, filePatterns, excludePatterns, maxResults, caseSensitive, offset, allowedDirectories) {
|
|
5
|
+
export async function handleSearchRegex(searchPath, pattern, filePatterns, excludePatterns, maxResults, caseSensitive, offset, allowedDirectories, countOnly) {
|
|
6
6
|
// Validate root path (may be a directory or a single file)
|
|
7
7
|
const validRootPath = await validatePath(searchPath, allowedDirectories);
|
|
8
8
|
const results = [];
|
|
9
|
+
const perFileCounts = new Map();
|
|
10
|
+
const filesSearchedList = [];
|
|
9
11
|
let filesSearched = 0;
|
|
10
|
-
let
|
|
11
|
-
let searchAborted = false;
|
|
12
|
+
let totalMatches = 0;
|
|
12
13
|
const effectiveCap = offset - 1 + maxResults;
|
|
13
14
|
// Create regex pattern
|
|
14
15
|
const regexFlags = caseSensitive ? "mg" : "img";
|
|
@@ -20,38 +21,40 @@ export async function handleSearchRegex(searchPath, pattern, filePatterns, exclu
|
|
|
20
21
|
throw new Error(`Invalid regular expression: ${pattern}`);
|
|
21
22
|
}
|
|
22
23
|
async function scanFile(fullPath) {
|
|
23
|
-
if (searchAborted)
|
|
24
|
-
return;
|
|
25
24
|
try {
|
|
26
25
|
filesSearched++;
|
|
26
|
+
filesSearchedList.push(fullPath);
|
|
27
27
|
const content = await fs.readFile(fullPath, "utf-8");
|
|
28
28
|
const lines = content.split("\n");
|
|
29
29
|
regex.lastIndex = 0;
|
|
30
30
|
let match;
|
|
31
|
+
// Every occurrence is counted (a line containing the pattern 4 times
|
|
32
|
+
// counts as 4 matches). Line results are collected only while under the
|
|
33
|
+
// pagination cap; counting always runs to completion so the reported
|
|
34
|
+
// total is exact.
|
|
31
35
|
while ((match = regex.exec(content)) !== null) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
charCount
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
totalMatches++;
|
|
37
|
+
perFileCounts.set(fullPath, (perFileCounts.get(fullPath) ?? 0) + 1);
|
|
38
|
+
if (!countOnly && results.length < effectiveCap) {
|
|
39
|
+
// Find the line number for this match
|
|
40
|
+
const matchPosition = match.index;
|
|
41
|
+
let lineNumber = 0;
|
|
42
|
+
let charCount = 0;
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
charCount += lines[i].length + 1; // +1 for the newline
|
|
45
|
+
if (charCount > matchPosition) {
|
|
46
|
+
lineNumber = i + 1;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
42
49
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
});
|
|
52
|
-
if (results.length >= effectiveCap) {
|
|
53
|
-
searchAborted = true;
|
|
54
|
-
break;
|
|
50
|
+
// Get the line content
|
|
51
|
+
const lineContent = lines[lineNumber - 1]?.trim() || "";
|
|
52
|
+
results.push({
|
|
53
|
+
file: fullPath,
|
|
54
|
+
line: lineNumber,
|
|
55
|
+
content: lineContent,
|
|
56
|
+
match: match[0],
|
|
57
|
+
});
|
|
55
58
|
}
|
|
56
59
|
// Guard against zero-length matches (e.g. pattern "a*") looping forever
|
|
57
60
|
if (match[0].length === 0) {
|
|
@@ -64,13 +67,9 @@ export async function handleSearchRegex(searchPath, pattern, filePatterns, exclu
|
|
|
64
67
|
}
|
|
65
68
|
}
|
|
66
69
|
async function searchDirectory(dirPath) {
|
|
67
|
-
if (searchAborted)
|
|
68
|
-
return;
|
|
69
70
|
try {
|
|
70
71
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
71
72
|
for (const entry of entries) {
|
|
72
|
-
if (searchAborted)
|
|
73
|
-
break;
|
|
74
73
|
const fullPath = path.join(dirPath, entry.name);
|
|
75
74
|
try {
|
|
76
75
|
// Validate each path before processing
|
|
@@ -122,22 +121,44 @@ export async function handleSearchRegex(searchPath, pattern, filePatterns, exclu
|
|
|
122
121
|
else {
|
|
123
122
|
await searchDirectory(validRootPath);
|
|
124
123
|
}
|
|
124
|
+
// Total-match summary (always included), with per-file counts for the
|
|
125
|
+
// files that contain at least one match, in first-match (walk) order.
|
|
126
|
+
const matchedFileEntries = [...perFileCounts.entries()];
|
|
127
|
+
const matchedFilesCount = matchedFileEntries.length;
|
|
128
|
+
let matchedFilesList = "";
|
|
129
|
+
for (const [file, count] of matchedFileEntries) {
|
|
130
|
+
matchedFilesList += ` ${stripLongPath(file)}: ${count}\n`;
|
|
131
|
+
}
|
|
132
|
+
const totalLine = `Total matches: ${totalMatches} (${matchedFilesCount} files)\n`;
|
|
125
133
|
// Format the results
|
|
126
134
|
let output = "";
|
|
127
|
-
const shown = results.slice(offset - 1);
|
|
128
|
-
if (
|
|
135
|
+
const shown = countOnly ? [] : results.slice(offset - 1);
|
|
136
|
+
if (countOnly) {
|
|
137
|
+
// Counts only: total header, files searched, then a per-file count line
|
|
138
|
+
// for EVERY searched file (0 for files with no matches).
|
|
139
|
+
output = totalLine;
|
|
140
|
+
output += `Searched ${filesSearched} files\n`;
|
|
141
|
+
if (totalMatches > 0) {
|
|
142
|
+
output += "\n";
|
|
143
|
+
for (const file of filesSearchedList) {
|
|
144
|
+
output += ` ${stripLongPath(file)}: ${perFileCounts.get(file) ?? 0}\n`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else if (totalMatches === 0) {
|
|
129
149
|
output = `No matches found for regex: ${pattern}\n`;
|
|
130
150
|
output += `Searched ${filesSearched} files\n`;
|
|
151
|
+
output += totalLine;
|
|
131
152
|
}
|
|
132
153
|
else if (shown.length === 0) {
|
|
133
|
-
output = `Found ${
|
|
154
|
+
output = `Found ${totalMatches} matches in ${results.length} locations (offset ${offset} is beyond the collected range)\n`;
|
|
155
|
+
output += "\n";
|
|
156
|
+
output += totalLine;
|
|
157
|
+
output += matchedFilesList;
|
|
134
158
|
}
|
|
135
159
|
else {
|
|
136
160
|
const end = offset + shown.length - 1;
|
|
137
|
-
output = `Found ${
|
|
138
|
-
if (searchAborted) {
|
|
139
|
-
output += ` (search stopped at ${effectiveCap} collected matches)`;
|
|
140
|
-
}
|
|
161
|
+
output = `Found ${totalMatches} matches in ${results.length} locations (showing ${offset}-${end})`;
|
|
141
162
|
output += "\n\n";
|
|
142
163
|
// Group by file for more readable output
|
|
143
164
|
const fileGroups = new Map();
|
|
@@ -148,12 +169,14 @@ export async function handleSearchRegex(searchPath, pattern, filePatterns, exclu
|
|
|
148
169
|
fileGroups.get(result.file)?.push(result);
|
|
149
170
|
}
|
|
150
171
|
for (const [file, fileResults] of fileGroups.entries()) {
|
|
151
|
-
output += `File: ${file}\n`;
|
|
172
|
+
output += `File: ${stripLongPath(file)}\n`;
|
|
152
173
|
for (const result of fileResults) {
|
|
153
174
|
output += ` Line ${result.line}: ${result.content}\n`;
|
|
154
175
|
}
|
|
155
176
|
output += "\n";
|
|
156
177
|
}
|
|
178
|
+
output += totalLine;
|
|
179
|
+
output += matchedFilesList;
|
|
157
180
|
}
|
|
158
181
|
return output;
|
|
159
182
|
}
|
|
@@ -7,4 +7,5 @@ export const SearchRegexArgsSchema = z.object({
|
|
|
7
7
|
maxResults: z.number().optional().default(100).describe("Maximum number of results to return"),
|
|
8
8
|
offset: z.number().int().min(1).optional().default(1).describe("1-indexed match number to start from. Default: 1."),
|
|
9
9
|
caseSensitive: z.boolean().optional().default(false).describe("Whether the search should be case-sensitive"),
|
|
10
|
+
countOnly: z.boolean().optional().default(false).describe("When true, return only per-file match counts and the total (no matching line text). Default: false."),
|
|
10
11
|
});
|
package/dist/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
4
4
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
5
|
+
import { stripLongPathAll } from "./helpers/path.js";
|
|
5
6
|
// Import all schemas
|
|
6
7
|
import { ReadFilesArgsSchema } from "./read_files/schema.js";
|
|
7
8
|
import { WriteNewFilesArgsSchema } from "./write_new_files/schema.js";
|
|
@@ -17,6 +18,7 @@ import { ListDirectoryArgsSchema } from "./list_directory/schema.js";
|
|
|
17
18
|
import { DirectoryTreeArgsSchema } from "./directory_tree/schema.js";
|
|
18
19
|
import { SearchFilesArgsSchema } from "./search_files/schema.js";
|
|
19
20
|
import { SearchRegexArgsSchema } from "./search_regex/schema.js";
|
|
21
|
+
import { ReplaceRegexArgsSchema } from "./replace_regex/schema.js";
|
|
20
22
|
import { SearchGlobArgsSchema } from "./search_glob/schema.js";
|
|
21
23
|
import { CountLinesArgsSchema } from "./count_lines/schema.js";
|
|
22
24
|
import { ChecksumFilesArgsSchema } from "./checksum_files/schema.js";
|
|
@@ -25,6 +27,13 @@ import { GetFileInfoArgsSchema } from "./file_info/schema.js";
|
|
|
25
27
|
import { EditFilesArgsSchema } from "./edit_files/schema.js";
|
|
26
28
|
import { FuzzyFindFilesArgsSchema } from "./fuzzy_find_files/schema.js";
|
|
27
29
|
import { DeleteFilesByPatternArgsSchema } from "./delete_files_by_pattern/schema.js";
|
|
30
|
+
import { LaunchFileArgsSchema } from "./launch_file/schema.js";
|
|
31
|
+
import { ReadBytesArgsSchema } from "./read_bytes/schema.js";
|
|
32
|
+
import { WriteBytesArgsSchema } from "./write_bytes/schema.js";
|
|
33
|
+
import { DirDiffArgsSchema } from "./dir_diff/schema.js";
|
|
34
|
+
import { ConvertEncodingArgsSchema } from "./convert_encoding/schema.js";
|
|
35
|
+
import { WaitForFileArgsSchema } from "./wait_for_file/schema.js";
|
|
36
|
+
import { CreateLinkArgsSchema } from "./create_link/schema.js";
|
|
28
37
|
import { ShellRunArgsSchema } from "./shell/schema.js";
|
|
29
38
|
import { ShellTestArgsSchema } from "./shell/schema.js";
|
|
30
39
|
import { ShellStartArgsSchema } from "./shell/schema.js";
|
|
@@ -48,6 +57,7 @@ import { handleListDirectory } from "./list_directory/handler.js";
|
|
|
48
57
|
import { handleDirectoryTree } from "./directory_tree/handler.js";
|
|
49
58
|
import { handleSearchFiles } from "./search_files/handler.js";
|
|
50
59
|
import { handleSearchRegex } from "./search_regex/handler.js";
|
|
60
|
+
import { handleReplaceRegex } from "./replace_regex/handler.js";
|
|
51
61
|
import { handleSearchGlob } from "./search_glob/handler.js";
|
|
52
62
|
import { handleCountLines } from "./count_lines/handler.js";
|
|
53
63
|
import { handleChecksumFiles } from "./checksum_files/handler.js";
|
|
@@ -56,6 +66,13 @@ import { handleGetFileInfo } from "./file_info/handler.js";
|
|
|
56
66
|
import { handleEditFiles } from "./edit_files/handler.js";
|
|
57
67
|
import { handleFuzzyFindFiles } from "./fuzzy_find_files/handler.js";
|
|
58
68
|
import { handleDeleteFilesByPattern } from "./delete_files_by_pattern/handler.js";
|
|
69
|
+
import { handleLaunchFile } from "./launch_file/handler.js";
|
|
70
|
+
import { handleReadBytes } from "./read_bytes/handler.js";
|
|
71
|
+
import { handleWriteBytes } from "./write_bytes/handler.js";
|
|
72
|
+
import { handleDirDiff } from "./dir_diff/handler.js";
|
|
73
|
+
import { handleConvertEncoding } from "./convert_encoding/handler.js";
|
|
74
|
+
import { handleWaitForFile } from "./wait_for_file/handler.js";
|
|
75
|
+
import { handleCreateLink } from "./create_link/handler.js";
|
|
59
76
|
import { handleShellRun } from "./shell/handler.js";
|
|
60
77
|
import { handleShellTest } from "./shell/handler.js";
|
|
61
78
|
import { handleShellStart } from "./shell/handler.js";
|
|
@@ -72,7 +89,7 @@ export class FilesystemServer {
|
|
|
72
89
|
this.allowedDirectories = allowedDirectories;
|
|
73
90
|
this.server = new Server({
|
|
74
91
|
name: "secure-filesystem-server",
|
|
75
|
-
version: "0.2.
|
|
92
|
+
version: "0.2.18",
|
|
76
93
|
}, {
|
|
77
94
|
capabilities: {
|
|
78
95
|
tools: {},
|
|
@@ -91,6 +108,8 @@ export class FilesystemServer {
|
|
|
91
108
|
"Each line is prefixed with its line number (format: \"1: line content\"). " +
|
|
92
109
|
"Each file's content is returned with its path as a reference. " +
|
|
93
110
|
"Use offset (1-indexed start line) and/or limit (max lines) to read a range of a large file. " +
|
|
111
|
+
"Each paths item may be a plain string or an object {path, encoding?, offset?, limit?}. " +
|
|
112
|
+
"encoding decodes the file as 'utf8' | 'utf16le' | 'utf16be' | 'cp1252' (a matching BOM is stripped); without encoding the file's encoding is auto-detected (BOM FF FE -> utf16le, FE FF -> utf16be, EF BB BF -> utf8, otherwise strict UTF-8 -> utf8, else cp1252). " +
|
|
94
113
|
"Only works within allowed directories.",
|
|
95
114
|
inputSchema: zodToJsonSchema(ReadFilesArgsSchema),
|
|
96
115
|
},
|
|
@@ -98,7 +117,7 @@ export class FilesystemServer {
|
|
|
98
117
|
name: "write_new_files",
|
|
99
118
|
description: "Create multiple new files in a single operation. Works on a single file too. " +
|
|
100
119
|
"Fails if a file already exists unless its overwrite flag is true. " +
|
|
101
|
-
"Handles text content with UTF-8 encoding, or base64 content for binary files (encoding: \"base64\"). " +
|
|
120
|
+
"Handles text content with UTF-8 encoding (or a per-file textEncoding of 'utf8' | 'utf16le' | 'utf16be' | 'cp1252'; utf16le/utf16be write a BOM), or base64 content for binary files (encoding: \"base64\", which writes raw bytes and wins over textEncoding). " +
|
|
102
121
|
"Parent directories are created as needed. " +
|
|
103
122
|
"Partial failures won't stop the entire operation. " +
|
|
104
123
|
"Only works within allowed directories.",
|
|
@@ -117,8 +136,10 @@ export class FilesystemServer {
|
|
|
117
136
|
name: "delete_files",
|
|
118
137
|
description: "Delete multiple files or directories in a single operation. Works on a single file too. " +
|
|
119
138
|
"Set recursive flag to true to delete directories with contents. " +
|
|
139
|
+
"Reparse points (junctions/symlinks) are always removed as links without following them - the link target is never touched, including nested links inside recursively deleted directories. " +
|
|
140
|
+
"Set toRecycleBin to true (Windows only) to send files and directories to the Recycle Bin instead of deleting permanently; reparse points are still removed as links (the bin cannot hold links), and any path that cannot be recycled is reported as an error and never permanently deleted as a fallback. " +
|
|
120
141
|
"Partial failures won't stop the entire operation. " +
|
|
121
|
-
"Use with caution as operation is permanent. " +
|
|
142
|
+
"Use with caution as the default operation is permanent. " +
|
|
122
143
|
"Only works within allowed directories.",
|
|
123
144
|
inputSchema: zodToJsonSchema(DeleteFilesArgsSchema),
|
|
124
145
|
},
|
|
@@ -151,7 +172,7 @@ export class FilesystemServer {
|
|
|
151
172
|
"Each patch has an action: replace (default, replace lines startLine-endLine with newText), insertBefore (insert newText before startLine; startLine = total lines + 1 appends to the end), or delete (remove lines startLine-endLine). " +
|
|
152
173
|
"All patches reference the original line numbers. " +
|
|
153
174
|
"Shows git-style diffs for all changes (dryRun: true previews without writing). " +
|
|
154
|
-
"
|
|
175
|
+
"Preserves the file's dominant line ending (CRLF or LF). " +
|
|
155
176
|
"Partial failures won't stop the entire operation. " +
|
|
156
177
|
"Only works within allowed directories.",
|
|
157
178
|
inputSchema: zodToJsonSchema(PatchFilesArgsSchema),
|
|
@@ -167,10 +188,11 @@ export class FilesystemServer {
|
|
|
167
188
|
},
|
|
168
189
|
{
|
|
169
190
|
name: "list_directory",
|
|
170
|
-
description: "List directory contents with [FILE] or [
|
|
191
|
+
description: "List directory contents with [FILE], [DIR], or [LINK] prefixes (single level). " +
|
|
171
192
|
"Entries are sorted by name and paginated with offset/limit (default limit 100). " +
|
|
172
193
|
"File sizes are shown by default (includeSizes: false to omit). " +
|
|
173
194
|
"Filter with type (files/directories/all) and ignore glob patterns. " +
|
|
195
|
+
"Reparse points (junctions/symlinks) show as [LINK] and are excluded from the files and directories filters (only listed with type 'all'); links are not stat'ed for size. " +
|
|
174
196
|
"Use directory_tree for recursive views. " +
|
|
175
197
|
"Only works within allowed directories.",
|
|
176
198
|
inputSchema: zodToJsonSchema(ListDirectoryArgsSchema),
|
|
@@ -180,6 +202,8 @@ export class FilesystemServer {
|
|
|
180
202
|
description: "Get a recursive tree view of files and directories as a JSON structure. " +
|
|
181
203
|
"Returns JSON with name, type, and children properties. " +
|
|
182
204
|
"Supports excluding files/directories using glob patterns. " +
|
|
205
|
+
"Reparse points (junctions/symlinks) appear as type 'link' with no children and are never recursed into. " +
|
|
206
|
+
"Empty directories are included. " +
|
|
183
207
|
"Only works within allowed directories.",
|
|
184
208
|
inputSchema: zodToJsonSchema(DirectoryTreeArgsSchema),
|
|
185
209
|
},
|
|
@@ -198,9 +222,23 @@ export class FilesystemServer {
|
|
|
198
222
|
"path can be a directory (recursively searched) or a single file. " +
|
|
199
223
|
"Supports filtering files by patterns. " +
|
|
200
224
|
"Returns matching lines with line numbers, paginated with offset/maxResults (default 100). " +
|
|
225
|
+
"Each occurrence of the pattern is counted (a line matching 4 times counts as 4), and the response " +
|
|
226
|
+
"always ends with the total match count plus per-file counts. " +
|
|
227
|
+
"Set countOnly=true to return only per-file match counts and the total, with no matching line text. " +
|
|
201
228
|
"Only searches within allowed directories.",
|
|
202
229
|
inputSchema: zodToJsonSchema(SearchRegexArgsSchema),
|
|
203
230
|
},
|
|
231
|
+
{
|
|
232
|
+
name: "replace_regex",
|
|
233
|
+
description: "In-place regular expression replacement across the given FILES. " +
|
|
234
|
+
"paths may list one or many files (directories are NOT accepted - a directory path is an error and nothing is written). " +
|
|
235
|
+
"The 'g' (global) flag is always applied so every match is replaced; additional flags such as 'i' are allowed. " +
|
|
236
|
+
"dryRun defaults to TRUE: it returns per-file match counts and a unified diff preview of the WOULD-BE change without writing anything. " +
|
|
237
|
+
"With dryRun=false the replacements are applied in place, preserving each file's dominant line ending (a pure-CRLF file stays fully CRLF), and per-file match counts plus a total are returned. " +
|
|
238
|
+
"Any missing path or an invalid regex produces a clear error and writes nothing. " +
|
|
239
|
+
"Only works within allowed directories.",
|
|
240
|
+
inputSchema: zodToJsonSchema(ReplaceRegexArgsSchema),
|
|
241
|
+
},
|
|
204
242
|
{
|
|
205
243
|
name: "search_glob",
|
|
206
244
|
description: "Find files using glob patterns. " +
|
|
@@ -238,7 +276,7 @@ export class FilesystemServer {
|
|
|
238
276
|
{
|
|
239
277
|
name: "get_file_info",
|
|
240
278
|
description: "Get detailed file/directory metadata. " +
|
|
241
|
-
"Returns size, creation time, modified time, access time, type, and
|
|
279
|
+
"Returns size, creation time, modified time, access time, type, permissions, isReparsePoint, and (on Windows) NTFS attributes. " +
|
|
242
280
|
"Only works within allowed directories.",
|
|
243
281
|
inputSchema: zodToJsonSchema(GetFileInfoArgsSchema),
|
|
244
282
|
},
|
|
@@ -279,6 +317,19 @@ export class FilesystemServer {
|
|
|
279
317
|
"Only works within allowed directories.",
|
|
280
318
|
inputSchema: zodToJsonSchema(DeleteFilesByPatternArgsSchema),
|
|
281
319
|
},
|
|
320
|
+
{
|
|
321
|
+
name: "create_link",
|
|
322
|
+
description: "Create a link object at linkPath pointing at target (Windows hardlink, directory junction, or file/directory symlink). " +
|
|
323
|
+
"type: 'auto' (default) | 'symlink' | 'junction' | 'hardlink'. " +
|
|
324
|
+
"target must exist (lstat-checked; clear error otherwise); linkPath must NOT already exist (clear error); linkPath's parent directory must already exist (clear error - it is NOT auto-created). " +
|
|
325
|
+
"'hardlink' creates a second name for an existing file (target must be a file; same volume - cross-volume errors surface naturally). " +
|
|
326
|
+
"'junction' creates a directory junction (Windows only; target must be a directory; clear error on non-Windows). " +
|
|
327
|
+
"'symlink'/'auto' create a real symlink based on the target's type: directory target -> directory symlink, file target -> file symlink; 'auto' never creates a junction (junctions must be explicit). " +
|
|
328
|
+
"Both paths go through the standard path validation and must be within allowed directories. " +
|
|
329
|
+
"Response is short text containing the user-supplied linkPath and target (never any \\\\?\\-prefixed form) plus the actual link type created. " +
|
|
330
|
+
"Only works within allowed directories.",
|
|
331
|
+
inputSchema: zodToJsonSchema(CreateLinkArgsSchema),
|
|
332
|
+
},
|
|
282
333
|
{
|
|
283
334
|
name: "transfer_files",
|
|
284
335
|
description: "Move or rename multiple files and directories in a single operation. Works on a single file too. " +
|
|
@@ -354,6 +405,68 @@ export class FilesystemServer {
|
|
|
354
405
|
"The default starts as the MCP server process's CWD and resets when the server restarts.",
|
|
355
406
|
inputSchema: zodToJsonSchema(ShellCwdArgsSchema),
|
|
356
407
|
},
|
|
408
|
+
{
|
|
409
|
+
name: "launch_file",
|
|
410
|
+
description: "Launch a file, folder, or web link in its Windows-associated application (the OS default: e.g. VLC for .mkv/.mp4, Explorer for folders, the default browser for http(s) URLs). " +
|
|
411
|
+
"File/folder paths must be within the allowed directories (C:/, D:/, M:, NAS UNC) and are checked for existence; only http:// and https:// links are launched - file: URLs and other protocol schemes (javascript:, ms-*, steam:, vlc:) are rejected. " +
|
|
412
|
+
"The launch is fully detached: it returns once Windows has spawned the shell opener - it never waits for the opened application, does not inherit the server's stdio pipes, creates no intermediary console window, and passes file names verbatim (spaces, Unicode, apostrophes, ampersands, parentheses all safe). " +
|
|
413
|
+
"Errors if the path does not exist, is outside the allowed directories, the URL scheme is not http/https, or the opener fails to spawn.",
|
|
414
|
+
inputSchema: zodToJsonSchema(LaunchFileArgsSchema),
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: "read_bytes",
|
|
418
|
+
description: "Read raw bytes from a file at a given (offset, length). " +
|
|
419
|
+
"All arguments are integers: offset >= 0, length >= 1. " +
|
|
420
|
+
"An offset at or beyond the file's end (offset >= size) is an error ('offset beyond EOF'); if offset+length exceeds the file size, the read is clamped to EOF. " +
|
|
421
|
+
"Returns JSON text: {path, offset, length, actualLength, size, dataBase64} where dataBase64 is base64 of the bytes actually read. " +
|
|
422
|
+
"Works on binary files of any size (e.g. GGUFs, blobs). " +
|
|
423
|
+
"Only works within allowed directories.",
|
|
424
|
+
inputSchema: zodToJsonSchema(ReadBytesArgsSchema),
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
name: "write_bytes",
|
|
428
|
+
description: "Write raw bytes to an existing file at a given offset (base64 input). " +
|
|
429
|
+
"The file must already exist (no implicit create); invalid base64 is an error. " +
|
|
430
|
+
"With the default extend=false, offset+bytes.length must not exceed the file size (else an error) and the bytes overwrite in place. " +
|
|
431
|
+
"With extend=true the file is grown when the write ends past EOF, zero-filling any gap between the old size and the offset. " +
|
|
432
|
+
"Only works within allowed directories.",
|
|
433
|
+
inputSchema: zodToJsonSchema(WriteBytesArgsSchema),
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
name: "dir_diff",
|
|
437
|
+
description: "Recursively compare the contents of two directory trees (files only). " +
|
|
438
|
+
"Each relative path is classified as added (exists only in dirB), removed (exists only in dirA), " +
|
|
439
|
+
"changed (present in both, content differs - decided by size first, then sha256), or identical. " +
|
|
440
|
+
"For changed files where BOTH sides look like text (size <= 1 MiB and no NUL byte in the first 8 KiB), " +
|
|
441
|
+
"a unified diff is included (diff? field, same helper as file_diff/content_diff). " +
|
|
442
|
+
"ignore: array of glob patterns matched against each file's relative path (minimatch, same semantics as list_directory's ignore param); ignored paths are excluded from all lists and counts. " +
|
|
443
|
+
"maxFiles: cap on total files compared (union of both trees); if the union exceeds it, the comparison stops and truncated=true (counts reflect what was compared). " +
|
|
444
|
+
"Returns JSON text: {added:[{path,size}], removed:[{path,size}], changed:[{path,sizeA,sizeB,shaA,shaB,diff?}], counts:{added,removed,changed,identical,compared}, truncated}. " +
|
|
445
|
+
"Only works within allowed directories.",
|
|
446
|
+
inputSchema: zodToJsonSchema(DirDiffArgsSchema),
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
name: "convert_encoding",
|
|
450
|
+
description: "Convert a file's text encoding to a new file. " +
|
|
451
|
+
"Reads the source file (encoding auto-detected from its bytes unless `from` is given: BOM FF FE -> utf16le, FE FF -> utf16be, EF BB BF -> utf8, otherwise strict UTF-8 -> utf8, else cp1252), decodes it, and writes outPath encoded as `to` ('utf8' | 'utf16le' | 'utf16be' | 'cp1252'; utf16le/utf16be output includes a BOM, utf8/cp1252 do not). " +
|
|
452
|
+
"outPath must differ from path and must not already exist unless overwrite=true. " +
|
|
453
|
+
"Returns JSON text: {path, outPath, from (detected or given), to, bytesIn, bytesOut}. " +
|
|
454
|
+
"Only works within allowed directories.",
|
|
455
|
+
inputSchema: zodToJsonSchema(ConvertEncodingArgsSchema),
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
name: "wait_for_file",
|
|
459
|
+
description: "Wait until at least one file matching a glob appears under a base directory (polling watcher). " +
|
|
460
|
+
"path = base directory (must be inside an allowed root); the glob is matched against each file's path relative to path " +
|
|
461
|
+
"(same glob machinery as search_glob: minimatch with dot:true; on Windows backslashes in the pattern are treated as path separators). " +
|
|
462
|
+
"timeoutMs defaults to 30000 (max 300000); intervalMs defaults to 500 (min 100). " +
|
|
463
|
+
"Polls with an async sleep between scans so the server thread is never blocked for longer than one interval; the tool call itself may legitimately take up to timeoutMs. " +
|
|
464
|
+
"As soon as at least one file matches it returns JSON text: {matched: [absolute paths], count, waitedMs, timedOut: false}; " +
|
|
465
|
+
"on timeout it returns the same shape with timedOut: true (matched may be empty). " +
|
|
466
|
+
"Only files are matched (directories are searched through, not matched). " +
|
|
467
|
+
"Only works within allowed directories.",
|
|
468
|
+
inputSchema: zodToJsonSchema(WaitForFileArgsSchema),
|
|
469
|
+
},
|
|
357
470
|
],
|
|
358
471
|
};
|
|
359
472
|
});
|
|
@@ -396,7 +509,7 @@ export class FilesystemServer {
|
|
|
396
509
|
if (!parsed.success) {
|
|
397
510
|
throw new Error(`Invalid arguments for delete_files: ${parsed.error}`);
|
|
398
511
|
}
|
|
399
|
-
const result = await handleDeleteFiles(parsed.data.paths, parsed.data.recursive, this.allowedDirectories);
|
|
512
|
+
const result = await handleDeleteFiles(parsed.data.paths, parsed.data.recursive, this.allowedDirectories, parsed.data.toRecycleBin);
|
|
400
513
|
return {
|
|
401
514
|
content: [{ type: "text", text: result }],
|
|
402
515
|
};
|
|
@@ -478,6 +591,16 @@ export class FilesystemServer {
|
|
|
478
591
|
}],
|
|
479
592
|
};
|
|
480
593
|
}
|
|
594
|
+
case "create_link": {
|
|
595
|
+
const parsed = CreateLinkArgsSchema.safeParse(args);
|
|
596
|
+
if (!parsed.success) {
|
|
597
|
+
throw new Error(`Invalid arguments for create_link: ${parsed.error}`);
|
|
598
|
+
}
|
|
599
|
+
const result = await handleCreateLink(parsed.data.target, parsed.data.linkPath, parsed.data.type, this.allowedDirectories);
|
|
600
|
+
return {
|
|
601
|
+
content: [{ type: "text", text: result }],
|
|
602
|
+
};
|
|
603
|
+
}
|
|
481
604
|
case "transfer_files": {
|
|
482
605
|
const parsed = MoveFilesArgsSchema.safeParse(args);
|
|
483
606
|
if (!parsed.success) {
|
|
@@ -503,7 +626,17 @@ export class FilesystemServer {
|
|
|
503
626
|
if (!parsed.success) {
|
|
504
627
|
throw new Error(`Invalid arguments for search_regex: ${parsed.error}`);
|
|
505
628
|
}
|
|
506
|
-
const result = await handleSearchRegex(parsed.data.path, parsed.data.pattern, parsed.data.filePatterns, parsed.data.excludePatterns, parsed.data.maxResults, parsed.data.caseSensitive, parsed.data.offset, this.allowedDirectories);
|
|
629
|
+
const result = await handleSearchRegex(parsed.data.path, parsed.data.pattern, parsed.data.filePatterns, parsed.data.excludePatterns, parsed.data.maxResults, parsed.data.caseSensitive, parsed.data.offset, this.allowedDirectories, parsed.data.countOnly);
|
|
630
|
+
return {
|
|
631
|
+
content: [{ type: "text", text: result }],
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
case "replace_regex": {
|
|
635
|
+
const parsed = ReplaceRegexArgsSchema.safeParse(args);
|
|
636
|
+
if (!parsed.success) {
|
|
637
|
+
throw new Error(`Invalid arguments for replace_regex: ${parsed.error}`);
|
|
638
|
+
}
|
|
639
|
+
const result = await handleReplaceRegex(parsed.data.paths, parsed.data.pattern, parsed.data.replacement, parsed.data.flags, parsed.data.dryRun, this.allowedDirectories);
|
|
507
640
|
return {
|
|
508
641
|
content: [{ type: "text", text: result }],
|
|
509
642
|
};
|
|
@@ -676,12 +809,73 @@ export class FilesystemServer {
|
|
|
676
809
|
content: [{ type: "text", text: result }],
|
|
677
810
|
};
|
|
678
811
|
}
|
|
812
|
+
case "launch_file": {
|
|
813
|
+
const parsed = LaunchFileArgsSchema.safeParse(args);
|
|
814
|
+
if (!parsed.success) {
|
|
815
|
+
throw new Error(`Invalid arguments for launch_file: ${parsed.error}`);
|
|
816
|
+
}
|
|
817
|
+
const result = await handleLaunchFile(parsed.data.path, this.allowedDirectories);
|
|
818
|
+
return {
|
|
819
|
+
content: [{ type: "text", text: result }],
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
case "read_bytes": {
|
|
823
|
+
const parsed = ReadBytesArgsSchema.safeParse(args);
|
|
824
|
+
if (!parsed.success) {
|
|
825
|
+
throw new Error(`Invalid arguments for read_bytes: ${parsed.error}`);
|
|
826
|
+
}
|
|
827
|
+
const result = await handleReadBytes(parsed.data.path, parsed.data.offset, parsed.data.length, this.allowedDirectories);
|
|
828
|
+
return {
|
|
829
|
+
content: [{ type: "text", text: result }],
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
case "write_bytes": {
|
|
833
|
+
const parsed = WriteBytesArgsSchema.safeParse(args);
|
|
834
|
+
if (!parsed.success) {
|
|
835
|
+
throw new Error(`Invalid arguments for write_bytes: ${parsed.error}`);
|
|
836
|
+
}
|
|
837
|
+
const result = await handleWriteBytes(parsed.data.path, parsed.data.offset, parsed.data.dataBase64, parsed.data.extend, this.allowedDirectories);
|
|
838
|
+
return {
|
|
839
|
+
content: [{ type: "text", text: result }],
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
case "dir_diff": {
|
|
843
|
+
const parsed = DirDiffArgsSchema.safeParse(args);
|
|
844
|
+
if (!parsed.success) {
|
|
845
|
+
throw new Error(`Invalid arguments for dir_diff: ${parsed.error}`);
|
|
846
|
+
}
|
|
847
|
+
const result = await handleDirDiff(parsed.data.dirA, parsed.data.dirB, parsed.data.ignore, parsed.data.maxFiles, this.allowedDirectories);
|
|
848
|
+
return {
|
|
849
|
+
content: [{ type: "text", text: result }],
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
case "convert_encoding": {
|
|
853
|
+
const parsed = ConvertEncodingArgsSchema.safeParse(args);
|
|
854
|
+
if (!parsed.success) {
|
|
855
|
+
throw new Error(`Invalid arguments for convert_encoding: ${parsed.error}`);
|
|
856
|
+
}
|
|
857
|
+
const result = await handleConvertEncoding(parsed.data.path, parsed.data.to, parsed.data.from, parsed.data.outPath, parsed.data.overwrite, this.allowedDirectories);
|
|
858
|
+
return {
|
|
859
|
+
content: [{ type: "text", text: result }],
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
case "wait_for_file": {
|
|
863
|
+
const parsed = WaitForFileArgsSchema.safeParse(args);
|
|
864
|
+
if (!parsed.success) {
|
|
865
|
+
throw new Error(`Invalid arguments for wait_for_file: ${parsed.error}`);
|
|
866
|
+
}
|
|
867
|
+
const result = await handleWaitForFile(parsed.data.path, parsed.data.glob, parsed.data.timeoutMs, parsed.data.intervalMs, this.allowedDirectories);
|
|
868
|
+
return {
|
|
869
|
+
content: [{ type: "text", text: result }],
|
|
870
|
+
};
|
|
871
|
+
}
|
|
679
872
|
default:
|
|
680
873
|
throw new Error(`Unknown tool: ${name}`);
|
|
681
874
|
}
|
|
682
875
|
}
|
|
683
876
|
catch (error) {
|
|
684
|
-
|
|
877
|
+
// Never surface the internal \\?\ long-path prefix in error text.
|
|
878
|
+
const errorMessage = stripLongPathAll(error instanceof Error ? error.message : String(error));
|
|
685
879
|
return {
|
|
686
880
|
content: [{ type: "text", text: `Error: ${errorMessage}` }],
|
|
687
881
|
isError: true,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { minimatch } from "minimatch";
|
|
4
|
+
import { validatePath, stripLongPath } from "../helpers/path.js";
|
|
5
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
// Recursively scan a directory tree and collect the files whose path relative
|
|
7
|
+
// to `root` matches `pattern` (minimatch, dot:true - same machinery as search_glob).
|
|
8
|
+
// Unreadable directories and entries that vanish mid-scan are skipped.
|
|
9
|
+
// Only files are matched; directories are searched through.
|
|
10
|
+
async function scanForMatches(root, pattern) {
|
|
11
|
+
const results = [];
|
|
12
|
+
async function walk(dir) {
|
|
13
|
+
let entries;
|
|
14
|
+
try {
|
|
15
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return; // skip directories we can't read
|
|
19
|
+
}
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const fullPath = path.join(dir, entry.name);
|
|
22
|
+
try {
|
|
23
|
+
if (entry.isDirectory()) {
|
|
24
|
+
await walk(fullPath);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (!entry.isFile()) {
|
|
28
|
+
continue; // skip symlinks/devices/etc. - only real files are matched
|
|
29
|
+
}
|
|
30
|
+
// Normalize separators to '/' so minimatch matching is platform-consistent.
|
|
31
|
+
const relativePath = path.relative(root, fullPath).split(path.sep).join("/");
|
|
32
|
+
if (minimatch(relativePath, pattern, { dot: true })) {
|
|
33
|
+
results.push(stripLongPath(fullPath));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Skip anything that errors (e.g. raced with a deletion)
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
await walk(root);
|
|
43
|
+
results.sort();
|
|
44
|
+
return results;
|
|
45
|
+
}
|
|
46
|
+
export async function handleWaitForFile(baseDir, glob, timeoutMs, intervalMs, allowedDirectories) {
|
|
47
|
+
const validRoot = await validatePath(baseDir, allowedDirectories);
|
|
48
|
+
let stats;
|
|
49
|
+
try {
|
|
50
|
+
stats = await fs.stat(validRoot);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
const code = error.code;
|
|
54
|
+
if (code === "ENOENT") {
|
|
55
|
+
throw new Error(`Base directory does not exist: ${baseDir}`);
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
if (!stats.isDirectory()) {
|
|
60
|
+
throw new Error(`Not a directory: ${baseDir}`);
|
|
61
|
+
}
|
|
62
|
+
// On Windows, treat backslashes in the pattern as path separators so both
|
|
63
|
+
// 'a/b/*.txt' and 'a\b\*.txt' work (minimatch otherwise parses '\\' as an escape).
|
|
64
|
+
const pattern = process.platform === "win32" ? glob.split("\\").join("/") : glob;
|
|
65
|
+
const startedAt = Date.now();
|
|
66
|
+
const deadline = startedAt + timeoutMs;
|
|
67
|
+
let matched = [];
|
|
68
|
+
for (;;) {
|
|
69
|
+
matched = await scanForMatches(validRoot, pattern);
|
|
70
|
+
if (matched.length > 0) {
|
|
71
|
+
return JSON.stringify({
|
|
72
|
+
matched,
|
|
73
|
+
count: matched.length,
|
|
74
|
+
waitedMs: Date.now() - startedAt,
|
|
75
|
+
timedOut: false,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const remaining = deadline - Date.now();
|
|
79
|
+
if (remaining <= 0) {
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
// Async sleep keeps the server event loop responsive between polls.
|
|
83
|
+
await sleep(Math.min(intervalMs, remaining));
|
|
84
|
+
}
|
|
85
|
+
return JSON.stringify({
|
|
86
|
+
matched,
|
|
87
|
+
count: matched.length,
|
|
88
|
+
waitedMs: Date.now() - startedAt,
|
|
89
|
+
timedOut: true,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const WaitForFileArgsSchema = z.object({
|
|
3
|
+
path: z.string().describe("Base directory to watch (must be inside an allowed root). The glob is matched against each file's path relative to this directory."),
|
|
4
|
+
glob: z.string().describe("Glob pattern matched against each file's path relative to path (same glob machinery as search_glob: minimatch with dot:true; on Windows backslashes in the pattern are treated as path separators, so 'a\\b\\*.txt' and 'a/b/*.txt' both work)."),
|
|
5
|
+
timeoutMs: z.number().int().min(0).max(300000).optional().default(30000).describe("Maximum time to wait, in milliseconds (default 30000, max 300000)."),
|
|
6
|
+
intervalMs: z.number().int().min(100).max(300000).optional().default(500).describe("Poll interval, in milliseconds (default 500, min 100)."),
|
|
7
|
+
});
|