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.
Files changed (47) hide show
  1. package/README.md +12 -2
  2. package/dist/append_files/handler.js +2 -2
  3. package/dist/checksum_files/handler.js +2 -2
  4. package/dist/checksum_files_verif/handler.js +2 -2
  5. package/dist/convert_encoding/handler.js +40 -0
  6. package/dist/convert_encoding/schema.js +19 -0
  7. package/dist/copy_files/handler.js +2 -2
  8. package/dist/count_lines/handler.js +3 -3
  9. package/dist/create_directories/handler.js +2 -2
  10. package/dist/create_link/handler.js +90 -0
  11. package/dist/create_link/schema.js +25 -0
  12. package/dist/delete_files/handler.js +205 -14
  13. package/dist/delete_files/schema.js +4 -0
  14. package/dist/delete_files_by_pattern/handler.js +7 -7
  15. package/dist/dir_diff/handler.js +155 -0
  16. package/dist/dir_diff/schema.js +7 -0
  17. package/dist/directory_tree/helpers.js +5 -5
  18. package/dist/edit_files/handler.js +2 -2
  19. package/dist/file_diff/handler.js +2 -2
  20. package/dist/file_info/handler.js +39 -3
  21. package/dist/fuzzy_find_files/handler.js +2 -2
  22. package/dist/helpers/encoding.js +154 -0
  23. package/dist/helpers/path.js +133 -6
  24. package/dist/launch_file/handler.js +154 -0
  25. package/dist/launch_file/schema.js +4 -0
  26. package/dist/list_directory/handler.js +16 -9
  27. package/dist/move_files/handler.js +2 -2
  28. package/dist/patch_files/handler.js +2 -2
  29. package/dist/patch_files/helpers.js +24 -4
  30. package/dist/read_bytes/handler.js +43 -0
  31. package/dist/read_bytes/schema.js +6 -0
  32. package/dist/read_files/handler.js +18 -9
  33. package/dist/read_files/schema.js +37 -3
  34. package/dist/replace_regex/handler.js +105 -0
  35. package/dist/replace_regex/schema.js +23 -0
  36. package/dist/search_files/handler.js +2 -2
  37. package/dist/search_glob/handler.js +2 -2
  38. package/dist/search_regex/handler.js +63 -40
  39. package/dist/search_regex/schema.js +1 -0
  40. package/dist/server.js +203 -9
  41. package/dist/wait_for_file/handler.js +91 -0
  42. package/dist/wait_for_file/schema.js +7 -0
  43. package/dist/write_bytes/handler.js +49 -0
  44. package/dist/write_bytes/schema.js +7 -0
  45. package/dist/write_new_files/handler.js +6 -3
  46. package/dist/write_new_files/schema.js +1 -0
  47. 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 matchesFound = 0;
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
- matchesFound++;
33
- // Find the line number for this match
34
- const matchPosition = match.index;
35
- let lineNumber = 0;
36
- let charCount = 0;
37
- for (let i = 0; i < lines.length; i++) {
38
- charCount += lines[i].length + 1; // +1 for the newline
39
- if (charCount > matchPosition) {
40
- lineNumber = i + 1;
41
- break;
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
- // Get the line content
45
- const lineContent = lines[lineNumber - 1]?.trim() || "";
46
- results.push({
47
- file: fullPath,
48
- line: lineNumber,
49
- content: lineContent,
50
- match: match[0],
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 (matchesFound === 0) {
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 ${matchesFound} matches in ${results.length} locations (offset ${offset} is beyond the collected range)\n`;
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 ${matchesFound} matches in ${results.length} locations (showing ${offset}-${end})`;
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.4",
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
- "Note: files are written back with LF line endings. " +
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 [DIR] prefixes (single level). " +
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 permissions. " +
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
- const errorMessage = error instanceof Error ? error.message : String(error);
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
+ });