@j0hanz/filesystem-mcp 1.1.2 → 1.2.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.
Files changed (62) hide show
  1. package/README.md +514 -188
  2. package/dist/cli.js +29 -12
  3. package/dist/completions.js +50 -24
  4. package/dist/config.d.ts +3 -2
  5. package/dist/config.js +1 -1
  6. package/dist/index.js +14 -12
  7. package/dist/instructions.md +109 -97
  8. package/dist/lib/constants.js +25 -14
  9. package/dist/lib/errors.js +11 -6
  10. package/dist/lib/file-operations/common.d.ts +4 -0
  11. package/dist/lib/file-operations/common.js +9 -0
  12. package/dist/lib/file-operations/file-info.js +22 -10
  13. package/dist/lib/file-operations/gitignore.js +14 -11
  14. package/dist/lib/file-operations/glob-engine.d.ts +1 -0
  15. package/dist/lib/file-operations/glob-engine.js +46 -33
  16. package/dist/lib/file-operations/list-directory.js +31 -35
  17. package/dist/lib/file-operations/read-multiple-files.js +70 -62
  18. package/dist/lib/file-operations/search-content.js +83 -64
  19. package/dist/lib/file-operations/search-files.js +32 -30
  20. package/dist/lib/file-operations/search-worker.js +22 -12
  21. package/dist/lib/file-operations/tree.js +43 -34
  22. package/dist/lib/fs-helpers.js +61 -124
  23. package/dist/lib/observability.js +29 -28
  24. package/dist/lib/path-format.d.ts +1 -0
  25. package/dist/lib/path-format.js +7 -0
  26. package/dist/lib/path-policy.js +22 -20
  27. package/dist/lib/path-validation.js +13 -7
  28. package/dist/lib/resource-store.d.ts +2 -0
  29. package/dist/lib/resource-store.js +26 -5
  30. package/dist/lib/type-guards.d.ts +1 -0
  31. package/dist/lib/type-guards.js +3 -0
  32. package/dist/prompts.d.ts +1 -5
  33. package/dist/prompts.js +9 -16
  34. package/dist/resources.d.ts +1 -5
  35. package/dist/resources.js +12 -26
  36. package/dist/schemas.d.ts +213 -30
  37. package/dist/schemas.js +52 -90
  38. package/dist/server.js +85 -44
  39. package/dist/tools/apply-patch.js +24 -24
  40. package/dist/tools/calculate-hash.js +42 -45
  41. package/dist/tools/create-directory.js +18 -21
  42. package/dist/tools/delete-file.js +36 -39
  43. package/dist/tools/diff-files.js +16 -21
  44. package/dist/tools/edit-file.js +16 -20
  45. package/dist/tools/list-directory.js +25 -25
  46. package/dist/tools/move-file.js +18 -21
  47. package/dist/tools/read-multiple.js +56 -68
  48. package/dist/tools/read.js +27 -32
  49. package/dist/tools/replace-in-files.js +28 -35
  50. package/dist/tools/roots.js +9 -10
  51. package/dist/tools/search-content.js +74 -74
  52. package/dist/tools/search-files.js +45 -52
  53. package/dist/tools/shared.d.ts +44 -6
  54. package/dist/tools/shared.js +86 -64
  55. package/dist/tools/stat-many.js +45 -68
  56. package/dist/tools/stat.js +11 -39
  57. package/dist/tools/task-support.d.ts +9 -1
  58. package/dist/tools/task-support.js +86 -81
  59. package/dist/tools/tree.js +13 -30
  60. package/dist/tools/write-file.js +18 -21
  61. package/dist/tools.js +23 -18
  62. package/package.json +6 -7
@@ -2,37 +2,24 @@ import * as path from 'node:path';
2
2
  import { DEFAULT_READ_MANY_MAX_TOTAL_SIZE, DEFAULT_SEARCH_TIMEOUT_MS, } from '../lib/constants.js';
3
3
  import { ErrorCode } from '../lib/errors.js';
4
4
  import { readMultipleFiles } from '../lib/file-operations/read-multiple-files.js';
