@j0hanz/filesystem-mcp 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +11 -0
  2. package/dist/cli.js +14 -8
  3. package/dist/completions.js +15 -6
  4. package/dist/lib/file-operations/glob-engine.js +3 -9
  5. package/dist/lib/file-operations/read-multiple-files.js +4 -23
  6. package/dist/lib/file-operations/search-content.js +11 -18
  7. package/dist/lib/observability.js +4 -4
  8. package/dist/lib/path-validation.js +22 -9
  9. package/dist/lib/resource-store.js +1 -1
  10. package/dist/pkg-info.d.ts +6 -0
  11. package/dist/pkg-info.js +9 -0
  12. package/dist/schemas.d.ts +28 -28
  13. package/dist/schemas.js +26 -26
  14. package/dist/server/bootstrap.d.ts +4 -0
  15. package/dist/server/bootstrap.js +117 -0
  16. package/dist/server/capabilities.d.ts +10 -0
  17. package/dist/server/capabilities.js +40 -0
  18. package/dist/server/logging.d.ts +7 -0
  19. package/dist/server/logging.js +41 -0
  20. package/dist/server/roots-manager.d.ts +19 -0
  21. package/dist/server/roots-manager.js +173 -0
  22. package/dist/server/types.d.ts +4 -0
  23. package/dist/server/types.js +1 -0
  24. package/dist/server.d.ts +2 -8
  25. package/dist/server.js +1 -346
  26. package/dist/tools/apply-patch.js +17 -3
  27. package/dist/tools/calculate-hash.js +48 -13
  28. package/dist/tools/create-directory.js +13 -3
  29. package/dist/tools/delete-file.js +9 -2
  30. package/dist/tools/diff-files.js +16 -2
  31. package/dist/tools/edit-file.js +8 -7
  32. package/dist/tools/list-directory.js +13 -2
  33. package/dist/tools/move-file.js +11 -3
  34. package/dist/tools/read-multiple.js +21 -3
  35. package/dist/tools/read.js +16 -2
  36. package/dist/tools/replace-in-files.js +44 -11
  37. package/dist/tools/roots.js +12 -2
  38. package/dist/tools/search-content.js +62 -29
  39. package/dist/tools/search-files.js +60 -13
  40. package/dist/tools/shared.d.ts +5 -1
  41. package/dist/tools/shared.js +55 -8
  42. package/dist/tools/stat-many.js +22 -3
  43. package/dist/tools/stat.js +12 -2
  44. package/dist/tools/task-support.js +60 -13
  45. package/dist/tools/tree.js +15 -2
  46. package/dist/tools/write-file.js +13 -3
  47. package/package.json +4 -2
@@ -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, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, wrapToolHandler, } from './shared.js';
6
+ import { buildResourceLink, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
7
  import { registerToolTaskIfAvailable } from './task-support.js';
8
8
  const READ_MULTIPLE_FILES_TOOL = {
9
9
  title: 'Read Multiple Files',
@@ -121,9 +121,27 @@ export function registerReadMultipleFilesTool(server, options = {}) {
121
121
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FILE, primaryPath),
122
122
  });
123
123
  };
