@j0hanz/filesystem-mcp 1.11.1 → 1.13.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.
@@ -3,16 +3,70 @@ import RE2 from 're2';
3
3
  import { DEFAULT_EXCLUDE_PATTERNS } from '../lib/constants.js';
4
4
  import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
5
5
  import { searchContent } from '../lib/file-operations/search.js';
6
- import { formatOperationSummary, joinLines } from '../config.js';
6
+ import { formatOperationSummary } 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;
11
- const SEARCH_COMPLETION_REASON_LABELS = {
12
- timeout: 'timeout',
13
- maxResults: 'max results',
14
- maxFiles: 'max files',
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
+ },
15
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
+ }
16
70
  export const SEARCH_CONTENT_TOOL = {
17
71
  name: 'grep',
18
72
  title: 'Search Content',
@@ -31,205 +85,216 @@ export const SEARCH_CONTENT_TOOL = {
31
85
  ],
32
86
  taskSupport: 'optional',
33
87
  };
34
- function findColumnOffset(content, pattern, matcher, caseSensitive) {
35
- try {
36
- if (matcher) {
37
- matcher.lastIndex = 0;
38
- const match = matcher.exec(content);
39
- 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}:`;
40
95
  }
41
- if (caseSensitive) {
42
- const idx = content.indexOf(pattern);
43
- return idx >= 0 ? idx : undefined;
44
- }
45
- const lowerContent = content.toLowerCase();
46
- const lowerPattern = pattern.toLowerCase();
47
- const idx = lowerContent.indexOf(lowerPattern);
48
- return idx >= 0 ? idx : undefined;
49
- }
50
- catch {
51
- return undefined;
52
- }
53
- }
54
- function buildSearchTextResult(heading, normalizedMatches, summary) {
55
- if (normalizedMatches.length === 0)
56
- return 'No matches';
57
- const text = buildMatchListText(heading, normalizedMatches);
58
- if (!summary) {
59
- return text;
60
- }
61
- const truncatedReason = summary.truncated
62
- ? resolveTruncatedReason(summary)
63
- : undefined;
64
- const summaryOptions = {
65
- truncated: summary.truncated,
66
- ...(truncatedReason ? { truncatedReason } : {}),
67
- };
68
- return text + formatOperationSummary(summaryOptions);
69
- }
70
- function resolveTruncatedReason(summary) {
71
- if (summary.stoppedReason === 'timeout')
72
- return 'timeout';
73
- if (summary.stoppedReason === 'maxFiles') {
74
- return `max files (${summary.filesScanned})`;
75
- }
76
- return `max results (${summary.matches})`;
77
- }
78
- function buildMatchListText(heading, matches) {
79
- const lines = [heading];
80
- for (const match of matches) {
81
- lines.push(formatSearchMatchLine(match));
82
- }
83
- return joinLines(lines);
84
- }
85
- function buildSearchMatchPayload(match, context) {
86
- const column = findColumnOffset(match.content, context.pattern, context.matcher, context.caseSensitive);
87
- return {
88
- file: match.relativeFile,
89
- line: match.line,
90
- ...(column !== undefined ? { column } : {}),
91
- content: match.content,
92
- matchCount: match.matchCount,
93
- ...(match.contextBefore ? { contextBefore: [...match.contextBefore] } : {}),
94
- ...(match.contextAfter ? { contextAfter: [...match.contextAfter] } : {}),
95
- };
96
- }
97
- function formatSearchMatchLine(match) {
98
- const lineNum = String(match.line).padStart(4);
99
- return ` ${match.relativeFile}:${lineNum}: ${match.content}`;
100
- }
101
- function buildSearchMatchPayloads(matches, context) {
102
- return matches.map((match) => buildSearchMatchPayload(match, context));
103
- }
104
- function buildStructuredSearchResult(summary, matches, options) {
105
- return {
106
- ok: true,
107
- patternType: options.patternType,
108
- caseSensitive: options.caseSensitive,
109
- matches,
110
- totalMatches: summary.matches,
111
- filesScanned: summary.filesScanned,
112
- ...(summary.truncated ? { truncated: summary.truncated } : {}),
113
- ...(summary.filesMatched ? { filesMatched: summary.filesMatched } : {}),
114
- ...(summary.skippedTooLarge
115
- ? { skippedTooLarge: summary.skippedTooLarge }
116
- : {}),
117
- ...(summary.skippedBinary ? { skippedBinary: summary.skippedBinary } : {}),
118
- ...(summary.skippedInaccessible
119
- ? { skippedInaccessible: summary.skippedInaccessible }
120
- : {}),
121
- ...(summary.linesSkippedDueToRegexTimeout
122
- ? { linesSkippedDueToRegexTimeout: summary.linesSkippedDueToRegexTimeout }
123
- : {}),
124
- ...(summary.stoppedReason ? { stoppedReason: summary.stoppedReason } : {}),
125
- };
126
- }
127
- function normalizeSearchMatches(result) {
128
- const relativeByFile = new Map();
129
- const normalized = [];
130
- let index = 0;
131
- for (const match of result.matches) {
132
- const cached = relativeByFile.get(match.file);
133
- const relative = cached ?? path.relative(result.basePath, match.file);
134
- if (!cached)
135
- relativeByFile.set(match.file, relative);
136
- 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) => ({
137
135
  ...match,
138
- relativeFile: relative,
136
+ relativeFile: getRelativeFile(match.file),
139
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
+ };
140
157
  });
141
- index += 1;
142
- }
143
- normalized.sort((a, b) => {
144
- const fileCompare = a.relativeFile.localeCompare(b.relativeFile);
145
- if (fileCompare !== 0)
146
- return fileCompare;
147
- if (a.line !== b.line)
148
- return a.line - b.line;
149
- return a.index - b.index;
150
- });
151
- return normalized;
152
- }
153
- async function handleSearchContent(args, signal, resourceStore, onProgress) {
154
- const basePath = resolvePathOrRoot(args.path);
155
- const excludePatterns = args.includeIgnored ? [] : DEFAULT_EXCLUDE_PATTERNS;
156
- const patternType = args.isRegex ? 'regex' : 'literal';
157
- let regexMatcher;
158
- if (args.isRegex) {
158
+ },
159
+ buildMatchList(heading, matches) {
160
+ if (matches.length === 0)
161
+ return heading;
162
+ // Fast path: calculate exact byte length to avoid arrays and string concatenation in V8
163
+ // +1 for newline after heading. Each match gets: " " (2) + relativeFile + ":" + line + ": " (2) + content + "\n"
164
+ let totalBytes = Buffer.byteLength(heading, 'utf8');
165
+ for (const match of matches) {
166
+ totalBytes +=
167
+ 1 + // \n
168
+ 2 + // " "
169
+ Buffer.byteLength(match.relativeFile, 'utf8') +
170
+ 1 + // ":"
171
+ Math.max(4, String(match.line).length) +
172
+ 2 + // ": "
173
+ Buffer.byteLength(match.content, 'utf8');
174
+ }
175
+ const buf = Buffer.allocUnsafe(totalBytes);
176
+ let offset = buf.write(heading, 0, 'utf8');
177
+ for (const match of matches) {
178
+ offset += buf.write('\n ', offset, 'utf8');
179
+ offset += buf.write(match.relativeFile, offset, 'utf8');
180
+ offset += buf.write(':', offset, 'utf8');
181
+ offset += buf.write(String(match.line).padStart(4), offset, 'utf8');
182
+ offset += buf.write(': ', offset, 'utf8');
183
+ offset += buf.write(match.content, offset, 'utf8');
184
+ }
185
+ return buf.toString('utf8', 0, offset);
186
+ },
187
+ resolveTruncatedReason(summary) {
188
+ if (summary.stoppedReason === 'timeout')
189
+ return 'timeout';
190
+ if (summary.stoppedReason === 'maxFiles') {
191
+ return `max files (${summary.filesScanned})`;
192
+ }
193
+ return `max results (${summary.matches})`;
194
+ },
195
+ findColumnOffset(content, context) {
196
+ try {
197
+ if (context.matcher) {
198
+ context.matcher.lastIndex = 0;
199
+ const match = context.matcher.exec(content);
200
+ return match ? match.index : undefined;
201
+ }
202
+ if (context.caseSensitive) {
203
+ const idx = content.indexOf(context.pattern);
204
+ return idx >= 0 ? idx : undefined;
205
+ }
206
+ // Case-insensitive literal search
207
+ const idx = content.toLowerCase().indexOf(context.foldedPattern ?? '');
208
+ return idx >= 0 ? idx : undefined;
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ },
214
+ };
215
+ const SearchExecutor = {
216
+ async run(args, basePath, signal, onProgress) {
217
+ const excludePatterns = args.includeIgnored ? [] : DEFAULT_EXCLUDE_PATTERNS;
218
+ const options = {
219
+ includeHidden: args.includeHidden,
220
+ excludePatterns,
221
+ filePattern: args.filePattern,
222
+ caseSensitive: args.caseSensitive,
223
+ wholeWord: args.wholeWord,
224
+ contextLines: args.contextLines,
225
+ maxResults: args.maxResults,
226
+ isLiteral: !args.isRegex,
227
+ multiline: args.multiline,
228
+ ...(signal ? { signal } : {}),
229
+ ...(onProgress ? { onProgress } : {}),
230
+ };
231
+ try {
232
+ return await searchContent(basePath, args.pattern, options);
233
+ }
234
+ catch (error) {
235
+ if (error instanceof Error && /regular expression/i.test(error.message)) {
236
+ throw new McpError(ErrorCode.E_INVALID_PATTERN, error.message);
237
+ }
238
+ throw error;
239
+ }
240
+ },
241
+ createMatcher(args) {
242
+ if (!args.isRegex)
243
+ return undefined;
159
244
  try {
160
245
  const flags = (args.caseSensitive ? '' : 'i') + (args.multiline ? 'm' : '');
161
- regexMatcher = new RE2(args.pattern, flags);
246
+ return new RE2(args.pattern, flags);
162
247
  }
163
248
  catch (error) {
164
249
  throw new McpError(ErrorCode.E_INVALID_PATTERN, `Invalid regex pattern: ${formatUnknownErrorMessage(error)}`);
165
250
  }
251
+ },
252
+ createSearchContext(args, matcher) {
253
+ return {
254
+ pattern: args.pattern,
255
+ caseSensitive: args.caseSensitive,
256
+ ...(matcher ? { matcher } : {}),
257
+ ...(!args.isRegex && !args.caseSensitive
258
+ ? { foldedPattern: args.pattern.toLowerCase() }
259
+ : {}),
260
+ };
261
+ },
262
+ };
263
+ async function handleSearchContent(args, signal, resourceStore, onProgress) {
264
+ const basePath = resolvePathOrRoot(args.path);
265
+ const patternType = args.isRegex ? 'regex' : 'literal';
266
+ const regexMatcher = SearchExecutor.createMatcher(args);
267
+ const result = await SearchExecutor.run(args, basePath, signal, onProgress);
268
+ const normalizedMatches = SearchResponseBuilder.normalizeMatches(result);
269
+ const searchContext = SearchExecutor.createSearchContext(args, regexMatcher);
270
+ const matchPayloads = SearchResponseBuilder.buildMatchPayloads(normalizedMatches, searchContext);
271
+ const fullStructured = SearchResponseBuilder.buildStructured(result.summary, matchPayloads, { patternType, caseSensitive: args.caseSensitive });
272
+ const preview = buildSearchPreviewState(normalizedMatches, matchPayloads);
273
+ if (resourceStore && preview.needsExternalize) {
274
+ const previewStructured = {
275
+ ...fullStructured,
276
+ matches: preview.visiblePayloads,
277
+ truncated: true,
278
+ };
279
+ const entry = resourceStore.putText({
280
+ name: 'grep:matches',
281
+ mimeType: 'application/json',
282
+ text: JSON.stringify(fullStructured),
283
+ });
284
+ previewStructured.resourceUri = entry.uri;
285
+ const text = SearchResponseBuilder.buildText(preview.heading, preview.visibleMatches);
286
+ return buildToolResponse(text, previewStructured, [
287
+ buildResourceLink({
288
+ uri: entry.uri,
289
+ name: entry.name,
290
+ mimeType: entry.mimeType,
291
+ description: 'Full grep results as JSON (structuredContent)',
292
+ expiresAt: entry.expiresAt,
293
+ }),
294
+ ]);
166
295
  }
167
- const options = {
168
- includeHidden: args.includeHidden,
169
- excludePatterns,
170
- filePattern: args.filePattern,
171
- caseSensitive: args.caseSensitive,
172
- wholeWord: args.wholeWord,
173
- contextLines: args.contextLines,
174
- maxResults: args.maxResults,
175
- isLiteral: !args.isRegex,
176
- multiline: args.multiline,
177
- };
178
- if (signal) {
179
- options.signal = signal;
180
- }
181
- if (onProgress) {
182
- options.onProgress = onProgress;
183
- }
184
- let result;
185
- try {
186
- result = await searchContent(basePath, args.pattern, options);
187
- }
188
- catch (error) {
189
- if (error instanceof Error && /regular expression/i.test(error.message)) {
190
- throw new McpError(ErrorCode.E_INVALID_PATTERN, error.message);
191
- }
192
- throw error;
193
- }
194
- const normalizedMatches = normalizeSearchMatches(result);
195
- const searchContext = {
196
- pattern: args.pattern,
197
- matcher: regexMatcher,
198
- caseSensitive: args.caseSensitive,
199
- };
200
- const matchPayloads = buildSearchMatchPayloads(normalizedMatches, searchContext);
201
- const structuredFull = buildStructuredSearchResult(result.summary, matchPayloads, {
202
- patternType,
203
- caseSensitive: args.caseSensitive,
204
- });
205
- const needsExternalize = normalizedMatches.length > MAX_INLINE_MATCHES;
206
- if (!resourceStore || !needsExternalize) {
207
- return buildToolResponse(buildSearchTextResult(`Found ${normalizedMatches.length}:`, normalizedMatches, result.summary), structuredFull);
208
- }
209
- const previewMatches = normalizedMatches.slice(0, MAX_INLINE_MATCHES);
210
- const previewPayload = buildSearchMatchPayloads(previewMatches, searchContext);
211
- const previewStructured = {
212
- ...structuredFull,
213
- matches: previewPayload,
214
- truncated: true,
215
- resourceUri: undefined,
216
- };
217
- const entry = resourceStore.putText({
218
- name: 'grep:matches',
219
- mimeType: 'application/json',
220
- text: JSON.stringify(structuredFull),
221
- });
222
- previewStructured.resourceUri = entry.uri;
223
- const text = buildSearchTextResult(`Found ${normalizedMatches.length} (showing first ${MAX_INLINE_MATCHES}):`, previewMatches);
224
- return buildToolResponse(text, previewStructured, [
225
- buildResourceLink({
226
- uri: entry.uri,
227
- name: entry.name,
228
- mimeType: entry.mimeType,
229
- description: 'Full grep results as JSON (structuredContent)',
230
- expiresAt: entry.expiresAt,
231
- }),
232
- ]);
296
+ const text = SearchResponseBuilder.buildText(preview.heading, preview.visibleMatches, result.summary);
297
+ return buildToolResponse(text, fullStructured);
233
298
  }
234
299
  export function registerSearchContentTool(server, options = {}) {
235
300
  const handler = (args, extra) => executeToolWithDiagnostics({
@@ -237,14 +302,14 @@ export function registerSearchContentTool(server, options = {}) {
237
302
  extra,
238
303
  context: { path: args.path ?? '.' },
239
304
  run: async (signal) => {
240
- const scope = args.filePattern;
241
- const { pattern } = args;
242
- const progress = createToolProgressSession(extra, `🔎︎ grep: ${pattern}`);
305
+ const { pattern, filePattern: scope } = args;
306
+ const progressLabel = `🔎︎ grep: ${pattern}`;
307
+ const progress = createToolProgressSession(extra, progressLabel);
243
308
  const progressWithMessage = ({ current, total, }) => {
244
309
  progress.update({
245
310
  current,
246
311
  ...(total !== undefined ? { total } : {}),
247
- message: `🔎︎ grep: ${pattern} [${current} files]`,
312
+ message: `${progressLabel} [${current} files]`,
248
313
  });
249
314
  };
250
315
  try {
@@ -253,18 +318,13 @@ export function registerSearchContentTool(server, options = {}) {
253
318
  const count = sc.ok && sc.totalMatches ? sc.totalMatches : 0;
254
319
  const filesMatched = sc.ok ? (sc.filesMatched ?? 0) : 0;
255
320
  const stoppedReason = sc.ok ? sc.stoppedReason : undefined;
256
- const suffix = buildCompletionSuffix({
257
- count,
258
- filesMatched,
259
- scope,
260
- stoppedReason,
261
- });
321
+ const suffix = buildCompletionSuffix(count, filesMatched, scope, stoppedReason);
262
322
  const finalCurrent = resolveFinalProgressCurrent(progress, (sc.filesScanned ?? 0) + 1);
263
- progress.complete(`🔎︎ grep: ${pattern} • ${suffix}`, finalCurrent);
323
+ progress.complete(`${progressLabel} • ${suffix}`, finalCurrent);
264
324
  return result;
265
325
  }
266
326
  catch (error) {
267
- progress.fail(`🔎︎ grep: ${pattern} • failed`);
327
+ progress.fail(`${progressLabel} • failed`);
268
328
  throw error;
269
329
  }
270
330
  },
@@ -279,14 +339,3 @@ export function registerSearchContentTool(server, options = {}) {
279
339
  return;
280
340
  server.registerTool('grep', withDefaultIcons({ ...SEARCH_CONTENT_TOOL }, options.iconInfo), validatedHandler);
281
341
  }
282
- function buildCompletionSuffix(params) {
283
- if (params.count === 0) {
284
- return `No matches in ${params.scope}`;
285
- }
286
- const matchWord = params.count === 1 ? 'match' : 'matches';
287
- const fileWord = params.filesMatched === 1 ? 'file' : 'files';
288
- const reasonSuffix = params.stoppedReason !== undefined
289
- ? ` [${SEARCH_COMPLETION_REASON_LABELS[params.stoppedReason]}]`
290
- : '';
291
- return `${params.count} ${matchWord} in ${params.filesMatched} ${fileWord}${reasonSuffix}`;
292
- }
@@ -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,
@@ -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;