5
- import { createTimedAbortSignal } from '../lib/fs-helpers.js';
6
- import { withToolDiagnostics } from '../lib/observability.js';
7
- import { ReadMultipleFilesInputSchema, ReadMultipleFilesOutputSchema, } from '../schemas.js';
8
- import { buildResourceLink, buildToolErrorResponse, buildToolResponse, getExperimentalTaskRegistration, maybeExternalizeTextContent, withDefaultIcons, withToolErrorHandling, wrapToolHandler, } from './shared.js';
9
- import { createToolTaskHandler } from './task-support.js';
5
+ import { ReadMultipleFilesInputSchema, } from '../schemas.js';
6
+ import { buildResourceLink, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, wrapToolHandler, } from './shared.js';
7
+ import { registerToolTaskIfAvailable } from './task-support.js';
10
8
  const READ_MULTIPLE_FILES_TOOL = {
11
9
  title: 'Read Multiple Files',
12
10
  description: 'Read multiple text files in a single request. ' +
13
11
  'Returns contents and metadata for each file. ' +
14
12
  'For single file, use read for simpler output.',
15
13
  inputSchema: ReadMultipleFilesInputSchema,
16
- outputSchema: ReadMultipleFilesOutputSchema,
17
- annotations: {
18
- readOnlyHint: true,
19
- idempotentHint: true,
20
- openWorldHint: false,
21
- },
14
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
22
15
  };
23
16
  async function handleReadMultipleFiles(args, signal, resourceStore) {
24
17
  const options = {
25
18
  ...(signal ? { signal } : {}),
19
+ ...(args.head !== undefined ? { head: args.head } : {}),
20
+ ...(args.startLine !== undefined ? { startLine: args.startLine } : {}),
21
+ ...(args.endLine !== undefined ? { endLine: args.endLine } : {}),
26
22
  };
27
- if (args.head !== undefined) {
28
- options.head = args.head;
29
- }
30
- if (args.startLine !== undefined) {
31
- options.startLine = args.startLine;
32
- }
33
- if (args.endLine !== undefined) {
34
- options.endLine = args.endLine;
35
- }
36
23
  const results = await readMultipleFiles(args.paths, options);
37
24
  const maxTotalSize = DEFAULT_READ_MANY_MAX_TOTAL_SIZE;
38
25
  const mappedResults = results.map((result) => {
@@ -65,78 +52,79 @@ async function handleReadMultipleFiles(args, signal, resourceStore) {
65
52
  truncationReason: 'externalized',
66
53
  };
67
54
  });
55
+ let succeeded = 0;
56
+ let failed = 0;
57
+ for (const result of mappedResults) {
58
+ if (result.error === undefined)
59
+ succeeded += 1;
60
+ else
61
+ failed += 1;
62
+ }
68
63
  const structured = {
69
64
  ok: true,
70
65
  results: mappedResults.map((result) => ({
71
66
  path: result.path,
72
- content: result.content,
73
- truncated: result.truncated,
74
- resourceUri: result.resourceUri,
75
- readMode: result.readMode,
76
- head: result.head,
77
- startLine: result.startLine,
78
- endLine: result.endLine,
79
- linesRead: result.linesRead,
80
- hasMoreLines: result.hasMoreLines,
81
- totalLines: result.totalLines,
82
- truncationReason: result.truncationReason,
83
- maxTotalSize: result.maxTotalSize,
84
- error: result.error,
67
+ ...(result.content !== undefined ? { content: result.content } : {}),
68
+ ...(result.truncated ? { truncated: result.truncated } : {}),
69
+ ...(result.resourceUri ? { resourceUri: result.resourceUri } : {}),
70
+ ...(result.head !== undefined ? { head: result.head } : {}),
71
+ ...(result.startLine !== undefined
72
+ ? { startLine: result.startLine }
73
+ : {}),
74
+ ...(result.endLine !== undefined ? { endLine: result.endLine } : {}),
75
+ ...(result.hasMoreLines ? { hasMoreLines: result.hasMoreLines } : {}),
76
+ ...(result.totalLines !== undefined
77
+ ? { totalLines: result.totalLines }
78
+ : {}),
79
+ ...(result.truncationReason
80
+ ? { truncationReason: result.truncationReason }
81
+ : {}),
82
+ ...(result.error ? { error: result.error } : {}),
85
83
  })),
86
84
  summary: {
87
85
  total: mappedResults.length,
88
- succeeded: mappedResults.filter((r) => r.error === undefined).length,
89
- failed: mappedResults.filter((r) => r.error !== undefined).length,
86
+ succeeded,
87
+ failed,
90
88
  },
91
89
  };
92
- const resourceLinks = mappedResults.flatMap((result) => {
90
+ const resourceLinks = [];
91
+ for (const result of mappedResults) {
93
92
  if (!result.resourceUri)
94
- return [];
95
- return [
96
- buildResourceLink({
97
- uri: result.resourceUri,
98
- name: `read:${path.basename(result.path)}`,
99
- description: 'Full file contents',
100
- }),
101
- ];
102
- });
93
+ continue;
94
+ resourceLinks.push(buildResourceLink({
95
+ uri: result.resourceUri,
96
+ name: `read:${path.basename(result.path)}`,
97
+ description: 'Full file contents',
98
+ }));
99
+ }
103
100
  const text = mappedResults
104
101
  .map((result) => {
102
+ const header = `=== ${result.path} ===`;
105
103
  if (result.error) {
106
- return `${result.path}: ${result.error}`;
104
+ return `${header}\nError: ${result.error}`;
107
105
  }
108
- return result.path;
106
+ return `${header}\n${result.content ?? ''}`;
109
107
  })
110
- .join('\n');
111
- return buildToolResponse(text, structured, resourceLinks, resourceStore);
108
+ .join('\n\n');
109
+ return buildToolResponse(text, structured, resourceLinks);
112
110
  }
