@j0hanz/filesystem-mcp 1.2.1 → 1.2.2

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/dist/cli.js CHANGED
@@ -3,11 +3,13 @@ import { getSystemErrorMessage, getSystemErrorName } from 'node:util';
3
3
  import { z } from 'zod';
4
4
  import { Command, CommanderError, InvalidArgumentError } from 'commander';
5
5
  import packageJsonRaw from '../package.json' with { type: 'json' };
6
+ import { processInParallel } from './lib/fs-helpers.js';
6
7
  import { getReservedDeviceNameForPath, isWindowsDriveRelativePath, normalizePath, } from './lib/path-validation.js';
7
8
  import { isRecord } from './lib/type-guards.js';
8
9
  const PackageJsonSchema = z.object({ version: z.string() });
9
10
  const { version: SERVER_VERSION } = PackageJsonSchema.parse(packageJsonRaw);
10
11
  const IS_WINDOWS = process.platform === 'win32';
12
+ const CLI_VALIDATE_CONCURRENCY = 8;
11
13
  export class CliExitError extends Error {
12
14
  exitCode;
13
15
  constructor(message, exitCode) {
@@ -92,11 +94,17 @@ async function validateDirectoryPath(inputPath) {
92
94
  }
93
95
  }
94
96
  async function normalizeCliDirectories(args) {
95
- const validations = [];
96
- for (const arg of args) {
97
- validations.push(validateDirectoryPath(arg));
97
+ const { results, errors } = await processInParallel([...args], validateDirectoryPath, CLI_VALIDATE_CONCURRENCY);
98
+ if (errors.length === 0) {
99
+ return results;
100
+ }
101
+ let first = errors[0];
102
+ for (const failure of errors) {
103
+ if (first && failure.index < first.index) {
104
+ first = failure;
105
+ }
98
106
  }
99
- return Promise.all(validations);
107
+ throw first?.error ?? new Error('Failed to validate directories');
100
108
  }
101
109
  function parseAllowedDirArgument(value, previous) {
102
110
  validateCliPath(value);
@@ -85,7 +85,7 @@ export function createInMemoryResourceStore(options = {}) {
85
85
  function getText(uri) {
86
86
  const existing = byUri.get(uri);
87
87
  if (!existing) {
88
- throw new McpError(ErrorCode.E_NOT_FOUND, `Resource not found: ${uri}`);
88
+ throw new McpError(ErrorCode.E_NOT_FOUND, `Resource not found: ${uri}. The cached result may have been evicted. Re-run the originating tool to regenerate.`);
89
89
  }
90
90
  return existing;
91
91
  }
package/dist/server.js CHANGED
@@ -300,35 +300,6 @@ export async function createServer(options = {}) {
300
300
  registerGetHelpPrompt(server, serverInstructions, localIcon);
301
301
  registerResultResources(server, resourceStore, localIcon);
302
302
  registerCompletions(server);
303
- {
304
- const stripStructured = process.env['FS_CONTEXT_STRIP_STRUCTURED'] !== '0';
305
- if (stripStructured) {
306
- const typedServer = server;
307
- const origReg = typedServer.registerTool.bind(server);
308
- typedServer.registerTool = (...regArgs) => {
309
- // Strip outputSchema so SDK won't require structuredContent
310
- if (regArgs.length >= 2 &&
311
- regArgs[1] &&
312
- typeof regArgs[1] === 'object') {
313
- const config = { ...regArgs[1] };
314
- delete config['outputSchema'];
315
- regArgs[1] = config;
316
- }
317
- const handlerIdx = regArgs.length - 1;
318
- const origHandler = regArgs[handlerIdx];
319
- if (typeof origHandler !== 'function')
320
- return origReg(...regArgs);
321
- regArgs[handlerIdx] = async (...hArgs) => {
322
- const r = await origHandler(...hArgs);
323
- if (!r || typeof r !== 'object')
324
- return r;
325
- const record = r;
326
- return Object.fromEntries(Object.entries(record).filter(([key]) => key !== 'structuredContent'));
327
- };
328
- return origReg(...regArgs);
329
- };
330
- }
331
- }
332
303
  registerAllTools(server, {
333
304
  resourceStore,
334
305
  isInitialized: () => rootsManager.isInitialized(),
@@ -73,7 +73,20 @@ export function registerApplyPatchTool(server, options = {}) {
73
73
  guard: options.isInitialized,
74
74
  progressMessage: (args) => {
75
75
  const name = path.basename(args.path);
76
- return `🛠 apply_patch: ${name}`;
76
+ return args.dryRun
77
+ ? `🛠 apply_patch: ${name} [dry run]`
78
+ : `🛠 apply_patch: ${name}`;
79
+ },
80
+ completionMessage: (args, result) => {
81
+ const name = path.basename(args.path);
82
+ if (result.isError)
83
+ return `🛠 apply_patch: ${name} • failed`;
84
+ const sc = result.structuredContent;
85
+ if (!sc.ok)
86
+ return `🛠 apply_patch: ${name} • failed`;
87
+ if (args.dryRun)
88
+ return `🛠 apply_patch: ${name} • dry run OK`;
89
+ return `🛠 apply_patch: ${name} • applied`;
77
90
  },
78
91
  });
79
92
  if (registerToolTaskIfAvailable(server, 'apply_patch', APPLY_PATCH_TOOL, wrappedHandler, options.iconInfo, options.isInitialized))
@@ -153,20 +153,54 @@ export function registerCalculateHashTool(server, options = {}) {
153
153
  timedSignal: {},
154
154
  context: { path: args.path },
155
155
  run: async (signal) => {
156
+ const baseName = path.basename(args.path);
157
+ let progressCursor = 0;
156
158
  notifyProgress(extra, {
157
159
  current: 0,
158
- message: `🕮 calculate_hash: ${path.basename(args.path)}`,
160
+ message: `🕮 calculate_hash: ${baseName}`,
159
161
  });
160
- const result = await handleCalculateHash(args, signal, createProgressReporter(extra));
161
- const sc = result.structuredContent;
162
- const totalFiles = sc.ok ? (sc.fileCount ?? 1) : 1;
163
- const finalCurrent = totalFiles + 1;
164
- const suffix = sc.ok ? `${(sc.hash ?? '').slice(0, 8)}...` : 'failed';
165
- notifyProgress(extra, {
166
- current: finalCurrent,
167
- message: `🕮 calculate_hash: ${path.basename(args.path)} ${suffix}`,
168
- });
169
- return result;
162
+ const baseReporter = createProgressReporter(extra);
163
+ const progressWithMessage = ({ current, total, }) => {
164
+ if (current > progressCursor)
165
+ progressCursor = current;
166
+ const fileWord = current === 1 ? 'file' : 'files';
167
+ baseReporter({
168
+ current,
169
+ ...(total !== undefined ? { total } : {}),
170
+ message: `🕮 calculate_hash: ${baseName} — ${current} ${fileWord} hashed`,
171
+ });
172
+ };
173
+ try {
174
+ const result = await handleCalculateHash(args, signal, progressWithMessage);
175
+ const sc = result.structuredContent;
176
+ const totalFiles = sc.ok ? (sc.fileCount ?? 1) : 1;
177
+ const finalCurrent = Math.max(totalFiles + 1, progressCursor + 1);
178
+ let suffix;
179
+ if (!sc.ok) {
180
+ suffix = 'failed';
181
+ }
182
+ else if (sc.fileCount !== undefined && sc.fileCount > 1) {
183
+ suffix = `${sc.fileCount} files • ${(sc.hash ?? '').slice(0, 8)}...`;
184
+ }
185
+ else {
186
+ suffix = `${(sc.hash ?? '').slice(0, 8)}...`;
187
+ }
188
+ notifyProgress(extra, {
189
+ current: finalCurrent,
190
+ total: finalCurrent,
191
+ message: `🕮 calculate_hash: ${baseName} • ${suffix}`,
192
+ });
193
+ return result;
194
+ }
195
+ catch (error) {
196
+ const finalCurrent = Math.max(progressCursor + 1, 1);
197
+ notifyProgress(extra, {
198
+ current: finalCurrent,
199
+ total: finalCurrent,
200
+ message: `🕮 calculate_hash: ${baseName} • failed`,
201
+ });
202
+ throw error;
203
+ }
170
204
  },
171
205
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path),
172
206
  });
@@ -32,7 +32,16 @@ export function registerCreateDirectoryTool(server, options = {}) {
32
32
  });
33
33
  const wrappedHandler = wrapToolHandler(handler, {
34
34
  guard: options.isInitialized,
35
- progressMessage: (args) => `🛠 mkdir: ${path.basename(args.path)}`,
35
+ progressMessage: (args) => {
36
+ const name = path.basename(args.path) || args.path;
37
+ return `🛠 mkdir: ${name}`;
38
+ },
39
+ completionMessage: (args, result) => {
40
+ const name = path.basename(args.path) || args.path;
41
+ if (result.isError)
42
+ return `🛠 mkdir: ${name} • failed`;
43
+ return `🛠 mkdir: ${name} • created`;
44
+ },
36
45
  });
37
46
  if (registerToolTaskIfAvailable(server, 'mkdir', CREATE_DIRECTORY_TOOL, wrappedHandler, options.iconInfo, options.isInitialized))
38
47
  return;
@@ -87,5 +87,18 @@ export function registerDiffFilesTool(server, options = {}) {
87
87
  const name2 = path.basename(args.modified);
88
88
  return `🕮 diff_files: ${name1} ⟷ ${name2}`;
89
89
  },
90
+ completionMessage: (args, result) => {
91
+ const n1 = path.basename(args.original);
92
+ const n2 = path.basename(args.modified);
93
+ if (result.isError)
94
+ return `🕮 diff_files: ${n1} ⟷ ${n2} • failed`;
95
+ const sc = result.structuredContent;
96
+ if (!sc.ok)
97
+ return `🕮 diff_files: ${n1} ⟷ ${n2} • failed`;
98
+ if (sc.isIdentical)
99
+ return `🕮 diff_files: ${n1} ⟷ ${n2} • identical`;
100
+ const hunks = (sc.diff?.match(/@@/g) ?? []).length;
101
+ return `🕮 diff_files: ${n1} ⟷ ${n2} • ${hunks} hunk${hunks !== 1 ? 's' : ''}`;
102
+ },
90
103
  }));
