@j0hanz/filesystem-mcp 1.13.2 → 1.14.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 (43) hide show
  1. package/README.md +117 -100
  2. package/dist/config.d.ts +0 -1
  3. package/dist/lib/errors.js +5 -2
  4. package/dist/lib/file-operations/metadata.js +9 -3
  5. package/dist/lib/file-operations/search.d.ts +0 -1
  6. package/dist/lib/file-operations/search.js +5 -12
  7. package/dist/lib/fs-helpers.js +10 -11
  8. package/dist/lib/globs.d.ts +2 -0
  9. package/dist/lib/globs.js +19 -0
  10. package/dist/lib/zod-codecs.d.ts +2 -0
  11. package/dist/lib/zod-codecs.js +18 -0
  12. package/dist/pkg-info.d.ts +1 -0
  13. package/dist/pkg-info.js +2 -2
  14. package/dist/prompts.js +3 -3
  15. package/dist/resources/generated-instructions.js +3 -12
  16. package/dist/resources/tool-catalog.js +10 -41
  17. package/dist/resources/tool-info.d.ts +0 -1
  18. package/dist/resources/tool-info.js +11 -39
  19. package/dist/resources/workflows.js +8 -1
  20. package/dist/schemas.d.ts +179 -459
  21. package/dist/schemas.js +156 -165
  22. package/dist/server/roots-manager.js +1 -1
  23. package/dist/tools/apply-patch.js +19 -8
  24. package/dist/tools/calculate-hash.js +3 -5
  25. package/dist/tools/create-directory.js +1 -1
  26. package/dist/tools/delete-file.js +2 -4
  27. package/dist/tools/diff-files.js +1 -3
  28. package/dist/tools/edit-file.js +5 -2
  29. package/dist/tools/list-directory.js +10 -15
  30. package/dist/tools/move-file.js +14 -26
  31. package/dist/tools/read-multiple.js +12 -7
  32. package/dist/tools/read.js +1 -2
  33. package/dist/tools/replace-in-files.js +58 -94
  34. package/dist/tools/roots.js +2 -6
  35. package/dist/tools/search-content.js +150 -186
  36. package/dist/tools/search-files.js +5 -9
  37. package/dist/tools/shared.d.ts +7 -0
  38. package/dist/tools/shared.js +38 -11
  39. package/dist/tools/stat-many.js +6 -4
  40. package/dist/tools/stat.js +2 -2
  41. package/dist/tools/tree.js +1 -1
  42. package/dist/tools/write-file.js +1 -5
  43. package/package.json +2 -1
@@ -1,5 +1,4 @@
1
1
  import * as path from 'node:path';
2
- import { performance } from 'node:perf_hooks';
3
2
  import RE2 from 're2';
4
3
  import { DEFAULT_EXCLUDE_PATTERNS } from '../lib/constants.js';
5
4
  import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
@@ -19,13 +18,11 @@ const CONFIG = {
19
18
  maxFiles: 'max files',
20
19
  },
21
20
  };
22
- let searchMetricSequence = 0;
23
21
  const TRUTHY_SUMMARY_FIELDS = [
24
22
  'filesMatched',
25
23
  'skippedTooLarge',
26
24
  'skippedBinary',
27
25
  'skippedInaccessible',
28
- 'linesSkippedDueToRegexTimeout',
29
26
  ];
