@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
@@ -2,11 +2,11 @@ import * as path from 'node:path';
2
2
  import { readFile, stat } from 'node:fs/promises';
3
3
  import { applyPatch, parsePatch } from 'diff';
4
4
  import { MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY } from '../lib/constants.js';
5
- import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
5
+ import { ErrorCode, McpError } from '../lib/errors.js';
6
6
  import { atomicWriteFile, processInParallel, withAbort, } from '../lib/fs-helpers.js';
7
7
  import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
8
8
  import { ApplyPatchInputSchema, ApplyPatchOutputSchema } from '../schemas.js';
9
- import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
9
+ import { buildStructuredError, buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
10
10
  import { registerToolTaskIfAvailable } from './task-support.js';
11
11
  export const APPLY_PATCH_TOOL = {
12
12
  name: 'apply_patch',
@@ -21,7 +21,6 @@ export const APPLY_PATCH_TOOL = {
21
21
  nuances: [
22
22
  'Multi-file patches use `path` as base directory; per-file results in `results[]`.',
23
23
  ],
24
- gotchas: ['Patch must include valid hunk headers; use `dryRun=true` first.'],
25
24
  taskSupport: 'forbidden',
26
25
  };
27
26
  function assertPatchTargetSizeWithinLimit(filePath, size, maxFileSize) {
@@ -68,11 +67,15 @@ async function applyDiff(filePath, diff, options, signal) {
68
67
  return {
69
68
  path: validPath,
70
69
  applied: false,
71
- error: 'Patch application failed',
70
+ error: buildStructuredError(new McpError(ErrorCode.E_INVALID_INPUT, 'Patch application failed', validPath), ErrorCode.E_INVALID_INPUT, validPath),
72
71
  };
73
72
  }
74
73
  if (patched === content) {
75
- return { path: validPath, applied: false, error: 'Patch had no effect' };
74
+ return {
75
+ path: validPath,
76
+ applied: false,
77
+ error: buildStructuredError(new McpError(ErrorCode.E_INVALID_INPUT, 'Patch had no effect', validPath), ErrorCode.E_INVALID_INPUT, validPath),
78
+ };
76
79
  }
77
80
  const patchStats = countStructuredPatchStats(diff);
78
81
  if (!options.dryRun) {
@@ -88,7 +91,7 @@ async function processMultiFilePatch(basePath, parsed, options, signal) {
88
91
  return () => Promise.resolve({
89
92
  path: '<unknown>',
90
93
  applied: false,
91
- error: 'Missing file name in patch header',
94
+ error: buildStructuredError(new McpError(ErrorCode.E_INVALID_INPUT, 'Missing file name in patch header'), ErrorCode.E_INVALID_INPUT),
92
95
  });
93
96
  }
94
97
  const filePath = path.resolve(validBase, fileName);
@@ -101,7 +104,7 @@ async function processMultiFilePatch(basePath, parsed, options, signal) {
101
104
  return {
102
105
  path: fileName,
103
106
  applied: false,
104
- error: formatUnknownErrorMessage(error),
107
+ error: buildStructuredError(error, ErrorCode.E_INVALID_INPUT, filePath),
105
108
  };
106
109
  }
107
110
  };
@@ -117,6 +120,13 @@ async function processMultiFilePatch(basePath, parsed, options, signal) {
117
120
  return acc;
118
121
  }, { applied: 0, hunks: 0, added: 0, removed: 0 });
119
122
  const label = options.dryRun ? ' (dry run)' : '';
123
+ if (totals.applied === 0) {
124
+ const failedPaths = results
125
+ .filter((r) => !r.applied)
126
+ .map((r) => r.path)
127
+ .join(', ');
128
+ throw new McpError(ErrorCode.E_INVALID_INPUT, `All ${parsed.length} file patches failed${label}. Files: ${failedPaths}. Generate fresh patches via diff_files and retry.`);
129
+ }
120
130
  const text = `Applied ${totals.applied}/${parsed.length} file patches${label}`;
121
131
  return buildToolResponse(text, {
122
132
  ok: totals.applied === parsed.length,
@@ -152,7 +162,7 @@ async function handleApplyPatch(args, signal) {
152
162
  }
153
163
  const result = await applyDiff(args.path, diff, options, signal);
154
164
  if (!result.applied) {
155
- throw new McpError(ErrorCode.E_INVALID_INPUT, result.error === 'Patch had no effect'
165
+ throw new McpError(ErrorCode.E_INVALID_INPUT, result.error?.message === 'Patch had no effect'
156
166
  ? 'Patch had no effect \u2014 the file content is unchanged after applying. The patch may not match the current file content. Generate a fresh patch via diff_files and retry.'
157
167
  : 'Patch application failed. The file content may have changed or patch context is insufficient. Generate a fresh patch via diff_files against the current file, then retry. If differences are minor, enable fuzzy matching with the fuzzFactor parameter.');
158
168
  }
@@ -172,6 +182,7 @@ export function registerApplyPatchTool(server, options = {}) {
172
182
  const handler = (args, extra) => executeToolWithDiagnostics({
173
183
  toolName: 'apply_patch',
174
184
  extra,
185
+ outputSchema: ApplyPatchOutputSchema,
175
186
  timedSignal: {},
176
187
  context: { path: args.path },
177
188
  run: (signal) => handleApplyPatch(args, signal),
@@ -157,6 +157,7 @@ export function registerCalculateHashTool(server, options = {}) {
157
157
  const handler = (args, extra) => executeToolWithDiagnostics({
158
158
  toolName: 'calculate_hash',
159
159
  extra,
160
+ outputSchema: CalculateHashOutputSchema,
160
161
  timedSignal: {},
161
162
  context: { path: args.path },
162
163
  run: async (signal) => {
@@ -172,13 +173,10 @@ export function registerCalculateHashTool(server, options = {}) {
172
173
  try {
173
174
  const result = await handleCalculateHash(args, signal, progressWithMessage);
174
175
  const sc = result.structuredContent;
175
- const totalFiles = sc.ok ? (sc.fileCount ?? 1) : 1;
176
+ const totalFiles = sc.fileCount ?? 1;
176
177
  const finalCurrent = resolveFinalProgressCurrent(progress, totalFiles + 1);
177
178
  let suffix;
178
- if (!sc.ok) {
179
- suffix = 'failed';
180
- }
181
- else if (sc.fileCount !== undefined && sc.fileCount > 1) {
179
+ if (sc.fileCount !== undefined && sc.fileCount > 1) {
182
180
  suffix = `${sc.fileCount} files • ${(sc.hash ?? '').slice(0, 8)}…`;
183
181
  }
184
182
  else {
@@ -13,7 +13,6 @@ export const CREATE_DIRECTORY_TOOL = {
13
13
  inputSchema: CreateDirectoryInputSchema,
14
14
  outputSchema: CreateDirectoryOutputSchema,
15
15
  annotations: IDEMPOTENT_WRITE_TOOL_ANNOTATIONS,
16
- nuances: ['Succeeds silently if the directory already exists (idempotent).'],
17
16
  taskSupport: 'forbidden',
18
17
  };
19
18
  async function handleCreateDirectory(args, signal) {
@@ -36,6 +35,7 @@ export function registerCreateDirectoryTool(server, options = {}) {
36
35
  const handler = (args, extra) => executeToolWithDiagnostics({
37
36
  toolName: 'mkdir',
38
37
  extra,
38
+ outputSchema: CreateDirectoryOutputSchema,
39
39
  timedSignal: {},
40
40
  context: { path: args.path ?? args.paths?.[0] },
41
41
  run: (signal) => handleCreateDirectory(args, signal),
@@ -13,10 +13,7 @@ export const DELETE_FILE_TOOL = {
13
13
  inputSchema: DeleteFileInputSchema,
14
14
  outputSchema: DeleteFileOutputSchema,
15
15
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
16
- gotchas: [
17
- 'No undo — deletion is permanent.',
18
- 'Non-empty directories require `recursive=true`.',
19
- ],
16
+ gotchas: ['Non-empty directories require `recursive=true`.'],
20
17
  taskSupport: 'forbidden',
21
18
  };
22
19
  async function handleDeleteFile(args, signal) {
@@ -59,6 +56,7 @@ export function registerDeleteFileTool(server, options = {}) {
59
56
  const handler = (args, extra) => executeToolWithDiagnostics({
60
57
  toolName: 'rm',
61
58
  extra,
59
+ outputSchema: DeleteFileOutputSchema,
62
60
  timedSignal: {},
63
61
  context: { path: args.path },
64
62
  run: (signal) => handleDeleteFile(args, signal),
@@ -17,7 +17,6 @@ export const DIFF_FILES_TOOL = {
17
17
  inputSchema: DiffFilesInputSchema,
18
18
  outputSchema: DiffFilesOutputSchema,
19
19
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
20
- gotchas: ['`isIdentical=true` means no hunks (`@@`) and empty diff.'],
21
20
  taskSupport: 'forbidden',
22
21
  };
23
22
  function computeDiffStats(hunks) {
@@ -105,6 +104,7 @@ export function registerDiffFilesTool(server, options = {}) {
105
104
  const handler = (args, extra) => executeToolWithDiagnostics({
106
105
  toolName: 'diff_files',
107
106
  extra,
107
+ outputSchema: DiffFilesOutputSchema,
108
108
  timedSignal: {},
109
109
  context: { path: args.original },
110
110
  run: (signal) => handleDiffFiles(args, signal, options.resourceStore),
@@ -123,8 +123,6 @@ export function registerDiffFilesTool(server, options = {}) {
123
123
  if (result.isError)
124
124
  return `🕮 diff: ${n1} ⟷ ${n2} • failed`;
125
125
  const sc = result.structuredContent;
126
- if (!sc.ok)
127
- return `🕮 diff: ${n1} ⟷ ${n2} • failed`;
128
126
  if (sc.isIdentical)
129
127
  return `🕮 diff: ${n1} ⟷ ${n2} • identical`;
130
128
  const added = sc.linesAdded ?? 0;
@@ -19,7 +19,6 @@ export const EDIT_FILE_TOOL = {
19
19
  outputSchema: EditFileOutputSchema,
20
20
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
21
21
  nuances: ['Each edit applies to the output of the previous edit.'],
22
- gotchas: ['Unmatched `oldText` entries listed in `unmatchedEdits`.'],
23
22
  taskSupport: 'forbidden',
24
23
  };
25
24
  function escapeRegExp(string) {
@@ -104,7 +103,7 @@ function mergeLineRange(currentRange, content, matchStartIndex, newText) {
104
103
  }
105
104
  function buildStructuredEditOutput(validPath, result) {
106
105
  return {
107
- ok: true,
106
+ ok: result.appliedEdits > 0 || result.unmatchedEdits.length === 0,
108
107
  path: validPath,
109
108
  appliedEdits: result.appliedEdits,
110
109
  ...(result.appliedEdits > 0
@@ -215,6 +214,9 @@ async function handleEditFile(args, signal) {
215
214
  }
216
215
  return buildToolResponse(`Dry run complete. ${editResult.appliedEdits} edits would be applied.`, structured);
217
216
  }
217
+ if (editResult.appliedEdits === 0 && editResult.unmatchedEdits.length > 0) {
218
+ throw new McpError(ErrorCode.E_INVALID_INPUT, `All ${editResult.unmatchedEdits.length} edits failed to match. Verify oldText includes exact content with surrounding context.`, args.path);
219
+ }
218
220
  if (editResult.appliedEdits > 0) {
219
221
  await atomicWriteFile(validPath, editResult.content, {
220
222
  encoding: 'utf-8',
@@ -227,6 +229,7 @@ export function registerEditFileTool(server, options = {}) {
227
229
  const handler = (args, extra) => executeToolWithDiagnostics({
228
230
  toolName: 'edit',
229
231
  extra,
232
+ outputSchema: EditFileOutputSchema,
230
233
  timedSignal: {},
231
234
  context: { path: args.path },
232
235
  run: (signal) => handleEditFile(args, signal),
@@ -1,8 +1,10 @@
1
1
  import * as path from 'node:path';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { z } from 'zod';
3
4
  import { DEFAULT_EXCLUDE_PATTERNS, MAX_LIST_ENTRIES, } from '../lib/constants.js';
4
5
  import { ErrorCode, McpError } from '../lib/errors.js';
5
6
  import { listDirectory } from '../lib/file-operations/metadata.js';
7
+ import { createBase64JsonCodec } from '../lib/zod-codecs.js';
6
8
  import { formatOperationSummary, joinLines } from '../config.js';
7
9
  import { ListDirectoryInputSchema, ListDirectoryOutputSchema, } from '../schemas.js';
8
10
  import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
@@ -23,6 +25,11 @@ const LIST_CURSOR_TTL_MS = parseInt(process.env['FS_CONTEXT_LIST_CURSOR_TTL_MS']
23
25
  5 * 60 * 1000;
24
26
  const listSnapshots = new Map();
25
27
  const listSnapshotTimers = new Map();
28
+ const ListCursorPayloadSchema = z.strictObject({
29
+ snapshotId: z.string().min(1),
30
+ offset: z.int().min(0),
31
+ });
32
+ const ListCursorCodec = createBase64JsonCodec(ListCursorPayloadSchema);
26
33
  function buildListFingerprint(args) {
27
34
  return JSON.stringify({
28
35
  path: resolvePathOrRoot(args.path),
@@ -55,22 +62,11 @@ function storeListSnapshot(snapshot) {
55
62
  return snapshotId;
56
63
  }
57
64
  function encodeListCursor(payload) {
58
- return Buffer.from(JSON.stringify(payload)).toString('base64url');
65
+ return z.encode(ListCursorCodec, payload);
59
66
  }
60
67
  function decodeListCursor(cursor) {
61
68
  try {
62
- const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'));
63
- if (typeof parsed === 'object' &&
64
- parsed !== null &&
65
- typeof parsed.snapshotId === 'string' &&
66
- typeof parsed.offset === 'number') {
67
- const payload = parsed;
68
- if (payload.snapshotId.length > 0 &&
69
- Number.isInteger(payload.offset) &&
70
- payload.offset >= 0) {
71
- return payload;
72
- }
73
- }
69
+ return ListCursorCodec.parse(cursor);
74
70
  }
75
71
  catch {
76
72
  // fall through to throw
@@ -201,6 +197,7 @@ export function registerListDirectoryTool(server, options = {}) {
201
197
  const handler = (args, extra) => executeToolWithDiagnostics({
202
198
  toolName: 'ls',
203
199
  extra,
200
+ outputSchema: ListDirectoryOutputSchema,
204
201
  context: { path: args.path ?? '.' },
205
202
  run: (signal) => handleListDirectory(args, signal),
206
203
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_DIRECTORY, args.path ?? '.'),
@@ -213,8 +210,6 @@ export function registerListDirectoryTool(server, options = {}) {
213
210
  if (result.isError)
214
211
  return `≣ ls: ${base} • failed`;
215
212
  const sc = result.structuredContent;
216
- if (!sc.ok)
217
- return `≣ ls: ${base} • failed`;
218
213
  const count = sc.totalEntries ?? 0;
219
214
  return `≣ ls: ${base} • ${count} ${count === 1 ? 'entry' : 'entries'}`;
220
215
  },
@@ -4,7 +4,7 @@ import { ErrorCode, formatUnknownErrorMessage, isNodeError, McpError, } from '..
4
4
  import { withAbort } from '../lib/fs-helpers.js';
5
5
  import { assertAllowedFileAccess, validateExistingPath, validatePathForWrite, } from '../lib/paths.js';
6
6
  import { MoveFileInputSchema, MoveFileOutputSchema } from '../schemas.js';
7
- import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
+ import { buildStructuredError, buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
8
  import { registerToolTaskIfAvailable } from './task-support.js';
9
9
  export const MOVE_FILE_TOOL = {
10
10
  name: 'mv',
@@ -13,12 +13,17 @@ export const MOVE_FILE_TOOL = {
13
13
  inputSchema: MoveFileInputSchema,
14
14
  outputSchema: MoveFileOutputSchema,
15
15
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
16
- nuances: ['Cross-device moves fall back to copy+delete.'],
17
16
  gotchas: [
18
17
  'On POSIX, an existing destination is silently overwritten; on Windows, rename fails with EEXIST if destination exists.',
19
18
  ],
20
19
  taskSupport: 'forbidden',
21
20
  };
21
+ function toMoveFailure(source, error, defaultCode = ErrorCode.E_UNKNOWN) {
22
+ return {
23
+ source,
24
+ error: buildStructuredError(error, defaultCode, source),
25
+ };
26
+ }
22
27
  async function handleMoveFile(args, signal) {
23
28
  const sources = args.sources ?? (args.source ? [args.source] : []);
24
29
  if (sources.length === 0) {
@@ -52,10 +57,7 @@ async function handleMoveFile(args, signal) {
52
57
  assertAllowedFileAccess(src, validSource);
53
58
  }
54
59
  catch (error) {
55
- failed.push({
56
- source: src,
57
- error: formatUnknownErrorMessage(error),
58
- });
60
+ failed.push(toMoveFailure(src, error, ErrorCode.E_ACCESS_DENIED));
59
61
  continue;
60
62
  }
61
63
  const targetPath = destIsDirectory
@@ -68,10 +70,7 @@ async function handleMoveFile(args, signal) {
68
70
  // Prevent moving a directory into its own subdirectory
69
71
  // Fixes "Missing validation for moving directory into its own subdirectory" finding
70
72
  if (path.resolve(targetPath).startsWith(path.resolve(validSource) + path.sep)) {
71
- failed.push({
72
- source: src,
73
- error: `Cannot move directory '${src}' into its own subdirectory '${targetPath}'`,
74
- });
73
+ failed.push(toMoveFailure(src, new McpError(ErrorCode.E_INVALID_INPUT, `Cannot move directory '${src}' into its own subdirectory '${targetPath}'`, src), ErrorCode.E_INVALID_INPUT));
75
74
  continue;
76
75
  }
77
76
  try {
@@ -85,10 +84,7 @@ async function handleMoveFile(args, signal) {
85
84
  await withAbort(fs.cp(validSource, targetPath, { recursive: true }), signal);
86
85
  }
87
86
  catch (copyError) {
88
- failed.push({
89
- source: src,
90
- error: formatUnknownErrorMessage(copyError),
91
- });
87
+ failed.push(toMoveFailure(src, copyError));
92
88
  continue;
93
89
  }
94
90
  // Copy succeeded — now remove source
@@ -103,23 +99,14 @@ async function handleMoveFile(args, signal) {
103
99
  }
104
100
  catch {
105
101
  // Rollback failed — data exists in both locations
106
- failed.push({
107
- source: src,
108
- error: `Cross-device move partially failed: data exists at both '${validSource}' and '${targetPath}'. ${formatUnknownErrorMessage(deleteError)}`,
109
- });
102
+ failed.push(toMoveFailure(src, new McpError(ErrorCode.E_UNKNOWN, `Cross-device move partially failed: data exists at both '${validSource}' and '${targetPath}'. ${formatUnknownErrorMessage(deleteError)}`, src)));
110
103
  continue;
111
104
  }
112
- failed.push({
113
- source: src,
114
- error: `Cross-device move failed: could not remove source. ${formatUnknownErrorMessage(deleteError)}`,
115
- });
105
+ failed.push(toMoveFailure(src, new McpError(ErrorCode.E_UNKNOWN, `Cross-device move failed: could not remove source. ${formatUnknownErrorMessage(deleteError)}`, src)));
116
106
  }
117
107
  }
118
108
  else {
119
- failed.push({
120
- source: src,
121
- error: formatUnknownErrorMessage(error),
122
- });
109
+ failed.push(toMoveFailure(src, error));
123
110
  }
124
111
  }
125
112
  }
@@ -137,6 +124,7 @@ export function registerMoveFileTool(server, options = {}) {
137
124
  const handler = (args, extra) => executeToolWithDiagnostics({
138
125
  toolName: 'mv',
139
126
  extra,
127
+ outputSchema: MoveFileOutputSchema,
140
128
  timedSignal: {},
141
129
  context: { path: args.source ?? args.sources?.[0] },
142
130
  run: (signal) => handleMoveFile(args, signal),
@@ -3,7 +3,7 @@ import { DEFAULT_SEARCH_TIMEOUT_MS } from '../lib/constants.js';
3
3
  import { ErrorCode } from '../lib/errors.js';
4
4
  import { readMultipleFiles } from '../lib/file-operations/metadata.js';
5
5
  import { ReadMultipleFilesInputSchema, ReadMultipleFilesOutputSchema, } from '../schemas.js';
6
- import { buildBatchCompletionSuffix, buildBatchPathContext, buildResourceLink, buildToolErrorResponse, buildToolResponse, createBatchProgressCallbacks, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
6
+ import { buildBatchCompletionSuffix, buildBatchPathContext, buildResourceLink, buildStructuredError, buildToolErrorResponse, buildToolResponse, createBatchProgressCallbacks, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
7
  import { registerToolTaskIfAvailable } from './task-support.js';
8
8
  const READ_MANY_TOOL_NAME = 'read_many';
9
9
  const READ_MANY_TOOL_LABEL = '🕮 read_many';
@@ -17,10 +17,6 @@ export const READ_MANY_TOOL = {
17
17
  outputSchema: ReadMultipleFilesOutputSchema,
18
18
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
19
19
  taskSupport: 'optional',
20
- nuances: ['Total read budget is capped by `MAX_READ_MANY_TOTAL_SIZE`.'],
21
- gotchas: [
22
- 'Per-file `truncationReason` can be `head`, `tail`, `range`, or `externalized`.',
23
- ],
24
20
  };
25
21
  function buildReadManyResourceName(filePath) {
26
22
  return `read:${path.basename(filePath)}`;
@@ -72,8 +68,12 @@ function resolveReadManyTruncationReason(result) {
72
68
  }
73
69
  function maybeExternalizeReadManyResult(result, resourceStore) {
74
70
  const truncationReason = resolveReadManyTruncationReason(result);
71
+ const { error, ...rest } = result;
75
72
  const baseResult = {
76
- ...result,
73
+ ...rest,
74
+ ...(error
75
+ ? { error: buildStructuredError(error, ErrorCode.E_UNKNOWN, result.path) }
76
+ : {}),
77
77
  ...(truncationReason ? { truncationReason } : {}),
78
78
  };
79
79
  if (!result.content) {
@@ -89,12 +89,13 @@ function maybeExternalizeReadManyResult(result, resourceStore) {
89
89
  truncated: true,
90
90
  resourceUri: externalized.entry.uri,
91
91
  truncationReason: 'externalized',
92
+ expiresAt: externalized.entry.expiresAt,
92
93
  };
93
94
  }
94
95
  function buildReadManyTextSection(result) {
95
96
  const header = `=== ${result.path} ===`;
96
97
  if (result.error) {
97
- return `${header}\nError: ${result.error}`;
98
+ return `${header}\nError [${result.error.code}]: ${result.error.message}`;
98
99
  }
99
100
  return `${header}\n${result.content ?? ''}`;
100
101
  }
@@ -128,6 +129,9 @@ function buildReadManyResponsePayload(results, resourceStore) {
128
129
  uri: mappedResult.resourceUri,
129
130
  name: buildReadManyResourceName(mappedResult.path),
130
131
  description: FULL_FILE_CONTENTS_DESCRIPTION,
132
+ ...(mappedResult.expiresAt
133
+ ? { expiresAt: mappedResult.expiresAt }
134
+ : {}),
131
135
  }));
132
136
  }
133
137
  if (mappedResult.error === undefined)
@@ -162,6 +166,7 @@ export function registerReadMultipleFilesTool(server, options = {}) {
162
166
  return executeToolWithDiagnostics({
163
167
  toolName: READ_MANY_TOOL_NAME,
164
168
  extra,
169
+ outputSchema: ReadMultipleFilesOutputSchema,
165
170
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
166
171
  context: { path: primaryPath },
167
172
  run: async (signal) => {
@@ -115,8 +115,6 @@ function buildReadCompletionMessage(args, result) {
115
115
  if (result.isError)
116
116
  return `${READ_TOOL_LABEL}: ${name} • failed`;
117
117
  const structured = result.structuredContent;
118
- if (!structured.ok)
119
- return `${READ_TOOL_LABEL}: ${name} • failed`;
120
118
  const lines = structured.linesRead ?? structured.totalLines;
121
119
  if (structured.startLine !== undefined) {
122
120
  const end = structured.linesRead
@@ -158,6 +156,7 @@ export function registerReadFileTool(server, options = {}) {
158
156
  const handler = (args, extra) => executeToolWithDiagnostics({
159
157
  toolName: READ_TOOL_NAME,
160
158
  extra,
159
+ outputSchema: ReadFileOutputSchema,
161
160
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
162
161
  context: { path: args.path },
163
162
  run: (signal) => handleReadFile(args, signal, options.resourceStore),