91
104
  }
@@ -83,19 +83,19 @@ export function registerEditFileTool(server, options = {}) {
83
83
  guard: options.isInitialized,
84
84
  progressMessage: (args) => {
85
85
  const name = path.basename(args.path);
86
- return `🛠 edit: ${name} (${args.edits.length} edits)`;
86
+ return `🛠 edit: ${name} [${args.edits.length} edits]`;
87
87
  },
88
88
  completionMessage: (args, result) => {
89
89
  const name = path.basename(args.path);
90
90
  if (result.isError)
91
- return `🛠 edit: ${name} Failed`;
91
+ return `🛠 edit: ${name} Failed`;
92
92
  const sc = result.structuredContent;
93
93
  if (!sc.ok)
94
- return `🛠 edit: ${name} Failed`;
94
+ return `🛠 edit: ${name} Failed`;
95
95
  if (sc.lineRange) {
96
- return `🛠 edit: ${name} [${sc.lineRange[0]}-${sc.lineRange[1]}]`;
96
+ return `🛠 edit: ${name} [${sc.lineRange[0]}-${sc.lineRange[1]}]`;
97
97
  }
98
- return `🛠 edit: ${name} (${sc.appliedEdits ?? 0} edits)`;
98
+ return `🛠 edit: ${name} [${sc.appliedEdits ?? 0} edits]`;
99
99
  },
100
100
  }));