124
- const wrappedHandler = wrapToolHandler(handler, {
124
+ const validatedHandler = withValidatedArgs(ReadMultipleFilesInputSchema, handler);
125
+ const wrappedHandler = wrapToolHandler(validatedHandler, {
125
126
  guard: options.isInitialized,
126
- progressMessage: (args) => `🕮 read_many: ${args.paths.length} files`,
127
+ progressMessage: (args) => {
128
+ const first = path.basename(args.paths[0] ?? '');
129
+ const extra = args.paths.length > 1 ? `, ${path.basename(args.paths[1] ?? '')}…` : '';
130
+ return `🕮 read_many: ${args.paths.length} files [${first}${extra}]`;
131
+ },
132
+ completionMessage: (args, result) => {
133
+ if (result.isError)
134
+ return `🕮 read_many: ${args.paths.length} files • failed`;
135
+ const sc = result.structuredContent;
136
+ if (!sc.ok)
137
+ return `🕮 read_many: ${args.paths.length} files • failed`;
138
+ const total = sc.summary?.total ?? 0;
139
+ const succeeded = sc.summary?.succeeded ?? 0;
140
+ const failed = sc.summary?.failed ?? 0;
141
+ if (failed)
142
+ return `🕮 read_many: ${succeeded}/${total} read, ${failed} failed`;
143
+ return `🕮 read_many: ${total} files read`;
144
+ },
127
145
  });
128
146
  if (registerToolTaskIfAvailable(server, 'read_many', READ_MULTIPLE_FILES_TOOL, wrappedHandler, options.iconInfo, options.isInitialized))
129
147
  return;
@@ -3,7 +3,7 @@ import { DEFAULT_SEARCH_TIMEOUT_MS, MAX_TEXT_FILE_SIZE, } from '../lib/constants
3
3
  import { ErrorCode } from '../lib/errors.js';
4
4
  import { readFile } from '../lib/fs-helpers.js';
5
5
  import { ReadFileInputSchema, ReadFileOutputSchema } from '../schemas.js';
6
- import { buildResourceLink, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, wrapToolHandler, } from './shared.js';
6
+ import { buildResourceLink, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
7
  import { registerToolTaskIfAvailable } from './task-support.js';
8
8
  const READ_FILE_TOOL = {
9
9
  title: 'Read File',
@@ -80,7 +80,8 @@ export function registerReadFileTool(server, options = {}) {
80
80
  run: (signal) => handleReadFile(args, signal, options.resourceStore),
81
81
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FILE, args.path),
82
82
  });
83
- const wrappedHandler = wrapToolHandler(handler, {
83
+ const validatedHandler = withValidatedArgs(ReadFileInputSchema, handler);
84
+ const wrappedHandler = wrapToolHandler(validatedHandler, {
84
85
  guard: options.isInitialized,
85
86
  progressMessage: (args) => {
86
87
  const name = path.basename(args.path);
@@ -90,6 +91,19 @@ export function registerReadFileTool(server, options = {}) {
90
91
  }
91
92
  return `🕮 read: ${name}`;
92
93
  },
94
+ completionMessage: (args, result) => {
95
+ const name = path.basename(args.path);
96
+ if (result.isError)
97
+ return `🕮 read: ${name} • failed`;
98
+ const sc = result.structuredContent;
99
+ if (!sc.ok)
100
+ return `🕮 read: ${name} • failed`;
101
+ if (sc.hasMoreLines)
102
+ return `🕮 read: ${name} • truncated [${sc.totalLines ?? '?'} lines]`;
103
+ if (sc.startLine !== undefined)
104
+ return `🕮 read: ${name} • lines ${sc.startLine}–${sc.endLine ?? '?'}`;
105
+ return `🕮 read: ${name} • ${sc.totalLines ?? '?'} lines`;
106
+ },
93
107
  });
94
108
  if (registerToolTaskIfAvailable(server, 'read', READ_FILE_TOOL, wrappedHandler, options.iconInfo, options.isInitialized))
95
109
  return;
@@ -8,7 +8,7 @@ import { globEntries } from '../lib/file-operations/glob-engine.js';
8
8
  import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
9
9
  import { validateExistingPath, validatePathForWrite, } from '../lib/path-validation.js';
10
10
  import { SearchAndReplaceInputSchema, SearchAndReplaceOutputSchema, } from '../schemas.js';
11
- import { buildToolErrorResponse, buildToolResponse, createProgressReporter, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, notifyProgress, resolvePathOrRoot, withDefaultIcons, wrapToolHandler, } from './shared.js';
11
+ import { buildToolErrorResponse, buildToolResponse, createProgressReporter, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, notifyProgress, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
12
12
  import { registerToolTaskIfAvailable } from './task-support.js';
13
13
  const SEARCH_AND_REPLACE_TOOL = {
14
14
  title: 'Search and Replace',
@@ -231,23 +231,56 @@ 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
  });
249
281
  const { isInitialized } = options;
250
- const wrappedHandler = wrapToolHandler(handler, {
282
+ const validatedHandler = withValidatedArgs(SearchAndReplaceInputSchema, handler);
283
+ const wrappedHandler = wrapToolHandler(validatedHandler, {
251
284
  guard: isInitialized,
252
285
  });
253
286
  if (registerToolTaskIfAvailable(server, 'search_and_replace', SEARCH_AND_REPLACE_TOOL, wrappedHandler, options.iconInfo, isInitialized))
@@ -2,7 +2,7 @@ import { joinLines } from '../config.js';
2
2
  import { ErrorCode } from '../lib/errors.js';
3
3
  import { getAllowedDirectories } from '../lib/path-validation.js';
4
4
  import { ListAllowedDirectoriesInputSchema, ListAllowedDirectoriesOutputSchema, } from '../schemas.js';
5
- import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, wrapToolHandler, } from './shared.js';
5
+ import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
6
6
  const LIST_ALLOWED_DIRECTORIES_TOOL = {
7
7
  title: 'Workspace Roots',
8
8
  description: 'List the workspace roots this server can access. ' +
@@ -38,8 +38,18 @@ export function registerListAllowedDirectoriesTool(server, options = {}) {
38
38
  run: () => handleListAllowedDirectories(),
39
39
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN),
40
40
  });
41
- server.registerTool('roots', withDefaultIcons({ ...LIST_ALLOWED_DIRECTORIES_TOOL }, options.iconInfo), wrapToolHandler(handler, {
41
+ const validatedHandler = withValidatedArgs(ListAllowedDirectoriesInputSchema, handler);
42
+ server.registerTool('roots', withDefaultIcons({ ...LIST_ALLOWED_DIRECTORIES_TOOL }, options.iconInfo), wrapToolHandler(validatedHandler, {
42
43
  guard: options.isInitialized,
43
44
  progressMessage: () => '≣ roots',
45
+ completionMessage: (_args, result) => {
46
+ if (result.isError)
47
+ return `≣ roots • failed`;
48
+ const sc = result.structuredContent;
49
+ if (!sc.ok)
50
+ return `≣ roots • failed`;
51
+ const count = sc.rootsCount ?? 0;
52
+ return `≣ roots • ${count} ${count === 1 ? 'root' : 'roots'}`;
53
+ },
44
54
  }));
45
55
  }
@@ -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, wrapToolHandler, } from './shared.js';
8
+ import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, notifyProgress, 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 = 50;
11
11
  const SEARCH_CONTENT_TOOL = {
@@ -69,16 +69,14 @@ function formatSearchMatchLine(match) {
69
69
  }
70
70
  function buildStructuredSearchResult(result, normalizedMatches, options) {
71
71
  const { summary } = result;
72
- const matches = [];
73
- for (const match of normalizedMatches) {
74
- matches.push(buildSearchMatchPayload(match));
75
- }
72
+ const matches = normalizedMatches.map((match) => buildSearchMatchPayload(match));
76
73
  return {
77
74
  ok: true,
78
75
  patternType: options.patternType,
79
76
  caseSensitive: options.caseSensitive,
80
77
  matches,
81
78
  totalMatches: summary.matches,
79
+ filesScanned: summary.filesScanned,
82
80
  ...(summary.truncated ? { truncated: summary.truncated } : {}),
83
81
  ...(summary.filesMatched ? { filesMatched: summary.filesMatched } : {}),
84
82
  ...(summary.skippedTooLarge
@@ -163,10 +161,7 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
163
161
  return buildToolResponse(buildSearchTextResult(result, normalizedMatches), structuredFull);
164
162
  }
165
163
  const previewMatches = normalizedMatches.slice(0, MAX_INLINE_MATCHES);
166
- const previewPayload = [];
167
- for (const match of previewMatches) {
168
- previewPayload.push(buildSearchMatchPayload(match));
169
- }
164
+ const previewPayload = previewMatches.map((match) => buildSearchMatchPayload(match));
170
165
  const previewStructured = {
171
166
  ...structuredFull,
172
167
  matches: previewPayload,
@@ -201,35 +196,73 @@ export function registerSearchContentTool(server, options = {}) {
201
196
  extra,
202
197
  context: { path: args.path ?? '.' },
203
198
  run: async (signal) => {
204
- const normalizedArgs = SearchContentInputSchema.parse(args);
199
+ const scope = args.filePattern;
200
+ const { pattern } = args;
201
+ let progressCursor = 0;
205
202
  notifyProgress(extra, {
206
203
  current: 0,
207
- message: `🔎︎ grep: ${normalizedArgs.pattern}`,
204
+ message: `🔎︎ grep: ${pattern} in ${scope}`,
208
205
  });
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';
206
+ const baseReporter = createProgressReporter(extra);
207
+ const progressWithMessage = ({ current, total, }) => {
208
+ if (current > progressCursor)
209
+ progressCursor = current;
210
+ const fileWord = current === 1 ? 'file' : 'files';
211
+ baseReporter({
212
+ current,
213
+ ...(total !== undefined ? { total } : {}),
214
+ message: `🔎︎ grep: ${pattern} — ${current} ${fileWord} scanned`,
215
+ });
216
+ };
217
+ try {
218
+ const result = await handleSearchContent(args, signal, options.resourceStore, progressWithMessage);
219
+ const sc = result.structuredContent;
220
+ const count = sc.ok && sc.totalMatches ? sc.totalMatches : 0;
221
+ const filesMatched = sc.ok ? (sc.filesMatched ?? 0) : 0;
222
+ const stoppedReason = sc.ok ? sc.stoppedReason : undefined;
223
+ let suffix;
224
+ if (count === 0) {
225
+ suffix = `No matches in ${scope}`;
226
+ }
227
+ else {
228
+ const matchWord = count === 1 ? 'match' : 'matches';
229
+ const fileInfo = filesMatched > 0
230
+ ? ` in ${filesMatched} ${filesMatched === 1 ? 'file' : 'files'}`
231
+ : '';
232
+ suffix = `${count} ${matchWord}${fileInfo}`;
233
+ if (stoppedReason === 'timeout') {
234
+ suffix += ' [stopped — timeout]';
235
+ }
236
+ else if (stoppedReason === 'maxResults') {
237
+ suffix += ' [truncated — max results]';
238
+ }
239
+ else if (stoppedReason === 'maxFiles') {
240
+ suffix += ' [truncated — max files]';
241
+ }
242
+ }
243
+ const finalCurrent = Math.max((sc.filesScanned ?? 0) + 1, progressCursor + 1);
244
+ notifyProgress(extra, {
245
+ current: finalCurrent,
246
+ total: finalCurrent,
247
+ message: `🔎︎ grep: ${pattern} • ${suffix}`,
248
+ });
249
+ return result;
215
250
  }
216
- else if (count === 1) {
217
- suffix = '1 match';
251
+ catch (error) {
252
+ const finalCurrent = Math.max(progressCursor + 1, 1);
253
+ notifyProgress(extra, {
254
+ current: finalCurrent,
255
+ total: finalCurrent,
256
+ message: `🔎︎ grep: ${pattern} in ${scope} • failed`,
257
+ });
258
+ throw error;
218
259
  }
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
260
  },
229
261
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path ?? '.'),
230
262
  });
231
263
  const { isInitialized } = options;
232
- const wrappedHandler = wrapToolHandler(handler, {
264
+ const validatedHandler = withValidatedArgs(SearchContentInputSchema, handler);
265
+ const wrappedHandler = wrapToolHandler(validatedHandler, {
233
266
  guard: isInitialized,
234
267
  });
235
268
  if (registerToolTaskIfAvailable(server, 'grep', SEARCH_CONTENT_TOOL, wrappedHandler, options.iconInfo, isInitialized))
@@ -4,7 +4,7 @@ import { DEFAULT_EXCLUDE_PATTERNS, DEFAULT_SEARCH_TIMEOUT_MS, } from '../lib/con
4
4
  import { ErrorCode } from '../lib/errors.js';
5
5
  import { searchFiles } from '../lib/file-operations/search-files.js';
6
6
  import { SearchFilesInputSchema, SearchFilesOutputSchema } from '../schemas.js';
7
- import { buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, wrapToolHandler, } from './shared.js';
7
+ import { buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
8
  import { registerToolTaskIfAvailable } from './task-support.js';
9
9
  const SEARCH_FILES_TOOL = {
10
10
  title: 'Find Files',
@@ -43,6 +43,7 @@ async function handleSearchFiles(args, signal, onProgress) {
43
43
  pattern: args.pattern,
44
44
  results: relativeResults,
45
45
  totalMatches: result.summary.matched,
46
+ filesScanned: result.summary.filesScanned,
46
47
  ...(result.summary.truncated
47
48
  ? { truncated: result.summary.truncated }
48
49
  : {}),
@@ -89,24 +90,70 @@ export function registerSearchFilesTool(server, options = {}) {
89
90
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
90
91
  context: { path: args.path ?? '.' },
91
92
  run: async (signal) => {
93
+ const rawScopeLabel = args.path ? path.basename(args.path) : '.';
94
+ const scopeLabel = rawScopeLabel || '.';
95
+ const { pattern } = args;
96
+ const context = `${pattern} in ${scopeLabel}`;
97
+ let progressCursor = 0;
92
98
  notifyProgress(extra, {
93
99
  current: 0,
94
- message: `🔎︎ find: ${args.pattern}`,
100
+ message: `🔎︎ find: ${context}`,
95
101
  });
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;
102
+ const baseReporter = createProgressReporter(extra);
103
+ const progressWithMessage = ({ current, total, }) => {
104
+ if (current > progressCursor)
105
+ progressCursor = current;
106
+ const fileWord = current === 1 ? 'file' : 'files';
107
+ baseReporter({
108
+ current,
109
+ ...(total !== undefined ? { total } : {}),
110
+ message: `🔎︎ find: ${pattern} [${current} ${fileWord} scanned]`,
111
+ });
112
+ };
113
+ try {
114
+ const result = await handleSearchFiles(args, signal, progressWithMessage);
115
+ const sc = result.structuredContent;
116
+ const count = sc.ok ? (sc.totalMatches ?? 0) : 0;
117
+ const stoppedReason = sc.ok ? sc.stoppedReason : undefined;
118
+ let suffix;
119
+ if (count === 0) {
120
+ suffix = `No matches in ${scopeLabel}`;
121
+ }
122
+ else {
123
+ suffix = `${count} ${count === 1 ? 'match' : 'matches'}`;
124
+ if (stoppedReason === 'timeout') {
125
+ suffix += ' [stopped — timeout]';
126
+ }
127
+ else if (stoppedReason === 'maxResults') {
128
+ suffix += ' [truncated — max results]';
129
+ }
130
+ else if (stoppedReason === 'maxFiles') {
131
+ suffix += ' [truncated — max files]';
132
+ }
133
+ }
134
+ const finalCurrent = Math.max((sc.filesScanned ?? 0) + 1, progressCursor + 1);
135
+ notifyProgress(extra, {
136
+ current: finalCurrent,
137
+ total: finalCurrent,
138
+ message: `🔎︎ find: ${context} • ${suffix}`,
139
+ });
140
+ return result;
141
+ }
142
+ catch (error) {
143
+ const finalCurrent = Math.max(progressCursor + 1, 1);
144
+ notifyProgress(extra, {
145
+ current: finalCurrent,
146
+ total: finalCurrent,
147
+ message: `🔎︎ find: ${context} • failed`,
148
+ });
149
+ throw error;
150
+ }
105
151
  },
106
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_INVALID_PATTERN, args.path),
152
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path),
107
153
  });
108
154
  const { isInitialized } = options;
109
- const wrappedHandler = wrapToolHandler(handler, {
155
+ const validatedHandler = withValidatedArgs(SearchFilesInputSchema, handler);
156
+ const wrappedHandler = wrapToolHandler(validatedHandler, {
110
157
  guard: isInitialized,
111
158
  });
112
159
  if (registerToolTaskIfAvailable(server, 'find', SEARCH_FILES_TOOL, wrappedHandler, options.iconInfo, isInitialized))
@@ -1,5 +1,5 @@
1
1
  import type { ContentBlock, Icon, ProgressNotificationParams } from '@modelcontextprotocol/sdk/types.js';
2
- import type { z } from 'zod';
2
+ import { z } from 'zod';
3
3
  import type { FileInfo } from '../config.js';
4
4
  import { ErrorCode } from '../lib/errors.js';
5
5
  import type { ResourceStore } from '../lib/resource-store.js';
@@ -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;
@@ -45,6 +47,8 @@ interface ToolErrorResponse extends Record<string, unknown> {
45
47
  isError: true;
46
48
  }
47
49
  export type ToolResult<T> = ToolResponse<T> | ToolErrorResponse;
50
+ export declare function parseToolArgs<Schema extends z.ZodType>(schema: Schema, args: unknown): z.infer<Schema>;
51
+ export declare function withValidatedArgs<Args, Result>(schema: z.ZodType<Args>, handler: (args: Args, extra: ToolExtra) => Promise<ToolResult<Result>>): (args: Args, extra: ToolExtra) => Promise<ToolResult<Result>>;
48
52
  type ProgressToken = string | number;
49
53
  export interface ToolExtra {
50
54
  signal?: AbortSignal;
@@ -1,3 +1,4 @@
1
+ import { z } from 'zod';
1
2
  import { createDetailedError, ErrorCode, formatDetailedError, getSuggestion, McpError, } from '../lib/errors.js';
2
3
  import { createTimedAbortSignal } from '../lib/fs-helpers.js';
3
4
  import { withToolDiagnostics } from '../lib/observability.js';
@@ -5,6 +6,7 @@ import { getAllowedDirectories } from '../lib/path-validation.js';
5
6
  const MAX_INLINE_CONTENT_CHARS = 20_000;
6
7
  const MAX_INLINE_PREVIEW_CHARS = 4_000;
7
8
  const PROGRESS_RATE_LIMIT_MS = 50;
9
+ const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes']);
8
10
  export const READ_ONLY_TOOL_ANNOTATIONS = {
9
11
  readOnlyHint: true,
10
12
  idempotentHint: true,
@@ -20,6 +22,30 @@ export const IDEMPOTENT_WRITE_TOOL_ANNOTATIONS = {
20
22
  idempotentHint: true,
21
23
  openWorldHint: false,
22
24
  };
25
+ export function shouldStripStructuredOutput() {
26
+ const value = process.env['FS_CONTEXT_STRIP_STRUCTURED'];
27
+ if (value === undefined)
28
+ return false;
29
+ return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
30
+ }
31
+ export function maybeStripStructuredContentFromResult(result) {
32
+ if (!shouldStripStructuredOutput())
33
+ return result;
34
+ if (!Object.hasOwn(result, 'structuredContent'))
35
+ return result;
36
+ const rest = { ...result };
37
+ delete rest['structuredContent'];
38
+ return rest;
39
+ }
40
+ function maybeStripOutputSchema(tool) {
41
+ if (!shouldStripStructuredOutput())
42
+ return tool;
43
+ if (!Object.hasOwn(tool, 'outputSchema'))
44
+ return tool;
45
+ const mutable = { ...tool };
46
+ delete mutable['outputSchema'];
47
+ return mutable;
48
+ }
23
49
  function buildTextPreview(text) {
24
50
  if (text.length <= MAX_INLINE_PREVIEW_CHARS)
25
51
  return text;
@@ -66,18 +92,33 @@ function resolveDetailedError(error, defaultCode, path) {
66
92
  export function buildToolResponse(text, structuredContent, extraContent = []) {
67
93
  return buildContentBlock(text, structuredContent, extraContent);
68
94
  }
95
+ export function parseToolArgs(schema, args) {
96
+ const candidate = args === undefined ? {} : args;
97
+ const parsed = schema.safeParse(candidate);
98
+ if (parsed.success) {
99
+ return parsed.data;
100
+ }
101
+ throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid tool arguments: ${parsed.error.message}`, undefined, { errors: z.treeifyError(parsed.error) });
102
+ }
103
+ export function withValidatedArgs(schema, handler) {
104
+ return async (args, extra) => {
105
+ const normalizedArgs = parseToolArgs(schema, args);
106
+ return handler(normalizedArgs, extra);
107
+ };
108
+ }
69
109
  function canSendProgress(extra) {
70
110
  return (extra._meta?.progressToken !== undefined &&
71
111
  extra.sendNotification !== undefined);
72
112
  }
73
113
  export function withDefaultIcons(tool, iconInfo) {
74
- if (!iconInfo)
75
- return tool;
114
+ if (!iconInfo) {
115
+ return maybeStripOutputSchema(tool);
116
+ }
76
117
  const existingIcons = tool.icons;
77
118
  if (existingIcons && existingIcons.length > 0) {
78
- return tool;
119
+ return maybeStripOutputSchema(tool);
79
120
  }
80
- return {
121
+ const withIcons = {
81
122
  ...tool,
82
123
  icons: [
83
124
  {
@@ -86,6 +127,7 @@ export function withDefaultIcons(tool, iconInfo) {
86
127
  },
87
128
  ],
88
129
  };
130
+ return maybeStripOutputSchema(withIcons);
89
131
  }
90
132
  export function buildFileInfoPayload(info) {
91
133
  return {
@@ -184,9 +226,11 @@ export function createProgressReporter(extra) {
184
226
  return (progress) => {
185
227
  const { current, total, message } = progress;
186
228
  // Enforce monotonic progress to prevent client confusion. Client behavior on
229
+ // out-of-order progress is undefined in the MCP spec.
187
230
  if (current <= lastProgress)
188
231
  return;
189
- // Enforce rate-limiting to prevent client flooding. Progress updates that are
232
+ // Enforce rate-limiting to prevent client flooding. Progress updates faster
233
+ // than PROGRESS_RATE_LIMIT_MS are silently dropped.
190
234
  const now = Date.now();
191
235
  if (now - lastSentMs < PROGRESS_RATE_LIMIT_MS)
192
236
  return;
@@ -239,6 +283,7 @@ async function withProgress(message, extra, run, getCompletionMessage) {
239
283
  progressToken: token,
240
284
  progress: total,
241
285
  total,
286
+ message: `${message} • failed`,
242
287
  });
243
288
  throw error;
244
289
  }
@@ -247,7 +292,7 @@ export function wrapToolHandler(handler, options) {
247
292
  return async (args, extra) => {
248
293
  const resolvedExtra = extra ?? {};
249
294
  if (options.guard && !options.guard()) {
250
- return buildNotInitializedResult();
295
+ return maybeStripStructuredContentFromResult(buildNotInitializedResult());
251
296
  }
252
297
  if (options.progressMessage) {
253
298
  const message = options.progressMessage(args);
@@ -255,9 +300,11 @@ export function wrapToolHandler(handler, options) {
255
300
  const completionFn = completionMessage
256
301
  ? (result) => completionMessage(args, result)
257
302
  : undefined;
258
- return withProgress(message, resolvedExtra, () => handler(args, resolvedExtra), completionFn);
303
+ const result = await withProgress(message, resolvedExtra, () => handler(args, resolvedExtra), completionFn);
304
+ return maybeStripStructuredContentFromResult(result);
259
305
  }
260
- return handler(args, resolvedExtra);
306
+ const result = await handler(args, resolvedExtra);
307
+ return maybeStripStructuredContentFromResult(result);
261
308
  };
262
309
  }
263
310
  export function resolvePathOrRoot(pathValue) {
@@ -1,9 +1,10 @@
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';
4
5
  import { getMultipleFileInfo } from '../lib/file-operations/file-info.js';
5
6
  import { GetMultipleFileInfoInputSchema, GetMultipleFileInfoOutputSchema, } from '../schemas.js';
6
- import { buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, wrapToolHandler, } from './shared.js';
7
+ import { buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
8
  import { registerToolTaskIfAvailable } from './task-support.js';
8
9
  const GET_MULTIPLE_FILE_INFO_TOOL = {
9
10
  title: 'Get Multiple File Info',
@@ -72,9 +73,27 @@ export function registerGetMultipleFileInfoTool(server, options = {}) {
72
73
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FOUND, primaryPath),
73
74
  });
74
75
  };
75
- const wrappedHandler = wrapToolHandler(handler, {
76
+ const validatedHandler = withValidatedArgs(GetMultipleFileInfoInputSchema, handler);
77
+ const wrappedHandler = wrapToolHandler(validatedHandler, {
76
78
  guard: options.isInitialized,
77
- progressMessage: (args) => `🕮 stat_many: ${args.paths.length} paths`,
79
+ progressMessage: (args) => {
80
+ const first = path.basename(args.paths[0] ?? '');
81
+ const extra = args.paths.length > 1 ? `, ${path.basename(args.paths[1] ?? '')}…` : '';
82
+ return `🕮 stat_many: ${args.paths.length} paths [${first}${extra}]`;
83
+ },
84
+ completionMessage: (args, result) => {
85
+ if (result.isError)
86
+ return `🕮 stat_many: ${args.paths.length} paths • failed`;
87
+ const sc = result.structuredContent;
88
+ if (!sc.ok)
89
+ return `🕮 stat_many: ${args.paths.length} paths • failed`;
90
+ const total = sc.summary?.total ?? 0;
91
+ const succeeded = sc.summary?.succeeded ?? 0;
92
+ const failed = sc.summary?.failed ?? 0;
93
+ if (failed)
94
+ return `🕮 stat_many: ${succeeded}/${total} OK, ${failed} failed`;
95
+ return `🕮 stat_many: ${total} OK`;
96
+ },
78
97
  });
79
98
  if (registerToolTaskIfAvailable(server, 'stat_many', GET_MULTIPLE_FILE_INFO_TOOL, wrappedHandler, options.iconInfo, options.isInitialized))
80
99
  return;
@@ -4,7 +4,7 @@ import { DEFAULT_SEARCH_TIMEOUT_MS } from '../lib/constants.js';
4
4
  import { ErrorCode } from '../lib/errors.js';
5
5
  import { getFileInfo } from '../lib/file-operations/file-info.js';
6
6
  import { GetFileInfoInputSchema, GetFileInfoOutputSchema } from '../schemas.js';
7
- import { buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, wrapToolHandler, } from './shared.js';
7
+ import { buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
8
  const GET_FILE_INFO_TOOL = {
9
9
  title: 'Get File Info',
10
10
  description: 'Get metadata (size, modified time, permissions, mime type) for a file or directory.',
@@ -45,8 +45,18 @@ export function registerGetFileInfoTool(server, options = {}) {
45
45
  run: (signal) => handleGetFileInfo(args, signal),
46
46
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FOUND, args.path),
47
47
  });
48
- server.registerTool('stat', withDefaultIcons({ ...GET_FILE_INFO_TOOL }, options.iconInfo), wrapToolHandler(handler, {
48
+ const validatedHandler = withValidatedArgs(GetFileInfoInputSchema, handler);
49
+ server.registerTool('stat', withDefaultIcons({ ...GET_FILE_INFO_TOOL }, options.iconInfo), wrapToolHandler(validatedHandler, {
49
50
  guard: options.isInitialized,
50
51
  progressMessage: (args) => `🕮 stat: ${path.basename(args.path)}`,
52
+ completionMessage: (args, result) => {
53
+ const name = path.basename(args.path);
54
+ if (result.isError)
55
+ return `🕮 stat: ${name} • failed`;
56
+ const sc = result.structuredContent;
57
+ if (!sc.ok || !sc.info)
58
+ return `🕮 stat: ${name} • failed`;
59
+ return `🕮 stat: ${sc.info.name} • ${sc.info.type}, ${formatBytes(sc.info.size)}`;
60
+ },
51
61
  }));
52
62
  }