@j0hanz/filesystem-mcp 1.1.2 → 1.2.1

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