101
101
  }
@@ -48,7 +48,7 @@ export function registerMoveFileTool(server, options = {}) {
48
48
  });
49
49
  const wrappedHandler = wrapToolHandler(handler, {
50
50
  guard: options.isInitialized,
51
- progressMessage: (args) => `🛠 mv: ${path.basename(args.source)} ${path.basename(args.destination)}`,
51
+ progressMessage: (args) => `🛠 mv: ${path.basename(args.source)} ${path.basename(args.destination)}`,
52
52
  });
53
53
  if (registerToolTaskIfAvailable(server, 'mv', MOVE_FILE_TOOL, wrappedHandler, options.iconInfo, options.isInitialized))
54
54
  return;
@@ -123,7 +123,24 @@ export function registerReadMultipleFilesTool(server, options = {}) {
123
123
  };
124
124
  const wrappedHandler = wrapToolHandler(handler, {
125
125
  guard: options.isInitialized,
126
- progressMessage: (args) => `🕮 read_many: ${args.paths.length} files`,
126
+ progressMessage: (args) => {
127
+ const first = path.basename(args.paths[0] ?? '');
128
+ const extra = args.paths.length > 1 ? `, ${path.basename(args.paths[1] ?? '')}…` : '';
129
+ return `🕮 read_many: ${args.paths.length} files [${first}${extra}]`;
130
+ },
131
+ completionMessage: (_args, result) => {
132
+ if (result.isError)
133
+ return `🕮 read_many • failed`;
134
+ const sc = result.structuredContent;
135
+ if (!sc.ok)
136
+ return `🕮 read_many • failed`;
137
+ const total = sc.summary?.total ?? 0;
138
+ const succeeded = sc.summary?.succeeded ?? 0;
139
+ const failed = sc.summary?.failed ?? 0;
140
+ if (failed)
141
+ return `🕮 read_many: ${succeeded}/${total} read, ${failed} failed`;
142
+ return `🕮 read_many: ${total} files read`;
143
+ },
127
144
  });
