@j0hanz/filesystem-mcp 1.11.0 → 1.12.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 (42) hide show
  1. package/dist/completions.js +25 -0
  2. package/dist/lib/constants.d.ts +2 -0
  3. package/dist/lib/constants.js +2 -0
  4. package/dist/lib/file-operations/metadata.js +71 -142
  5. package/dist/lib/file-operations/search.js +119 -19
  6. package/dist/lib/file-operations/traversal.js +45 -102
  7. package/dist/lib/fs-helpers.js +175 -158
  8. package/dist/prompts.d.ts +1 -0
  9. package/dist/prompts.js +58 -0
  10. package/dist/resources/generated-instructions.js +11 -3
  11. package/dist/resources/tool-catalog.js +49 -3
  12. package/dist/resources/tool-info.d.ts +1 -0
  13. package/dist/resources/tool-info.js +139 -8
  14. package/dist/resources.js +2 -0
  15. package/dist/schemas.d.ts +2 -0
  16. package/dist/schemas.js +10 -4
  17. package/dist/server/bootstrap.js +41 -5
  18. package/dist/tools/apply-patch.js +30 -33
  19. package/dist/tools/calculate-hash.js +21 -24
  20. package/dist/tools/contract.d.ts +1 -1
  21. package/dist/tools/create-directory.js +3 -3
  22. package/dist/tools/delete-file.js +2 -2
  23. package/dist/tools/diff-files.js +32 -20
  24. package/dist/tools/edit-file.d.ts +4 -1
  25. package/dist/tools/edit-file.js +172 -104
  26. package/dist/tools/list-directory.js +105 -10
  27. package/dist/tools/move-file.js +3 -3
  28. package/dist/tools/read-multiple.d.ts +1 -1
  29. package/dist/tools/read-multiple.js +123 -99
  30. package/dist/tools/read.js +104 -76
  31. package/dist/tools/replace-in-files.d.ts +5 -2
  32. package/dist/tools/replace-in-files.js +142 -76
  33. package/dist/tools/roots.js +1 -1
  34. package/dist/tools/search-content.js +249 -218
  35. package/dist/tools/shared.js +39 -7
  36. package/dist/tools/stat-many.js +12 -19
  37. package/dist/tools/stat.js +1 -1
  38. package/dist/tools/task-support.d.ts +7 -0
  39. package/dist/tools/task-support.js +140 -101
  40. package/dist/tools/write-file.js +4 -4
  41. package/dist/tools.js +2 -2
  42. package/package.json +1 -1
@@ -7,7 +7,66 @@ import { formatOperationSummary, joinLines } from '../config.js';
7
7
  import { SearchContentInputSchema, SearchContentOutputSchema, } from '../schemas.js';
8
8
  import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
9
9
  import { registerToolTaskIfAvailable } from './task-support.js';
