@j0hanz/filesystem-mcp 1.6.2 → 1.7.0

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 CHANGED
@@ -474,10 +474,14 @@ Replace text in all files matching a glob. Replaces **all** occurrences per file
474
474
 
475
475
  ### Resources
476
476
 
477
- | URI | Description | MIME Type |
478
- | :----------------------------- | :--------------------------------- | :-------------- |
479
- | `internal://instructions` | Usage guidance for models | `text/markdown` |
480
- | `filesystem-mcp://result/{id}` | Ephemeral cached large tool output | varies |
477
+ | URI | Description | MIME Type |
478
+ | :----------------------------- | :---------------------------------- | :----------------- |
479
+ | `internal://instructions` | Usage guidance for models | `text/markdown` |
480
+ | `internal://tool-catalog` | Tool routing and data-flow guide | `text/markdown` |
481
+ | `internal://workflows` | Explore/search/edit/patch workflows | `text/markdown` |
482
+ | `internal://tool-info/{name}` | Per-tool nuances and gotchas | `text/markdown` |
483
+ | `filesystem-mcp://metrics` | Live per-tool metrics snapshot | `application/json` |
484
+ | `filesystem-mcp://result/{id}` | Ephemeral cached large tool output | varies |
481
485
 
482
486
  When a tool response includes a `resource_link`/`resourceUri`, treat it as authoritative for full payload retrieval and call `resources/read` with that URI.
483
487
 
@@ -565,7 +569,7 @@ Set `FS_CONTEXT_STRIP_STRUCTURED=1` to strip `structuredContent` from tool resul
565
569
  - **Input limits**: Paths are bounded to 4,096 characters; patterns to 1,000 characters.
566
570
  - **Atomic writes**: File writes use an atomic write-then-rename strategy to prevent partial writes.
567
571
  - **Docker**: The container runs as a non-root user (`mcp`).
568
- - **HTTP host binding**: The HTTP transport binds to `127.0.0.1` by default. Setting `FILESYSTEM_MCP_HTTP_HOST=0.0.0.0` binds to all network interfaces and exposes the server externally — only do this behind a trusted reverse proxy with `FILESYSTEM_MCP_AUTH_TOKEN` configured.
572
+ - **HTTP host binding**: The HTTP transport binds to `127.0.0.1` by default. Setting `FILESYSTEM_MCP_HTTP_HOST=0.0.0.0` binds to all network interfaces and exposes the server externally — only do this behind a trusted reverse proxy with `FILESYSTEM_MCP_API_KEY` configured.
569
573
 
570
574
  > [!IMPORTANT]
571
575
  > All diagnostic output goes to `stderr`. Tool handlers must never write to `stdout`, as doing so would corrupt the stdio transport.
@@ -580,18 +584,16 @@ npm ci
580
584
 
581
585
  ### Scripts
582
586
 
583
- | Script | Command | Purpose |
584
- | :-------------- | :-------------------------------------------------------- | :---------------------------------- |
585
- | `dev` | `tsc --watch` | Watch-mode TypeScript compilation |
586
- | `dev:run` | `node --watch dist/index.js` | Run built server with file watching |
587
- | `build` | `node scripts/tasks.mjs build` | Production build |
588
- | `test` | `node scripts/tasks.mjs test` | Run full test suite |
589
- | `test:fast` | `node --test --import tsx/esm src/__tests__/**/*.test.ts` | Fast test runner (no build step) |
590
- | `test:coverage` | `node scripts/tasks.mjs test --coverage` | Test with coverage |
591
- | `lint` | `eslint .` | Lint source files |
592
- | `lint:fix` | `eslint . --fix` | Auto-fix lint issues |
593
- | `format` | `prettier --write .` | Format all files |
594
- | `type-check` | `node scripts/tasks.mjs type-check` | TypeScript type checking |
587
+ | Script | Command | Purpose |
588
+ | :----------- | :---------------------------------- | :---------------------------------- |
589
+ | `dev` | `tsc --watch` | Watch-mode TypeScript compilation |
590
+ | `dev:run` | `node --watch dist/index.js` | Run built server with file watching |
591
+ | `build` | `node scripts/tasks.mjs build` | Production build |
592
+ | `test` | `node scripts/tasks.mjs test` | Run full test suite |
593
+ | `lint` | `eslint .` | Lint source files |
594
+ | `lint:fix` | `eslint . --fix` | Auto-fix lint issues |
595
+ | `format` | `prettier --write .` | Format all files |
596
+ | `type-check` | `node scripts/tasks.mjs type-check` | TypeScript type checking |
595
597
 
596
598
  ### MCP Inspector
597
599
 
@@ -125,15 +125,16 @@ function walkErrorChain(error, visitor) {
125
125
  let current = error;
126
126
  const visited = new Set();
127
127
  while (current !== undefined && current !== null && !visited.has(current)) {
128
- if (visitor(current))
129
- return true;
128
+ const visitedResult = visitor(current);
129
+ if (visitedResult !== undefined)
130
+ return visitedResult;
130
131
  if (!isNativeError(current))
131
132
  break;
132
133
  visited.add(current);
133
134
  const next = current.cause;
134
135
  current = next;
135
136
  }
136
- return false;
137
+ return undefined;
137
138
  }