128
145
  if (registerToolTaskIfAvailable(server, 'read_many', READ_MULTIPLE_FILES_TOOL, wrappedHandler, options.iconInfo, options.isInitialized))
129
146
  return;
@@ -90,6 +90,19 @@ export function registerReadFileTool(server, options = {}) {
90
90
  }
91
91
  return `🕮 read: ${name}`;
92
92
  },
93
+ completionMessage: (args, result) => {
94
+ const name = path.basename(args.path);
95
+ if (result.isError)
96
+ return `🕮 read: ${name} • failed`;
97
+ const sc = result.structuredContent;
98
+ if (!sc.ok)
99
+ return `🕮 read: ${name} • failed`;
100
+ if (sc.hasMoreLines)
101
+ return `🕮 read: ${name} • truncated [${sc.totalLines ?? '?'} lines]`;
102
+ if (sc.startLine !== undefined)
103
+ return `🕮 read: ${name} • lines ${sc.startLine}–${sc.endLine ?? '?'}`;
104
+ return `🕮 read: ${name} • ${sc.totalLines ?? '?'} lines`;
105
+ },
93
106
  });
94
107
  if (registerToolTaskIfAvailable(server, 'read', READ_FILE_TOOL, wrappedHandler, options.iconInfo, options.isInitialized))
95
108
  return;
@@ -231,18 +231,50 @@ export function registerSearchAndReplaceTool(server, options = {}) {
231
231
  timedSignal: {},
232
232
  ...(args.path ? { context: { path: args.path } } : {}),
233
233
  run: async (signal) => {
234
+ const dryLabel = args.dryRun ? ' [dry run]' : '';
235
+ const context = `"${args.searchPattern}" in ${args.filePattern}${dryLabel}`;
236
+ let progressCursor = 0;
234
237
  notifyProgress(extra, {
235
238
  current: 0,
236
- message: `🛠 search_and_replace: ${args.filePattern}`,
239
+ message: `🛠 search_and_replace: ${context}`,
237
240
  });
238
- const result = await handleSearchAndReplace(args, signal, createProgressReporter(extra));
239
- const sc = result.structuredContent;
240
- const finalCurrent = (sc.processedFiles ?? 0) + 1;
241
- notifyProgress(extra, {
242
- current: finalCurrent,
243
- message: `🛠 search_and_replace: ${args.filePattern} ➟ ${String(sc.filesChanged ?? 0)} files`,
244
- });
245
- return result;
241
+ const baseReporter = createProgressReporter(extra);
242
+ const progressWithMessage = ({ current, total, }) => {
243
+ if (current > progressCursor)
244
+ progressCursor = current;
245
+ baseReporter({
246
+ current,
247
+ ...(total !== undefined ? { total } : {}),
248
+ message: `🛠 search_and_replace: "${args.searchPattern}" — ${current} files processed`,
249
+ });
250
+ };
251
+ try {
252
+ const result = await handleSearchAndReplace(args, signal, progressWithMessage);
253
+ const sc = result.structuredContent;
254
+ const finalCurrent = Math.max((sc.processedFiles ?? 0) + 1, progressCursor + 1);
255
+ const matchWord = (sc.matches ?? 0) === 1 ? 'match' : 'matches';
256
+ const fileWord = (sc.filesChanged ?? 0) === 1 ? 'file' : 'files';
257
+ let endSuffix = `${sc.matches ?? 0} ${matchWord} in ${sc.filesChanged ?? 0} ${fileWord}`;
258
+ if (sc.failedFiles)
259
+ endSuffix += `, ${sc.failedFiles} failed`;
260
+ if (sc.dryRun)
261
+ endSuffix += ' [dry run]';
262
+ notifyProgress(extra, {
263
+ current: finalCurrent,
264
+ total: finalCurrent,
265
+ message: `🛠 search_and_replace: ${context} • ${endSuffix}`,
266
+ });
267
+ return result;
268
+ }
269
+ catch (error) {
270
+ const finalCurrent = Math.max(progressCursor + 1, 1);
271
+ notifyProgress(extra, {
272
+ current: finalCurrent,
273
+ total: finalCurrent,
274
+ message: `🛠 search_and_replace: ${context} • failed`,
275
+ });
276
+ throw error;
277
+ }
246
278
  },
247
279
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path),
248
280
  });