10
- const MAX_INLINE_MATCHES = parseInt(process.env['FS_CONTEXT_MAX_INLINE_MATCHES'] ?? '', 10) || 50;
10
+ /**
11
+ * Configuration constants for the Search Content tool.
12
+ */
13
+ const CONFIG = {
14
+ MAX_INLINE_MATCHES: parseInt(process.env['FS_CONTEXT_MAX_INLINE_MATCHES'] ?? '', 10) || 50,
15
+ COMPLETION_LABELS: {
16
+ timeout: 'timeout',
17
+ maxResults: 'max results',
18
+ maxFiles: 'max files',
19
+ },
20
+ };
21
+ const TRUTHY_SUMMARY_FIELDS = [
22
+ 'filesMatched',
23
+ 'skippedTooLarge',
24
+ 'skippedBinary',
25
+ 'skippedInaccessible',
26
+ 'linesSkippedDueToRegexTimeout',
27
+ ];
28
+ function buildStructuredSummaryFields(summary) {
29
+ const truthyFields = Object.fromEntries(TRUTHY_SUMMARY_FIELDS.flatMap((key) => {
30
+ const value = summary[key];
31
+ return value ? [[key, value]] : [];
32
+ }));
33
+ return {
34
+ ...truthyFields,
35
+ ...(summary.truncated ? { truncated: true } : {}),
36
+ ...(summary.stoppedReason ? { stoppedReason: summary.stoppedReason } : {}),
37
+ };
38
+ }
39
+ function buildCompletionSuffix(count, filesMatched, scope, stoppedReason) {
40
+ if (count === 0)
41
+ return `No matches in ${scope}`;
42
+ const matchWord = count === 1 ? 'match' : 'matches';
43
+ const fileWord = filesMatched === 1 ? 'file' : 'files';
44
+ const reasonSuffix = stoppedReason !== undefined
45
+ ? ` [${CONFIG.COMPLETION_LABELS[stoppedReason]}]`
46
+ : '';
47
+ return `${count} ${matchWord} in ${filesMatched} ${fileWord}${reasonSuffix}`;
48
+ }
49
+ function compareNormalizedMatches(left, right) {
50
+ const fileCompare = left.relativeFile.localeCompare(right.relativeFile);
51
+ if (fileCompare !== 0)
52
+ return fileCompare;
53
+ if (left.line !== right.line)
54
+ return left.line - right.line;
55
+ return left.index - right.index;
56
+ }
57
+ function buildSearchPreviewState(matches, payloads) {
58
+ const needsExternalize = matches.length > CONFIG.MAX_INLINE_MATCHES;
59
+ const visibleCount = needsExternalize
60
+ ? CONFIG.MAX_INLINE_MATCHES
61
+ : matches.length;
62
+ const visibleMatches = matches.slice(0, visibleCount);
63
+ return {
64
+ needsExternalize,
65
+ visibleMatches,
66
+ visiblePayloads: payloads.slice(0, visibleCount),
67
+ heading: SearchResponseBuilder.buildHeading(matches.length, visibleMatches.length),
68
+ };
69
+ }
11
70
  export const SEARCH_CONTENT_TOOL = {
12
71
  name: 'grep',
13
72
  title: 'Search Content',
@@ -26,203 +85,196 @@ export const SEARCH_CONTENT_TOOL = {
26
85
  ],
27
86
  taskSupport: 'optional',
28
87
  };