113
111
  export function registerReadMultipleFilesTool(server, options = {}) {
114
112
  const handler = (args, extra) => {
115
113
  const primaryPath = args.paths[0] ?? '';
116
- return withToolDiagnostics('read_many', () => withToolErrorHandling(async () => {
117
- const { signal, cleanup } = createTimedAbortSignal(extra.signal, DEFAULT_SEARCH_TIMEOUT_MS);
118
- try {
119
- return await handleReadMultipleFiles(args, signal, options.resourceStore);
120
- }
121
- finally {
122
- cleanup();
123
- }
124
- }, (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FILE, primaryPath)), { path: primaryPath });
114
+ return executeToolWithDiagnostics({
115
+ toolName: 'read_many',
116
+ extra,
117
+ timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
118
+ context: { path: primaryPath },
119
+ run: (signal) => handleReadMultipleFiles(args, signal, options.resourceStore),
120
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FILE, primaryPath),
121
+ });
125
122
  };
126
123
  const wrappedHandler = wrapToolHandler(handler, {
127
124
  guard: options.isInitialized,
128
125
  progressMessage: (args) => `🕮 read_many: ${args.paths.length} files`,
129
126
  });
130
- const taskOptions = options.isInitialized
131
- ? { guard: options.isInitialized }
132
- : undefined;
133
- const tasks = getExperimentalTaskRegistration(server);
134
- if (tasks?.registerToolTask) {
135
- tasks.registerToolTask('read_many', withDefaultIcons({
136
- ...READ_MULTIPLE_FILES_TOOL,
137
- execution: { taskSupport: 'optional' },
138
- }, options.iconInfo), createToolTaskHandler(wrappedHandler, taskOptions));
127
+ if (registerToolTaskIfAvailable(server, 'read_many', READ_MULTIPLE_FILES_TOOL, wrappedHandler, options.iconInfo, options.isInitialized))
139
128
  return;
140
- }
141
129
  server.registerTool('read_many', withDefaultIcons({ ...READ_MULTIPLE_FILES_TOOL }, options.iconInfo), wrappedHandler);
142
130
  }
@@ -2,22 +2,16 @@ import * as path from 'node:path';
2
2
  import { DEFAULT_SEARCH_TIMEOUT_MS, MAX_TEXT_FILE_SIZE, } from '../lib/constants.js';
3
3
  import { ErrorCode } from '../lib/errors.js';
4
4
  import { readFile } from '../lib/fs-helpers.js';
5
- import { createTimedAbortSignal } from '../lib/fs-helpers.js';
6
- import { withToolDiagnostics } from '../lib/observability.js';
7
- import { ReadFileInputSchema, ReadFileOutputSchema } from '../schemas.js';
8
- import { buildResourceLink, buildToolErrorResponse, buildToolResponse, maybeExternalizeTextContent, withDefaultIcons, withToolErrorHandling, wrapToolHandler, } from './shared.js';
5
+ import { ReadFileInputSchema } from '../schemas.js';
6
+ import { buildResourceLink, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, wrapToolHandler, } from './shared.js';
7
+ import { registerToolTaskIfAvailable } from './task-support.js';
9
8
  const READ_FILE_TOOL = {
10
9
  title: 'Read File',
11
10
  description: 'Read the text contents of a file. ' +
12
11
  'Use head parameter to preview the first N lines of large files. ' +
13
12
  'For multiple files, use read_many for efficiency.',
14
13
  inputSchema: ReadFileInputSchema,
15
- outputSchema: ReadFileOutputSchema,
16
- annotations: {
17
- readOnlyHint: true,
18
- idempotentHint: true,
19
- openWorldHint: false,
20
- },
14
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
21
15
  };