30
27
  function buildStructuredSummaryFields(summary) {
31
28
  const result = {};
@@ -53,15 +50,6 @@ function buildCompletionSuffix(count, filesMatched, scope, stoppedReason) {
53
50
  : '';
54
51
  return `${count} ${matchWord} in ${filesMatched} ${fileWord}${reasonSuffix}`;
55
52
  }
56
- function createSearchMetricNames() {
57
- searchMetricSequence += 1;
58
- const metricSuffix = `${Date.now()}_${searchMetricSequence}`;
59
- return {
60
- timerStartName: `searchContentStart_${metricSuffix}`,
61
- timerEndName: `searchContentEnd_${metricSuffix}`,
62
- metricName: `searchContent_${metricSuffix}`,
63
- };
64
- }
65
53
  function compareNormalizedMatches(left, right) {
66
54
  const fileCompare = left.relativeFile.localeCompare(right.relativeFile);
67
55
  if (fileCompare !== 0)
@@ -80,7 +68,7 @@ function buildSearchPreviewState(matches, payloads) {
80
68
  needsExternalize,
81
69
  visibleMatches,
82
70
  visiblePayloads: payloads.slice(0, visibleCount),
83
- heading: SearchResponseBuilder.buildHeading(matches.length, visibleMatches.length),
71
+ heading: buildHeading(matches.length, visibleMatches.length),
84
72
  };
85
73
  }
86
74
  export const SEARCH_CONTENT_TOOL = {
@@ -94,187 +82,164 @@ export const SEARCH_CONTENT_TOOL = {
94
82
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
95
83
  nuances: [
96
84
  'Inline results capped at 50 matches; full results via `resourceUri`.',
97
- 'Skips binary and oversized files.',
98
85
  ],
99
86
  gotchas: [
100
87
  'Skips binary/oversized files silently — verify with `stat` if no matches.',
101
88
  ],
102
89
  taskSupport: 'optional',
103
90
  };
104
- /**
105
- * Handles formatting and building the search response.
106
- */
107
- const SearchResponseBuilder = {
108
- buildHeading(totalMatches, visibleMatches) {
109
- if (visibleMatches >= totalMatches) {
110
- return `Found ${totalMatches}:`;
111
- }
112
- return `Found ${totalMatches} (showing first ${visibleMatches}):`;
113
- },
114
- buildText(heading, matches, summary) {
115
- if (matches.length === 0)
116
- return 'No matches';
117
- const text = this.buildMatchList(heading, matches);
118
- if (!summary)
119
- return text;
120
- const summaryOpts = {
121
- truncated: summary.truncated,
122
- ...(summary.truncated
123
- ? { truncatedReason: this.resolveTruncatedReason(summary) }
124
- : {}),
125
- };
126
- return text + formatOperationSummary(summaryOpts);
127
- },
128
- buildStructured(summary, matches, options) {
91
+ function buildHeading(totalMatches, visibleMatches) {
92
+ if (visibleMatches >= totalMatches) {
93
+ return `Found ${totalMatches}:`;
94
+ }
95
+ return `Found ${totalMatches} (showing first ${visibleMatches}):`;
96
+ }
97
+ function buildSearchText(heading, matches, summary) {
98
+ if (matches.length === 0)
99
+ return 'No matches';
100
+ const text = buildMatchList(heading, matches);
101
+ if (!summary)
102
+ return text;
103
+ const summaryOpts = {
104
+ truncated: summary.truncated,
105
+ ...(summary.truncated
106
+ ? { truncatedReason: resolveTruncatedReason(summary) }
107
+ : {}),
108
+ };
109
+ return text + formatOperationSummary(summaryOpts);
110
+ }
111
+ function buildSearchStructured(summary, matches) {
112
+ return {
113
+ ok: true,
114
+ matches,
115
+ totalMatches: summary.matches,
116
+ filesScanned: summary.filesScanned,
117
+ ...buildStructuredSummaryFields(summary),
118
+ };
119
+ }
120
+ function normalizeMatches(result) {
121
+ const relativeByFile = new Map();
122
+ const getRelativeFile = (file) => {
123
+ const cached = relativeByFile.get(file);
124
+ if (cached !== undefined)
125
+ return cached;
126
+ const relative = path.relative(result.basePath, file);
127
+ relativeByFile.set(file, relative);
128
+ return relative;
129
+ };
130
+ return result.matches
131
+ .map((match, index) => ({
132
+ ...match,
133
+ relativeFile: getRelativeFile(match.file),
134
+ index,
135
+ }))
136
+ .sort(compareNormalizedMatches);
137
+ }
138
+ function buildMatchPayloads(matches, context) {
139
+ return matches.map((match) => {
140
+ const column = findColumnOffset(match.content, context);
129
141
  return {
130
- ok: true,
131
- patternType: options.patternType,
132
- caseSensitive: options.caseSensitive,
133
- matches,
134
- totalMatches: summary.matches,
135
- filesScanned: summary.filesScanned,
136
- ...buildStructuredSummaryFields(summary),
137
- };
138
- },
139
- normalizeMatches(result) {
140
- const relativeByFile = new Map();
141
- const getRelativeFile = (file) => {
142
- const cached = relativeByFile.get(file);
143
- if (cached !== undefined)
144
- return cached;
145
- const relative = path.relative(result.basePath, file);
146
- relativeByFile.set(file, relative);
147
- return relative;
142
+ file: match.relativeFile,
143
+ line: match.line,
144
+ ...(column !== undefined ? { column } : {}),
145
+ content: match.content,
146
+ matchCount: match.matchCount,
147
+ ...(match.contextBefore
148
+ ? { contextBefore: [...match.contextBefore] }
149
+ : {}),
150
+ ...(match.contextAfter ? { contextAfter: [...match.contextAfter] } : {}),
148
151
  };
149
- return result.matches
150
- .map((match, index) => ({
151
- ...match,
152
- relativeFile: getRelativeFile(match.file),
153
- index,
154
- }))
155
- .sort(compareNormalizedMatches);
156
- },
157
- buildMatchPayloads(matches, context) {
158
- return matches.map((match) => {
159
- const column = this.findColumnOffset(match.content, context);
160
- return {
161
- file: match.relativeFile,
162
- line: match.line,
163
- ...(column !== undefined ? { column } : {}),
164
- content: match.content,
165
- matchCount: match.matchCount,
166
- ...(match.contextBefore
167
- ? { contextBefore: [...match.contextBefore] }
168
- : {}),
169
- ...(match.contextAfter
170
- ? { contextAfter: [...match.contextAfter] }
171
- : {}),
172
- };
173
- });
174
- },
175
- buildMatchList(heading, matches) {
176
- if (matches.length === 0)
177
- return heading;
178
- const parts = [heading];
179
- for (const match of matches) {
180
- parts.push(`\n ${match.relativeFile}:${String(match.line).padStart(4)}: ${match.content}`);
181
- }
182
- return parts.join('');
183
- },
184
- resolveTruncatedReason(summary) {
185
- if (summary.stoppedReason === 'timeout')
186
- return 'timeout';
187
- if (summary.stoppedReason === 'maxFiles') {
188
- return `max files (${summary.filesScanned})`;
152
+ });
153
+ }
154
+ function buildMatchList(heading, matches) {
155
+ if (matches.length === 0)
156
+ return heading;
157
+ const parts = [heading];
158
+ for (const match of matches) {
159
+ parts.push(`\n ${match.relativeFile}:${String(match.line).padStart(4)}: ${match.content}`);
160
+ }
161
+ return parts.join('');
162
+ }
163
+ function resolveTruncatedReason(summary) {
164
+ if (summary.stoppedReason === 'timeout')
165
+ return 'timeout';
166
+ if (summary.stoppedReason === 'maxFiles') {
167
+ return `max files (${summary.filesScanned})`;
168
+ }
169
+ return `max results (${summary.matches})`;
170
+ }
171
+ function findColumnOffset(content, context) {
172
+ try {
173
+ if (context.matcher) {
174
+ context.matcher.lastIndex = 0;
175
+ const match = context.matcher.exec(content);
176
+ return match ? match.index : undefined;
189
177
  }
190
- return `max results (${summary.matches})`;
191
- },
192
- findColumnOffset(content, context) {
193
- try {
194
- if (context.matcher) {
195
- context.matcher.lastIndex = 0;
196
- const match = context.matcher.exec(content);
197
- return match ? match.index : undefined;
198
- }
199
- if (context.caseSensitive) {
200
- const idx = content.indexOf(context.pattern);
201
- return idx >= 0 ? idx : undefined;
202
- }
203
- // Case-insensitive literal search
204
- const idx = content.toLowerCase().indexOf(context.foldedPattern ?? '');
178
+ if (context.caseSensitive) {
179
+ const idx = content.indexOf(context.pattern);
205
180
  return idx >= 0 ? idx : undefined;
206
181
  }
207
- catch {
208
- return undefined;
209
- }
210
- },
211
- };
212
- const SearchExecutor = {
213
- async run(args, basePath, signal, onProgress) {
214
- const excludePatterns = args.includeIgnored ? [] : DEFAULT_EXCLUDE_PATTERNS;
215
- const { timerStartName, timerEndName, metricName } = createSearchMetricNames();
216
- const options = {
217
- includeHidden: args.includeHidden,
218
- excludePatterns,
219
- filePattern: args.filePattern,
220
- caseSensitive: args.caseSensitive,
221
- wholeWord: args.wholeWord,
222
- contextLines: args.contextLines,
223
- maxResults: args.maxResults,
224
- isLiteral: !args.isRegex,
225
- multiline: args.multiline,
226
- ...(signal ? { signal } : {}),
227
- ...(onProgress ? { onProgress } : {}),
228
- };
229
- performance.mark(timerStartName);
230
- try {
231
- return await searchContent(basePath, args.pattern, options);
232
- }
233
- catch (error) {
234
- if (error instanceof Error && /regular expression/i.test(error.message)) {
235
- throw new McpError(ErrorCode.E_INVALID_PATTERN, error.message);
236
- }
237
- throw error;
238
- }
239
- finally {
240
- performance.mark(timerEndName);
241
- performance.measure(metricName, timerStartName, timerEndName);
242
- performance.clearMarks(timerStartName);
243
- performance.clearMarks(timerEndName);
244
- performance.clearMeasures(metricName);
245
- }
246
- },
247
- createMatcher(args) {
248
- if (!args.isRegex)
249
- return undefined;
250
- try {
251
- const flags = (args.caseSensitive ? '' : 'i') + (args.multiline ? 'm' : '');
252
- return new RE2(args.pattern, flags);
253
- }
254
- catch (error) {
255
- throw new McpError(ErrorCode.E_INVALID_PATTERN, `Invalid regex pattern: ${formatUnknownErrorMessage(error)}`);
182
+ // Case-insensitive literal search
183
+ const idx = content.toLowerCase().indexOf(context.foldedPattern ?? '');
184
+ return idx >= 0 ? idx : undefined;
185
+ }
186
+ catch {
187
+ return undefined;
188
+ }
189
+ }
190
+ async function executeSearch(args, basePath, signal, onProgress) {
191
+ const excludePatterns = args.includeIgnored ? [] : DEFAULT_EXCLUDE_PATTERNS;
192
+ const options = {
193
+ includeHidden: args.includeHidden,
194
+ excludePatterns,
195
+ filePattern: args.filePattern,
196
+ caseSensitive: args.caseSensitive,
197
+ wholeWord: args.wholeWord,
198
+ contextLines: args.contextLines,
199
+ maxResults: args.maxResults,
200
+ isLiteral: !args.isRegex,
201
+ ...(signal ? { signal } : {}),
202
+ ...(onProgress ? { onProgress } : {}),
203
+ };
204
+ try {
205
+ return await searchContent(basePath, args.pattern, options);
206
+ }
207
+ catch (error) {
208
+ if (error instanceof Error && /regular expression/i.test(error.message)) {
209
+ throw new McpError(ErrorCode.E_INVALID_PATTERN, error.message);
256
210
  }
257
- },
258
- createSearchContext(args, matcher) {
259
- return {
260
- pattern: args.pattern,
261
- caseSensitive: args.caseSensitive,
262
- ...(matcher ? { matcher } : {}),
263
- ...(!args.isRegex && !args.caseSensitive
264
- ? { foldedPattern: args.pattern.toLowerCase() }
265
- : {}),
266
- };
267
- },
268
- };
211
+ throw error;
212
+ }
213
+ }
214
+ function createSearchMatcher(args) {
215
+ if (!args.isRegex)
216
+ return undefined;
217
+ try {
218
+ const flags = args.caseSensitive ? '' : 'i';
219
+ return new RE2(args.pattern, flags);
220
+ }
221
+ catch (error) {
222
+ throw new McpError(ErrorCode.E_INVALID_PATTERN, `Invalid regex pattern: ${formatUnknownErrorMessage(error)}`);
223
+ }
224
+ }
225
+ function createSearchContext(args, matcher) {
226
+ return {
227
+ pattern: args.pattern,
228
+ caseSensitive: args.caseSensitive,
229
+ ...(matcher ? { matcher } : {}),
230
+ ...(!args.isRegex && !args.caseSensitive
231
+ ? { foldedPattern: args.pattern.toLowerCase() }
232
+ : {}),
233
+ };
234
+ }
269
235
  async function handleSearchContent(args, signal, resourceStore, onProgress) {
270
236
  const basePath = resolvePathOrRoot(args.path);
271
- const patternType = args.isRegex ? 'regex' : 'literal';
272
- const regexMatcher = SearchExecutor.createMatcher(args);
273
- const result = await SearchExecutor.run(args, basePath, signal, onProgress);
274
- const normalizedMatches = SearchResponseBuilder.normalizeMatches(result);
275
- const searchContext = SearchExecutor.createSearchContext(args, regexMatcher);
276
- const matchPayloads = SearchResponseBuilder.buildMatchPayloads(normalizedMatches, searchContext);
277
- const fullStructured = SearchResponseBuilder.buildStructured(result.summary, matchPayloads, { patternType, caseSensitive: args.caseSensitive });
237
+ const regexMatcher = createSearchMatcher(args);
238
+ const result = await executeSearch(args, basePath, signal, onProgress);
239
+ const normalizedMatches = normalizeMatches(result);
240
+ const searchContext = createSearchContext(args, regexMatcher);
241
+ const matchPayloads = buildMatchPayloads(normalizedMatches, searchContext);
242
+ const fullStructured = buildSearchStructured(result.summary, matchPayloads);
278
243
  const preview = buildSearchPreviewState(normalizedMatches, matchPayloads);
279
244
  if (resourceStore && preview.needsExternalize) {
280
245
  const previewStructured = {
@@ -288,7 +253,7 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
288
253
  text: JSON.stringify(fullStructured),
289
254
  });
290
255
  previewStructured.resourceUri = entry.uri;
291
- const text = SearchResponseBuilder.buildText(preview.heading, preview.visibleMatches);
256
+ const text = buildSearchText(preview.heading, preview.visibleMatches);
292
257
  return buildToolResponse(text, previewStructured, [
293
258
  buildResourceLink({
294
259
  uri: entry.uri,
@@ -299,13 +264,14 @@ async function handleSearchContent(args, signal, resourceStore, onProgress) {
299
264
  }),
300
265
  ]);
301
266
  }
302
- const text = SearchResponseBuilder.buildText(preview.heading, preview.visibleMatches, result.summary);
267
+ const text = buildSearchText(preview.heading, preview.visibleMatches, result.summary);
303
268
  return buildToolResponse(text, fullStructured);
304
269
  }
305
270
  export function registerSearchContentTool(server, options = {}) {
306
271
  const handler = (args, extra) => executeToolWithDiagnostics({
307
272
  toolName: 'grep',
308
273
  extra,
274
+ outputSchema: SearchContentOutputSchema,
309
275
  context: { path: args.path ?? '.' },
310
276
  run: async (signal) => {
311
277
  const { pattern, filePattern: scope } = args;
@@ -321,10 +287,8 @@ export function registerSearchContentTool(server, options = {}) {
321
287
  try {
322
288
  const result = await handleSearchContent(args, signal, options.resourceStore, progressWithMessage);
323
289
  const sc = result.structuredContent;
324
- const count = sc.ok && sc.totalMatches ? sc.totalMatches : 0;
325
- const filesMatched = sc.ok ? (sc.filesMatched ?? 0) : 0;
326
- const stoppedReason = sc.ok ? sc.stoppedReason : undefined;
327
- const suffix = buildCompletionSuffix(count, filesMatched, scope, stoppedReason);
290
+ const { totalMatches = 0, filesMatched = 0, stoppedReason } = sc;
291
+ const suffix = buildCompletionSuffix(totalMatches, filesMatched, scope, stoppedReason);
328
292
  const finalCurrent = resolveFinalProgressCurrent(progress, (sc.filesScanned ?? 0) + 1);
329
293
  progress.complete(`${progressLabel} • ${suffix}`, finalCurrent);
330
294
  return result;
@@ -14,10 +14,7 @@ export const SEARCH_FILES_TOOL = {
14
14
  inputSchema: SearchFilesInputSchema,
15
15
  outputSchema: SearchFilesOutputSchema,
16
16
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
17
- nuances: [
18
- 'Respects `.gitignore` unless `includeIgnored=true`.',
19
- 'Returns relative paths plus metadata; may truncate.',
20
- ],
17
+ nuances: ['Respects `.gitignore` unless `includeIgnored=true`.'],
21
18
  taskSupport: 'optional',
22
19
  };
23
20
  async function handleSearchFiles(args, signal, onProgress) {
@@ -52,7 +49,6 @@ async function handleSearchFiles(args, signal, onProgress) {
52
49
  const structured = {
53
50
  ok: true,
54
51
  root: basePath,
55
- pattern: args.pattern,
56
52
  results: relativeResults,
57
53
  totalMatches: result.summary.matched,
58
54
  filesScanned: result.summary.filesScanned,
@@ -103,6 +99,7 @@ export function registerSearchFilesTool(server, options = {}) {
103
99
  const handler = (args, extra) => executeToolWithDiagnostics({
104
100
  toolName: 'find',
105
101
  extra,
102
+ outputSchema: SearchFilesOutputSchema,
106
103
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
107
104
  context: { path: args.path ?? '.' },
108
105
  run: async (signal) => {
@@ -129,14 +126,13 @@ export function registerSearchFilesTool(server, options = {}) {
129
126
  try {
130
127
  const result = await handleSearchFiles(args, signal, progressWithMessage);
131
128
  const sc = result.structuredContent;
132
- const count = sc.ok ? (sc.totalMatches ?? 0) : 0;
133
- const stoppedReason = sc.ok ? sc.stoppedReason : undefined;
129
+ const { totalMatches = 0, stoppedReason } = sc;
134
130
  let suffix;
135
- if (count === 0) {
131
+ if (totalMatches === 0) {
136
132
  suffix = 'No matches';
137
133
  }
138
134
  else {
139
- suffix = `${count} ${count === 1 ? 'match' : 'matches'}`;
135
+ suffix = `${totalMatches} ${totalMatches === 1 ? 'match' : 'matches'}`;
140
136
  if (stoppedReason === 'timeout') {
141
137
  suffix += ' [timeout]';
142
138
  }
@@ -38,6 +38,12 @@ export declare function buildResourceLink(params: {
38
38
  description?: string;
39
39
  expiresAt?: string;
40
40
  }): ContentBlock;
41
+ export declare function buildStructuredError(error: unknown, defaultCode: ErrorCode, path?: string): {
42
+ code: ErrorCode;
43
+ message: string;
44
+ path?: string;
45
+ suggestion?: string;
46
+ };
41
47
  export declare function buildToolResponse<T>(text: string, structuredContent: T, extraContent?: ContentBlock[]): {
42
48
  content: ContentBlock[];
43
49
  structuredContent: T;
@@ -94,6 +100,7 @@ export declare function buildFileInfoPayload(info: FileInfo): FileInfoPayload;
94
100
  interface ToolExecutionOptions<T> {
95
101
  toolName: string;
96
102
  extra: ToolExtra;
103
+ outputSchema?: z.ZodType<T>;
97
104
  run: (signal: AbortSignal | undefined) => ToolResponse<T> | Promise<ToolResponse<T>>;
98
105
  onError: (error: unknown) => ToolResult<T>;
99
106
  context?: Record<string, unknown>;
@@ -6,6 +6,7 @@ import { createDetailedError, ErrorCode, formatDetailedError, getSuggestion, Mcp
6
6
  import { createTimedAbortSignal } from '../lib/fs-helpers.js';
7
7
  import { withToolDiagnostics } from '../lib/observability.js';
8
8
  import { getAllowedDirectories } from '../lib/paths.js';
9
+ import { createBase64JsonCodec } from '../lib/zod-codecs.js';
9
10
  export {} from './contract.js';
10
11
  const MAX_INLINE_CONTENT_CHARS = parseInt(process.env['FS_CONTEXT_MAX_INLINE_CHARS'] ?? '', 10) || 20_000;
11
12
  const MAX_INLINE_PREVIEW_CHARS = 4_000;
@@ -133,19 +134,48 @@ function resolveDetailedError(error, defaultCode, path) {
133
134
  }
134
135
  return detailed;
135
136
  }
137
+ export function buildStructuredError(error, defaultCode, path) {
138
+ const detailed = resolveDetailedError(error, defaultCode, path);
139
+ return {
140
+ code: detailed.code,
141
+ message: detailed.message,
142
+ ...(detailed.path !== undefined ? { path: detailed.path } : {}),
143
+ ...(detailed.suggestion !== undefined
144
+ ? { suggestion: detailed.suggestion }
145
+ : {}),
146
+ };
147
+ }
136
148
  export function buildToolResponse(text, structuredContent, extraContent = []) {
137
149
  return {
138
150
  content: [{ type: 'text', text }, ...extraContent],
139
151
  structuredContent,
140
152
  };
141
153
  }
154
+ function validateStructuredContent(toolName, outputSchema, structuredContent) {
155
+ const parsed = outputSchema.safeParse(structuredContent);
156
+ if (parsed.success) {
157
+ return parsed.data;
158
+ }
159
+ throw new McpError(ErrorCode.E_UNKNOWN, `Tool "${toolName}" returned invalid structuredContent.`, undefined, { errors: z.treeifyError(parsed.error) });
160
+ }
161
+ function validateToolResponse(toolName, result, outputSchema) {
162
+ if (!outputSchema)
163
+ return result;
164
+ if (!Object.hasOwn(result, 'structuredContent')) {
165
+ throw new McpError(ErrorCode.E_UNKNOWN, `Tool "${toolName}" returned success without structuredContent.`);
166
+ }
167
+ return {
168
+ ...result,
169
+ structuredContent: validateStructuredContent(toolName, outputSchema, result.structuredContent),
170
+ };
171
+ }
142
172
  function parseToolArgs(schema, args) {
143
173
  const candidate = args === undefined ? {} : args;
144
174
  const parsed = schema.safeParse(candidate);
145
175
  if (parsed.success) {
146
176
  return parsed.data;
147
177
  }
148
- throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid tool arguments: ${parsed.error.message}`, undefined, { errors: z.treeifyError(parsed.error) });
178
+ throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid tool arguments:\n${z.prettifyError(parsed.error)}`, undefined, { errors: z.treeifyError(parsed.error) });
149
179
  }
150
180
  export function withValidatedArgs(schema, handler) {
151
181
  return async (args, extra) => {
@@ -222,7 +252,7 @@ export async function executeToolWithDiagnostics(options) {
222
252
  return withToolDiagnostics(options.toolName, () => withToolErrorHandling(async () => {
223
253
  const { signal, cleanup } = getToolSignal(options.extra.signal, options.timedSignal);
224
254
  try {
225
- return await options.run(signal);
255
+ return validateToolResponse(options.toolName, await options.run(signal), options.outputSchema);
226
256
  }
227
257
  finally {
228
258
  cleanup();
@@ -461,19 +491,16 @@ export function resolvePathOrRoot(pathValue) {
461
491
  }
462
492
  return root;
463
493
  }
494
+ const OffsetCursorSchema = z.strictObject({
495
+ offset: z.int().min(0),
496
+ });
497
+ const OffsetCursorCodec = createBase64JsonCodec(OffsetCursorSchema);
464
498
  export function encodeOffsetCursor(offset) {
465
- return Buffer.from(JSON.stringify({ offset })).toString('base64url');
499
+ return z.encode(OffsetCursorCodec, { offset });
466
500
  }
467
501
  export function decodeOffsetCursor(cursor) {
468
502
  try {
469
- const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'));
470
- if (typeof parsed === 'object' &&
471
- parsed !== null &&
472
- typeof parsed.offset === 'number') {
473
- const { offset } = parsed;
474
- if (Number.isInteger(offset) && offset >= 0)
475
- return offset;
476
- }
503
+ return OffsetCursorCodec.parse(cursor).offset;
477
504
  }
478
505
  catch {
479
506
  // fall through to throw
@@ -3,7 +3,7 @@ import { ErrorCode } from '../lib/errors.js';
3
3
  import { getMultipleFileInfo } from '../lib/file-operations/metadata.js';
4
4
  import { formatBytes, joinLines } from '../config.js';
5
5
  import { GetMultipleFileInfoInputSchema, GetMultipleFileInfoOutputSchema, } from '../schemas.js';
6
- import { buildBatchCompletionSuffix, buildBatchPathContext, buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, createBatchProgressCallbacks, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
6
+ import { buildBatchCompletionSuffix, buildBatchPathContext, buildFileInfoPayload, buildStructuredError, buildToolErrorResponse, buildToolResponse, createBatchProgressCallbacks, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
7
  import { registerToolTaskIfAvailable } from './task-support.js';
8
8
  export const GET_MULTIPLE_FILE_INFO_TOOL = {
9
9
  name: 'stat_many',
@@ -14,7 +14,6 @@ export const GET_MULTIPLE_FILE_INFO_TOOL = {
14
14
  outputSchema: GetMultipleFileInfoOutputSchema,
15
15
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
16
16
  taskSupport: 'optional',
17
- nuances: ['Use before read/search when file size/type uncertainty exists.'],
18
17
  };
19
18
  function formatFileInfoDetail(info) {
20
19
  const lines = [
@@ -38,11 +37,13 @@ async function handleGetMultipleFileInfo(args, signal, onProgress) {
38
37
  const structuredResults = result.results.map((entry) => ({
39
38
  path: entry.path,
40
39
  info: entry.info ? buildFileInfoPayload(entry.info) : undefined,
41
- error: entry.error,
40
+ error: entry.error
41
+ ? buildStructuredError(entry.error, ErrorCode.E_NOT_FOUND, entry.path)
42
+ : undefined,
42
43
  }));
43
44
  const text = result.results
44
45
  .map((entry) => entry.error
45
- ? `${entry.path}: ${entry.error}`
46
+ ? `${entry.path}: ${buildStructuredError(entry.error, ErrorCode.E_NOT_FOUND, entry.path).message}`
46
47
  : entry.info
47
48
  ? formatFileInfoDetail(entry.info)
48
49
  : entry.path)
@@ -64,6 +65,7 @@ export function registerGetMultipleFileInfoTool(server, options = {}) {
64
65
  return executeToolWithDiagnostics({
65
66
  toolName: 'stat_many',
66
67
  extra,
68
+ outputSchema: GetMultipleFileInfoOutputSchema,
67
69
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
68
70
  context: { path: primaryPath },
69
71
  run: async (signal) => {
@@ -15,7 +15,6 @@ export const GET_FILE_INFO_TOOL = {
15
15
  outputSchema: GetFileInfoOutputSchema,
16
16
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
17
17
  taskSupport: 'forbidden',
18
- nuances: ['Use before read/search when file size/type uncertainty exists.'],
19
18
  };
20
19
  function formatFileInfoDetails(info) {
21
20
  const lines = [
@@ -45,6 +44,7 @@ export function registerGetFileInfoTool(server, options = {}) {
45
44
  const handler = (args, extra) => executeToolWithDiagnostics({
46
45
  toolName: 'stat',
47
46
  extra,
47
+ outputSchema: GetFileInfoOutputSchema,
48
48
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
49
49
  context: { path: args.path },
50
50
  run: (signal) => handleGetFileInfo(args, signal),
@@ -58,7 +58,7 @@ export function registerGetFileInfoTool(server, options = {}) {
58
58
  if (result.isError)
59
59
  return `🕮 stat: ${name} • failed`;
60
60
  const sc = result.structuredContent;
61
- if (!sc.ok || !sc.info)
61
+ if (!sc.info)
62
62
  return `🕮 stat: ${name} • failed`;
63
63
  return `🕮 stat: ${sc.info.name} • ${sc.info.type}, ${formatBytes(sc.info.size)}`;
64
64
  },
@@ -14,7 +14,6 @@ export const TREE_TOOL = {
14
14
  outputSchema: TreeOutputSchema,
15
15
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
16
16
  taskSupport: 'optional',
17
- gotchas: ['`maxDepth=0` returns only the root node.'],
18
17
  };
19
18
  async function handleTree(args, signal, onProgress) {
20
19
  const basePath = resolvePathOrRoot(args.path);
@@ -45,6 +44,7 @@ export function registerTreeTool(server, options = {}) {
45
44
  return executeToolWithDiagnostics({
46
45
  toolName: 'tree',
47
46
  extra,
47
+ outputSchema: TreeOutputSchema,
48
48
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
49
49
  context: { path: targetPath },
50
50
  run: async (signal) => {