@j0hanz/filesystem-mcp 1.1.2 → 1.2.1

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 (62) hide show
  1. package/README.md +514 -188
  2. package/dist/cli.js +29 -12
  3. package/dist/completions.js +50 -24
  4. package/dist/config.d.ts +4 -2
  5. package/dist/config.js +2 -1
  6. package/dist/index.js +14 -12
  7. package/dist/instructions.md +109 -97
  8. package/dist/lib/constants.js +25 -14
  9. package/dist/lib/errors.js +15 -8
  10. package/dist/lib/file-operations/common.d.ts +4 -0
  11. package/dist/lib/file-operations/common.js +9 -0
  12. package/dist/lib/file-operations/file-info.js +22 -10
  13. package/dist/lib/file-operations/gitignore.js +14 -11
  14. package/dist/lib/file-operations/glob-engine.d.ts +1 -0
  15. package/dist/lib/file-operations/glob-engine.js +46 -33
  16. package/dist/lib/file-operations/list-directory.js +31 -35
  17. package/dist/lib/file-operations/read-multiple-files.js +70 -62
  18. package/dist/lib/file-operations/search-content.js +83 -64
  19. package/dist/lib/file-operations/search-files.js +32 -30
  20. package/dist/lib/file-operations/search-worker.js +22 -12
  21. package/dist/lib/file-operations/tree.js +43 -34
  22. package/dist/lib/fs-helpers.js +61 -124
  23. package/dist/lib/observability.js +29 -28
  24. package/dist/lib/path-format.d.ts +1 -0
  25. package/dist/lib/path-format.js +7 -0
  26. package/dist/lib/path-policy.js +22 -20
  27. package/dist/lib/path-validation.js +13 -7
  28. package/dist/lib/resource-store.d.ts +2 -0
  29. package/dist/lib/resource-store.js +26 -5
  30. package/dist/lib/type-guards.d.ts +1 -0
  31. package/dist/lib/type-guards.js +3 -0
  32. package/dist/prompts.d.ts +1 -5
  33. package/dist/prompts.js +9 -16
  34. package/dist/resources.d.ts +1 -5
  35. package/dist/resources.js +12 -26
  36. package/dist/schemas.d.ts +232 -30
  37. package/dist/schemas.js +52 -90
  38. package/dist/server.js +96 -44
  39. package/dist/tools/apply-patch.js +23 -22
  40. package/dist/tools/calculate-hash.js +41 -43
  41. package/dist/tools/create-directory.js +17 -19
  42. package/dist/tools/delete-file.js +35 -37
  43. package/dist/tools/diff-files.js +15 -19
  44. package/dist/tools/edit-file.js +15 -18
  45. package/dist/tools/list-directory.js +24 -23
  46. package/dist/tools/move-file.js +17 -19
  47. package/dist/tools/read-multiple.js +55 -66
  48. package/dist/tools/read.js +26 -30
  49. package/dist/tools/replace-in-files.js +27 -33
  50. package/dist/tools/roots.js +8 -8
  51. package/dist/tools/search-content.js +73 -72
  52. package/dist/tools/search-files.js +44 -50
  53. package/dist/tools/shared.d.ts +44 -6
  54. package/dist/tools/shared.js +86 -64
  55. package/dist/tools/stat-many.js +44 -66
  56. package/dist/tools/stat.js +10 -37
  57. package/dist/tools/task-support.d.ts +9 -1
  58. package/dist/tools/task-support.js +86 -81
  59. package/dist/tools/tree.js +12 -28
  60. package/dist/tools/write-file.js +17 -19
  61. package/dist/tools.js +23 -18
  62. package/package.json +6 -7
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { z } from 'zod';
4
4
  import { Command, CommanderError, InvalidArgumentError } from 'commander';
5
5
  import packageJsonRaw from '../package.json' with { type: 'json' };
6
6
  import { getReservedDeviceNameForPath, isWindowsDriveRelativePath, normalizePath, } from './lib/path-validation.js';
7
+ import { isRecord } from './lib/type-guards.js';
7
8
  const PackageJsonSchema = z.object({ version: z.string() });
8
9
  const { version: SERVER_VERSION } = PackageJsonSchema.parse(packageJsonRaw);
9
10
  const IS_WINDOWS = process.platform === 'win32';
@@ -27,16 +28,30 @@ function validateCliPath(inputPath) {
27
28
  throw new InvalidArgumentError(`Windows reserved device name not allowed: ${reserved}.`);
28
29
  }
29
30
  }