22
16
  async function handleReadFile(args, signal, resourceStore) {
23
17
  const options = {
@@ -42,19 +36,18 @@ async function handleReadFile(args, signal, resourceStore) {
42
36
  ok: true,
43
37
  path: args.path,
44
38
  content: result.content,
45
- truncated: result.truncated,
46
- resourceUri: undefined,
47
- totalLines: result.totalLines,
48
- readMode: result.readMode,
49
- head: result.head,
50
- startLine: result.startLine,
51
- endLine: result.endLine,
52
- linesRead: result.linesRead,
53
- hasMoreLines: result.hasMoreLines,
39
+ ...(result.truncated ? { truncated: result.truncated } : {}),
40
+ ...(result.totalLines !== undefined
41
+ ? { totalLines: result.totalLines }
42
+ : {}),
43
+ ...(result.head !== undefined ? { head: result.head } : {}),
44
+ ...(result.startLine !== undefined ? { startLine: result.startLine } : {}),
45
+ ...(result.endLine !== undefined ? { endLine: result.endLine } : {}),
46
+ ...(result.hasMoreLines ? { hasMoreLines: result.hasMoreLines } : {}),
54
47
  };
55
48
  const externalized = maybeExternalizeTextContent(resourceStore, result.content, { name: `read:${path.basename(args.path)}`, mimeType: 'text/plain' });
56
49
  if (!externalized) {
57
- return buildToolResponse(result.content, structured, [], resourceStore);
50
+ return buildToolResponse(result.content, structured);
58
51
  }
59
52
  const { entry, preview } = externalized;
60
53
  const structuredWithResource = {
@@ -75,19 +68,18 @@ async function handleReadFile(args, signal, resourceStore) {
75
68
  mimeType: entry.mimeType,
76
69
  description: 'Full file contents',
77
70
  }),
78
- ], resourceStore);
71
+ ]);
79
72
  }
80
73
  export function registerReadFileTool(server, options = {}) {
81
- const handler = (args, extra) => withToolDiagnostics('read', () => withToolErrorHandling(async () => {
82
- const { signal, cleanup } = createTimedAbortSignal(extra.signal, DEFAULT_SEARCH_TIMEOUT_MS);
83
- try {
84
- return await handleReadFile(args, signal, options.resourceStore);
85
- }
86
- finally {
87
- cleanup();
88
- }
89
- }, (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FILE, args.path)), { path: args.path });
90
- server.registerTool('read', withDefaultIcons({ ...READ_FILE_TOOL }, options.iconInfo), wrapToolHandler(handler, {
74
+ const handler = (args, extra) => executeToolWithDiagnostics({
75
+ toolName: 'read',
76
+ extra,
77
+ timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
78
+ context: { path: args.path },
79
+ run: (signal) => handleReadFile(args, signal, options.resourceStore),
80
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FILE, args.path),
81
+ });
82
+ const wrappedHandler = wrapToolHandler(handler, {
91
83
  guard: options.isInitialized,
92
84
  progressMessage: (args) => {
93
85
  const name = path.basename(args.path);
@@ -97,5 +89,8 @@ export function registerReadFileTool(server, options = {}) {
97
89
  }
98
90
  return `🕮 read: ${name}`;
99
91
  },
100
- }));
92
+ });
93
+ if (registerToolTaskIfAvailable(server, 'read', READ_FILE_TOOL, wrappedHandler, options.iconInfo, options.isInitialized))
94
+ return;
95
+ server.registerTool('read', withDefaultIcons({ ...READ_FILE_TOOL }, options.iconInfo), wrappedHandler);
101
96
  }
@@ -2,24 +2,23 @@ import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import RE2 from 're2';
4
4
  import safeRegex from 'safe-regex2';
5
- import { MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY } from '../lib/constants.js';
5
+ import { DEFAULT_EXCLUDE_PATTERNS, MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY, } from '../lib/constants.js';
6
6
  import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
7
7
  import { globEntries } from '../lib/file-operations/glob-engine.js';
8
- import { atomicWriteFile, createTimedAbortSignal, withAbort, } from '../lib/fs-helpers.js';
9
- import { withToolDiagnostics } from '../lib/observability.js';
8
+ import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
10
9
  import { validateExistingPath, validatePathForWrite, } from '../lib/path-validation.js';