138
139
  function isAbortErrorSingle(error) {
139
140
  if (!isNativeError(error))
@@ -144,7 +145,7 @@ function isAbortErrorSingle(error) {
144
145
  return code === 'ABORT_ERR';
145
146
  }
146
147
  export function isAbortError(error) {
147
- return walkErrorChain(error, isAbortErrorSingle);
148
+ return (walkErrorChain(error, (candidate) => isAbortErrorSingle(candidate) ? true : undefined) === true);
148
149
  }
149
150
  function isTimeoutErrorSingle(error) {
150
151
  if (!isNativeError(error))
@@ -158,7 +159,7 @@ function isTimeoutErrorSingle(error) {
158
159
  return message.includes('timed out') || message.includes('timeout');
159
160
  }
160
161
  export function isTimeoutLikeError(error) {
161
- return walkErrorChain(error, isTimeoutErrorSingle);
162
+ return (walkErrorChain(error, (candidate) => isTimeoutErrorSingle(candidate) ? true : undefined) === true);
162
163
  }
163
164
  export class McpError extends Error {
164
165
  code;
@@ -224,17 +225,20 @@ function classifyMessageError(error) {
224
225
  return undefined;
225
226
  }
226
227
  function classifyError(error) {
227
- if (isAbortError(error)) {
228
- return ErrorCode.E_CANCELLED;
229
- }
230
- if (isTimeoutLikeError(error)) {
231
- return ErrorCode.E_TIMEOUT;
232
- }
233
- const direct = getDirectErrorCode(error);
234
- if (direct)
235
- return direct;
236
- const messageCode = classifyMessageError(error);
237
- return messageCode ?? ErrorCode.E_UNKNOWN;
228
+ let timeoutCode;
229
+ let fallbackCode;
230
+ const terminalCode = walkErrorChain(error, (candidate) => {
231
+ if (isAbortErrorSingle(candidate)) {
232
+ return ErrorCode.E_CANCELLED;
233
+ }
234
+ if (timeoutCode === undefined && isTimeoutErrorSingle(candidate)) {
235
+ timeoutCode = ErrorCode.E_TIMEOUT;
236
+ }
237
+ fallbackCode ??=
238
+ getDirectErrorCode(candidate) ?? classifyMessageError(candidate);
239
+ return undefined;
240
+ });
241
+ return terminalCode ?? timeoutCode ?? fallbackCode ?? ErrorCode.E_UNKNOWN;
238
242
  }
239
243
  export function createDetailedError(error, path, additionalDetails) {
240
244
  const message = error instanceof Error ? error.message : String(error);
@@ -106,40 +106,57 @@ function buildHiddenPatterns(normalizedPattern, maxDepth) {
106
106
  function shouldUseGlobDirents(options) {
107
107
  return !options.stats && !options.followSymbolicLinks;
108
108
  }
109
- function assertOptionsShape(options) {
110
- const unknownOptions = options;
111
- if (unknownOptions === null || typeof unknownOptions !== 'object') {
112
- throw new TypeError('globEntries: options must be an object');
113
- }
114
- const o = unknownOptions;
115
- if (typeof o.cwd !== 'string') {
116
- throw new TypeError('globEntries: options.cwd must be a string');
109
+ function assertOptionString(options, key) {
110
+ if (typeof options[key] !== 'string') {
111
+ throw new TypeError(`globEntries: options.${key} must be a string`);
117
112
  }
118
- if (typeof o.pattern !== 'string') {
119
- throw new TypeError('globEntries: options.pattern must be a string');
120
- }
121
- if (!Array.isArray(o.excludePatterns)) {
113
+ }
114
+ function assertExcludePatternsOption(options) {
115
+ if (!Array.isArray(options.excludePatterns)) {
122
116
  throw new TypeError('globEntries: options.excludePatterns must be an array');
123
117
  }
124
- for (const p of o.excludePatterns) {
125
- if (typeof p !== 'string') {
118
+ for (const pattern of options.excludePatterns) {
119
+ if (typeof pattern !== 'string') {
126
120
  throw new TypeError('globEntries: options.excludePatterns must contain only strings');
127
121
  }
128
122
  }
123
+ }
124
+ function assertBooleanOptions(options) {
129
125
  for (const key of GLOB_BOOLEAN_OPTION_KEYS) {
130
- if (typeof o[key] !== 'boolean') {
126
+ if (typeof options[key] !== 'boolean') {
131
127
  throw new TypeError(`globEntries: options.${key} must be a boolean`);
132
128
  }
133
129
  }
134
- if (o.maxDepth !== undefined) {
135
- if (typeof o.maxDepth !== 'number' || !Number.isFinite(o.maxDepth)) {
136
- throw new TypeError('globEntries: options.maxDepth must be a finite number');
137
- }
130
+ }
131
+ function assertOptionalMaxDepth(options) {
132
+ const { maxDepth } = options;
133
+ if (maxDepth === undefined)
134
+ return;
135
+ if (typeof maxDepth !== 'number' || !Number.isFinite(maxDepth)) {
136
+ throw new TypeError('globEntries: options.maxDepth must be a finite number');
138
137
  }
139
- if (o.suppressErrors !== undefined && typeof o.suppressErrors !== 'boolean') {
138
+ }
139
+ function assertOptionalSuppressErrors(options) {
140
+ const { suppressErrors } = options;
141
+ if (suppressErrors === undefined)
142
+ return;
143
+ if (typeof suppressErrors !== 'boolean') {
140
144
  throw new TypeError('globEntries: options.suppressErrors must be a boolean');
141
145
  }
142
146
  }
147
+ function assertOptionsShape(options) {
148
+ const unknownOptions = options;
149
+ if (unknownOptions === null || typeof unknownOptions !== 'object') {
150
+ throw new TypeError('globEntries: options must be an object');
151
+ }
152
+ const o = unknownOptions;
153
+ assertOptionString(o, 'cwd');
154
+ assertOptionString(o, 'pattern');
155
+ assertExcludePatternsOption(o);
156
+ assertBooleanOptions(o);
157
+ assertOptionalMaxDepth(o);
158
+ assertOptionalSuppressErrors(o);
159
+ }
143
160
  function normalizeOptions(options) {
144
161
  const cwd = path.resolve(options.cwd);
145
162
  const normalizedPattern = normalizePattern(options.pattern, options.baseNameMatch);
@@ -120,23 +120,22 @@ function createParallelAbortError() {
120
120
  return createAbortError();
121
121
  }
122
122
  export async function processInParallel(items, processor, concurrency = PARALLEL_CONCURRENCY, signal) {
123
- if (items.length === 0)
123
+ const itemCount = items.length;
124
+ if (itemCount === 0)
124
125
  return { results: [], errors: [] };
125
126
  const effectiveConcurrency = normalizeConcurrency(concurrency);
126
127
  // Pre-allocate slots by index to guarantee input-order output.
127
- const resultSlots = new Array(items.length);
128
+ const resultSlots = new Array(itemCount);
128
129
  const errors = [];
129
130
  if (signal?.aborted)
130
131
  throw createParallelAbortError();
131
132
  let nextIndex = 0;
132
133
  const next = async () => {
133
- while (nextIndex < items.length) {
134
+ while (nextIndex < itemCount) {
134
135
  if (signal?.aborted)
135
136
  throw createParallelAbortError();
136
- const index = nextIndex++;
137
- // Check again because another worker might have incremented past length
138
- if (index >= items.length)
139
- break;
137
+ const index = nextIndex;
138
+ nextIndex += 1;
140
139
  const item = items[index];
141
140
  try {
142
141
  const result = await processor(item);
@@ -154,7 +153,7 @@ export async function processInParallel(items, processor, concurrency = PARALLEL
154
153
  }
155
154
  }
156
155
  };
157
- const workerCount = Math.min(items.length, effectiveConcurrency);
156
+ const workerCount = Math.min(itemCount, effectiveConcurrency);
158
157
  const workers = new Array(workerCount);
159
158
  for (let index = 0; index < workerCount; index += 1) {
160
159
  workers[index] = next();
@@ -36,45 +36,61 @@ function compilePatterns(patterns) {
36
36
  const normalized = normalizeForMatch(pattern);
37
37
  const matchesPath = normalized.includes('/');
38
38
  compiled.push({
39
- raw: normalized,
40
39
  globs: matchesPath ? compilePatternGlobs(normalized) : [normalized],
41
40
  matchesPath,
42
41
  });
43
42
  }
44
43
  return compiled;
45
44
  }
46
- const DENY_PATTERNS = compilePatterns(SENSITIVE_FILE_DENYLIST);
47
- const ALLOW_PATTERNS = compilePatterns(SENSITIVE_FILE_ALLOWLIST);
45
+ function toPatternSet(patterns) {
46
+ const pathGlobs = new Set();
47
+ const nameGlobs = new Set();
48
+ for (const pattern of patterns) {
49
+ const target = pattern.matchesPath ? pathGlobs : nameGlobs;
50
+ for (const glob of pattern.globs) {
51
+ target.add(glob);
52
+ }
53
+ }
54
+ return {
55
+ pathGlobs: [...pathGlobs],
56
+ nameGlobs: [...nameGlobs],
57
+ };
58
+ }
59
+ const DENY_PATTERNS = toPatternSet(compilePatterns(SENSITIVE_FILE_DENYLIST));
60
+ const ALLOW_PATTERNS = toPatternSet(compilePatterns(SENSITIVE_FILE_ALLOWLIST));
48
61
  function uniquePair(primary, secondary) {
49
62
  if (!secondary || secondary === primary)
50
63
  return [primary];
51
64
  return [primary, secondary];
52
65
  }
53
- function matchesAny(patterns, pathCandidates, nameCandidates) {
54
- for (const pattern of patterns) {
55
- const candidates = pattern.matchesPath ? pathCandidates : nameCandidates;
56
- for (const candidate of candidates) {
57
- for (const glob of pattern.globs) {
58
- if (path.posix.matchesGlob(candidate, glob))
59
- return true;
60
- }
66
+ function matchesAnyGlobs(globs, candidates) {
67
+ if (globs.length === 0 || candidates.length === 0)
68
+ return false;
69
+ for (const candidate of candidates) {
70
+ for (const glob of globs) {
71
+ if (path.posix.matchesGlob(candidate, glob))
72
+ return true;
61
73
  }
62
74
  }
63
75
  return false;
64
76
  }
65
77
  export function isSensitivePath(requestedPath, resolvedPath) {
66
- if (DENY_PATTERNS.length === 0)
78
+ if (DENY_PATTERNS.pathGlobs.length === 0 &&
79
+ DENY_PATTERNS.nameGlobs.length === 0) {
67
80
  return false;
81
+ }
68
82
  const normalizedRequested = normalizeForMatch(requestedPath);
69
83
  const normalizedResolved = resolvedPath
70
84
  ? normalizeForMatch(resolvedPath)
71
85
  : undefined;
72
86
  const pathCandidates = uniquePair(normalizedRequested, normalizedResolved);
73
87
  const nameCandidates = uniquePair(path.posix.basename(normalizedRequested), normalizedResolved ? path.posix.basename(normalizedResolved) : undefined);
74
- if (matchesAny(ALLOW_PATTERNS, pathCandidates, nameCandidates)) {
88
+ if (matchesAnyGlobs(ALLOW_PATTERNS.pathGlobs, pathCandidates) ||
89
+ matchesAnyGlobs(ALLOW_PATTERNS.nameGlobs, nameCandidates)) {
75
90
  return false;
76
91
  }
77
- return matchesAny(DENY_PATTERNS, pathCandidates, nameCandidates);
92
+ return (matchesAnyGlobs(DENY_PATTERNS.pathGlobs, pathCandidates) ||
93
+ matchesAnyGlobs(DENY_PATTERNS.nameGlobs, nameCandidates));
78
94
  }
79
95
  export function assertAllowedFileAccess(requestedPath, resolvedPath) {
80
96
  if (!isSensitivePath(requestedPath, resolvedPath))
@@ -15,6 +15,10 @@ Expert filesystem agent. Operate ONLY within allowed roots. Always discover befo
15
15
  </tools_overview>
16
16
 
17
17
  <resources>
18
+ - \`internal://instructions\`: Full server usage guide.
19
+ - \`internal://tool-catalog\`: Tool routing and cross-tool data-flow guide.
20
+ - \`internal://workflows\`: Standard operating sequences (explore/search/edit/patch).
21
+ - \`internal://tool-info/{name}\`: Per-tool details (nuances/gotchas), e.g. \`internal://tool-info/read\`.
18
22
  - \`filesystem-mcp://result/{id}\`: Large output cache. Call \`resources/read\` immediately if \`resourceUri\` is returned.
19
23
  - \`filesystem-mcp://metrics\`: Live per-tool stats.
20
24
  </resources>
@@ -9,7 +9,7 @@ import { globEntries } from '../lib/file-operations/glob-engine.js';
9
9
  import { assertNotAborted, withAbort } from '../lib/fs-helpers.js';
10
10
  import { validateExistingPath } from '../lib/path-validation.js';
11
11
  import { CalculateHashInputSchema, CalculateHashOutputSchema, } from '../schemas.js';
12
- import { buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
12
+ import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
13
13
  import { registerToolTaskIfAvailable } from './task-support.js';
14
14
  const WINDOWS_PATH_SEPARATOR = /\\/gu;
15
15
  export const CALCULATE_HASH_TOOL = {
@@ -158,17 +158,10 @@ export function registerCalculateHashTool(server, options = {}) {
158
158
  context: { path: args.path },
159
159
  run: async (signal) => {
160
160
  const baseName = path.basename(args.path);
161
- let progressCursor = 0;
162
- notifyProgress(extra, {
163
- current: 0,
164
- message: `🕮 calculate_hash: ${baseName}`,
165
- });
166
- const baseReporter = createProgressReporter(extra);
161
+ const progress = createToolProgressSession(extra, `🕮 calculate_hash: ${baseName}`);
167
162
  const progressWithMessage = ({ current, total, }) => {
168
- if (current > progressCursor)
169
- progressCursor = current;
170
163
  const fileWord = current === 1 ? 'file' : 'files';
171
- baseReporter({
164
+ progress.update({
172
165
  current,
173
166
  ...(total !== undefined ? { total } : {}),
174
167
  message: `🕮 calculate_hash: ${baseName} [${current} ${fileWord} hashed]`,
@@ -178,7 +171,7 @@ export function registerCalculateHashTool(server, options = {}) {
178
171
  const result = await handleCalculateHash(args, signal, progressWithMessage);
179
172
  const sc = result.structuredContent;
180
173
  const totalFiles = sc.ok ? (sc.fileCount ?? 1) : 1;
181
- const finalCurrent = Math.max(totalFiles + 1, progressCursor + 1);
174
+ const finalCurrent = Math.max(totalFiles + 1, progress.getCurrent() + 1);
182
175
  let suffix;
183
176
  if (!sc.ok) {
184
177
  suffix = 'failed';
@@ -189,20 +182,11 @@ export function registerCalculateHashTool(server, options = {}) {
189
182
  else {
190
183
  suffix = `${(sc.hash ?? '').slice(0, 8)}...`;
191
184
  }
192
- notifyProgress(extra, {
193
- current: finalCurrent,
194
- total: finalCurrent,
195
- message: `🕮 calculate_hash: ${baseName} • ${suffix}`,
196
- });
185
+ progress.complete(`🕮 calculate_hash: ${baseName} • ${suffix}`, finalCurrent);
197
186
  return result;
198
187
  }
199
188
  catch (error) {
200
- const finalCurrent = Math.max(progressCursor + 1, 1);
201
- notifyProgress(extra, {
202
- current: finalCurrent,
203
- total: finalCurrent,
204
- message: `🕮 calculate_hash: ${baseName} • failed`,
205
- });
189
+ progress.fail(`🕮 calculate_hash: ${baseName} • failed`);
206
190
  throw error;
207
191
  }
208
192
  },
@@ -3,7 +3,7 @@ import { DEFAULT_READ_MANY_MAX_TOTAL_SIZE, DEFAULT_SEARCH_TIMEOUT_MS, } from '..
3
3
  import { ErrorCode } from '../lib/errors.js';
4
4
  import { readMultipleFiles } from '../lib/file-operations/read-multiple-files.js';
5
5
  import { ReadMultipleFilesInputSchema, ReadMultipleFilesOutputSchema, } from '../schemas.js';
6
- import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, maybeExternalizeTextContent, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
6
+ import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
7
  import { registerToolTaskIfAvailable } from './task-support.js';
8
8
  export const READ_MULTIPLE_FILES_TOOL = {
9
9
  name: 'read_many',
@@ -20,6 +20,33 @@ export const READ_MULTIPLE_FILES_TOOL = {
20
20
  'Per-file `truncationReason` can be `head`, `range`, or `externalized`.',
21
21
  ],
22
22
  };
23
+ function toStructuredReadManyResult(result) {
24
+ const structured = {
25
+ path: result.path,
26
+ };
27
+ if (result.content !== undefined)
28
+ structured.content = result.content;
29
+ if (result.truncated)
30
+ structured.truncated = result.truncated;
31
+ if (result.resourceUri)
32
+ structured.resourceUri = result.resourceUri;
33
+ if (result.head !== undefined)
34
+ structured.head = result.head;
35
+ if (result.startLine !== undefined)
36
+ structured.startLine = result.startLine;
37
+ if (result.endLine !== undefined)
38
+ structured.endLine = result.endLine;
39
+ if (result.hasMoreLines)
40
+ structured.hasMoreLines = result.hasMoreLines;
41
+ if (result.totalLines !== undefined)
42
+ structured.totalLines = result.totalLines;
43
+ if (result.truncationReason) {
44
+ structured.truncationReason = result.truncationReason;
45
+ }
46
+ if (result.error)
47
+ structured.error = result.error;
48
+ return structured;
49
+ }
23
50
  async function handleReadMultipleFiles(args, signal, resourceStore, onReadComplete) {
24
51
  const options = {
25
52
  ...(signal ? { signal } : {}),
@@ -70,25 +97,7 @@ async function handleReadMultipleFiles(args, signal, resourceStore, onReadComple
70
97
  }
71
98
  const structured = {
72
99
  ok: true,
73
- results: mappedResults.map((result) => ({
74
- path: result.path,
75
- ...(result.content !== undefined ? { content: result.content } : {}),
76
- ...(result.truncated ? { truncated: result.truncated } : {}),
77
- ...(result.resourceUri ? { resourceUri: result.resourceUri } : {}),
78
- ...(result.head !== undefined ? { head: result.head } : {}),
79
- ...(result.startLine !== undefined
80
- ? { startLine: result.startLine }
81
- : {}),
82
- ...(result.endLine !== undefined ? { endLine: result.endLine } : {}),
83
- ...(result.hasMoreLines ? { hasMoreLines: result.hasMoreLines } : {}),
84
- ...(result.totalLines !== undefined
85
- ? { totalLines: result.totalLines }
86
- : {}),
87
- ...(result.truncationReason
88
- ? { truncationReason: result.truncationReason }
89
- : {}),
90
- ...(result.error ? { error: result.error } : {}),
91
- })),
100
+ results: mappedResults.map((result) => toStructuredReadManyResult(result)),
92
101
  summary: {
93
102
  total: mappedResults.length,
94
103
  succeeded,
@@ -130,18 +139,9 @@ export function registerReadMultipleFilesTool(server, options = {}) {
130
139
  ? `, ${path.basename(args.paths[1] ?? '')}${args.paths.length > 2 ? '…' : ''}`
131
140
  : '';
132
141
  const context = `${args.paths.length} files [${first}${extraPaths}]`;
133
- let progressCursor = 0;
134
- notifyProgress(extra, {
135
- current: 0,
136
- message: `🕮 read_many: ${context}`,
137
- });
138
- const baseReporter = createProgressReporter(extra);
142
+ const progress = createToolProgressSession(extra, `🕮 read_many: ${context}`);
139
143
  const onReadComplete = () => {
140
- progressCursor++;
141
- baseReporter({
142
- current: progressCursor,
143
- message: `🕮 read_many: ${context} [${progressCursor}/${args.paths.length} read]`,
144
- });
144
+ progress.increment((current) => `🕮 read_many: ${context} [${current}/${args.paths.length} read]`);
145
145
  };
146
146
  try {
147
147
  const result = await handleReadMultipleFiles(args, signal, options.resourceStore, onReadComplete);
@@ -156,21 +156,12 @@ export function registerReadMultipleFilesTool(server, options = {}) {
156
156
  else {
157
157
  suffix = `${total} files read`;
158
158
  }
159
- const finalCurrent = Math.max(total, progressCursor + 1);
160
- notifyProgress(extra, {
161
- current: finalCurrent,
162
- total: finalCurrent,
163
- message: `🕮 read_many: ${context} • ${suffix}`,
164
- });
159
+ const finalCurrent = Math.max(total, progress.getCurrent() + 1);
160
+ progress.complete(`🕮 read_many: ${context} • ${suffix}`, finalCurrent);
165
161
  return result;
166
162
  }
167
163
  catch (error) {
168
- const finalCurrent = Math.max(progressCursor + 1, 1);
169
- notifyProgress(extra, {
170
- current: finalCurrent,
171
- total: finalCurrent,
172
- message: `🕮 read_many: ${context} • failed`,
173
- });
164
+ progress.fail(`🕮 read_many: ${context} • failed`);
174
165
  throw error;
175
166
  }
176
167
  },
@@ -9,7 +9,7 @@ import { globEntries } from '../lib/file-operations/glob-engine.js';
9
9
  import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
10
10
  import { validateExistingPath, validatePathForWrite, } from '../lib/path-validation.js';
11
11
  import { SearchAndReplaceInputSchema, SearchAndReplaceOutputSchema, } from '../schemas.js';
12
- import { buildToolErrorResponse, buildToolResponse, createProgressReporter, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, notifyProgress, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
12
+ import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
13
13
  import { registerToolTaskIfAvailable } from './task-support.js';
14
14
  export const SEARCH_AND_REPLACE_TOOL = {
15
15
  name: 'search_and_replace',
@@ -56,31 +56,42 @@ function createRegexMatcher(pattern) {
56
56
  throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid regex pattern: ${formatUnknownErrorMessage(error)}`);
57
57
  }
58
58
  }
59
- function countRegexMatches(content, regex) {
60
- regex.lastIndex = 0;
61
- let count = 0;
62
- while (regex.exec(content) !== null) {
63
- count++;
64
- if (regex.lastIndex === 0) {
65
- regex.lastIndex++;
59
+ function createRegexReplacementMatcher(regex) {
60
+ const count = (content) => {
61
+ regex.lastIndex = 0;
62
+ let matchCount = 0;
63
+ while (regex.exec(content) !== null) {
64
+ matchCount++;
65
+ if (regex.lastIndex === 0) {
66
+ regex.lastIndex++;
67
+ }
66
68
  }
67
- }
68
- return count;
69
+ return matchCount;
70
+ };
71
+ const replace = (content, replacement) => {
72
+ regex.lastIndex = 0;
73
+ return content.replace(regex, replacement);
74
+ };
75
+ return { count, replace };
69
76
  }
70
- function countLiteralMatches(content, searchPattern) {
71
- let count = 0;
72
- let pos = content.indexOf(searchPattern);
73
- const patternLength = searchPattern.length;
74
- while (pos !== -1) {
75
- count++;
76
- pos = content.indexOf(searchPattern, pos + patternLength);
77
- }
78
- return count;
77
+ function createLiteralReplacementMatcher(searchPattern) {
78
+ const count = (content) => {
79
+ let matchCount = 0;
80
+ let pos = content.indexOf(searchPattern);
81
+ const patternLength = searchPattern.length;
82
+ while (pos !== -1) {
83
+ matchCount++;
84
+ pos = content.indexOf(searchPattern, pos + patternLength);
85
+ }
86
+ return matchCount;
87
+ };
88
+ const replace = (content, replacement) => content.replaceAll(searchPattern, () => replacement);
89
+ return { count, replace };
79
90
  }
80
91
  function formatFileTooLargeError(filePath, size, maxFileSize) {
81
92
  return `File too large: ${filePath} (${size} bytes > ${maxFileSize} bytes)`;
82
93
  }
83
- async function processEntry(entryPath, args, regex, maxFileSize, signal, summary) {
94
+ async function processEntry(entryPath, options, replacement, matcher, maxFileSize, signal, summary) {
84
95
  let validPath;
85
96
  try {
86
97
  validPath = await validatePathForWrite(entryPath, signal);
@@ -107,22 +118,13 @@ async function processEntry(entryPath, args, regex, maxFileSize, signal, summary
107
118
  encoding: 'utf-8',
108
119
  signal,
109
120
  });
110
- const matchCount = args.isRegex && regex
111
- ? countRegexMatches(content, regex)
112
- : countLiteralMatches(content, args.searchPattern);
121
+ const matchCount = matcher.count(content);
113
122
  if (matchCount > 0) {
114
123
  summary.totalMatches += matchCount;
115
124
  summary.filesChanged++;
116
125
  recordChangedFile(summary, validPath, matchCount);
117
- let newContent;
118
- if (args.isRegex && regex) {
119
- regex.lastIndex = 0;
120
- newContent = content.replace(regex, args.replacement);
121
- }
122
- else {
123
- newContent = content.replaceAll(args.searchPattern, () => args.replacement);
124
- }
125
- if ((args.dryRun || args.returnDiff) &&
126
+ const newContent = matcher.replace(content, replacement);
127
+ if ((options.dryRun || options.returnDiff) &&
126
128
  summary.diff.length < MAX_DIFF_SIZE) {
127
129
  const patch = createTwoFilesPatch(path.basename(validPath), path.basename(validPath), content, newContent, 'Original', 'Modified');
128
130
  // Only append if it won't exceed the limit too much
@@ -130,7 +132,7 @@ async function processEntry(entryPath, args, regex, maxFileSize, signal, summary
130
132
  summary.diff += patch;
131
133
  }
132
134
  }
133
- if (!args.dryRun) {
135
+ if (!options.dryRun) {
134
136
  await atomicWriteFile(validPath, newContent, {
135
137
  encoding: 'utf-8',
136
138
  signal,
@@ -195,6 +197,13 @@ function createReplacementRegex(args) {
195
197
  }
196
198
  return createRegexMatcher(args.searchPattern);
197
199
  }
200
+ function createReplacementMatcher(args) {
201
+ const regex = createReplacementRegex(args);
202
+ if (regex) {
203
+ return createRegexReplacementMatcher(regex);
204
+ }
205
+ return createLiteralReplacementMatcher(args.searchPattern);
206
+ }
198
207
  function reportReplaceProgress(onProgress, current, force = false) {
199
208
  if (current === 0)
200
209
  return;
@@ -205,7 +214,7 @@ function reportReplaceProgress(onProgress, current, force = false) {
205
214
  export async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
206
215
  const maxFileSize = MAX_TEXT_FILE_SIZE;
207
216
  const root = await resolveSearchRoot(args.path, signal);
208
- const regex = createReplacementRegex(args);
217
+ const matcher = createReplacementMatcher(args);
209
218
  const entries = globEntries({
210
219
  cwd: root,
211
220
  pattern: args.filePattern,
@@ -226,7 +235,10 @@ export async function handleSearchAndReplace(args, signal, onProgress = () => {
226
235
  summary.processedFiles++;
227
236
  reportReplaceProgress(onProgress, summary.processedFiles);
228
237
  },
229
- runEntry: async (entryPath) => processEntry(entryPath, args, regex, maxFileSize, signal, summary),
238
+ runEntry: async (entryPath) => processEntry(entryPath, {
239
+ dryRun: args.dryRun,
240
+ returnDiff: args.returnDiff ?? false,
241
+ }, args.replacement, matcher, maxFileSize, signal, summary),
230
242
  });
231
243
  reportReplaceProgress(onProgress, summary.processedFiles, true);
232
244
  const failureSuffix = summary.failedFiles > 0 ? ` (${summary.failedFiles} failed)` : '';
@@ -256,16 +268,9 @@ export function registerSearchAndReplaceTool(server, options = {}) {
256
268
  run: async (signal) => {
257
269
  const dryLabel = args.dryRun ? ' [dry run]' : '';
258
270
  const context = `"${args.searchPattern}" in ${args.filePattern}${dryLabel}`;
259
- let progressCursor = 0;
260
- notifyProgress(extra, {
261
- current: 0,
262
- message: `🛠 search_and_replace: ${context}`,
263
- });
264
- const baseReporter = createProgressReporter(extra);
271
+ const progress = createToolProgressSession(extra, `🛠 search_and_replace: ${context}`);
265
272
  const progressWithMessage = ({ current, total, }) => {
266
- if (current > progressCursor)
267
- progressCursor = current;
268
- baseReporter({
273
+ progress.update({
269
274
  current,
270
275
  ...(total !== undefined ? { total } : {}),
271
276
  message: `🛠 search_and_replace: ${args.searchPattern} [${current} files processed]`,
@@ -274,7 +279,7 @@ export function registerSearchAndReplaceTool(server, options = {}) {
274
279
  try {
275
280
  const result = await handleSearchAndReplace(args, signal, progressWithMessage);
276
281
  const sc = result.structuredContent;
277
- const finalCurrent = Math.max((sc.processedFiles ?? 0) + 1, progressCursor + 1);
282
+ const finalCurrent = Math.max((sc.processedFiles ?? 0) + 1, progress.getCurrent() + 1);
278
283
  const matchWord = (sc.matches ?? 0) === 1 ? 'match' : 'matches';
279
284
  const fileWord = (sc.filesChanged ?? 0) === 1 ? 'file' : 'files';
280
285
  let endSuffix = `${sc.matches ?? 0} ${matchWord} in ${sc.filesChanged ?? 0} ${fileWord}`;
@@ -282,20 +287,11 @@ export function registerSearchAndReplaceTool(server, options = {}) {
282
287
  endSuffix += `, ${sc.failedFiles} failed`;
283
288
  if (sc.dryRun)
284
289
  endSuffix += ' [dry run]';
285
- notifyProgress(extra, {
286
- current: finalCurrent,
287
- total: finalCurrent,
288
- message: `🛠 search_and_replace: ${context} • ${endSuffix}`,
289
- });
290
+ progress.complete(`🛠 search_and_replace: ${context} • ${endSuffix}`, finalCurrent);
290
291
  return result;
291
292
  }
292
293
  catch (error) {
293
- const finalCurrent = Math.max(progressCursor + 1, 1);
294
- notifyProgress(extra, {
295
- current: finalCurrent,
296
- total: finalCurrent,
297
- message: `🛠 search_and_replace: ${context} • failed`,
298
- });
294
+ progress.fail(`🛠 search_and_replace: ${context} • failed`);
299
295
  throw error;
300
296
  }
301
297
  },
@@ -5,7 +5,7 @@ import { DEFAULT_EXCLUDE_PATTERNS } from '../lib/constants.js';
5
5
  import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
6
6
  import { searchContent } from '../lib/file-operations/search-content.js';
7
7
  import { SearchContentInputSchema, SearchContentOutputSchema, } from '../schemas.js';
8
- import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
+ import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
9
9
  import { registerToolTaskIfAvailable } from './task-support.js';
10
10
  const MAX_INLINE_MATCHES = parseInt(process.env['FS_CONTEXT_MAX_INLINE_MATCHES'] ?? '', 10) || 50;
11
11
  export const SEARCH_CONTENT_TOOL = {
@@ -207,28 +207,16 @@ export function registerSearchContentTool(server, options = {}) {
207
207
  run: async (signal) => {
208
208
  const scope = args.filePattern;
209
209
  const { pattern } = args;
210
- let progressCursor = 0;
211
- notifyProgress(extra, {
212
- current: 0,
213
- message: `🔎︎ grep: ${pattern} in ${scope}`,
214
- });
215
- const baseReporter = createProgressReporter(extra);
210
+ const progress = createToolProgressSession(extra, `🔎︎ grep: ${pattern} in ${scope}`);
216
211
  const progressWithMessage = ({ current, total, }) => {
217
- if (current > progressCursor)
218
- progressCursor = current;
219
212
  const fileWord = current === 1 ? 'file' : 'files';
220
- baseReporter({
213
+ progress.update({
221
214
  current,
222
215
  ...(total !== undefined ? { total } : {}),
223
216
  message: `🔎︎ grep: ${pattern} [${current} ${fileWord} scanned]`,
224
217
  });
225
218
  };
226
219
  try {
227
- if (signal) {
228
- signal.addEventListener('abort', () => {
229
- console.error('searchContent signal aborted!');
230
- });
231
- }
232
220
  const result = await handleSearchContent(args, signal, options.resourceStore, progressWithMessage);
233
221
  const sc = result.structuredContent;
234
222
  const count = sc.ok && sc.totalMatches ? sc.totalMatches : 0;
@@ -254,21 +242,12 @@ export function registerSearchContentTool(server, options = {}) {
254
242
  suffix += ' [truncated — max files]';
255
243
  }
256
244
  }
257
- const finalCurrent = Math.max((sc.filesScanned ?? 0) + 1, progressCursor + 1);
258
- notifyProgress(extra, {
259
- current: finalCurrent,
260
- total: finalCurrent,
261
- message: `🔎︎ grep: ${pattern} • ${suffix}`,
262
- });
245
+ const finalCurrent = Math.max((sc.filesScanned ?? 0) + 1, progress.getCurrent() + 1);
246
+ progress.complete(`🔎︎ grep: ${pattern} • ${suffix}`, finalCurrent);
263
247
  return result;
264
248
  }
265
249
  catch (error) {
266
- const finalCurrent = Math.max(progressCursor + 1, 1);
267
- notifyProgress(extra, {
268
- current: finalCurrent,
269
- total: finalCurrent,
270
- message: `🔎︎ grep: ${pattern} in ${scope} • failed`,
271
- });
250
+ progress.fail(`🔎︎ grep: ${pattern} in ${scope} failed`);
272
251
  throw error;
273
252
  }
274
253
  },
@@ -112,6 +112,18 @@ export declare function notifyProgress(extra: ToolExtra, progress: {
112
112
  total?: number;
113
113
  message?: string;
114
114
  }): void;
115
+ export interface ToolProgressSession {
116
+ update: (progress: {
117
+ current: number;
118
+ total?: number;
119
+ message: string;
120
+ }) => void;
121
+ increment: (messageForCurrent: (current: number) => string) => void;
122
+ complete: (message: string, minimumCurrent?: number) => void;
123
+ fail: (message: string, minimumCurrent?: number) => void;
124
+ getCurrent: () => number;
125
+ }
126
+ export declare function createToolProgressSession(extra: ToolExtra, startMessage: string): ToolProgressSession;
115
127
  export declare function wrapToolHandler<Args, Result>(handler: (args: Args, extra: ToolExtra) => Promise<ToolResult<Result>>, options: {
116
128
  guard?: (() => boolean) | undefined;
117
129
  progressMessage?: (args: Args) => string;
@@ -280,6 +280,55 @@ export function notifyProgress(extra, progress) {
280
280
  return;
281
281
  void reportProgress(extra, progress);
282
282
  }
283
+ export function createToolProgressSession(extra, startMessage) {
284
+ notifyProgress(extra, {
285
+ current: 0,
286
+ message: startMessage,
287
+ });
288
+ let cursor = 0;
289
+ const baseReporter = createProgressReporter(extra);
290
+ const setCursor = (value) => {
291
+ if (value > cursor)
292
+ cursor = value;
293
+ return cursor;
294
+ };
295
+ return {
296
+ update: ({ current, total, message }) => {
297
+ const normalized = setCursor(current);
298
+ baseReporter({
299
+ current: normalized,
300
+ ...(total !== undefined ? { total } : {}),
301
+ message,
302
+ });
303
+ },
304
+ increment: (messageForCurrent) => {
305
+ const next = setCursor(cursor + 1);
306
+ baseReporter({
307
+ current: next,
308
+ message: messageForCurrent(next),
309
+ });
310
+ },
311
+ complete: (message, minimumCurrent) => {
312
+ const finalCurrent = Math.max(cursor + 1, minimumCurrent ?? 1, 1);
313
+ notifyProgress(extra, {
314
+ current: finalCurrent,
315
+ total: finalCurrent,
316
+ message,
317
+ });
318
+ cursor = finalCurrent;
319
+ },
320
+ fail: (message, minimumCurrent) => {
321
+ const finalCurrent = Math.max(cursor + 1, minimumCurrent ?? 1, 1);
322
+ notifyProgress(extra, {
323
+ current: finalCurrent,
324
+ total: finalCurrent,
325
+ message,
326
+ });
327
+ cursor = finalCurrent;
328
+ },
329
+ getCurrent: () => cursor,
330
+ };
331
+ }
283
332
  async function withProgress(message, extra, run, getCompletionMessage) {
284
333
  if (!canReportProgress(extra)) {
285
334
  return run();
@@ -4,7 +4,7 @@ import { DEFAULT_SEARCH_TIMEOUT_MS } from '../lib/constants.js';
4
4
  import { ErrorCode } from '../lib/errors.js';
5
5
  import { getMultipleFileInfo } from '../lib/file-operations/file-info.js';
6
6
  import { GetMultipleFileInfoInputSchema, GetMultipleFileInfoOutputSchema, } from '../schemas.js';
7
- import { buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
+ import { buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
8
  import { registerToolTaskIfAvailable } from './task-support.js';
9
9
  export const GET_MULTIPLE_FILE_INFO_TOOL = {
10
10
  name: 'stat_many',
@@ -79,18 +79,9 @@ export function registerGetMultipleFileInfoTool(server, options = {}) {
79
79
  ? `, ${path.basename(args.paths[1] ?? '')}${args.paths.length > 2 ? '…' : ''}`
80
80
  : '';
81
81
  const context = `${args.paths.length} paths [${first}${extraPaths}]`;
82
- let progressCursor = 0;
83
- notifyProgress(extra, {
84
- current: 0,
85
- message: `🕮 stat_many: ${context}`,
86
- });
87
- const baseReporter = createProgressReporter(extra);
82
+ const progress = createToolProgressSession(extra, `🕮 stat_many: ${context}`);
88
83
  const onProgress = () => {
89
- progressCursor++;
90
- baseReporter({
91
- current: progressCursor,
92
- message: `🕮 stat_many: ${context} [${progressCursor}/${args.paths.length} scanned]`,
93
- });
84
+ progress.increment((current) => `🕮 stat_many: ${context} [${current}/${args.paths.length} scanned]`);
94
85
  };
95
86
  try {
96
87
  const result = await handleGetMultipleFileInfo(args, signal, onProgress);
@@ -105,21 +96,12 @@ export function registerGetMultipleFileInfoTool(server, options = {}) {
105
96
  else {
106
97
  suffix = `${total} OK`;
107
98
  }
108
- const finalCurrent = Math.max(total, progressCursor + 1);
109
- notifyProgress(extra, {
110
- current: finalCurrent,
111
- total: finalCurrent,
112
- message: `🕮 stat_many: ${context} • ${suffix}`,
113
- });
99
+ const finalCurrent = Math.max(total, progress.getCurrent() + 1);
100
+ progress.complete(`🕮 stat_many: ${context} • ${suffix}`, finalCurrent);
114
101
  return result;
115
102
  }
116
103
  catch (error) {
117
- const finalCurrent = Math.max(progressCursor + 1, 1);
118
- notifyProgress(extra, {
119
- current: finalCurrent,
120
- total: finalCurrent,
121
- message: `🕮 stat_many: ${context} • failed`,
122
- });
104
+ progress.fail(`🕮 stat_many: ${context} • failed`);
123
105
  throw error;
124
106
  }
125
107
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/filesystem-mcp",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "mcpName": "io.github.j0hanz/filesystem-mcp",
5
5
  "description": "MCP Server that enables LLMs to interact with the local filesystem.",
6
6
  "type": "module",
@@ -37,8 +37,6 @@
37
37
  "lint": "eslint .",
38
38
  "lint:fix": "eslint . --fix",
39
39
  "test": "node scripts/tasks.mjs test",
40
- "test:fast": "node --test --import tsx/esm src/__tests__/**/*.test.ts node-tests/**/*.test.ts",
41
- "test:coverage": "node scripts/tasks.mjs test --coverage",
42
40
  "knip": "knip",
43
41
  "knip:fix": "knip --fix",
44
42
  "inspector": "npm run build && npx -y @modelcontextprotocol/inspector node dist/index.js ${workspaceFolder}",