@@ -202,29 +202,67 @@ export function registerSearchContentTool(server, options = {}) {
202
202
  context: { path: args.path ?? '.' },
203
203
  run: async (signal) => {
204
204
  const normalizedArgs = SearchContentInputSchema.parse(args);
205
+ const scope = normalizedArgs.filePattern;
206
+ const { pattern } = normalizedArgs;
207
+ let progressCursor = 0;
205
208
  notifyProgress(extra, {
206
209
  current: 0,
207
- message: `🔎︎ grep: ${normalizedArgs.pattern}`,
210
+ message: `🔎︎ grep: ${pattern} in ${scope}`,
208
211
  });
209
- const result = await handleSearchContent(normalizedArgs, signal, options.resourceStore, createProgressReporter(extra));
210
- const sc = result.structuredContent;
211
- const count = sc.ok && sc.totalMatches ? sc.totalMatches : 0;
212
- let suffix;
213
- if (count === 0) {
214
- suffix = 'No matches';
212
+ const baseReporter = createProgressReporter(extra);
213
+ const progressWithMessage = ({ current, total, }) => {
214
+ if (current > progressCursor)
215
+ progressCursor = current;
216
+ const fileWord = current === 1 ? 'file' : 'files';
217
+ baseReporter({
218
+ current,
219
+ ...(total !== undefined ? { total } : {}),
220
+ message: `🔎︎ grep: ${pattern} • ${current} ${fileWord} scanned`,
221
+ });
222
+ };
223
+ try {
224
+ const result = await handleSearchContent(normalizedArgs, signal, options.resourceStore, progressWithMessage);
225
+ const sc = result.structuredContent;
226
+ const count = sc.ok && sc.totalMatches ? sc.totalMatches : 0;
227
+ const filesMatched = sc.ok ? (sc.filesMatched ?? 0) : 0;
228
+ const stoppedReason = sc.ok ? sc.stoppedReason : undefined;
229
+ let suffix;
230
+ if (count === 0) {
231
+ suffix = `No matches in ${scope}`;
232
+ }
233
+ else {
234
+ const matchWord = count === 1 ? 'match' : 'matches';
235
+ const fileInfo = filesMatched > 0
236
+ ? ` in ${filesMatched} ${filesMatched === 1 ? 'file' : 'files'}`
237
+ : '';
238
+ suffix = `${count} ${matchWord}${fileInfo}`;
239
+ if (stoppedReason === 'timeout') {
240
+ suffix += ' [stopped — timeout]';
241
+ }
242
+ else if (stoppedReason === 'maxResults') {
243
+ suffix += ' [truncated — max results]';
244
+ }
245
+ else if (stoppedReason === 'maxFiles') {
246
+ suffix += ' [truncated — max files]';
247
+ }
248
+ }
249
+ const finalCurrent = Math.max((sc.filesScanned ?? 0) + 1, progressCursor + 1);
250
+ notifyProgress(extra, {
251
+ current: finalCurrent,
252
+ total: finalCurrent,
253
+ message: `🔎︎ grep: ${pattern} • ${suffix}`,
254
+ });
255
+ return result;
215
256
  }
216
- else if (count === 1) {
217
- suffix = '1 match';
257
+ catch (error) {
258
+ const finalCurrent = Math.max(progressCursor + 1, 1);
259
+ notifyProgress(extra, {
260
+ current: finalCurrent,
261
+ total: finalCurrent,
262
+ message: `🔎︎ grep: ${pattern} in ${scope} • failed`,
263
+ });
264
+ throw error;
218
265
  }
219
- else {
220
- suffix = `${count} matches`;
221
- }
222
- const finalCurrent = (sc.filesScanned ?? 0) + 1;
223
- notifyProgress(extra, {
224
- current: finalCurrent,
225
- message: `🔎︎ grep: ${normalizedArgs.pattern} ➟ ${suffix}`,
226
- });
227
- return result;
228
266
  },
229
267
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path ?? '.'),
230
268
  });