11
- import { SearchAndReplaceInputSchema, SearchAndReplaceOutputSchema, } from '../schemas.js';
12
- import { buildToolErrorResponse, buildToolResponse, createProgressReporter, getExperimentalTaskRegistration, notifyProgress, resolvePathOrRoot, withDefaultIcons, withToolErrorHandling, wrapToolHandler, } from './shared.js';
13
- import { createToolTaskHandler } from './task-support.js';
10
+ import { SearchAndReplaceInputSchema, } from '../schemas.js';
11
+ import { buildToolErrorResponse, buildToolResponse, createProgressReporter, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, notifyProgress, resolvePathOrRoot, withDefaultIcons, wrapToolHandler, } from './shared.js';
12
+ import { registerToolTaskIfAvailable } from './task-support.js';
14
13
  const SEARCH_AND_REPLACE_TOOL = {
15
14
  title: 'Search and Replace',
16
- description: 'Search and replace text across multiple files.',
15
+ description: 'Search and replace text across multiple files matching a glob pattern. ' +
16
+ 'Replaces ALL occurrences in each file (unlike `edit` which replaces only the first). ' +
17
+ 'Use `filePattern` to scope which files are touched. ' +
18
+ 'Always run with `dryRun: true` first to verify matches before writing. ' +
19
+ 'Literal mode (default) matches exact text; `isRegex: true` enables RE2 regex with capture groups ($1, $2).',
17
20
  inputSchema: SearchAndReplaceInputSchema,
18
- outputSchema: SearchAndReplaceOutputSchema,
19
- annotations: {
20
- readOnlyHint: false,
21
- openWorldHint: false,
22
- },
21
+ annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
23
22
  };
24
23
  const MAX_FAILURES = 20;
25
24
  const REPLACE_CONCURRENCY = Math.min(PARALLEL_CONCURRENCY, 8);
@@ -110,7 +109,7 @@ async function processEntry(entryPath, args, regex, maxFileSize, signal, summary
110
109
  newContent = content.replace(regex, args.replacement);
111
110
  }
112
111
  else {
113
- newContent = content.replaceAll(args.searchPattern, args.replacement);
112
+ newContent = content.replaceAll(args.searchPattern, () => args.replacement);
114
113
  }
115
114
  await atomicWriteFile(validPath, newContent, {
116
115
  encoding: 'utf-8',
@@ -183,13 +182,13 @@ function reportReplaceProgress(onProgress, current, force = false) {
183
182
  onProgress({ current });
184
183
  }
185
184
  async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
186
- const maxFileSize = args.maxFileSize ?? MAX_TEXT_FILE_SIZE;
185
+ const maxFileSize = MAX_TEXT_FILE_SIZE;
187
186
  const root = await resolveSearchRoot(args.path, signal);
188
187
  const regex = createReplacementRegex(args);
189
188
  const entries = globEntries({
190
189
  cwd: root,
191
190
  pattern: args.filePattern,
192
- excludePatterns: args.excludePatterns,
191
+ excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
193
192
  includeHidden: false,
194
193
  baseNameMatch: false,
195
194
  caseSensitiveMatch: true, // Default to sensitive for file paths
@@ -225,13 +224,16 @@ async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
225
224
  });
226
225
  }
227
226
  export function registerSearchAndReplaceTool(server, options = {}) {
228
- const handler = (args, extra) => withToolDiagnostics('search_and_replace', () => withToolErrorHandling(async () => {
229
- notifyProgress(extra, {
230
- current: 0,
231
- message: `🛠 search_and_replace: ${args.filePattern}`,
232
- });
233
- const { signal, cleanup } = createTimedAbortSignal(extra.signal);
234
- try {
227
+ const handler = (args, extra) => executeToolWithDiagnostics({
228
+ toolName: 'search_and_replace',
229
+ extra,
230
+ timedSignal: {},
231
+ ...(args.path ? { context: { path: args.path } } : {}),
232
+ run: async (signal) => {
233
+ notifyProgress(extra, {
234
+ current: 0,
235
+ message: `🛠 search_and_replace: ${args.filePattern}`,
236
+ });
235
237
  const result = await handleSearchAndReplace(args, signal, createProgressReporter(extra));
236
238
  const sc = result.structuredContent;
237
239
  const finalCurrent = (sc.processedFiles ?? 0) + 1;
@@ -240,23 +242,14 @@ export function registerSearchAndReplaceTool(server, options = {}) {
240
242
  message: `🛠 search_and_replace: ${args.filePattern} ➟ ${String(sc.filesChanged ?? 0)} files`,
241
243
  });
242
244
  return result;
243
- }
244
- finally {
245
- cleanup();
246
- }
247
- }, (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path)), args.path ? { path: args.path } : {});
245
+ },
246
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path),
247
+ });
248
248
  const { isInitialized } = options;
249
249
  const wrappedHandler = wrapToolHandler(handler, {
250
250
  guard: isInitialized,
251
251
  });
