@j0hanz/filesystem-mcp 1.5.3 → 1.6.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.
@@ -9,6 +9,7 @@ interface DetailedError {
9
9
  }
10
10
  export declare function isNodeError(error: unknown): error is NodeJS.ErrnoException;
11
11
  export declare function formatUnknownErrorMessage(error: unknown): string;
12
+ export declare function normalizeUnknownError(error: unknown): Error;
12
13
  export declare function isAbortError(error: unknown): boolean;
13
14
  export declare function isTimeoutLikeError(error: unknown): boolean;
14
15
  export declare class McpError extends Error {
@@ -94,6 +94,11 @@ export function formatUnknownErrorMessage(error) {
94
94
  return String(error);
95
95
  }
96
96
  }
97
+ export function normalizeUnknownError(error) {
98
+ return error instanceof Error
99
+ ? error
100
+ : new Error(formatUnknownErrorMessage(error));
101
+ }
97
102
  const NODE_ERROR_CODE_MAP = {
98
103
  ENOENT: ErrorCode.E_NOT_FOUND,
99
104
  EACCES: ErrorCode.E_PERMISSION_DENIED,
@@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto';
5
5
  import { Writable } from 'node:stream';
6
6
  import { pipeline } from 'node:stream/promises';
7
7
  import { BINARY_CHECK_BUFFER_SIZE, KNOWN_BINARY_EXTENSIONS, MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY, } from './constants.js';
8
- import { ErrorCode, formatUnknownErrorMessage, McpError } from './errors.js';
8
+ import { ErrorCode, McpError, normalizeUnknownError } from './errors.js';
9
9
  import { assertAllowedFileAccess } from './path-policy.js';
10
10
  import { validateExistingPath } from './path-validation.js';
11
11
  function createAbortError(message = 'Operation aborted') {
@@ -20,11 +20,6 @@ function normalizeAbortReason(reason, message) {
20
20
  function isFiniteNumber(value) {
21
21
  return typeof value === 'number' && Number.isFinite(value);
22
22
  }
23
- function normalizeUnknownError(error) {
24
- return error instanceof Error
25
- ? error
26
- : new Error(formatUnknownErrorMessage(error));
27
- }
28
23
  export function assertNotAborted(signal, message) {
29
24
  if (!signal)
30
25
  return;
package/dist/schemas.d.ts CHANGED
@@ -33,6 +33,8 @@ export declare const ToolErrorResponseSchema: z.ZodObject<{
33
33
  suggestion: z.ZodOptional<z.ZodString>;
34
34
  }, z.core.$strict>;
35
35
  }, z.core.$strict>;
36
+ declare const HeadLinesSchema: z.ZodOptional<z.ZodInt>;
37
+ declare const LineNumberSchema: z.ZodNumber;
36
38
  export declare const ListDirectoryInputSchema: z.ZodObject<{
37
39
  path: z.ZodOptional<z.ZodString>;
38
40
  includeHidden: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
@@ -85,16 +87,16 @@ export declare const SearchContentInputSchema: z.ZodObject<{
85
87
  includeIgnored: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
86
88
  }, z.core.$strict>;
87
89
  export declare const ReadFileInputSchema: z.ZodObject<{
90
+ head: typeof HeadLinesSchema;
91
+ startLine: z.ZodOptional<typeof LineNumberSchema>;
92
+ endLine: z.ZodOptional<typeof LineNumberSchema>;
88
93
  path: z.ZodString;
89
- head: z.ZodOptional<z.ZodInt>;
90
- startLine: z.ZodOptional<z.ZodNumber>;
91
- endLine: z.ZodOptional<z.ZodNumber>;
92
94
  }, z.core.$strict>;
93
95
  export declare const ReadMultipleFilesInputSchema: z.ZodObject<{
96
+ head: typeof HeadLinesSchema;
97
+ startLine: z.ZodOptional<typeof LineNumberSchema>;
98
+ endLine: z.ZodOptional<typeof LineNumberSchema>;
94
99
  paths: z.ZodArray<z.ZodString>;
95
- head: z.ZodOptional<z.ZodInt>;
96
- startLine: z.ZodOptional<z.ZodNumber>;
97
- endLine: z.ZodOptional<z.ZodNumber>;
98
100
  }, z.core.$strict>;
99
101
  export declare const GetFileInfoInputSchema: z.ZodObject<{
100
102
  path: z.ZodString;
@@ -472,11 +474,13 @@ export declare const GetMultipleFileInfoOutputSchema: z.ZodObject<{
472
474
  }, z.core.$strict>>;
473
475
  }, z.core.$strict>;
474
476
  export declare const CreateDirectoryInputSchema: z.ZodObject<{
475
- path: z.ZodString;
477
+ path: z.ZodOptional<z.ZodString>;
478
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
476
479
  }, z.core.$strict>;
477
480
  export declare const CreateDirectoryOutputSchema: z.ZodObject<{
478
481
  ok: z.ZodBoolean;
479
482
  path: z.ZodOptional<z.ZodString>;
483
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
480
484
  error: z.ZodOptional<z.ZodObject<{
481
485
  code: z.ZodEnum<{
482
486
  readonly E_ACCESS_DENIED: "E_ACCESS_DENIED";
@@ -532,6 +536,7 @@ export declare const EditFileInputSchema: z.ZodObject<{
532
536
  newText: z.ZodString;
533
537
  }, z.core.$strict>>;
534
538
  dryRun: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
539
+ ignoreWhitespace: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
535
540
  }, z.core.$strict>;
536
541
  export declare const EditFileOutputSchema: z.ZodObject<{
537
542
  ok: z.ZodBoolean;
@@ -560,13 +565,19 @@ export declare const EditFileOutputSchema: z.ZodObject<{
560
565
  }, z.core.$strict>>;
561
566
  }, z.core.$strict>;
562
567
  export declare const MoveFileInputSchema: z.ZodObject<{
563
- source: z.ZodString;
568
+ source: z.ZodOptional<z.ZodString>;
569
+ sources: z.ZodOptional<z.ZodArray<z.ZodString>>;
564
570
  destination: z.ZodString;
565
571
  }, z.core.$strict>;
566
572
  export declare const MoveFileOutputSchema: z.ZodObject<{
567
573
  ok: z.ZodBoolean;
568
574
  source: z.ZodOptional<z.ZodString>;
575
+ sources: z.ZodOptional<z.ZodArray<z.ZodString>>;
569
576
  destination: z.ZodOptional<z.ZodString>;
577
+ failed: z.ZodOptional<z.ZodArray<z.ZodObject<{
578
+ source: z.ZodString;
579
+ error: z.ZodString;
580
+ }, z.core.$strict>>>;
570
581
  error: z.ZodOptional<z.ZodObject<{
571
582
  code: z.ZodEnum<{
572
583
  readonly E_ACCESS_DENIED: "E_ACCESS_DENIED";
@@ -717,6 +728,7 @@ export declare const SearchAndReplaceInputSchema: z.ZodObject<{
717
728
  dryRun: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
718
729
  includeHidden: z.ZodOptional<z.ZodBoolean>;
719
730
  includeIgnored: z.ZodOptional<z.ZodBoolean>;
731
+ returnDiff: z.ZodOptional<z.ZodBoolean>;
720
732
  }, z.core.$strict>;
721
733
  export declare const SearchAndReplaceOutputSchema: z.ZodObject<{
722
734
  ok: z.ZodBoolean;
package/dist/schemas.js CHANGED
@@ -81,6 +81,13 @@ const validateReadRange = (value, ctx) => {
81
81
  addReadRangeIssue(ctx, 'endLine', "'endLine' must be >= 'startLine'");
82
82
  }
83
83
  };
84
+ function createReadRangeInputFields(descriptions) {
85
+ return {
86
+ head: HeadLinesSchema.describe(descriptions.head),
87
+ startLine: LineNumberSchema.optional().describe(descriptions.startLine),
88
+ endLine: LineNumberSchema.optional().describe(descriptions.endLine),
89
+ };
90
+ }
84
91
  const FileInfoSchema = z.strictObject({
85
92
  name: z.string().describe('Name'),
86
93
  path: z.string().describe('Absolute path'),
@@ -280,9 +287,11 @@ export const SearchContentInputSchema = z.strictObject({
280
287
  export const ReadFileInputSchema = z
281
288
  .strictObject({
282
289
  path: RequiredPathSchema.describe(DESC_PATH_REQUIRED),
283
- head: HeadLinesSchema.describe('Read first N lines (preview)'),
284
- startLine: LineNumberSchema.optional().describe('Start line (1-based, inclusive)'),
285
- endLine: LineNumberSchema.optional().describe('End line (1-based, inclusive). Requires startLine.'),
290
+ ...createReadRangeInputFields({
291
+ head: 'Read first N lines (preview)',
292
+ startLine: 'Start line (1-based, inclusive)',
293
+ endLine: 'End line (1-based, inclusive). Requires startLine.',
294
+ }),
286
295
  })
287
296
  .superRefine(validateReadRange);
288
297
  export const ReadMultipleFilesInputSchema = z
@@ -292,9 +301,11 @@ export const ReadMultipleFilesInputSchema = z
292
301
  .min(1, 'Min 1 path required')
293
302
  .max(100, 'Max 100 files')
294
303
  .describe('Files to read. e.g. ["src/index.ts"]'),
295
- head: HeadLinesSchema.describe('Read first N lines of each file'),
296
- startLine: LineNumberSchema.optional().describe('Start line (1-based, inclusive) per file'),
297
- endLine: LineNumberSchema.optional().describe('End line (1-based, inclusive) per file. Requires startLine.'),
304
+ ...createReadRangeInputFields({
305
+ head: 'Read first N lines of each file',
306
+ startLine: 'Start line (1-based, inclusive) per file',
307
+ endLine: 'End line (1-based, inclusive) per file. Requires startLine.',
308
+ }),
298
309
  })
299
310
  .superRefine(validateReadRange);
300
311
  export const GetFileInfoInputSchema = z.strictObject({
@@ -459,12 +470,22 @@ export const GetMultipleFileInfoOutputSchema = z.strictObject({
459
470
  summary: OperationSummarySchema.optional(),
460
471
  error: ErrorSchema.optional(),
461
472
  });
462
- export const CreateDirectoryInputSchema = z.strictObject({
463
- path: RequiredPathSchema.describe(DESC_PATH_REQUIRED),
473
+ export const CreateDirectoryInputSchema = z
474
+ .strictObject({
475
+ path: RequiredPathSchema.optional().describe(DESC_PATH_REQUIRED),
476
+ paths: z
477
+ .array(RequiredPathSchema)
478
+ .optional()
479
+ .describe('Absolute paths to directories to create'),
480
+ })
481
+ .refine((data) => data.path !== undefined || data.paths !== undefined, {
482
+ message: "Either 'path' or 'paths' must be provided",
483
+ path: ['path'],
464
484
  });
465
485
  export const CreateDirectoryOutputSchema = z.strictObject({
466
486
  ok: z.boolean(),
467
487
  path: z.string().optional(),
488
+ paths: z.array(z.string()).optional(),
468
489
  error: ErrorSchema.optional(),
469
490
  });
470
491
  export const WriteFileInputSchema = z.strictObject({
@@ -495,6 +516,11 @@ export const EditFileInputSchema = z.strictObject({
495
516
  .optional()
496
517
  .default(false)
497
518
  .describe('Preview edits without writing. Check unmatchedEdits in the response to verify all oldText values were found.'),
519
+ ignoreWhitespace: z
520
+ .boolean()
521
+ .optional()
522
+ .default(false)
523
+ .describe('Ignore leading/trailing whitespace and treat all whitespace sequences as equivalent when matching oldText.'),
498
524
  });
499
525
  export const EditFileOutputSchema = z.strictObject({
500
526
  ok: z.boolean(),
@@ -510,14 +536,28 @@ export const EditFileOutputSchema = z.strictObject({
510
536
  .describe('Edits that could not be applied'),
511
537
  error: ErrorSchema.optional(),
512
538
  });
513
- export const MoveFileInputSchema = z.strictObject({
514
- source: RequiredPathSchema.describe('Path to move'),
539
+ export const MoveFileInputSchema = z
540
+ .strictObject({
541
+ source: RequiredPathSchema.optional().describe('Path to move (deprecated: use sources)'),
542
+ sources: z.array(RequiredPathSchema).optional().describe('Paths to move'),
515
543
  destination: RequiredPathSchema.describe('New path'),
544
+ })
545
+ .refine((data) => (data.source ?? data.sources) !== undefined, {
546
+ message: "Either 'source' or 'sources' must be provided",
547
+ path: ['source'],
516
548
  });
517
549
  export const MoveFileOutputSchema = z.strictObject({
518
550
  ok: z.boolean(),
519
551
  source: z.string().optional(),
552
+ sources: z.array(z.string()).optional(),
520
553
  destination: z.string().optional(),
554
+ failed: z
555
+ .array(z.strictObject({
556
+ source: z.string().describe('Source path'),
557
+ error: z.string().describe('Error message'),
558
+ }))
559
+ .optional()
560
+ .describe('List of files that failed to move'),
521
561
  error: ErrorSchema.optional(),
522
562
  });
523
563
  export const DeleteFileInputSchema = z.strictObject({
@@ -643,6 +683,10 @@ export const SearchAndReplaceInputSchema = z.strictObject({
643
683
  .boolean()
644
684
  .optional()
645
685
  .describe('Include files and directories ignored by .gitignore rules (e.g. node_modules, dist). Default: false.'),
686
+ returnDiff: z
687
+ .boolean()
688
+ .optional()
689
+ .describe('Return unified diff of changes even if dryRun is false. Default: false.'),
646
690
  });
647
691
  export const SearchAndReplaceOutputSchema = z.strictObject({
648
692
  ok: z.boolean(),
@@ -1,4 +1,7 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { type ToolContract, type ToolRegistrationOptions } from './shared.js';
2
+ import type { z } from 'zod';
3
+ import { CreateDirectoryInputSchema, CreateDirectoryOutputSchema } from '../schemas.js';
4
+ import { type ToolContract, type ToolRegistrationOptions, type ToolResponse } from './shared.js';
3
5
  export declare const CREATE_DIRECTORY_TOOL: ToolContract;
6
+ export declare function handleCreateDirectory(args: z.infer<typeof CreateDirectoryInputSchema>, signal?: AbortSignal): Promise<ToolResponse<z.infer<typeof CreateDirectoryOutputSchema>>>;
4
7
  export declare function registerCreateDirectoryTool(server: McpServer, options?: ToolRegistrationOptions): void;
@@ -1,6 +1,5 @@
1
1
  import * as fs from 'node:fs/promises';
2
- import * as path from 'node:path';
3
- import { ErrorCode } from '../lib/errors.js';
2
+ import { ErrorCode, McpError } from '../lib/errors.js';
4
3
  import { withAbort } from '../lib/fs-helpers.js';
5
4
  import { validatePathForWrite } from '../lib/path-validation.js';
6
5
  import { CreateDirectoryInputSchema, CreateDirectoryOutputSchema, } from '../schemas.js';
@@ -15,12 +14,20 @@ export const CREATE_DIRECTORY_TOOL = {
15
14
  annotations: IDEMPOTENT_WRITE_TOOL_ANNOTATIONS,
16
15
  nuances: ['Succeeds silently if the directory already exists (idempotent).'],
17
16
  };
18
- async function handleCreateDirectory(args, signal) {
19
- const validPath = await validatePathForWrite(args.path, signal);
20
- await withAbort(fs.mkdir(validPath, { recursive: true }), signal);
21
- return buildToolResponse(`Successfully created directory: ${args.path}`, {
17
+ export async function handleCreateDirectory(args, signal) {
18
+ const allPaths = [];
19
+ if (args.path)
20
+ allPaths.push(args.path);
21
+ if (args.paths)
22
+ allPaths.push(...args.paths);
23
+ if (allPaths.length === 0) {
24
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'No paths provided to create.');
25
+ }
26
+ const validPaths = await Promise.all(allPaths.map((p) => validatePathForWrite(p, signal)));
27
+ await Promise.all(validPaths.map((p) => withAbort(fs.mkdir(p, { recursive: true }), signal)));
28
+ return buildToolResponse(`Successfully created ${validPaths.length} director${validPaths.length === 1 ? 'y' : 'ies'}`, {
22
29
  ok: true,
23
- path: validPath,
30
+ paths: validPaths,
24
31
  });
25
32
  }
26
33
  export function registerCreateDirectoryTool(server, options = {}) {
@@ -28,21 +35,21 @@ export function registerCreateDirectoryTool(server, options = {}) {
28
35
  toolName: 'mkdir',
29
36
  extra,
30
37
  timedSignal: {},
31
- context: { path: args.path },
38
+ context: { path: args.path ?? args.paths?.[0] },
32
39
  run: (signal) => handleCreateDirectory(args, signal),
33
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path),
40
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path ?? args.paths?.[0]),
34
41
  });
35
42
  const wrappedHandler = wrapToolHandler(handler, {
36
43
  guard: options.isInitialized,
37
44
  progressMessage: (args) => {
38
- const name = path.basename(args.path) || '.';
39
- return `🛠 mkdir: ${name}`;
45
+ const count = (args.path ? 1 : 0) + (args.paths?.length ?? 0);
46
+ return `🛠 mkdir: ${count} director${count === 1 ? 'y' : 'ies'}`;
40
47
  },
41
48
  completionMessage: (args, result) => {
42
- const name = path.basename(args.path) || '.';
49
+ const count = (args.path ? 1 : 0) + (args.paths?.length ?? 0);
43
50
  if (result.isError)
44
- return `🛠 mkdir: ${name} • failed`;
45
- return `🛠 mkdir: ${name} • created`;
51
+ return `🛠 mkdir: ${count} • failed`;
52
+ return `🛠 mkdir: ${count} • created`;
46
53
  },
47
54
  });
48
55
  const validatedHandler = withValidatedArgs(CreateDirectoryInputSchema, wrappedHandler);
@@ -1,4 +1,7 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { type ToolContract, type ToolRegistrationOptions } from './shared.js';
2
+ import type { z } from 'zod';
3
+ import { EditFileInputSchema, EditFileOutputSchema } from '../schemas.js';
4
+ import { type ToolContract, type ToolRegistrationOptions, type ToolResponse } from './shared.js';
3
5
  export declare const EDIT_FILE_TOOL: ToolContract;
6
+ export declare function handleEditFile(args: z.infer<typeof EditFileInputSchema>, signal?: AbortSignal): Promise<ToolResponse<z.infer<typeof EditFileOutputSchema>>>;
4
7
  export declare function registerEditFileTool(server: McpServer, options?: ToolRegistrationOptions): void;
@@ -23,28 +23,57 @@ export const EDIT_FILE_TOOL = {
23
23
  '`oldText` must match exactly; unmatched items are reported in `unmatchedEdits`.',
24
24
  ],
25
25
  };
26
- function applyEdits(content, edits) {
26
+ function escapeRegExp(string) {
27
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
28
+ }
29
+ function applyEdits(content, edits, ignoreWhitespace) {
27
30
  let newContent = content;
28
31
  let appliedEdits = 0;
29
32
  const unmatchedEdits = [];
30
33
  let minLine;
31
34
  let maxLine;
32
35
  for (const edit of edits) {
33
- if (!newContent.includes(edit.oldText)) {
34
- unmatchedEdits.push(edit.oldText);
35
- continue;
36
+ if (ignoreWhitespace) {
37
+ const pattern = escapeRegExp(edit.oldText).replace(/\s+/g, '\\s+');
38
+ const regex = new RegExp(pattern);
39
+ const match = regex.exec(newContent);
40
+ if (!match) {
41
+ unmatchedEdits.push(edit.oldText);
42
+ continue;
43
+ }
44
+ const { index } = match;
45
+ const matchLength = match[0].length;
46
+ const linesBefore = newContent.slice(0, index).split('\n').length;
47
+ const newTextLines = edit.newText.split('\n').length;
48
+ const startLine = linesBefore;
49
+ const endLine = linesBefore + newTextLines - 1;
50
+ if (minLine === undefined || startLine < minLine)
51
+ minLine = startLine;
52
+ if (maxLine === undefined || endLine > maxLine)
53
+ maxLine = endLine;
54
+ newContent =
55
+ newContent.slice(0, index) +
56
+ edit.newText +
57
+ newContent.slice(index + matchLength);
58
+ appliedEdits += 1;
59
+ }
60
+ else {
61
+ if (!newContent.includes(edit.oldText)) {
62
+ unmatchedEdits.push(edit.oldText);
63
+ continue;
64
+ }
65
+ const index = newContent.indexOf(edit.oldText);
66
+ const linesBefore = newContent.slice(0, index).split('\n').length;
67
+ const newTextLines = edit.newText.split('\n').length;
68
+ const startLine = linesBefore;
69
+ const endLine = linesBefore + newTextLines - 1;
70
+ if (minLine === undefined || startLine < minLine)
71
+ minLine = startLine;
72
+ if (maxLine === undefined || endLine > maxLine)
73
+ maxLine = endLine;
74
+ newContent = newContent.replace(edit.oldText, () => edit.newText);
75
+ appliedEdits += 1;
36
76
  }
37
- const index = newContent.indexOf(edit.oldText);
38
- const linesBefore = newContent.slice(0, index).split('\n').length;
39
- const newTextLines = edit.newText.split('\n').length;
40
- const startLine = linesBefore;
41
- const endLine = linesBefore + newTextLines - 1;
42
- if (minLine === undefined || startLine < minLine)
43
- minLine = startLine;
44
- if (maxLine === undefined || endLine > maxLine)
45
- maxLine = endLine;
46
- newContent = newContent.replace(edit.oldText, () => edit.newText);
47
- appliedEdits += 1;
48
77
  }
49
78
  const result = {
50
79
  content: newContent,
@@ -56,10 +85,10 @@ function applyEdits(content, edits) {
56
85
  }
57
86
  return result;
58
87
  }
59
- async function handleEditFile(args, signal) {
88
+ export async function handleEditFile(args, signal) {
60
89
  const validPath = await validateExistingPath(args.path, signal);
61
90
  const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
62
- const { content: newContent, appliedEdits, unmatchedEdits, lineRange, } = applyEdits(content, args.edits);
91
+ const { content: newContent, appliedEdits, unmatchedEdits, lineRange, } = applyEdits(content, args.edits, args.ignoreWhitespace);
63
92
  const structured = {
64
93
  ok: true,
65
94
  path: validPath,
@@ -1,4 +1,7 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { type ToolContract, type ToolRegistrationOptions } from './shared.js';
2
+ import type { z } from 'zod';
3
+ import { MoveFileInputSchema, MoveFileOutputSchema } from '../schemas.js';
4
+ import { type ToolContract, type ToolRegistrationOptions, type ToolResponse } from './shared.js';
3
5
  export declare const MOVE_FILE_TOOL: ToolContract;
6
+ export declare function handleMoveFile(args: z.infer<typeof MoveFileInputSchema>, signal?: AbortSignal): Promise<ToolResponse<z.infer<typeof MoveFileOutputSchema>>>;
4
7
  export declare function registerMoveFileTool(server: McpServer, options?: ToolRegistrationOptions): void;
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
- import { ErrorCode, isNodeError } from '../lib/errors.js';
3
+ import { ErrorCode, formatUnknownErrorMessage, isNodeError, McpError, } from '../lib/errors.js';
4
4
  import { withAbort } from '../lib/fs-helpers.js';
5
5
  import { validateExistingPath, validatePathForWrite, } from '../lib/path-validation.js';
6
6
  import { MoveFileInputSchema, MoveFileOutputSchema } from '../schemas.js';
@@ -18,28 +18,95 @@ export const MOVE_FILE_TOOL = {
18
18
  'On POSIX, an existing destination is silently overwritten; on Windows, rename fails with EEXIST if destination exists.',
19
19
  ],
20
20
  };
21
- async function handleMoveFile(args, signal) {
22
- const validSource = await validateExistingPath(args.source, signal);
21
+ export async function handleMoveFile(args, signal) {
22
+ const sources = args.sources ?? (args.source ? [args.source] : []);
23
+ if (sources.length === 0) {
24
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'No sources provided.');
25
+ }
23
26
  const validDest = await validatePathForWrite(args.destination, signal);
24
- // Ensure destination parent directory exists
25
- await withAbort(fs.mkdir(path.dirname(validDest), { recursive: true }), signal);
27
+ // Check if destination exists and is a directory
28
+ let destIsDirectory = false;
26
29
  try {
27
- await withAbort(fs.rename(validSource, validDest), signal);
30
+ const stats = await fs.stat(validDest);
31
+ destIsDirectory = stats.isDirectory();
28
32
  }
29
33
  catch (error) {
30
- if (isNodeError(error) && error.code === 'EXDEV') {
31
- // Cross-device link, fallback to copy + delete
32
- await withAbort(fs.cp(validSource, validDest, { recursive: true }), signal);
33
- await withAbort(fs.rm(validSource, { recursive: true, force: true }), signal);
34
- }
35
- else {
34
+ if (isNodeError(error) && error.code !== 'ENOENT') {
36
35
  throw error;
37
36
  }
38
37
  }
39
- return buildToolResponse(`Successfully moved ${args.source} to ${args.destination}`, {
40
- ok: true,
41
- source: validSource,
38
+ if (sources.length > 1 && !destIsDirectory) {
39
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'Destination must be an existing directory when moving multiple files.');
40
+ }
41
+ // Ensure destination parent directory exists if it's not an existing directory
42
+ if (!destIsDirectory) {
43
+ await withAbort(fs.mkdir(path.dirname(validDest), { recursive: true }), signal);
44
+ }
45
+ const movedSources = [];
46
+ const failed = [];
47
+ for (const src of sources) {
48
+ let validSource;
49
+ try {
50
+ validSource = await validateExistingPath(src, signal);
51
+ }
52
+ catch (error) {
53
+ failed.push({
54
+ source: src,
55
+ error: formatUnknownErrorMessage(error),
56
+ });
57
+ continue;
58
+ }
59
+ const targetPath = destIsDirectory
60
+ ? path.join(validDest, path.basename(validSource))
61
+ : validDest;
62
+ // Prevent moving a file onto itself
63
+ if (path.resolve(validSource) === path.resolve(targetPath)) {
64
+ continue;
65
+ }
66
+ // Prevent moving a directory into its own subdirectory
67
+ // Fixes "Missing validation for moving directory into its own subdirectory" finding
68
+ if (path.resolve(targetPath).startsWith(path.resolve(validSource) + path.sep)) {
69
+ failed.push({
70
+ source: src,
71
+ error: `Cannot move directory '${src}' into its own subdirectory '${targetPath}'`,
72
+ });
73
+ continue;
74
+ }
75
+ try {
76
+ await withAbort(fs.rename(validSource, targetPath), signal);
77
+ movedSources.push(validSource);
78
+ }
79
+ catch (error) {
80
+ if (isNodeError(error) && error.code === 'EXDEV') {
81
+ // Cross-device link, fallback to copy + delete
82
+ try {
83
+ await withAbort(fs.cp(validSource, targetPath, { recursive: true }), signal);
84
+ await withAbort(fs.rm(validSource, { recursive: true, force: true }), signal);
85
+ movedSources.push(validSource);
86
+ }
87
+ catch (copyError) {
88
+ failed.push({
89
+ source: src,
90
+ error: formatUnknownErrorMessage(copyError),
91
+ });
92
+ }
93
+ }
94
+ else {
95
+ failed.push({
96
+ source: src,
97
+ error: formatUnknownErrorMessage(error),
98
+ });
99
+ }
100
+ }
101
+ }
102
+ const message = failed.length > 0
103
+ ? `Moved ${movedSources.length} item${movedSources.length === 1 ? '' : 's'}; failed to move ${failed.length} item${failed.length === 1 ? '' : 's'}`
104
+ : `Successfully moved ${movedSources.length} item${movedSources.length === 1 ? '' : 's'} to ${args.destination}`;
105
+ return buildToolResponse(message, {
106
+ ok: failed.length === 0,
107
+ sources: movedSources,
42
108
  destination: validDest,
109
+ ...(failed.length > 0 ? { failed } : {}),
43
110
  });
44
111
  }
45
112
  export function registerMoveFileTool(server, options = {}) {
@@ -47,19 +114,23 @@ export function registerMoveFileTool(server, options = {}) {
47
114
  toolName: 'mv',
48
115
  extra,
49
116
  timedSignal: {},
50
- context: { path: args.source },
117
+ context: { path: args.source ?? args.sources?.[0] },
51
118
  run: (signal) => handleMoveFile(args, signal),
52
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.source),
119
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.source ?? args.sources?.[0]),
53
120
  });
54
121
  const wrappedHandler = wrapToolHandler(handler, {
55
122
  guard: options.isInitialized,
56
- progressMessage: (args) => `🛠 mv: ${path.basename(args.source)} → ${path.basename(args.destination)}`,
123
+ progressMessage: (args) => {
124
+ const count = (args.source ? 1 : 0) + (args.sources?.length ?? 0);
125
+ const dest = path.basename(args.destination);
126
+ return `🛠 mv: ${count} item${count === 1 ? '' : 's'} → ${dest}`;
127
+ },
57
128
  completionMessage: (args, result) => {
58
- const src = path.basename(args.source);
59
- const dst = path.basename(args.destination);
129
+ const count = (args.source ? 1 : 0) + (args.sources?.length ?? 0);
130
+ const dest = path.basename(args.destination);
60
131
  if (result.isError)
61
- return `🛠 mv: ${src} → ${dst} • failed`;
62
- return `🛠 mv: ${src} → ${dst} • moved`;
132
+ return `🛠 mv: ${count} → ${dest} • failed`;
133
+ return `🛠 mv: ${count} → ${dest} • moved`;
63
134
  },
64
135
  });
65
136
  const validatedHandler = withValidatedArgs(MoveFileInputSchema, wrappedHandler);
@@ -14,7 +14,7 @@ export const READ_MULTIPLE_FILES_TOOL = {
14
14
  inputSchema: ReadMultipleFilesInputSchema,
15
15
  outputSchema: ReadMultipleFilesOutputSchema,
16
16
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
17
- taskSupport: 'required',
17
+ taskSupport: 'optional',
18
18
  nuances: ['Total read budget is capped by `MAX_READ_MANY_TOTAL_SIZE`.'],
19
19
  gotchas: [
20
20
  'Per-file `truncationReason` can be `head`, `range`, or `externalized`.',
@@ -1,4 +1,10 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { type ToolContract, type ToolRegistrationOptions } from './shared.js';
2
+ import type { z } from 'zod';
3
+ import { SearchAndReplaceInputSchema, SearchAndReplaceOutputSchema } from '../schemas.js';
4
+ import { type ToolContract, type ToolRegistrationOptions, type ToolResponse } from './shared.js';
3
5
  export declare const SEARCH_AND_REPLACE_TOOL: ToolContract;
6
+ export declare function handleSearchAndReplace(args: z.infer<typeof SearchAndReplaceInputSchema>, signal?: AbortSignal, onProgress?: (progress: {
7
+ total?: number;
8
+ current: number;
9
+ }) => void): Promise<ToolResponse<z.infer<typeof SearchAndReplaceOutputSchema>>>;
4
10
  export declare function registerSearchAndReplaceTool(server: McpServer, options?: ToolRegistrationOptions): void;
@@ -23,7 +23,7 @@ export const SEARCH_AND_REPLACE_TOOL = {
23
23
  inputSchema: SearchAndReplaceInputSchema,
24
24
  outputSchema: SearchAndReplaceOutputSchema,
25
25
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
26
- taskSupport: 'required',
26
+ taskSupport: 'optional',
27
27
  gotchas: [
28
28
  'Literal mode is default; `isRegex=true` enables RE2 + capture replacements (`$1`, `$2`).',
29
29
  ],
@@ -122,7 +122,8 @@ async function processEntry(entryPath, args, regex, maxFileSize, signal, summary
122
122
  else {
123
123
  newContent = content.replaceAll(args.searchPattern, () => args.replacement);
124
124
  }
125
- if (args.dryRun && summary.diff.length < MAX_DIFF_SIZE) {
125
+ if ((args.dryRun || args.returnDiff) &&
126
+ summary.diff.length < MAX_DIFF_SIZE) {
126
127
  const patch = createTwoFilesPatch(path.basename(validPath), path.basename(validPath), content, newContent, 'Original', 'Modified');
127
128
  // Only append if it won't exceed the limit too much
128
129
  if (summary.diff.length + patch.length <= MAX_DIFF_SIZE + 1024) {
@@ -201,7 +202,7 @@ function reportReplaceProgress(onProgress, current, force = false) {
201
202
  return;
202
203
  onProgress({ current });
203
204
  }
204
- async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
205
+ export async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
205
206
  const maxFileSize = MAX_TEXT_FILE_SIZE;
206
207
  const root = await resolveSearchRoot(args.path, signal);
207
208
  const regex = createReplacementRegex(args);
@@ -240,7 +241,9 @@ async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
240
241
  ? { changedFiles: summary.changedFiles }
241
242
  : {}),
242
243
  ...(summary.changedFilesTruncated ? { changedFilesTruncated: true } : {}),
243
- ...(args.dryRun && summary.diff ? { diff: summary.diff } : {}),
244
+ ...((args.dryRun || args.returnDiff) && summary.diff
245
+ ? { diff: summary.diff }
246
+ : {}),
244
247
  dryRun: args.dryRun,
245
248
  });
246
249
  }
@@ -26,7 +26,7 @@ export const SEARCH_CONTENT_TOOL = {
26
26
  gotchas: [
27
27
  'Skips binary and oversized files silently — check file type with `stat` if no matches appear.',
28
28
  ],
29
- taskSupport: 'required',
29
+ taskSupport: 'optional',
30
30
  };
31
31
  function assertValidRegexPattern(pattern) {
32
32
  try {
@@ -38,7 +38,7 @@ export const SEARCH_FILES_TOOL = {
38
38
  'Respects `.gitignore` unless `includeIgnored=true`.',
39
39
  'Returns relative paths plus metadata; may truncate.',
40
40
  ],
41
- taskSupport: 'required',
41
+ taskSupport: 'optional',
42
42
  };
43
43
  async function handleSearchFiles(args, signal, onProgress) {
44
44
  const basePath = resolvePathOrRoot(args.path);
@@ -89,12 +89,6 @@ export function buildResourceLink(params) {
89
89
  ...(params.mimeType ? { mimeType: params.mimeType } : {}),
90
90
  };
91
91
  }
92
- function buildContentBlock(text, structuredContent, extraContent = []) {
93
- return {
94
- content: [{ type: 'text', text }, ...extraContent],
95
- structuredContent,
96
- };
97
- }
98
92
  function resolveDetailedError(error, defaultCode, path) {
99
93
  const detailed = createDetailedError(error, path);
100
94
  if (detailed.code === ErrorCode.E_UNKNOWN) {
@@ -104,7 +98,10 @@ function resolveDetailedError(error, defaultCode, path) {
104
98
  return detailed;
105
99
  }
106
100
  export function buildToolResponse(text, structuredContent, extraContent = []) {
107
- return buildContentBlock(text, structuredContent, extraContent);
101
+ return {
102
+ content: [{ type: 'text', text }, ...extraContent],
103
+ structuredContent,
104
+ };
108
105
  }
109
106
  function parseToolArgs(schema, args) {
110
107
  const candidate = args === undefined ? {} : args;
@@ -213,7 +210,8 @@ export function buildToolErrorResponse(error, defaultCode, path) {
213
210
  error: errorContent,
214
211
  };
215
212
  return {
216
- ...buildContentBlock(text, structuredContent),
213
+ content: [{ type: 'text', text }],
214
+ structuredContent,
217
215
  isError: true,
218
216
  };
219
217
  }
@@ -13,7 +13,7 @@ export const GET_MULTIPLE_FILE_INFO_TOOL = {
13
13
  inputSchema: GetMultipleFileInfoInputSchema,
14
14
  outputSchema: GetMultipleFileInfoOutputSchema,
15
15
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
16
- taskSupport: 'required',
16
+ taskSupport: 'optional',
17
17
  nuances: ['Use before read/search when file size/type uncertainty exists.'],
18
18
  };
19
19
  function formatFileInfoDetail(info) {
@@ -14,7 +14,7 @@ export const TREE_TOOL = {
14
14
  inputSchema: TreeInputSchema,
15
15
  outputSchema: TreeOutputSchema,
16
16
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
17
- taskSupport: 'required',
17
+ taskSupport: 'optional',
18
18
  gotchas: ['`maxDepth=0` returns only the root node.'],
19
19
  };
20
20
  async function handleTree(args, signal, onProgress) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/filesystem-mcp",
3
- "version": "1.5.3",
3
+ "version": "1.6.0",
4
4
  "mcpName": "io.github.j0hanz/filesystem-mcp",
5
5
  "description": "MCP Server that enables LLMs to interact with the local filesystem.",
6
6
  "type": "module",