@@ -89,19 +89,62 @@ export function registerSearchFilesTool(server, options = {}) {
89
89
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
90
90
  context: { path: args.path ?? '.' },
91
91
  run: async (signal) => {
92
+ const scope = args.path ?? '.';
93
+ const { pattern } = args;
94
+ let progressCursor = 0;
92
95
  notifyProgress(extra, {
93
96
  current: 0,
94
- message: `🔎︎ find: ${args.pattern}`,
97
+ message: `🔎︎ find: ${pattern} in ${scope}`,
95
98
  });
96
- const result = await handleSearchFiles(args, signal, createProgressReporter(extra));
97
- const sc = result.structuredContent;
98
- const suffix = sc.ok && sc.totalMatches ? String(sc.totalMatches) : 'No matches';
99
- const finalCurrent = (sc.filesScanned ?? 0) + 1;
100
- notifyProgress(extra, {
101
- current: finalCurrent,
102
- message: `🔎︎ find: ${args.pattern} ➟ ${suffix}`,
103
- });
104
- return result;
99
+ const baseReporter = createProgressReporter(extra);
100
+ const progressWithMessage = ({ current, total, }) => {
101
+ if (current > progressCursor)
102
+ progressCursor = current;
103
+ const fileWord = current === 1 ? 'file' : 'files';
104
+ baseReporter({
105
+ current,
106
+ ...(total !== undefined ? { total } : {}),
107
+ message: `🔎︎ find: ${pattern} — ${current} ${fileWord} scanned`,
108
+ });
109
+ };
110
+ try {
111
+ const result = await handleSearchFiles(args, signal, progressWithMessage);
112
+ const sc = result.structuredContent;
113
+ const count = sc.ok ? (sc.totalMatches ?? 0) : 0;
114
+ const stoppedReason = sc.ok ? sc.stoppedReason : undefined;
115
+ let suffix;
116
+ if (count === 0) {
117
+ suffix = `No matches in ${scope}`;
118
+ }
119
+ else {
120
+ suffix = `${count} ${count === 1 ? 'match' : 'matches'}`;
121
+ if (stoppedReason === 'timeout') {
122
+ suffix += ' [stopped — timeout]';
123
+ }
124
+ else if (stoppedReason === 'maxResults') {
125
+ suffix += ' [truncated — max results]';
126
+ }
127
+ else if (stoppedReason === 'maxFiles') {
128
+ suffix += ' [truncated — max files]';
129
+ }
130
+ }
131
+ const finalCurrent = Math.max((sc.filesScanned ?? 0) + 1, progressCursor + 1);
132
+ notifyProgress(extra, {
133
+ current: finalCurrent,
134
+ total: finalCurrent,
135
+ message: `🔎︎ find: ${pattern} • ${suffix}`,
136
+ });
137
+ return result;
138
+ }
139
+ catch (error) {
140
+ const finalCurrent = Math.max(progressCursor + 1, 1);
141
+ notifyProgress(extra, {
142
+ current: finalCurrent,
143
+ total: finalCurrent,
144
+ message: `🔎︎ find: ${pattern} in ${scope} • failed`,
145
+ });
146
+ throw error;
147
+ }
105
148
  },
106
149
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_INVALID_PATTERN, args.path),
107
150
  });
@@ -19,6 +19,8 @@ export declare const IDEMPOTENT_WRITE_TOOL_ANNOTATIONS: {
19
19
  readonly idempotentHint: true;
20
20
  readonly openWorldHint: false;
21
21
  };
22
+ export declare function shouldStripStructuredOutput(): boolean;
23
+ export declare function maybeStripStructuredContentFromResult<T extends object>(result: T): T;
22
24
  type ResourceEntry = ReturnType<ResourceStore['putText']>;