252
- const taskOptions = isInitialized ? { guard: isInitialized } : undefined;
253
- const tasks = getExperimentalTaskRegistration(server);
254
- if (tasks?.registerToolTask) {
255
- tasks.registerToolTask('search_and_replace', withDefaultIcons({
256
- ...SEARCH_AND_REPLACE_TOOL,
257
- execution: { taskSupport: 'optional' },
258
- }, options.iconInfo), createToolTaskHandler(wrappedHandler, taskOptions));
252
+ if (registerToolTaskIfAvailable(server, 'search_and_replace', SEARCH_AND_REPLACE_TOOL, wrappedHandler, options.iconInfo, isInitialized))
259
253
  return;
260
- }
261
254
  server.registerTool('search_and_replace', withDefaultIcons({ ...SEARCH_AND_REPLACE_TOOL }, options.iconInfo), wrappedHandler);
262
255
  }
@@ -1,21 +1,15 @@
1
1
  import { joinLines } from '../config.js';
2
2
  import { ErrorCode } from '../lib/errors.js';
3
- import { withToolDiagnostics } from '../lib/observability.js';
4
3
  import { getAllowedDirectories } from '../lib/path-validation.js';
5
- import { ListAllowedDirectoriesInputSchema, ListAllowedDirectoriesOutputSchema, } from '../schemas.js';
6
- import { buildToolErrorResponse, buildToolResponse, withDefaultIcons, withToolErrorHandling, wrapToolHandler, } from './shared.js';
4
+ import { ListAllowedDirectoriesInputSchema, } from '../schemas.js';
5
+ import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, wrapToolHandler, } from './shared.js';
7
6
  const LIST_ALLOWED_DIRECTORIES_TOOL = {
8
7
  title: 'Workspace Roots',
9
8
  description: 'List the workspace roots this server can access. ' +
10
9
  'Call this first to see available directories. ' +
11
10
  'All other tools only work within these directories.',
12
11
  inputSchema: ListAllowedDirectoriesInputSchema,
13
- outputSchema: ListAllowedDirectoriesOutputSchema,
14
- annotations: {
15
- readOnlyHint: true,
16
- idempotentHint: true,
17
- openWorldHint: false,
18
- },
12
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
19
13
  };