29
- function assertValidRegexPattern(pattern) {
30
- try {
31
- new RE2(pattern);
32
- }
33
- catch (error) {
34
- throw new McpError(ErrorCode.E_INVALID_PATTERN, `Invalid regex pattern: ${formatUnknownErrorMessage(error)}`);
35
- }
36
- }
37
- function findColumnOffset(content, pattern, isRegex, caseSensitive) {
38
- try {
39
- if (isRegex) {
40
- const flags = caseSensitive ? '' : 'i';
41
- const regex = new RE2(pattern, flags);
42
- const match = regex.exec(content);
43
- return match ? match.index : undefined;
88
+ /**
89
+ * Handles formatting and building the search response.
90
+ */
91
+ const SearchResponseBuilder = {
92
+ buildHeading(totalMatches, visibleMatches) {
93
+ if (visibleMatches >= totalMatches) {
94
+ return `Found ${totalMatches}:`;
44
95
  }
45
- if (caseSensitive) {
46
- const idx = content.indexOf(pattern);
47
- return idx >= 0 ? idx : undefined;
48
- }
49
- const lowerContent = content.toLowerCase();
50
- const lowerPattern = pattern.toLowerCase();
51
- const idx = lowerContent.indexOf(lowerPattern);
52
- return idx >= 0 ? idx : undefined;
53
- }
54
- catch {
55
- return undefined;
56
- }
57
- }
58
- function buildSearchTextResult(result, normalizedMatches) {
59
- const { summary } = result;
60
- if (normalizedMatches.length === 0)
61
- return 'No matches';
62
- let truncatedReason;
63
- if (summary.truncated) {
64
- truncatedReason = resolveTruncatedReason(summary);
65
- }
66
- const summaryOptions = {
67
- truncated: summary.truncated,
68
- ...(truncatedReason ? { truncatedReason } : {}),
69
- };
70
- return (buildMatchListText(`Found ${normalizedMatches.length}:`, normalizedMatches) + formatOperationSummary(summaryOptions));
71
- }
72
- function resolveTruncatedReason(summary) {
73
- if (summary.stoppedReason === 'timeout')
74
- return 'timeout';
75
- if (summary.stoppedReason === 'maxFiles') {
76
- return `max files (${summary.filesScanned})`;
77
- }
78
- return `max results (${summary.matches})`;
79
- }
80
- function buildMatchListText(heading, matches) {
81
- const lines = [heading];
82
- for (const match of matches) {
83
- lines.push(formatSearchMatchLine(match));
84
- }
85
- return joinLines(lines);
86
- }
87
- function buildSearchMatchPayload(match, context) {
88
- const column = findColumnOffset(match.content, context.pattern, context.isRegex, context.caseSensitive);
89
- return {
90
- file: match.relativeFile,
91
- line: match.line,
92
- ...(column !== undefined ? { column } : {}),
93
- content: match.content,
94
- matchCount: match.matchCount,
95
- ...(match.contextBefore ? { contextBefore: [...match.contextBefore] } : {}),
96
- ...(match.contextAfter ? { contextAfter: [...match.contextAfter] } : {}),
97
- };
98
- }
99
- function formatSearchMatchLine(match) {
100
- const lineNum = String(match.line).padStart(4);
101
- return ` ${match.relativeFile}:${lineNum}: ${match.content}`;
102
- }
103
- function buildStructuredSearchResult(result, normalizedMatches, options, context) {
104
- const { summary } = result;
105
- const matches = normalizedMatches.map((match) => buildSearchMatchPayload(match, context));
106
- return {
107
- ok: true,
108
- patternType: options.patternType,
109
- caseSensitive: options.caseSensitive,
110
- matches,
111
- totalMatches: summary.matches,
112
- filesScanned: summary.filesScanned,
113
- ...(summary.truncated ? { truncated: summary.truncated } : {}),
114
- ...(summary.filesMatched ? { filesMatched: summary.filesMatched } : {}),
115
- ...(summary.skippedTooLarge
116
- ? { skippedTooLarge: summary.skippedTooLarge }
117
- : {}),
118
- ...(summary.skippedBinary ? { skippedBinary: summary.skippedBinary } : {}),
119
- ...(summary.skippedInaccessible
120
- ? { skippedInaccessible: summary.skippedInaccessible }
121
- : {}),
122
- ...(summary.linesSkippedDueToRegexTimeout
123
- ? { linesSkippedDueToRegexTimeout: summary.linesSkippedDueToRegexTimeout }
124
- : {}),
125
- ...(summary.stoppedReason ? { stoppedReason: summary.stoppedReason } : {}),
126
- };
127
- }
128
- function normalizeSearchMatches(result) {
129
- const relativeByFile = new Map();
130
- const normalized = [];
131
- let index = 0;
132
- for (const match of result.matches) {
133
- const cached = relativeByFile.get(match.file);
134
- const relative = cached ?? path.relative(result.basePath, match.file);
135
- if (!cached)
136
- relativeByFile.set(match.file, relative);
137
- normalized.push({
96
+ return `Found ${totalMatches} (showing first ${visibleMatches}):`;
97
+ },
98
+ buildText(heading, matches, summary) {
99
+ if (matches.length === 0)
100
+ return 'No matches';
101
+ const text = this.buildMatchList(heading, matches);
102
+ if (!summary)
103
+ return text;
104
+ const summaryOpts = {
105
+ truncated: summary.truncated,
106
+ ...(summary.truncated
107
+ ? { truncatedReason: this.resolveTruncatedReason(summary) }
108
+ : {}),
109
+ };
110
+ return text + formatOperationSummary(summaryOpts);
111
+ },
112
+ buildStructured(summary, matches, options) {
113
+ return {
114
+ ok: true,
115
+ patternType: options.patternType,
116
+ caseSensitive: options.caseSensitive,
117
+ matches,
118
+ totalMatches: summary.matches,
119
+ filesScanned: summary.filesScanned,
120
+ ...buildStructuredSummaryFields(summary),
121
+ };
122
+ },
123
+ normalizeMatches(result) {
124
+ const relativeByFile = new Map();
125
+ const getRelativeFile = (file) => {
126
+ const cached = relativeByFile.get(file);
127
+ if (cached !== undefined)
128
+ return cached;
129
+ const relative = path.relative(result.basePath, file);
130
+ relativeByFile.set(file, relative);
131
+ return relative;
132
+ };
133
+ return result.matches
134
+ .map((match, index) => ({
138
135
  ...match,
139
- relativeFile: relative,
136
+ relativeFile: getRelativeFile(match.file),
140
137
  index,
138
+ }))
139
+ .sort(compareNormalizedMatches);
140
+ },
141
+ buildMatchPayloads(matches, context) {
142
+ return matches.map((match) => {
143
+ const column = this.findColumnOffset(match.content, context);
144
+ return {
145
+ file: match.relativeFile,
146
+ line: match.line,
147
+ ...(column !== undefined ? { column } : {}),
148
+ content: match.content,
149
+ matchCount: match.matchCount,
150
+ ...(match.contextBefore
151
+ ? { contextBefore: [...match.contextBefore] }
152
+ : {}),
153
+ ...(match.contextAfter
154
+ ? { contextAfter: [...match.contextAfter] }
155
+ : {}),
156
+ };
141
157
  });
142
- index += 1;
143
- }
144
- normalized.sort((a, b) => {
145
- const fileCompare = a.relativeFile.localeCompare(b.relativeFile);
146
- if (fileCompare !== 0)
147
- return fileCompare;
148
- if (a.line !== b.line)
149
- return a.line - b.line;
150
- return a.index - b.index;
151
- });
152
- return normalized;
153
- }
158
+ },
159
+ buildMatchList(heading, matches) {
160
+ const lines = [heading];
161
+ for (const match of matches) {
162
+ const lineNum = String(match.line).padStart(4);
163
+ lines.push(` ${match.relativeFile}:${lineNum}: ${match.content}`);
164
+ }
165
+ return joinLines(lines);
166
+ },
167
+ resolveTruncatedReason(summary) {
168
+ if (summary.stoppedReason === 'timeout')
169
+ return 'timeout';
170
+ if (summary.stoppedReason === 'maxFiles') {
171
+ return `max files (${summary.filesScanned})`;
172
+ }
173
+ return `max results (${summary.matches})`;
174
+ },
175
+ findColumnOffset(content, context) {
176
+ try {
177
+ if (context.matcher) {
178
+ context.matcher.lastIndex = 0;
179
+ const match = context.matcher.exec(content);
180
+ return match ? match.index : undefined;
181
+ }
182
+ if (context.caseSensitive) {
183
+ const idx = content.indexOf(context.pattern);
184
+ return idx >= 0 ? idx : undefined;
185
+ }
186
+ // Case-insensitive literal search
187
+ const idx = content.toLowerCase().indexOf(context.foldedPattern ?? '');
188
+ return idx >= 0 ? idx : undefined;
189
+ }
190
+ catch {
191
+ return undefined;
192
+ }
193
+ },
194
+ };
195
+ const SearchExecutor = {
196
+ async run(args, basePath, signal, onProgress) {
197
+ const excludePatterns = args.includeIgnored ? [] : DEFAULT_EXCLUDE_PATTERNS;
198
+ const options = {
199
+ includeHidden: args.includeHidden,
200
+ excludePatterns,
201
+ filePattern: args.filePattern,
202
+ caseSensitive: args.caseSensitive,
203
+ wholeWord: args.wholeWord,
204
+ contextLines: args.contextLines,
205
+ maxResults: args.maxResults,
206
+ isLiteral: !args.isRegex,
207
+ multiline: args.multiline,
208
+ ...(signal ? { signal } : {}),
209
+ ...(onProgress ? { onProgress } : {}),
210
+ };
211
+ try {
212
+ return await searchContent(basePath, args.pattern, options);
213
+ }
214
+ catch (error) {
215
+ if (error instanceof Error && /regular expression/i.test(error.message)) {
216
+ throw new McpError(ErrorCode.E_INVALID_PATTERN, error.message);
217
+ }
218
+ throw error;
219
+ }
220
+ },
221
+ createMatcher(args) {
222
+ if (!args.isRegex)
223
+ return undefined;
224
+ try {
225
+ const flags = (args.caseSensitive ? '' : 'i') + (args.multiline ? 'm' : '');
226
+ return new RE2(args.pattern, flags);
227
+ }
228
+ catch (error) {
229
+ throw new McpError(ErrorCode.E_INVALID_PATTERN, `Invalid regex pattern: ${formatUnknownErrorMessage(error)}`);
230
+ }
231
+ },
232
+ createSearchContext(args, matcher) {
233
+ return {
234
+ pattern: args.pattern,
235
+ caseSensitive: args.caseSensitive,
236
+ ...(matcher ? { matcher } : {}),
237
+ ...(!args.isRegex && !args.caseSensitive
238
+ ? { foldedPattern: args.pattern.toLowerCase() }
239
+ : {}),
240
+ };
241
+ },
242
+ };
154
243
  async function handleSearchContent(args, signal, resourceStore, onProgress) {
155
244
  const basePath = resolvePathOrRoot(args.path);
156
- const excludePatterns = args.includeIgnored ? [] : DEFAULT_EXCLUDE_PATTERNS;
157
245
  const patternType = args.isRegex ? 'regex' : 'literal';
158
- if (args.isRegex) {
159
- assertValidRegexPattern(args.pattern);
160
- }
161
- const options = {
162
- includeHidden: args.includeHidden,
163
- excludePatterns,
164
- filePattern: args.filePattern,
165
- caseSensitive: args.caseSensitive,
166
- wholeWord: args.wholeWord,
167
- contextLines: args.contextLines,
168
- maxResults: args.maxResults,
169
- isLiteral: !args.isRegex,
170
- multiline: args.multiline,
171
- };
172
- if (signal) {
173
- options.signal = signal;
174
- }
175
- if (onProgress) {
176
- options.onProgress = onProgress;
177
- }
178
- let result;
179
- try {
180
- result = await searchContent(basePath, args.pattern, options);
181
- }
182
- catch (error) {
183
- if (error instanceof Error && /regular expression/i.test(error.message)) {
184
- throw new McpError(ErrorCode.E_INVALID_PATTERN, error.message);
185
- }
186
- throw error;
187
- }
188
- const normalizedMatches = normalizeSearchMatches(result);
189
- const searchContext = {
190
- pattern: args.pattern,
191
- isRegex: args.isRegex,
192
- caseSensitive: args.caseSensitive,
193
- };
194
- const structuredFull = buildStructuredSearchResult(result, normalizedMatches, {
195
- patternType,
196
- caseSensitive: args.caseSensitive,
197
- }, searchContext);
198
- const needsExternalize = normalizedMatches.length > MAX_INLINE_MATCHES;
199
- if (!resourceStore || !needsExternalize) {
200
- return buildToolResponse(buildSearchTextResult(result, normalizedMatches), structuredFull);
246
+ const regexMatcher = SearchExecutor.createMatcher(args);
247
+ const result = await SearchExecutor.run(args, basePath, signal, onProgress);
248
+ const normalizedMatches = SearchResponseBuilder.normalizeMatches(result);
249
+ const searchContext = SearchExecutor.createSearchContext(args, regexMatcher);
250
+ const matchPayloads = SearchResponseBuilder.buildMatchPayloads(normalizedMatches, searchContext);
251
+ const fullStructured = SearchResponseBuilder.buildStructured(result.summary, matchPayloads, { patternType, caseSensitive: args.caseSensitive });
252
+ const preview = buildSearchPreviewState(normalizedMatches, matchPayloads);
253
+ if (resourceStore && preview.needsExternalize) {
254
+ const previewStructured = {
255
+ ...fullStructured,
256
+ matches: preview.visiblePayloads,
257
+ truncated: true,
258
+ };
259
+ const entry = resourceStore.putText({
260
+ name: 'grep:matches',
261
+ mimeType: 'application/json',
262
+ text: JSON.stringify(fullStructured),
263
+ });
264
+ previewStructured.resourceUri = entry.uri;
265
+ const text = SearchResponseBuilder.buildText(preview.heading, preview.visibleMatches);
266
+ return buildToolResponse(text, previewStructured, [
267
+ buildResourceLink({
268
+ uri: entry.uri,
269
+ name: entry.name,
270
+ mimeType: entry.mimeType,
271
+ description: 'Full grep results as JSON (structuredContent)',
272
+ expiresAt: entry.expiresAt,
273
+ }),
274
+ ]);
201
275
  }
202
- const previewMatches = normalizedMatches.slice(0, MAX_INLINE_MATCHES);
203
- const previewPayload = previewMatches.map((match) => buildSearchMatchPayload(match, searchContext));
204
- const previewStructured = {
205
- ...structuredFull,
206
- matches: previewPayload,
207
- truncated: true,
208
- resourceUri: undefined,
209
- };
210
- const entry = resourceStore.putText({
211
- name: 'grep:matches',
212
- mimeType: 'application/json',
213
- text: JSON.stringify(structuredFull),
214
- });
215
- previewStructured.resourceUri = entry.uri;
216
- const text = buildMatchListText(`Found ${normalizedMatches.length} (showing first ${MAX_INLINE_MATCHES}):`, previewMatches);
217
- return buildToolResponse(text, previewStructured, [
218
- buildResourceLink({
219
- uri: entry.uri,
220
- name: entry.name,
221
- mimeType: entry.mimeType,
222
- description: 'Full grep results as JSON (structuredContent)',
223
- expiresAt: entry.expiresAt,
224
- }),
225
- ]);
276
+ const text = SearchResponseBuilder.buildText(preview.heading, preview.visibleMatches, result.summary);
277
+ return buildToolResponse(text, fullStructured);
226
278
  }
227
279
  export function registerSearchContentTool(server, options = {}) {
228
280
  const handler = (args, extra) => executeToolWithDiagnostics({
@@ -230,14 +282,14 @@ export function registerSearchContentTool(server, options = {}) {
230
282
  extra,
231
283
  context: { path: args.path ?? '.' },
232
284
  run: async (signal) => {
233
- const scope = args.filePattern;
234
- const { pattern } = args;
235
- const progress = createToolProgressSession(extra, `🔎︎ grep: ${pattern}`);
285
+ const { pattern, filePattern: scope } = args;
286
+ const progressLabel = `🔎︎ grep: ${pattern}`;
287
+ const progress = createToolProgressSession(extra, progressLabel);
236
288
  const progressWithMessage = ({ current, total, }) => {
237
289
  progress.update({
238
290
  current,
239
291
  ...(total !== undefined ? { total } : {}),
240
- message: `🔎︎ grep: ${pattern} [${current} files]`,
292
+ message: `${progressLabel} [${current} files]`,
241
293
  });
242
294
  };
243
295
  try {
@@ -246,39 +298,18 @@ export function registerSearchContentTool(server, options = {}) {
246
298
  const count = sc.ok && sc.totalMatches ? sc.totalMatches : 0;
247
299
  const filesMatched = sc.ok ? (sc.filesMatched ?? 0) : 0;
248
300
  const stoppedReason = sc.ok ? sc.stoppedReason : undefined;
249
- const suffix = buildCompletionSuffix({
250
- count,
251
- filesMatched,
252
- scope,
253
- stoppedReason,
254
- });
301
+ const suffix = buildCompletionSuffix(count, filesMatched, scope, stoppedReason);
255
302
  const finalCurrent = resolveFinalProgressCurrent(progress, (sc.filesScanned ?? 0) + 1);
256
- progress.complete(`🔎︎ grep: ${pattern} • ${suffix}`, finalCurrent);
303
+ progress.complete(`${progressLabel} • ${suffix}`, finalCurrent);
257
304
  return result;
258
305
  }
259
306
  catch (error) {
260
- progress.fail(`🔎︎ grep: ${pattern} • failed`);
307
+ progress.fail(`${progressLabel} • failed`);
261
308
  throw error;
262
309
  }
263
310
  },
264
311
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path ?? '.'),
265
312
  });
266
- function buildCompletionSuffix(params) {
267
- if (params.count === 0) {
268
- return `No matches in ${params.scope}`;
269
- }
270
- const matchWord = params.count === 1 ? 'match' : 'matches';
271
- const fileWord = params.filesMatched === 1 ? 'file' : 'files';
272
- const reasonLabels = {
273
- timeout: 'timeout',
274
- maxResults: 'max results',
275
- maxFiles: 'max files',
276
- };
277
- const reasonSuffix = params.stoppedReason !== undefined
278
- ? ` [${reasonLabels[params.stoppedReason]}]`
279
- : '';
280
- return `${params.count} ${matchWord} in ${params.filesMatched} ${fileWord}${reasonSuffix}`;
281
- }
282
313
  const { isInitialized } = options;
283
314
  const wrappedHandler = wrapToolHandler(handler, {
284
315
  guard: isInitialized,
@@ -34,6 +34,36 @@ export const IDEMPOTENT_WRITE_TOOL_ANNOTATIONS = {
34
34
  destructiveHint: false,
35
35
  openWorldHint: false,
36
36
  };
37
+ function isTaskSupportLevel(value) {
38
+ return value === 'optional' || value === 'required' || value === 'forbidden';
39
+ }
40
+ function normalizeToolExecution(tool) {
41
+ const candidate = tool;
42
+ const topLevelTaskSupport = candidate['taskSupport'];
43
+ const existingExecution = candidate['execution'] && typeof candidate['execution'] === 'object'
44
+ ? candidate['execution']
45
+ : undefined;
46
+ const executionTaskSupport = existingExecution?.['taskSupport'];
47
+ const resolvedTaskSupport = isTaskSupportLevel(topLevelTaskSupport)
48
+ ? topLevelTaskSupport
49
+ : isTaskSupportLevel(executionTaskSupport)
50
+ ? executionTaskSupport
51
+ : undefined;
52
+ if (resolvedTaskSupport === undefined && topLevelTaskSupport === undefined) {
53
+ return tool;
54
+ }
55
+ const normalized = { ...candidate };
56
+ delete normalized['taskSupport'];
57
+ if (resolvedTaskSupport !== undefined || existingExecution !== undefined) {
58
+ normalized['execution'] = {
59
+ ...(existingExecution ?? {}),
60
+ ...(resolvedTaskSupport !== undefined
61
+ ? { taskSupport: resolvedTaskSupport }
62
+ : {}),
63
+ };
64
+ }
65
+ return normalized;
66
+ }
37
67
  export function shouldStripStructuredOutput() {
38
68
  return parseTrueEnvFlag(process.env['FS_CONTEXT_STRIP_STRUCTURED']);
39
69
  }
@@ -133,15 +163,16 @@ function canReportProgress(extra) {
133
163
  return canSendProgress(extra) || hasTask;
134
164
  }
135
165
  export function withDefaultIcons(tool, iconInfo) {
166
+ const normalizedTool = normalizeToolExecution(tool);
136
167
  if (!iconInfo) {
137
- return maybeStripOutputSchema(tool);
168
+ return maybeStripOutputSchema(normalizedTool);
138
169
  }
139
- const existingIcons = tool.icons;
170
+ const existingIcons = normalizedTool.icons;
140
171
  if (existingIcons && existingIcons.length > 0) {
141
- return maybeStripOutputSchema(tool);
172
+ return maybeStripOutputSchema(normalizedTool);
142
173
  }
143
174
  const withIcons = {
144
- ...tool,
175
+ ...normalizedTool,
145
176
  icons: [
146
177
  {
147
178
  src: iconInfo.src,
@@ -440,13 +471,14 @@ export function decodeOffsetCursor(cursor) {
440
471
  parsed !== null &&
441
472
  typeof parsed.offset === 'number') {
442
473
  const { offset } = parsed;
443
- return Number.isInteger(offset) && offset >= 0 ? offset : 0;
474
+ if (Number.isInteger(offset) && offset >= 0)
475
+ return offset;
444
476
  }
445
477
  }
446
478
  catch {
447
- // ignore malformed cursor
479
+ // fall through to throw
448
480
  }
449
- return 0;
481
+ throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid cursor: the cursor value is malformed or corrupted. Request the first page without a cursor.`);
450
482
  }
451
483
  export function buildBatchPathContext(paths, unitLabel = 'paths') {
452
484
  const normalizedLabel = paths.length === 1 ? unitLabel.replace(/s$/i, '') : unitLabel;
@@ -35,24 +35,18 @@ async function handleGetMultipleFileInfo(args, signal, onProgress) {
35
35
  ...(signal ? { signal } : {}),
36
36
  ...(onProgress ? { onProgress } : {}),
37
37
  });
38
- const structuredResults = [];
39
- const textBlocks = [];
40
- for (const entry of result.results) {
41
- structuredResults.push({
42
- path: entry.path,
43
- info: entry.info ? buildFileInfoPayload(entry.info) : undefined,
44
- error: entry.error,
45
- });
46
- if (entry.error) {
47
- textBlocks.push(`${entry.path}: ${entry.error}`);
48
- }
49
- else if (entry.info) {
50
- textBlocks.push(formatFileInfoDetail(entry.info));
51
- }
52
- else {
53
- textBlocks.push(entry.path);
54
- }
55
- }
38
+ const structuredResults = result.results.map((entry) => ({
39
+ path: entry.path,
40
+ info: entry.info ? buildFileInfoPayload(entry.info) : undefined,
41
+ error: entry.error,
42
+ }));
43
+ const text = result.results
44
+ .map((entry) => entry.error
45
+ ? `${entry.path}: ${entry.error}`
46
+ : entry.info
47
+ ? formatFileInfoDetail(entry.info)
48
+ : entry.path)
49
+ .join('\n\n');
56
50
  const structured = {
57
51
  ok: true,
58
52
  results: structuredResults,
@@ -62,7 +56,6 @@ async function handleGetMultipleFileInfo(args, signal, onProgress) {
62
56
  failed: result.summary.failed,
63
57
  },
64
58
  };
65
- const text = textBlocks.join('\n\n');
66
59
  return buildToolResponse(text, structured);
67
60
  }
68
61
  export function registerGetMultipleFileInfoTool(server, options = {}) {
@@ -14,7 +14,7 @@ export const GET_FILE_INFO_TOOL = {
14
14
  inputSchema: GetFileInfoInputSchema,
15
15
  outputSchema: GetFileInfoOutputSchema,
16
16
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
17
- taskSupport: 'optional',
17
+ taskSupport: 'forbidden',
18
18
  nuances: ['Use before read/search when file size/type uncertainty exists.'],
19
19
  };
20
20
  function formatFileInfoDetails(info) {
@@ -1,8 +1,15 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
1
2
  import type { ToolTaskHandler } from '@modelcontextprotocol/sdk/experimental/tasks/interfaces.js';
2
3
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
4
  import type { AnySchema, SchemaOutput, ShapeOutput, ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js';
4
5
  import type { RequestTaskStore } from '@modelcontextprotocol/sdk/shared/protocol.js';
5
6
  import type { IconInfo, ToolExtra, ToolResult } from './shared.js';
7
+ export interface TaskContext {
8
+ taskId: string;
9
+ toolName?: string | undefined;
10
+ startTime: number;
11
+ }
12
+ export declare const taskContext: AsyncLocalStorage<TaskContext>;
6
13
  type TaskToolExtra = ToolExtra & {
7
14
  taskId?: string;
8
15
  taskStore?: RequestTaskStore;