23
25
  export declare function maybeExternalizeTextContent(resourceStore: ResourceStore | undefined, content: string, params: {
24
26
  name: string;
@@ -5,6 +5,7 @@ import { getAllowedDirectories } from '../lib/path-validation.js';
5
5
  const MAX_INLINE_CONTENT_CHARS = 20_000;
6
6
  const MAX_INLINE_PREVIEW_CHARS = 4_000;
7
7
  const PROGRESS_RATE_LIMIT_MS = 50;
8
+ const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes']);
8
9
  export const READ_ONLY_TOOL_ANNOTATIONS = {
9
10
  readOnlyHint: true,
10
11
  idempotentHint: true,
@@ -20,6 +21,30 @@ export const IDEMPOTENT_WRITE_TOOL_ANNOTATIONS = {
20
21
  idempotentHint: true,
21
22
  openWorldHint: false,
22
23
  };
24
+ export function shouldStripStructuredOutput() {
25
+ const value = process.env['FS_CONTEXT_STRIP_STRUCTURED'];
26
+ if (value === undefined)
27
+ return false;
28
+ return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
29
+ }
30
+ export function maybeStripStructuredContentFromResult(result) {
31
+ if (!shouldStripStructuredOutput())
32
+ return result;
33
+ if (!Object.hasOwn(result, 'structuredContent'))
34
+ return result;
35
+ const rest = { ...result };
36
+ delete rest['structuredContent'];
37
+ return rest;
38
+ }
39
+ function maybeStripOutputSchema(tool) {
40
+ if (!shouldStripStructuredOutput())
41
+ return tool;
42
+ if (!Object.hasOwn(tool, 'outputSchema'))
43
+ return tool;
44
+ const mutable = { ...tool };
45
+ delete mutable['outputSchema'];
46
+ return mutable;
47
+ }
23
48
  function buildTextPreview(text) {
24
49
  if (text.length <= MAX_INLINE_PREVIEW_CHARS)
25
50
  return text;
@@ -71,13 +96,14 @@ function canSendProgress(extra) {
71
96
  extra.sendNotification !== undefined);
72
97
  }
73
98
  export function withDefaultIcons(tool, iconInfo) {
74
- if (!iconInfo)
75
- return tool;
99
+ if (!iconInfo) {
100
+ return maybeStripOutputSchema(tool);
101
+ }
76
102
  const existingIcons = tool.icons;
77
103
  if (existingIcons && existingIcons.length > 0) {
78
- return tool;
104
+ return maybeStripOutputSchema(tool);
79
105
  }
80
- return {
106
+ const withIcons = {
81
107
  ...tool,
82
108
  icons: [
83
109
  {
@@ -86,6 +112,7 @@ export function withDefaultIcons(tool, iconInfo) {
86
112
  },
87
113
  ],
88
114
  };
115
+ return maybeStripOutputSchema(withIcons);
89
116
  }
90
117
  export function buildFileInfoPayload(info) {
91
118
  return {
@@ -247,7 +274,7 @@ export function wrapToolHandler(handler, options) {
247
274
  return async (args, extra) => {
248
275
  const resolvedExtra = extra ?? {};
249
276
  if (options.guard && !options.guard()) {
250
- return buildNotInitializedResult();
277
+ return maybeStripStructuredContentFromResult(buildNotInitializedResult());
251
278
  }
252
279
  if (options.progressMessage) {
253
280
  const message = options.progressMessage(args);
@@ -255,9 +282,11 @@ export function wrapToolHandler(handler, options) {
255
282
  const completionFn = completionMessage
256
283
  ? (result) => completionMessage(args, result)
257
284
  : undefined;
258
- return withProgress(message, resolvedExtra, () => handler(args, resolvedExtra), completionFn);
285
+ const result = await withProgress(message, resolvedExtra, () => handler(args, resolvedExtra), completionFn);
286
+ return maybeStripStructuredContentFromResult(result);
259
287
  }
260
- return handler(args, resolvedExtra);
288
+ const result = await handler(args, resolvedExtra);
289
+ return maybeStripStructuredContentFromResult(result);
261
290
  };
262
291
  }
263
292
  export function resolvePathOrRoot(pathValue) {
@@ -1,3 +1,4 @@
1
+ import * as path from 'node:path';
1
2
  import { formatBytes, joinLines } from '../config.js';
2
3
  import { DEFAULT_SEARCH_TIMEOUT_MS } from '../lib/constants.js';
3
4
  import { ErrorCode } from '../lib/errors.js';
@@ -74,7 +75,24 @@ export function registerGetMultipleFileInfoTool(server, options = {}) {
74
75
  };
75
76
  const wrappedHandler = wrapToolHandler(handler, {
76
77
  guard: options.isInitialized,
77
- progressMessage: (args) => `🕮 stat_many: ${args.paths.length} paths`,
78
+ progressMessage: (args) => {
79
+ const first = path.basename(args.paths[0] ?? '');
80
+ const extra = args.paths.length > 1 ? `, ${path.basename(args.paths[1] ?? '')}…` : '';
81
+ return `🕮 stat_many: ${args.paths.length} paths [${first}${extra}]`;
82
+ },
83
+ completionMessage: (_args, result) => {
84
+ if (result.isError)
85
+ return `🕮 stat_many • failed`;
86
+ const sc = result.structuredContent;
87
+ if (!sc.ok)
88
+ return `🕮 stat_many • failed`;
89
+ const total = sc.summary?.total ?? 0;
90
+ const succeeded = sc.summary?.succeeded ?? 0;
91
+ const failed = sc.summary?.failed ?? 0;
92
+ if (failed)
93
+ return `🕮 stat_many: ${succeeded}/${total} OK, ${failed} failed`;
94
+ return `🕮 stat_many: ${total} OK`;
95
+ },
78
96
  });
79
97
  if (registerToolTaskIfAvailable(server, 'stat_many', GET_MULTIPLE_FILE_INFO_TOOL, wrappedHandler, options.iconInfo, options.isInitialized))
80
98
  return;
@@ -48,5 +48,14 @@ export function registerGetFileInfoTool(server, options = {}) {
48
48
  server.registerTool('stat', withDefaultIcons({ ...GET_FILE_INFO_TOOL }, options.iconInfo), wrapToolHandler(handler, {
49
49
  guard: options.isInitialized,
50
50
  progressMessage: (args) => `🕮 stat: ${path.basename(args.path)}`,
51
+ completionMessage: (args, result) => {
52
+ const name = path.basename(args.path);
53
+ if (result.isError)
54
+ return `🕮 stat: ${name} • failed`;
55
+ const sc = result.structuredContent;
56
+ if (!sc.ok || !sc.info)
57
+ return `🕮 stat: ${name} • failed`;
58
+ return `🕮 stat: ${sc.info.name} [${sc.info.type}, ${formatBytes(sc.info.size)}]`;
59
+ },
51
60
  }));
52
61
  }
@@ -1,7 +1,7 @@
1
1
  import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { ErrorCode, McpError } from '../lib/errors.js';
3
3
  import { isRecord } from '../lib/type-guards.js';
4
- import { buildToolErrorResponse, withDefaultIcons } from './shared.js';
4
+ import { buildToolErrorResponse, maybeStripStructuredContentFromResult, withDefaultIcons, } from './shared.js';
5
5
  function isExperimentalTaskRegistration(value) {
6
6
  if (!value || typeof value !== 'object')
7
7
  return false;
@@ -191,13 +191,13 @@ async function tryStoreTaskResult(taskStore, taskId, status, result) {
191
191
  }
192
192
  async function runTaskInBackground(run, args, extra, taskStore, taskId) {
193
193
  try {
194
- const result = await run(args, extra);
194
+ const result = maybeStripStructuredContentFromResult(await run(args, extra));
195
195
  const status = isErrorResult(result) ? 'failed' : 'completed';
196
196
  await tryStoreTaskResult(taskStore, taskId, status, result);
197
197
  await notifyTaskStatusIfPossible(extra, taskStore, taskId);
198
198
  }
199
199
  catch (error) {
200
- const fallback = buildToolErrorResponse(error, ErrorCode.E_UNKNOWN);
200
+ const fallback = maybeStripStructuredContentFromResult(buildToolErrorResponse(error, ErrorCode.E_UNKNOWN));
201
201
  try {
202
202
  await tryStoreTaskResult(taskStore, taskId, 'failed', fallback);
203
203
  await notifyTaskStatusIfPossible(extra, taskStore, taskId);
@@ -36,7 +36,16 @@ export function registerWriteFileTool(server, options = {}) {
36
36
  });
37
37
  const wrappedHandler = wrapToolHandler(handler, {
38
38
  guard: options.isInitialized,
39
- progressMessage: (args) => `🛠 write: ${path.basename(args.path)}`,
39
+ progressMessage: (args) => `🛠 write: ${path.basename(args.path)} [${args.content.length} chars]`,
40
+ completionMessage: (args, result) => {
41
+ const name = path.basename(args.path);
42
+ if (result.isError)
43
+ return `🛠 write: ${name} • failed`;
44
+ const sc = result.structuredContent;
45
+ if (!sc.ok)
46
+ return `🛠 write: ${name} • failed`;
47
+ return `🛠 write: ${name} • ${sc.bytesWritten ?? 0} bytes`;
48
+ },
40
49
  });
41
50
  if (registerToolTaskIfAvailable(server, 'write', WRITE_FILE_TOOL, wrappedHandler, options.iconInfo, options.isInitialized))
42
51
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/filesystem-mcp",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
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",
@@ -31,12 +31,14 @@
31
31
  "start": "node dist/index.js",
32
32
  "format": "prettier --write .",
33
33
  "type-check": "node scripts/tasks.mjs type-check",
34
+ "type-check:src": "node node_modules/typescript/bin/tsc -p tsconfig.json --noEmit",
35
+ "type-check:tests": "node node_modules/typescript/bin/tsc -p tsconfig.test.json --noEmit",
34
36
  "type-check:diagnostics": "tsc --noEmit --extendedDiagnostics",
35
37
  "type-check:trace": "node -e \"require('fs').rmSync('.ts-trace',{recursive:true,force:true})\" && tsc --noEmit --generateTrace .ts-trace",
36
38
  "lint": "eslint .",
37
39
  "lint:fix": "eslint . --fix",
38
40
  "test": "node scripts/tasks.mjs test",
39
- "test:fast": "node --test --import tsx/esm src/__tests__/**/*.test.ts",
41
+ "test:fast": "node --test --import tsx/esm src/__tests__/**/*.test.ts node-tests/**/*.test.ts",
40
42
  "test:coverage": "node scripts/tasks.mjs test --coverage",
41
43
  "knip": "knip",
42
44
  "knip:fix": "knip --fix",