20
14
  function buildTextRoots(dirs) {
21
15
  if (dirs.length === 0) {
@@ -37,7 +31,12 @@ function handleListAllowedDirectories() {
37
31
  return buildToolResponse(buildTextRoots(dirs), structured);
38
32
  }
39
33
  export function registerListAllowedDirectoriesTool(server, options = {}) {
40
- const handler = () => withToolErrorHandling(() => withToolDiagnostics('roots', () => Promise.resolve(handleListAllowedDirectories())), (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN));
34
+ const handler = (_args, extra) => executeToolWithDiagnostics({
35
+ toolName: 'roots',
36
+ extra,
37
+ run: () => handleListAllowedDirectories(),
38
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN),
39
+ });
41
40
  server.registerTool('roots', withDefaultIcons({ ...LIST_ALLOWED_DIRECTORIES_TOOL }, options.iconInfo), wrapToolHandler(handler, {
42
41
  guard: options.isInitialized,
43
42
  progressMessage: () => '≣ roots',
@@ -4,24 +4,19 @@ import { formatOperationSummary, joinLines } from '../config.js';
4
4
  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
- import { withToolDiagnostics } from '../lib/observability.js';
8
- import { SearchContentInputSchema, SearchContentOutputSchema, } from '../schemas.js';
9
- import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createProgressReporter, getExperimentalTaskRegistration, notifyProgress, resolvePathOrRoot, withDefaultIcons, withToolErrorHandling, wrapToolHandler, } from './shared.js';
10
- import { createToolTaskHandler } from './task-support.js';
7
+ import { SearchContentInputSchema, } from '../schemas.js';
8
+ import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, wrapToolHandler, } from './shared.js';
9
+ import { registerToolTaskIfAvailable } from './task-support.js';
11
10
  const MAX_INLINE_MATCHES = 50;
12
11
  const SEARCH_CONTENT_TOOL = {
13
12
  title: 'Search Content',
14
13
  description: 'Search for text within file contents (grep-like). ' +
15
14
  'Returns matching lines. ' +
16
15
  'Path may be a directory or a single file. ' +
16
+ 'Use `filePattern` to scope by file type (e.g. `**/*.ts`) and avoid noisy results. ' +
17
17
  'Use includeHidden=true to include hidden files and directories.',
18
18
  inputSchema: SearchContentInputSchema,
19
- outputSchema: SearchContentOutputSchema,
20
- annotations: {
21
- readOnlyHint: true,
22
- idempotentHint: true,
23
- openWorldHint: false,
24
- },
19
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
25
20
  };
26
21
  function assertValidRegexPattern(pattern) {
27
22
  try {
@@ -73,35 +68,47 @@ function formatSearchMatchLine(match) {
73
68
  }
74
69
  function buildStructuredSearchResult(result, normalizedMatches, options) {
75
70
  const { summary } = result;
71
+ const matches = [];
72
+ for (const match of normalizedMatches) {
73
+ matches.push(buildSearchMatchPayload(match));
74
+ }
76
75
  return {
77
76
  ok: true,
78
77
  patternType: options.patternType,
79
78
  caseSensitive: options.caseSensitive,
80
- matches: normalizedMatches.map(buildSearchMatchPayload),
79
+ matches,
81
80
  totalMatches: summary.matches,
82
- truncated: summary.truncated,
83
- filesScanned: summary.filesScanned,
84
- filesMatched: summary.filesMatched,
85
- skippedTooLarge: summary.skippedTooLarge,
86
- skippedBinary: summary.skippedBinary,
87
- skippedInaccessible: summary.skippedInaccessible,
88
- linesSkippedDueToRegexTimeout: summary.linesSkippedDueToRegexTimeout,
81
+ ...(summary.truncated ? { truncated: summary.truncated } : {}),
82
+ ...(summary.filesMatched ? { filesMatched: summary.filesMatched } : {}),
83
+ ...(summary.skippedTooLarge
84
+ ? { skippedTooLarge: summary.skippedTooLarge }
85
+ : {}),
86
+ ...(summary.skippedBinary ? { skippedBinary: summary.skippedBinary } : {}),
87
+ ...(summary.skippedInaccessible
88
+ ? { skippedInaccessible: summary.skippedInaccessible }
89
+ : {}),
90
+ ...(summary.linesSkippedDueToRegexTimeout
91
+ ? { linesSkippedDueToRegexTimeout: summary.linesSkippedDueToRegexTimeout }
92
+ : {}),
89
93
  ...(summary.stoppedReason ? { stoppedReason: summary.stoppedReason } : {}),
90
94
  };
91
95
  }
92
96
  function normalizeSearchMatches(result) {
93
97
  const relativeByFile = new Map();
94
- const normalized = result.matches.map((match, index) => {
98
+ const normalized = [];
99
+ let index = 0;
100
+ for (const match of result.matches) {
95
101
  const cached = relativeByFile.get(match.file);
96
102
  const relative = cached ?? path.relative(result.basePath, match.file);
97
103
  if (!cached)
98
104
  relativeByFile.set(match.file, relative);
99
- return {
105
+ normalized.push({
100
106
  ...match,
101
107
  relativeFile: relative,
102
108
  index,
103
- };
104
- });
109
+ });
110
+ index += 1;
111
+ }
105
112
  normalized.sort((a, b) => {
106
113
  const fileCompare = a.relativeFile.localeCompare(b.relativeFile);
107
114
  if (fileCompare !== 0)
@@ -127,7 +134,6 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
127
134
  wholeWord: args.wholeWord,
128
135
  contextLines: args.contextLines,
129
136
  maxResults: args.maxResults,
130
- maxFilesScanned: args.maxFilesScanned,
131
137
  isLiteral: !args.isRegex,
132
138
  };
133
139
  if (signal) {
@@ -156,22 +162,14 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
156
162
  return buildToolResponse(buildSearchTextResult(result, normalizedMatches), structuredFull);
157
163
  }
158
164
  const previewMatches = normalizedMatches.slice(0, MAX_INLINE_MATCHES);
165
+ const previewPayload = [];
166
+ for (const match of previewMatches) {
167
+ previewPayload.push(buildSearchMatchPayload(match));
168
+ }
159
169
  const previewStructured = {
160
- ok: true,
161
- patternType,
162
- caseSensitive: args.caseSensitive,
163
- matches: previewMatches.map(buildSearchMatchPayload),
164
- totalMatches: structuredFull.totalMatches,
170
+ ...structuredFull,
171
+ matches: previewPayload,
165
172
  truncated: true,
166
- filesScanned: structuredFull.filesScanned,
167
- filesMatched: structuredFull.filesMatched,
168
- skippedTooLarge: structuredFull.skippedTooLarge,
169
- skippedBinary: structuredFull.skippedBinary,
170
- skippedInaccessible: structuredFull.skippedInaccessible,
171
- linesSkippedDueToRegexTimeout: structuredFull.linesSkippedDueToRegexTimeout,
172
- ...(structuredFull.stoppedReason
173
- ? { stoppedReason: structuredFull.stoppedReason }
174
- : {}),
175
173
  resourceUri: undefined,
176
174
  };
177
175
  const entry = resourceStore.putText({
@@ -180,10 +178,13 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
180
178
  text: JSON.stringify(structuredFull),
181
179
  });
182
180
  previewStructured.resourceUri = entry.uri;
183
- const text = joinLines([
181
+ const textLines = [
184
182
  `Found ${normalizedMatches.length} (showing first ${MAX_INLINE_MATCHES}):`,
185
- ...previewMatches.map(formatSearchMatchLine),
186
- ]);
183
+ ];
184
+ for (const match of previewMatches) {
185
+ textLines.push(formatSearchMatchLine(match));
186
+ }
187
+ const text = joinLines(textLines);
187
188
  return buildToolResponse(text, previewStructured, [
188
189
  buildResourceLink({
189
190
  uri: entry.uri,
@@ -194,44 +195,43 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
194
195
  ]);
195
196
  }
196
197
  export function registerSearchContentTool(server, options = {}) {
197
- const handler = (args, extra) => withToolDiagnostics('grep', () => withToolErrorHandling(async () => {
198
- const normalizedArgs = SearchContentInputSchema.parse(args);
199
- notifyProgress(extra, {
200
- current: 0,
201
- message: `🔎︎ grep: ${normalizedArgs.pattern}`,
202
- });
203
- const result = await handleSearchContent(normalizedArgs, extra.signal, options.resourceStore, createProgressReporter(extra));
204
- const sc = result.structuredContent;
205
- const count = sc.ok && sc.totalMatches ? sc.totalMatches : 0;
206
- let suffix;
207
- if (count === 0) {
208
- suffix = 'No matches';
209
- }
210
- else if (count === 1) {
211
- suffix = '1 match';
212
- }
213
- else {
214
- suffix = `${count} matches`;
215
- }
216
- const finalCurrent = (sc.filesScanned ?? 0) + 1;
217
- notifyProgress(extra, {
218
- current: finalCurrent,
219
- message: `🔎︎ grep: ${normalizedArgs.pattern} ➟ ${suffix}`,
220
- });
221
- return result;
222
- }, (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path ?? '.')), { path: args.path ?? '.' });
198
+ const handler = (args, extra) => executeToolWithDiagnostics({
199
+ toolName: 'grep',
200
+ extra,
201
+ context: { path: args.path ?? '.' },
202
+ run: async (signal) => {
203
+ const normalizedArgs = SearchContentInputSchema.parse(args);
204
+ notifyProgress(extra, {
205
+ current: 0,
206
+ message: `🔎︎ grep: ${normalizedArgs.pattern}`,
207
+ });
208
+ const result = await handleSearchContent(normalizedArgs, signal, options.resourceStore, createProgressReporter(extra));
209
+ const sc = result.structuredContent;
210
+ const count = sc.ok && sc.totalMatches ? sc.totalMatches : 0;
211
+ let suffix;
212
+ if (count === 0) {
213
+ suffix = 'No matches';
214
+ }
215
+ else if (count === 1) {
216
+ suffix = '1 match';
217
+ }
218
+ else {
219
+ suffix = `${count} matches`;
220
+ }
221
+ const finalCurrent = (sc.filesScanned ?? 0) + 1;
222
+ notifyProgress(extra, {
223
+ current: finalCurrent,
224
+ message: `🔎︎ grep: ${normalizedArgs.pattern} ➟ ${suffix}`,
225
+ });
226
+ return result;
227
+ },
228
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path ?? '.'),
229
+ });
223
230
  const { isInitialized } = options;
224
231
  const wrappedHandler = wrapToolHandler(handler, {
225
232
  guard: isInitialized,
226
233
  });
227
- const taskOptions = isInitialized ? { guard: isInitialized } : undefined;
228
- const tasks = getExperimentalTaskRegistration(server);
229
- if (tasks?.registerToolTask) {
230
- tasks.registerToolTask('grep', withDefaultIcons({
231
- ...SEARCH_CONTENT_TOOL,
232
- execution: { taskSupport: 'optional' },
233
- }, options.iconInfo), createToolTaskHandler(wrappedHandler, taskOptions));
234
+ if (registerToolTaskIfAvailable(server, 'grep', SEARCH_CONTENT_TOOL, wrappedHandler, options.iconInfo, isInitialized))
234
235
  return;
235
- }
236
236
  server.registerTool('grep', withDefaultIcons({ ...SEARCH_CONTENT_TOOL }, options.iconInfo), wrappedHandler);
237
237
  }