30
- function getNodeErrorCode(error) {
31
- if (typeof error !== 'object' || error === null)
31
+ function getNodeErrorProperty(error, key) {
32
+ if (!isRecord(error))
32
33
  return undefined;
33
- const { code } = error;
34
+ const value = error[key];
35
+ if (typeof value === 'string' || typeof value === 'number') {
36
+ return value;
37
+ }
38
+ return undefined;
39
+ }
40
+ function collectStringValues(values) {
41
+ const result = [];
42
+ for (const value of values) {
43
+ if (typeof value === 'string') {
44
+ result.push(value);
45
+ }
46
+ }
47
+ return result;
48
+ }
49
+ function getNodeErrorCode(error) {
50
+ const code = getNodeErrorProperty(error, 'code');
34
51
  return typeof code === 'string' ? code : undefined;
35
52
  }
36
53
  function getNodeErrorErrno(error) {
37
- if (typeof error !== 'object' || error === null)
38
- return undefined;
39
- const { errno } = error;
54
+ const errno = getNodeErrorProperty(error, 'errno');
40
55
  return typeof errno === 'number' ? errno : undefined;
41
56
  }
42
57
  function normalizeDirectoryError(error, inputPath) {
@@ -77,20 +92,22 @@ async function validateDirectoryPath(inputPath) {
77
92
  }
78
93
  }
79
94
  async function normalizeCliDirectories(args) {
80
- return Promise.all(args.map(validateDirectoryPath));
95
+ const validations = [];
96
+ for (const arg of args) {
97
+ validations.push(validateDirectoryPath(arg));
98
+ }
99
+ return Promise.all(validations);
81
100
  }
82
101
  function parseAllowedDirArgument(value, previous) {
83
102
  validateCliPath(value);
84
- const values = Array.isArray(previous)
85
- ? previous.filter((item) => typeof item === 'string')
86
- : [];
103
+ const values = Array.isArray(previous) ? collectStringValues(previous) : [];
87
104
  return [...values, value];
88
105
  }
89
106
  function getParsedAllowedDirs(cli) {
90
107
  const [allowedDirs] = cli.processedArgs;
91
108
  if (!Array.isArray(allowedDirs))
92
109
  return [];
93
- return allowedDirs.filter((candidate) => typeof candidate === 'string');
110
+ return collectStringValues(allowedDirs);
94
111
  }
95
112
  function createCliProgram(output) {
96
113
  const cli = new Command();
@@ -163,7 +180,7 @@ export async function parseArgs() {
163
180
  const options = cli.opts();
164
181
  const allowCwd = options.allowCwd === true;
165
182
  const positionals = getParsedAllowedDirs(cli);
166
- let allowedDirs = [];
183
+ let allowedDirs;
167
184
  try {
168
185
  allowedDirs =
169
186
  positionals.length > 0 ? await normalizeCliDirectories(positionals) : [];
@@ -1,7 +1,9 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import { CompleteRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
+ import { toPosixPath } from './lib/path-format.js';
4
5
  import { getAllowedDirectories, isPathWithinDirectories, normalizePath, } from './lib/path-validation.js';
6
+ import { isRecord } from './lib/type-guards.js';
5
7
  const MAX_COMPLETION_ITEMS = 100;
6
8
  const PATH_ARGUMENTS = new Set([
7
9
  'path',
@@ -14,9 +16,6 @@ const PATH_ARGUMENTS = new Set([
14
16
  'root',
15
17
  'cwd',
16
18
  ]);
17
- function isRecord(value) {
18
- return typeof value === 'object' && value !== null;
19
- }
20
19
  function isPathLikeArgumentName(argName) {
21
20
  return (PATH_ARGUMENTS.has(argName) ||
22
21
  argName.endsWith('paths') ||
@@ -95,10 +94,17 @@ function extractContextArguments(value) {
95
94
  const context = value['arguments'];
96
95
  if (!isRecord(context))
97
96
  return undefined;
98
- const entries = Object.entries(context).filter((entry) => typeof entry[1] === 'string');
99
- if (entries.length === 0)
97
+ const normalized = {};
98
+ let count = 0;
99
+ for (const [key, entryValue] of Object.entries(context)) {
100
+ if (typeof entryValue !== 'string')
101
+ continue;
102
+ normalized[key.toLowerCase()] = entryValue;
103
+ count += 1;
104
+ }
105
+ if (count === 0)
100
106
  return undefined;
101
- return Object.fromEntries(entries.map(([key, val]) => [key.toLowerCase(), val]));
107
+ return normalized;
102
108
  }
103
109
  function hasTrailingSeparator(value) {
104
110
  return (value.endsWith(path.sep) || value.endsWith('/') || value.endsWith('\\'));
@@ -119,27 +125,34 @@ function resolveFromBase(base, rawValue, trailingSeparator) {
119
125
  };
120
126
  }
121
127
  function resolveNamedRootContext(currentValue, allowed) {
122
- const normalizedInput = currentValue.replace(/\\/gu, '/');
123
- const [rootName, ...rest] = normalizedInput.split('/');
124
- if (!rootName)
128
+ const parsed = parseNamedRootInput(currentValue);
129
+ if (!parsed)
125
130
  return undefined;
126
- const root = allowed.find((candidate) => path.basename(candidate).toLowerCase() === rootName.toLowerCase());
131
+ const root = findAllowedRootByName(parsed.rootName, allowed);
127
132
  if (!root)
128
133
  return undefined;
129
134
  const trailingSeparator = hasTrailingSeparator(currentValue);
130
- const remainder = rest.join(path.sep);
131
- return resolveFromBase(root, remainder, trailingSeparator);
135
+ return resolveFromBase(root, parsed.remainder, trailingSeparator);
132
136
  }
133
137
  function resolveNamedRootPath(value, allowed) {
134
- const normalizedInput = value.replace(/\\/gu, '/');
135
- const [rootName, ...rest] = normalizedInput.split('/');
136
- if (!rootName)
138
+ const parsed = parseNamedRootInput(value);
139
+ if (!parsed)
137
140
  return undefined;
138
- const root = allowed.find((candidate) => path.basename(candidate).toLowerCase() === rootName.toLowerCase());
141
+ const root = findAllowedRootByName(parsed.rootName, allowed);
139
142
  if (!root)
140
143
  return undefined;
141
- const remainder = rest.join(path.sep);
142
- return normalizePath(path.resolve(root, remainder));
144
+ return normalizePath(path.resolve(root, parsed.remainder));
145
+ }
146
+ function parseNamedRootInput(value) {
147
+ const normalizedInput = toPosixPath(value);
148
+ const [rootName, ...rest] = normalizedInput.split('/');
149
+ if (!rootName)
150
+ return undefined;
151
+ return { rootName, remainder: rest.join(path.sep) };
152
+ }
153
+ function findAllowedRootByName(rootName, allowed) {
154
+ const normalizedRootName = rootName.toLowerCase();
155
+ return allowed.find((candidate) => path.basename(candidate).toLowerCase() === normalizedRootName);
143
156
  }
144
157
  function chooseContextKeys(argumentName) {
145
158
  const normalized = argumentName.toLowerCase();
@@ -244,14 +257,22 @@ async function findMatchesInDirectory(searchDir, prefix, allowed) {
244
257
  return matches;
245
258
  }
246
259
  function findRootPrefixMatches(currentValue, allowed) {
247
- const normalizedInput = currentValue.replace(/\\/gu, '/');
260
+ const normalizedInput = toPosixPath(currentValue);
248
261
  const rootPrefix = (normalizedInput.split('/')[0] ?? '').toLowerCase();
249
262
  if (!rootPrefix) {
250
- return allowed.map((root) => `${root}${path.sep}`);
263
+ const matches = [];
264
+ for (const root of allowed) {
265
+ matches.push(`${root}${path.sep}`);
266
+ }
267
+ return matches;
251
268
  }
252
- return allowed
253
- .filter((root) => path.basename(root).toLowerCase().startsWith(rootPrefix))
254
- .map((root) => `${root}${path.sep}`);
269
+ const matches = [];
270
+ for (const root of allowed) {
271
+ if (!path.basename(root).toLowerCase().startsWith(rootPrefix))
272
+ continue;
273
+ matches.push(`${root}${path.sep}`);
274
+ }
275
+ return matches;
255
276
  }
256
277
  function findMatchingRoots(searchDir, prefix, allowed) {
257
278
  const matches = [];
@@ -296,7 +317,12 @@ export async function getPathCompletions(currentValue, options = {}) {
296
317
  Promise.resolve(findMatchingRoots(searchDir, prefix, allowed)),
297
318
  ]);
298
319
  // Deduplicate and sort
299
- const uniqueMatches = Array.from(new Set([...dirMatches, ...rootMatches]));
320
+ const unique = new Set();
321
+ for (const match of dirMatches)
322
+ unique.add(match);
323
+ for (const match of rootMatches)
324
+ unique.add(match);
325
+ const uniqueMatches = Array.from(unique);
300
326
  uniqueMatches.sort((a, b) => {
301
327
  const aIsDir = a.endsWith(path.sep);
302
328
  const bIsDir = b.endsWith(path.sep);
package/dist/config.d.ts CHANGED
@@ -102,6 +102,7 @@ export declare const ErrorCode: {
102
102
  readonly E_NOT_DIRECTORY: "E_NOT_DIRECTORY";
103
103
  readonly E_TOO_LARGE: "E_TOO_LARGE";
104
104
  readonly E_TIMEOUT: "E_TIMEOUT";
105
+ readonly E_CANCELLED: "E_CANCELLED";
105
106
  readonly E_INVALID_PATTERN: "E_INVALID_PATTERN";
106
107
  readonly E_INVALID_INPUT: "E_INVALID_INPUT";
107
108
  readonly E_PERMISSION_DENIED: "E_PERMISSION_DENIED";
@@ -111,7 +112,8 @@ export declare const ErrorCode: {
111
112
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
112
113
  export declare function formatBytes(bytes: number): string;
113
114
  export declare function joinLines(lines: readonly string[]): string;
114
- export declare function formatOperationSummary(summary: {
115
+ export interface OperationSummary {
115
116
  truncated?: boolean;
116
117
  truncatedReason?: string;
117
- }): string;
118
+ }
119
+ export declare function formatOperationSummary(summary: OperationSummary): string;
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ export const ErrorCode = {
5
5
  E_NOT_DIRECTORY: 'E_NOT_DIRECTORY',
6
6
  E_TOO_LARGE: 'E_TOO_LARGE',
7
7
  E_TIMEOUT: 'E_TIMEOUT',
8
+ E_CANCELLED: 'E_CANCELLED',
8
9
  E_INVALID_PATTERN: 'E_INVALID_PATTERN',
9
10
  E_INVALID_INPUT: 'E_INVALID_INPUT',
10
11
  E_PERMISSION_DENIED: 'E_PERMISSION_DENIED',
@@ -17,7 +18,7 @@ export function formatBytes(bytes) {
17
18
  return '0 B';
18
19
  const unitIndex = Math.floor(Math.log(bytes) / Math.log(1024));
19
20
  const unit = BYTE_UNIT_LABELS[unitIndex] ?? 'B';
20
- const value = bytes / Math.pow(1024, unitIndex);
21
+ const value = bytes / 1024 ** unitIndex;
21
22
  return `${parseFloat(value.toFixed(2))} ${unit}`;
22
23
  }
23
24
  export function joinLines(lines) {
package/dist/index.js CHANGED
@@ -9,6 +9,16 @@ import { createServer, startServer } from './server.js';
9
9
  const SHUTDOWN_TIMEOUT_MS = 5000;
10
10
  let activeServer;
11
11
  let shutdownStarted = false;
12
+ function isStdinEvent(event) {
13
+ return event === 'end' || event === 'close';
14
+ }
15
+ function registerShutdownTrigger(event) {
16
+ const target = isStdinEvent(event) ? process.stdin : process;
17
+ target.once(event, () => {
18
+ const reason = isStdinEvent(event) ? `stdin ${event}` : event;
19
+ void shutdown(reason, 0);
20
+ });
21
+ }
12
22
  async function shutdown(reason, exitCode = 0) {
13
23
  if (shutdownStarted)
14
24
  return;
@@ -75,18 +85,10 @@ async function main() {
75
85
  activeServer = server;
76
86
  await startServer(server);
77
87
  }
78
- process.once('SIGTERM', () => {
79
- void shutdown('SIGTERM', 0);
80
- });
81
- process.once('SIGINT', () => {
82
- void shutdown('SIGINT', 0);
83
- });
84
- process.stdin.once('end', () => {
85
- void shutdown('stdin end', 0);
86
- });
87
- process.stdin.once('close', () => {
88
- void shutdown('stdin close', 0);
89
- });
88
+ registerShutdownTrigger('SIGTERM');
89
+ registerShutdownTrigger('SIGINT');
90
+ registerShutdownTrigger('end');
91
+ registerShutdownTrigger('close');
90
92
  process.once('unhandledRejection', (reason) => {
91
93
  console.error('Unhandled rejection:', formatUnknownErrorMessage(reason));
92
94
  void shutdown('unhandledRejection', 1);
@@ -6,9 +6,9 @@ These instructions are available as a resource (internal://instructions) or prom
6
6
 
7
7
  ## CORE CAPABILITY
8
8
 
9
- - Domain: Filesystem operations via an MCP server, enabling LLMs to interact with the local filesystem — read, write, search, diff, patch, and manage files/directories securely.
10
- - Primary Resources: Files, Directories, Search Results, File Metadata.
11
- - Tools: `roots`, `ls`, `find`, `tree`, `read`, `read_many`, `stat`, `stat_many`, `grep`, `calculate_hash`, `diff_files` (READ); `mkdir`, `write`, `edit`, `mv`, `rm`, `apply_patch`, `search_and_replace` (WRITE).
9
+ - Domain: Filesystem operations via an MCP server for LLM agents that need safe read/search/edit/diff/patch workflows within allowed roots.
10
+ - Primary Resources: Files, directories, metadata, search matches, and ephemeral cached result resources.
11
+ - Tools: READ: `roots`, `ls`, `find`, `tree`, `read`, `read_many`, `stat`, `stat_many`, `grep`, `calculate_hash`, `diff_files`. WRITE: `mkdir`, `write`, `edit`, `mv`, `rm`, `apply_patch`, `search_and_replace`.
12
12
 
13
13
  ---
14
14
 
@@ -21,54 +21,55 @@ These instructions are available as a resource (internal://instructions) or prom
21
21
  ## RESOURCES & RESOURCE LINKS
22
22
 
23
23
  - `internal://instructions`: This document.
24
- - `filesystem-mcp://result/{id}`: Cached large output (ephemeral).
25
- - If a tool response includes a `resourceUri` or `resource_link`, call `resources/read` with the URI to fetch the full payload.
24
+ - `filesystem-mcp://result/{id}`: Ephemeral cached tool output (in-memory); used when payloads are externalized.
25
+ - If a tool response includes a `resourceUri` or `resource_link`, call `resources/read` with that URI to fetch full content.
26
26
 
27
27
  ---
28
28
 
29
29
  ## PROGRESS & TASKS
30
30
 
31
31
  - Include `_meta.progressToken` in requests to receive `notifications/progress` updates for long-running tools.
32
- - Task-augmented tool calls are supported for `grep`, `find`, `calculate_hash`, `search_and_replace`, `tree`, `read_many`, and `stat_many`:
33
- - These tools declare `execution.taskSupport: "optional"` invoke normally or as a task.
34
- - Send `tools/call` with `task` to get a task id.
35
- - Poll `tasks/get` and fetch results via `tasks/result`.
32
+ - Task-augmented tool calls are supported for `find`, `tree`, `read`, `read_many`, `stat_many`, `grep`, `mkdir`, `write`, `mv`, `rm`, `calculate_hash`, `apply_patch`, and `search_and_replace`:
33
+ - Send `tools/call` with `task` to create a task.
34
+ - Poll `tasks/get` and fetch final output with `tasks/result`.
36
35
  - Use `tasks/cancel` to abort.
37
- - Task data is stored in memory and cleared on restart.
38
- - Tools without task support (e.g., `read`, `stat`, `ls`) execute synchronously and do not support `task` invocation.
36
+ - Task status notifications are emitted via `notifications/tasks/status` when supported.
39
37
 
40
38
  ---
41
39
 
42
40
  ## THE "GOLDEN PATH" WORKFLOWS (CRITICAL)
43
41
 
44
- ### WORKFLOW A: DISCOVERY & NAVIGATION
42
+ ### WORKFLOW A: DISCOVER AND INSPECT
45
43
 
46
- - Call `roots` to see allowed directories.
47
- - Call `ls` (single dir) or `tree` (recursive) to map layout.
48
- - Call `stat` or `stat_many` to check file types/sizes before reading.
49
- NOTE: Never guess paths. Always list first.
44
+ - Call `roots` first to get allowed workspace roots.
45
+ - Call `ls` for non-recursive listing, or `tree` for bounded recursive overview.
46
+ - Call `stat` or `stat_many` to confirm path types/sizes before reading.
47
+ - Call `read` for one file or `read_many` for batches.
48
+ NOTE: Never guess paths. Resolve from `roots`/`ls`/`find` first.
50
49
 
51
- ### WORKFLOW B: SEARCH & RETRIEVAL
50
+ ### WORKFLOW B: SEARCH CONTENT SAFELY
52
51
 
53
- - Call `find` to locate files by glob (e.g., `**/*.ts`).
54
- - Call `grep` to search contents by regex or literal text.
55
- - Call `read` or `read_many` to inspect files.
56
- - If content is truncated, use `resourceUri` from response or paginated `read` with `startLine`.
52
+ - Call `find` to locate candidate files by glob.
53
+ - Call `grep` with `filePattern` to search content only in relevant file types.
54
+ - If output is truncated or externalized, call `resources/read` on returned `resourceUri`.
55
+ - Call `read` on exact hits to inspect surrounding context.
56
+ NOTE: `grep` regex uses RE2; do not rely on lookbehind/lookahead/backreferences.
57
57
 
58
- ### WORKFLOW C: MODIFICATION (IF PERMITTED)
58
+ ### WORKFLOW C: MODIFY FILES WITH LOW RISK
59
59
 
60
- - Call `mkdir` to ensure paths exist.
61
- - Call `write` to create/overwrite files.
62
- - Call `edit` for targeted replacements.
63
- - Call `mv` or `rm` for organization.
64
- NOTE: Always confirm destructive actions (delete/overwrite) with the user first.
60
+ - Call `mkdir` to prepare directories if needed.
61
+ - Use `edit` for precise first-occurrence replacements in one file.
62
+ - Use `search_and_replace` for bulk replacements across globs.
63
+ - Use `mv` to rename/move paths and `rm` to delete paths.
64
+ NOTE: Confirm destructive operations (`write`, `mv`, `rm`, bulk replace) with the user before execution.
65
65
 
66
- ### WORKFLOW D: DIFF, PATCH & BULK REPLACE
66
+ ### WORKFLOW D: DIFF/PATCH LOOP
67
67
 
68
- - Call `diff_files` to compare two files (unified diff).
69
- - Call `apply_patch` to apply a unified patch to a file. Use `dryRun: true` first.
70
- - Call `search_and_replace` for bulk text replacement across files matching a glob. Use `dryRun: true` first.
71
- NOTE: Always dry-run before applying patches or bulk replacements.
68
+ - Call `diff_files` to generate a unified diff.
69
+ - Call `apply_patch` with `dryRun: true` first.
70
+ - If dry run succeeds, call `apply_patch` again with `dryRun: false`.
71
+ - Call `diff_files` again to verify `isIdentical: true` when expected.
72
+ NOTE: If patch apply fails, regenerate patch against current file content and retry.
72
73
 
73
74
  ---
74
75
 
@@ -76,113 +77,124 @@ These instructions are available as a resource (internal://instructions) or prom
76
77
 
77
78
  `roots`
78
79
 
79
- - Purpose: List allowed workspace roots. Call this first in every session.
80
- - Output: Includes `rootsCount` and `hasMultipleRoots`.
80
+ - Purpose: Enumerate allowed workspace roots.
81
+ - Gotcha: Other tools are constrained to these roots.
81
82
 
82
83
  `ls`
83
84
 
84
- - Purpose: List directory contents (non-recursive).
85
- - Input: `path` (optional, default root), `includeIgnored`, `includeHidden`, optional `pattern`, `maxDepth`, `maxEntries`, `sortBy`, `includeSymlinkTargets`.
86
- - Limits: Use `tree` for recursion (depth limited).
85
+ - Purpose: List directory contents (non-recursive by default).
86
+ - Nuance: `pattern` enables filtered recursive traversal up to `maxDepth`.
87
87
 
88
88
  `find`
89
89
 
90
- - Purpose: Search file paths by glob.
91
- - Input: `pattern` (required), `path` (optional root), optional `includeHidden`, `includeIgnored`, `sortBy`, `maxDepth`, `maxFilesScanned`.
92
- - Output: Includes `root` and `pattern` for traceability.
90
+ - Purpose: Find files by glob.
91
+ - Output: Returns relative paths plus metadata; may truncate based on limits.
93
92
  - Nuance: Respects `.gitignore` unless `includeIgnored=true`.
94
93
 
95
94
  `tree`
96
95
 
97
- - Purpose: Render a bounded directory tree (ASCII + JSON).
98
- - Input: `path`, `maxDepth` (0–50, default 5), `maxEntries` (default 1000).
99
- - Gotcha: `maxDepth=0` returns only the root node with empty children array.
96
+ - Purpose: Return both ASCII and JSON tree views.
97
+ - Gotcha: `maxDepth=0` returns only the root node.
100
98
 
101
99
  `read`
102
100
 
103
- - Purpose: Read file text.
104
- - Input: `path`, `head` (first N lines), `startLine`/`endLine` (range).
105
- - Gotcha: `head` is mutually exclusive with `startLine`/`endLine`. Large files return `resourceUri`; read it or use pagination.
101
+ - Purpose: Read a single text file with optional head/range.
102
+ - Gotcha: Large content is externalized to `filesystem-mcp://result/{id}` and preview is returned inline.
106
103
 
107
104
  `read_many`
108
105
 
109
- - Purpose: Read multiple files in one call.
110
- - Input: `paths` (max 100), `head`, `startLine`/`endLine`.
111
- - Output: per-file `truncationReason` when truncated.
112
- - Limits: Total budget capped by `MAX_READ_MANY_TOTAL_SIZE` (default 512 KB).
106
+ - Purpose: Batch read multiple files.
107
+ - Gotcha: Per-file `truncationReason` can be `head`, `range`, or `externalized`.
108
+ - Limits: Total read budget is capped by `MAX_READ_MANY_TOTAL_SIZE`.
113
109
 
114
110
  `stat` / `stat_many`
115
111
 
116
- - Purpose: Get file/directory metadata (size, modified, permissions, MIME type).
117
- - Output: Includes `tokenEstimate` (≈ size/4) for LLM context budgeting.
112
+ - Purpose: Return metadata including token estimate, MIME type, and timestamps.
113
+ - Nuance: Use before read/search when file size/type uncertainty exists.
118
114
 
119
115
  `grep`
120
116
 
121
- - Purpose: Search file content (grep-like).
122
- - Input: `pattern` (literal by default), `isRegex` (opt-in), `caseSensitive`, `wholeWord`, `contextLines`, `filePattern`, `maxResults`, `maxFilesScanned`.
123
- - Output: Includes `patternType` and `caseSensitive`.
124
- - Limits: Skips binaries and files larger than `MAX_SEARCH_SIZE` (default 1 MB). Returns max results per `maxResults` (default 500).
125
- - Gotcha: Regex uses RE2 engine — no backreferences or lookahead/lookbehind.
117
+ - Purpose: Search file contents by literal or RE2 regex.
118
+ - Gotcha: Inline match rows are capped (first 50); full structured results are externalized via `resourceUri`.
119
+ - Limits: Skips binary and oversized files; reports skips in structured output.
126
120
 
127
- `calculate_hash`
121
+ `write`
128
122
 
129
- - Purpose: Compute a SHA-256 hash for a file or directory.
130
- - Input: `path` (file or directory).
131
- - Behavior: Auto-detects file vs directory using `fs.stat`.
132
- - **Files**: Returns `{ hash, isDirectory: false }`.
133
- - **Directories**: Returns `{ hash, isDirectory: true, fileCount }`. Uses deterministic hash-of-hashes pattern (lexicographically sorted paths, respects `.gitignore`).
123
+ - Purpose: Create or overwrite a file atomically.
124
+ - Side effects: Creates parent directories automatically; overwrites existing content.
134
125
 
135
- `diff_files`
126
+ `edit`
136
127
 
137
- - Purpose: Create a unified diff between two files.
138
- - Input: `original`, `modified`, optional `context`, `ignoreWhitespace`, `stripTrailingCr`.
139
- - Output: Includes `isIdentical` (diff may be empty when true).
140
- - Gotcha: Large diffs may be returned via `resourceUri`.
128
+ - Purpose: Apply sequential literal replacements (first occurrence per edit).
129
+ - Gotcha: `oldText` must match exactly; unmatched items are reported in `unmatchedEdits`.
141
130
 
142
- `edit`
131
+ `mv`
132
+
133
+ - Purpose: Move or rename file/directory paths.
134
+ - Nuance: Cross-device moves fall back to copy+delete.
135
+
136
+ `rm`
137
+
138
+ - Purpose: Delete file/directory paths.
139
+ - Gotcha: Non-empty directory delete requires `recursive=true`; else returns actionable input error.
140
+
141
+ `calculate_hash`
142
+
143
+ - Purpose: SHA-256 for files or deterministic composite hash for directories.
144
+ - Nuance: Directory hashing respects root `.gitignore` and sorts paths for stable output.
143
145
 
144
- - Purpose: Sequential string replacement in a file.
145
- - Input: `path`, `edits` (array of `{oldText, newText}`), `dryRun`.
146
- - Output: `unmatchedEdits` lists any `oldText` values not found.
147
- - Gotcha: `oldText` must match exactly. First occurrence only per edit.
146
+ `diff_files`
147
+
148
+ - Purpose: Generate unified diff between two files.
149
+ - Gotcha: `isIdentical=true` means no hunks (`@@`) and empty diff.
148
150
 
149
151
  `apply_patch`
150
152
 
151
- - Purpose: Apply a unified diff patch to a file.
152
- - Input: `path`, `patch`, optional `fuzzy`/`fuzzFactor`, `autoConvertLineEndings`, `dryRun`.
153
+ - Purpose: Apply unified diff text to a file.
154
+ - Gotcha: Patch must include valid hunk headers; use `dryRun=true` first.
153
155
 
154
156
  `search_and_replace`
155
157
 
156
- - Purpose: Replace text across files matching a glob.
157
- - Input: `filePattern`, `searchPattern`, `replacement`, optional `isRegex`, `dryRun`.
158
- - Output: Includes `changedFiles` with per-file match counts (may be truncated).
159
- - Gotcha: Review `processedFiles`, `failedFiles`, and `failures` for partial errors.
158
+ - Purpose: Replace all matches across files selected by `filePattern`.
159
+ - Gotcha: Literal mode is default; `isRegex=true` enables RE2 + capture replacements (`$1`, `$2`).
160
+ - Limits: Changed-file sample and failure sample are capped/truncated in output.
160
161
 
161
- `write`
162
+ ---
162
163
 
163
- - Purpose: Create or overwrite a file.
164
- - Side effects: Destructive — overwrites existing content without confirmation.
164
+ ## CROSS-FEATURE RELATIONSHIPS
165
165
 
166
- `rm`
166
+ - Use `roots` output to scope all other tool calls.
167
+ - Use `find` → `grep` → `read` as the default search triad.
168
+ - Use `diff_files` output as input to `apply_patch`.
169
+ - Use `resourceUri` from `read`, `read_many`, `grep`, and `diff_files` with `resources/read` for full payload retrieval.
170
+ - Use `stat`/`stat_many` before `read`/`read_many` when size/type may violate limits.
171
+
172
+ ---
173
+
174
+ ## CONSTRAINTS & LIMITATIONS
167
175
 
168
- - Purpose: Delete a file or directory.
169
- - Input: `path`, `recursive` (for non-empty dirs), `ignoreIfNotExists`.
170
- - Side effects: Destructive and irreversible.
176
+ - Access is restricted to allowed roots negotiated from CLI and MCP Roots.
177
+ - If multiple roots are configured and no path is provided, tools requiring base path fail with disambiguation error.
178
+ - Default timeouts and size caps are enforced (`DEFAULT_SEARCH_TIMEOUT`, `MAX_FILE_SIZE`, `MAX_SEARCH_SIZE`, `MAX_READ_MANY_TOTAL_SIZE`).
179
+ - Sensitive files are denylisted by default unless explicitly allowed via environment settings.
180
+ - Binary files are skipped for content search/read workflows where text is required.
181
+ - Externalized resource cache is in-memory, bounded (entry size/count/total bytes), and ephemeral.
182
+ - Regex engine is RE2-based; advanced PCRE features are unsupported.
171
183
 
172
184
  ---
173
185
 
174
186
  ## ERROR HANDLING STRATEGY
175
187
 
176
- - `E_NOT_FOUND`: Check path with `ls` or `find`.
177
- - `E_ACCESS_DENIED`: Path outside allowed `roots`.
178
- - `E_NOT_FILE`: Path is a directory. Use `ls` to explore its contents.
179
- - `E_NOT_DIRECTORY`: Path is a file. Use `read` to read file contents.
180
- - `E_TOO_LARGE`: File exceeds size limit. Use `head` to preview, or narrow scope.
181
- - `E_TIMEOUT`: Reduce scope (narrower path), fewer results (`maxResults`), or search fewer files.
182
- - `E_INVALID_PATTERN`: Fix glob/regex syntax.
183
- - `E_INVALID_INPUT`: Check tool documentation for correct parameter usage.
184
- - `E_PERMISSION_DENIED`: OS-level permission denied. Check file permissions.
185
- - `E_SYMLINK_NOT_ALLOWED`: Symlinks escaping allowed directories are blocked for security.
186
- - `E_UNKNOWN`: Unexpected error. Check the error message for details.
188
+ - `E_ACCESS_DENIED`: Path is outside allowed roots or roots are not configured. → Call `roots`, then retry with an allowed path.
189
+ - `E_NOT_FOUND`: Path or resource does not exist. → Call `ls`/`find` to verify existence and exact spelling.
190
+ - `E_NOT_FILE`: Path points to a directory/non-file for file-only operation. Call `ls` or switch to directory tool.
191
+ - `E_NOT_DIRECTORY`: Path points to a file for directory operation. Call `read` for file content or choose a directory path.
192
+ - `E_TOO_LARGE`: File/content exceeds limits. Narrow scope, use range/head reads, or reduce candidate files.
193
+ - `E_TIMEOUT`: Operation exceeded timeout. → Reduce path scope, lower result limits, or simplify pattern.
194
+ - `E_INVALID_PATTERN`: Glob/regex invalid. → Fix syntax (RE2 for regex) and retry.
195
+ - `E_INVALID_INPUT`: Arguments are invalid for current context (e.g., ambiguous roots, bad patch, missing flags). → Correct parameters and retry.
196
+ - `E_PERMISSION_DENIED`: OS-level permission denied. Adjust file permissions or choose accessible paths.
197
+ - `E_SYMLINK_NOT_ALLOWED`: Symlink traversal escapes allowed roots. Use paths within allowed directories.
198
+ - `E_UNKNOWN`: Unclassified failure. Inspect message details and retry with narrower, validated inputs.
187
199
 
188
200
  ---