@j0hanz/filesystem-mcp 1.14.0 → 1.15.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.
- package/LICENSE +21 -0
- package/README.md +143 -71
- package/dist/cli.js +2 -2
- package/dist/completions.js +54 -51
- package/dist/config.d.ts +13 -13
- package/dist/config.js +12 -12
- package/dist/index.js +1 -1
- package/dist/lib/abort.d.ts +7 -0
- package/dist/lib/abort.js +81 -0
- package/dist/lib/constants.d.ts +3 -1
- package/dist/lib/constants.js +8 -2
- package/dist/lib/errors.d.ts +7 -3
- package/dist/lib/errors.js +59 -39
- package/dist/lib/file-operations/core.d.ts +3 -3
- package/dist/lib/file-operations/core.js +23 -20
- package/dist/lib/file-operations/metadata.d.ts +2 -2
- package/dist/lib/file-operations/metadata.js +60 -19
- package/dist/lib/file-operations/search.js +83 -84
- package/dist/lib/file-operations/traversal.js +13 -15
- package/dist/lib/fs-helpers.d.ts +3 -10
- package/dist/lib/fs-helpers.js +20 -98
- package/dist/lib/globs.js +1 -1
- package/dist/lib/logger.d.ts +28 -0
- package/dist/lib/logger.js +91 -0
- package/dist/lib/observability.d.ts +7 -0
- package/dist/lib/observability.js +19 -9
- package/dist/lib/paths.js +55 -55
- package/dist/lib/resource-store.js +4 -4
- package/dist/lib/utils.d.ts +0 -12
- package/dist/lib/utils.js +0 -13
- package/dist/resources/generated-instructions.js +40 -31
- package/dist/resources/tool-catalog.js +32 -26
- package/dist/resources/tool-info.js +34 -29
- package/dist/resources/workflows.js +39 -18
- package/dist/resources.d.ts +1 -1
- package/dist/resources.js +4 -4
- package/dist/schemas.d.ts +66 -66
- package/dist/schemas.js +21 -44
- package/dist/server/bootstrap.d.ts +12 -11
- package/dist/server/bootstrap.js +95 -86
- package/dist/server/roots-manager.d.ts +5 -2
- package/dist/server/roots-manager.js +8 -6
- package/dist/server/task-store.d.ts +10 -0
- package/dist/server/task-store.js +73 -0
- package/dist/tools/apply-patch.js +26 -18
- package/dist/tools/calculate-hash.js +11 -22
- package/dist/tools/create-directory.js +10 -8
- package/dist/tools/delete-file.js +17 -15
- package/dist/tools/diff-files.js +15 -15
- package/dist/tools/edit-file.js +7 -4
- package/dist/tools/list-directory.js +6 -6
- package/dist/tools/move-file.js +98 -81
- package/dist/tools/read-multiple.js +4 -4
- package/dist/tools/read.js +5 -5
- package/dist/tools/replace-in-files.js +20 -23
- package/dist/tools/roots.js +1 -1
- package/dist/tools/search-content.js +32 -41
- package/dist/tools/search-files.js +54 -41
- package/dist/tools/shared.d.ts +3 -0
- package/dist/tools/shared.js +70 -28
- package/dist/tools/stat-many.js +11 -7
- package/dist/tools/stat.js +4 -4
- package/dist/tools/task-support.d.ts +10 -9
- package/dist/tools/task-support.js +94 -23
- package/dist/tools/tree.js +3 -3
- package/dist/tools/write-file.js +10 -7
- package/package.json +9 -9
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import * as fs from 'node:fs/promises';
|
|
2
|
-
import * as path from 'node:path';
|
|
3
1
|
import { createHash } from 'node:crypto';
|
|
4
2
|
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { stat } from 'node:fs/promises';
|
|
4
|
+
import { basename, relative, win32 } from 'node:path';
|
|
5
5
|
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { assertNotAborted, withAbort } from '../lib/abort.js';
|
|
6
7
|
import { PARALLEL_CONCURRENCY } from '../lib/constants.js';
|
|
7
8
|
import { ErrorCode } from '../lib/errors.js';
|
|
8
9
|
import { isIgnoredByGitignore, loadRootGitignore, } from '../lib/file-operations/core.js';
|
|
9
10
|
import { globEntries } from '../lib/file-operations/traversal.js';
|
|
10
|
-
import { assertNotAborted, withAbort } from '../lib/fs-helpers.js';
|
|
11
11
|
import { validateExistingPath } from '../lib/paths.js';
|
|
12
|
-
import { reportPeriodicProgress } from '../lib/utils.js';
|
|
13
12
|
import { CalculateHashInputSchema, CalculateHashOutputSchema, } from '../schemas.js';
|
|
14
13
|
import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
15
14
|
import { registerToolTaskIfAvailable } from './task-support.js';
|
|
@@ -32,8 +31,8 @@ async function hashFile(filePath, encoding, signal) {
|
|
|
32
31
|
return encoding ? hasher.digest(encoding) : hasher.digest();
|
|
33
32
|
}
|
|
34
33
|
function toStableRelativePath(root, entryPath) {
|
|
35
|
-
const relativePath =
|
|
36
|
-
return relativePath.includes(
|
|
34
|
+
const relativePath = relative(root, entryPath);
|
|
35
|
+
return relativePath.includes(win32.sep)
|
|
37
36
|
? relativePath.replace(WINDOWS_PATH_SEPARATOR, '/')
|
|
38
37
|
: relativePath;
|
|
39
38
|
}
|
|
@@ -93,18 +92,11 @@ async function hashDirectory(dirPath, options = {}) {
|
|
|
93
92
|
const fileHash = await hashFile(task.filePath, undefined, signal);
|
|
94
93
|
entries.push({ path: task.relativePath, hash: fileHash });
|
|
95
94
|
filesHashed++;
|
|
96
|
-
|
|
97
|
-
throttleModulo: 25,
|
|
98
|
-
total: totalFiles,
|
|
99
|
-
});
|
|
95
|
+
onProgress?.({ current: filesHashed, total: totalFiles });
|
|
100
96
|
}
|
|
101
97
|
};
|
|
102
98
|
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
103
|
-
|
|
104
|
-
throttleModulo: 25,
|
|
105
|
-
total: totalFiles,
|
|
106
|
-
force: true,
|
|
107
|
-
});
|
|
99
|
+
onProgress?.({ current: filesHashed, total: totalFiles });
|
|
108
100
|
assertNotAborted(signal);
|
|
109
101
|
// Sort by path with byte-wise semantics for deterministic ordering.
|
|
110
102
|
entries.sort(comparePaths);
|
|
@@ -123,7 +115,7 @@ async function hashDirectory(dirPath, options = {}) {
|
|
|
123
115
|
async function handleCalculateHash(args, signal, onProgress) {
|
|
124
116
|
const validPath = await validateExistingPath(args.path, signal);
|
|
125
117
|
// Check if path is a directory or file
|
|
126
|
-
const stats = await withAbort(
|
|
118
|
+
const stats = await withAbort(stat(validPath), signal);
|
|
127
119
|
if (stats.isDirectory()) {
|
|
128
120
|
// Hash directory: composite hash of all files
|
|
129
121
|
const { hash, fileCount } = await hashDirectory(validPath, {
|
|
@@ -141,10 +133,7 @@ async function handleCalculateHash(args, signal, onProgress) {
|
|
|
141
133
|
else {
|
|
142
134
|
// Hash single file
|
|
143
135
|
const hash = await hashFile(validPath, 'hex', signal);
|
|
144
|
-
|
|
145
|
-
throttleModulo: 25,
|
|
146
|
-
force: true,
|
|
147
|
-
});
|
|
136
|
+
onProgress?.({ current: 1 });
|
|
148
137
|
return buildToolResponse(hash, {
|
|
149
138
|
ok: true,
|
|
150
139
|
path: validPath,
|
|
@@ -161,7 +150,7 @@ export function registerCalculateHashTool(server, options = {}) {
|
|
|
161
150
|
timedSignal: {},
|
|
162
151
|
context: { path: args.path },
|
|
163
152
|
run: async (signal) => {
|
|
164
|
-
const baseName =
|
|
153
|
+
const baseName = basename(args.path);
|
|
165
154
|
const progress = createToolProgressSession(extra, `🕮 hash: ${baseName}`);
|
|
166
155
|
const progressWithMessage = ({ current, total, }) => {
|
|
167
156
|
progress.update({
|
|
@@ -190,7 +179,7 @@ export function registerCalculateHashTool(server, options = {}) {
|
|
|
190
179
|
throw error;
|
|
191
180
|
}
|
|
192
181
|
},
|
|
193
|
-
onError: (error) => buildToolErrorResponse(error, ErrorCode.
|
|
182
|
+
onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path),
|
|
194
183
|
});
|
|
195
184
|
const wrappedHandler = wrapToolHandler(handler, {
|
|
196
185
|
guard: options.isInitialized,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { withAbort } from '../lib/abort.js';
|
|
3
4
|
import { ErrorCode, McpError } from '../lib/errors.js';
|
|
4
|
-
import { withAbort } from '../lib/fs-helpers.js';
|
|
5
5
|
import { validatePathForWrite } from '../lib/paths.js';
|
|
6
6
|
import { CreateDirectoryInputSchema, CreateDirectoryOutputSchema, } from '../schemas.js';
|
|
7
7
|
import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, IDEMPOTENT_WRITE_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
@@ -22,12 +22,14 @@ async function handleCreateDirectory(args, signal) {
|
|
|
22
22
|
if (args.paths)
|
|
23
23
|
allPaths.push(...args.paths);
|
|
24
24
|
if (allPaths.length === 0) {
|
|
25
|
-
throw new McpError(ErrorCode.
|
|
25
|
+
throw new McpError(ErrorCode.INVALID_INPUT, 'No paths provided to create.');
|
|
26
26
|
}
|
|
27
27
|
const validPaths = await Promise.all(allPaths.map((p) => validatePathForWrite(p, signal)));
|
|
28
|
-
await Promise.all(validPaths.map((p) => withAbort(
|
|
28
|
+
await Promise.all(validPaths.map((p) => withAbort(mkdir(p, { recursive: true }), signal)));
|
|
29
|
+
const createdPath = validPaths.length === 1 ? validPaths[0] : undefined;
|
|
29
30
|
return buildToolResponse(`Successfully created ${validPaths.length} director${validPaths.length === 1 ? 'y' : 'ies'}`, {
|
|
30
31
|
ok: true,
|
|
32
|
+
...(createdPath ? { path: createdPath } : {}),
|
|
31
33
|
paths: validPaths,
|
|
32
34
|
});
|
|
33
35
|
}
|
|
@@ -39,20 +41,20 @@ export function registerCreateDirectoryTool(server, options = {}) {
|
|
|
39
41
|
timedSignal: {},
|
|
40
42
|
context: { path: args.path ?? args.paths?.[0] },
|
|
41
43
|
run: (signal) => handleCreateDirectory(args, signal),
|
|
42
|
-
onError: (error) => buildToolErrorResponse(error, ErrorCode.
|
|
44
|
+
onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path ?? args.paths?.[0]),
|
|
43
45
|
});
|
|
44
46
|
const wrappedHandler = wrapToolHandler(handler, {
|
|
45
47
|
guard: options.isInitialized,
|
|
46
48
|
progressMessage: (args) => {
|
|
47
49
|
if (args.path && !args.paths?.length) {
|
|
48
|
-
return `🛠 mkdir: ${
|
|
50
|
+
return `🛠 mkdir: ${basename(args.path)}`;
|
|
49
51
|
}
|
|
50
52
|
const count = (args.path ? 1 : 0) + (args.paths?.length ?? 0);
|
|
51
53
|
return `🛠 mkdir: ${count} directories`;
|
|
52
54
|
},
|
|
53
55
|
completionMessage: (args, result) => {
|
|
54
56
|
if (args.path && !args.paths?.length) {
|
|
55
|
-
const name =
|
|
57
|
+
const name = basename(args.path);
|
|
56
58
|
if (result.isError)
|
|
57
59
|
return `🛠 mkdir: ${name} • failed`;
|
|
58
60
|
return `🛠 mkdir: ${name}`;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import { lstat, rm, rmdir } from 'node:fs/promises';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { withAbort } from '../lib/abort.js';
|
|
3
4
|
import { ErrorCode, isNodeError, McpError } from '../lib/errors.js';
|
|
4
|
-
import {
|
|
5
|
+
import { Logger } from '../lib/logger.js';
|
|
5
6
|
import { isAllowedDirectoryRoot, validatePathForWrite } from '../lib/paths.js';
|
|
6
7
|
import { DeleteFileInputSchema, DeleteFileOutputSchema } from '../schemas.js';
|
|
7
8
|
import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
@@ -19,11 +20,11 @@ export const DELETE_FILE_TOOL = {
|
|
|
19
20
|
async function handleDeleteFile(args, signal) {
|
|
20
21
|
const validPath = await validatePathForWrite(args.path, signal);
|
|
21
22
|
if (isAllowedDirectoryRoot(validPath)) {
|
|
22
|
-
throw new McpError(ErrorCode.
|
|
23
|
+
throw new McpError(ErrorCode.ACCESS_DENIED, 'Deleting a workspace root directory is not allowed');
|
|
23
24
|
}
|
|
24
25
|
let stats;
|
|
25
26
|
try {
|
|
26
|
-
stats = await withAbort(
|
|
27
|
+
stats = await withAbort(lstat(validPath), signal);
|
|
27
28
|
}
|
|
28
29
|
catch (error) {
|
|
29
30
|
if (isNodeError(error) &&
|
|
@@ -39,14 +40,15 @@ async function handleDeleteFile(args, signal) {
|
|
|
39
40
|
if (stats.isDirectory() && !args.recursive) {
|
|
40
41
|
// Use rmdir for non-recursive directory deletes so non-empty directories
|
|
41
42
|
// consistently return ENOTEMPTY-style errors with actionable guidance.
|
|
42
|
-
await withAbort(
|
|
43
|
+
await withAbort(rmdir(validPath), signal);
|
|
43
44
|
}
|
|
44
45
|
else {
|
|
45
|
-
await withAbort(
|
|
46
|
+
await withAbort(rm(validPath, {
|
|
46
47
|
recursive: args.recursive,
|
|
47
48
|
force: args.ignoreIfNotExists,
|
|
48
49
|
}), signal);
|
|
49
50
|
}
|
|
51
|
+
Logger.info(`rm: ${args.path}`);
|
|
50
52
|
return buildToolResponse(`Successfully deleted: ${args.path}`, {
|
|
51
53
|
ok: true,
|
|
52
54
|
path: validPath,
|
|
@@ -63,29 +65,29 @@ export function registerDeleteFileTool(server, options = {}) {
|
|
|
63
65
|
onError: (error) => {
|
|
64
66
|
if (isNodeError(error)) {
|
|
65
67
|
if (error.code === 'ENOENT') {
|
|
66
|
-
return buildToolErrorResponse(error, ErrorCode.
|
|
68
|
+
return buildToolErrorResponse(error, ErrorCode.NOT_FOUND, args.path);
|
|
67
69
|
}
|
|
68
70
|
if (error.code === 'ENOTEMPTY') {
|
|
69
|
-
return buildToolErrorResponse(new Error(
|
|
71
|
+
return buildToolErrorResponse(new Error('Directory not empty. Set recursive: true.'), ErrorCode.INVALID_INPUT, args.path);
|
|
70
72
|
}
|
|
71
73
|
if (error.code === 'EISDIR') {
|
|
72
|
-
return buildToolErrorResponse(new Error(
|
|
74
|
+
return buildToolErrorResponse(new Error('Path is a directory. Set recursive: true.'), ErrorCode.INVALID_INPUT, args.path);
|
|
73
75
|
}
|
|
74
76
|
if (error.code === 'EEXIST') {
|
|
75
|
-
return buildToolErrorResponse(new Error(
|
|
77
|
+
return buildToolErrorResponse(new Error('Directory not empty. Set recursive: true.'), ErrorCode.INVALID_INPUT, args.path);
|
|
76
78
|
}
|
|
77
79
|
if (error.code === 'EPERM' || error.code === 'EACCES') {
|
|
78
|
-
return buildToolErrorResponse(error, ErrorCode.
|
|
80
|
+
return buildToolErrorResponse(error, ErrorCode.PERMISSION_DENIED, args.path);
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
|
-
return buildToolErrorResponse(error, ErrorCode.
|
|
83
|
+
return buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path);
|
|
82
84
|
},
|
|
83
85
|
});
|
|
84
86
|
const wrappedHandler = wrapToolHandler(handler, {
|
|
85
87
|
guard: options.isInitialized,
|
|
86
|
-
progressMessage: (args) => `🛠 rm: ${
|
|
88
|
+
progressMessage: (args) => `🛠 rm: ${basename(args.path)}`,
|
|
87
89
|
completionMessage: (args, result) => {
|
|
88
|
-
const name =
|
|
90
|
+
const name = basename(args.path);
|
|
89
91
|
if (result.isError)
|
|
90
92
|
return `🛠 rm: ${name} • failed`;
|
|
91
93
|
return `🛠 rm: ${name}`;
|
package/dist/tools/diff-files.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
3
|
import { formatPatch, structuredPatch } from 'diff';
|
|
4
|
+
import { withAbort } from '../lib/abort.js';
|
|
4
5
|
import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
|
|
5
6
|
import { ErrorCode, McpError } from '../lib/errors.js';
|
|
6
|
-
import { withAbort } from '../lib/fs-helpers.js';
|
|
7
7
|
import { validateExistingPath } from '../lib/paths.js';
|
|
8
8
|
import { DiffFilesInputSchema, DiffFilesOutputSchema } from '../schemas.js';
|
|
9
9
|
import { buildResourceLink, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
@@ -35,7 +35,7 @@ function computeDiffStats(hunks) {
|
|
|
35
35
|
function assertDiffFileSizeWithinLimit(filePath, size, maxFileSize) {
|
|
36
36
|
if (size <= maxFileSize)
|
|
37
37
|
return;
|
|
38
|
-
throw new McpError(ErrorCode.
|
|
38
|
+
throw new McpError(ErrorCode.TOO_LARGE, `File too large for diff (${size} bytes > ${maxFileSize} bytes).`, filePath, { size, maxFileSize });
|
|
39
39
|
}
|
|
40
40
|
async function handleDiffFiles(args, signal, resourceStore) {
|
|
41
41
|
const maxFileSize = MAX_TEXT_FILE_SIZE;
|
|
@@ -44,17 +44,17 @@ async function handleDiffFiles(args, signal, resourceStore) {
|
|
|
44
44
|
validateExistingPath(args.modified, signal),
|
|
45
45
|
]);
|
|
46
46
|
const [originalStats, modifiedStats] = await Promise.all([
|
|
47
|
-
withAbort(
|
|
48
|
-
withAbort(
|
|
47
|
+
withAbort(stat(originalPath), signal),
|
|
48
|
+
withAbort(stat(modifiedPath), signal),
|
|
49
49
|
]);
|
|
50
50
|
assertDiffFileSizeWithinLimit(originalPath, originalStats.size, maxFileSize);
|
|
51
51
|
assertDiffFileSizeWithinLimit(modifiedPath, modifiedStats.size, maxFileSize);
|
|
52
52
|
const [originalContent, modifiedContent] = await Promise.all([
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
readFile(originalPath, { encoding: 'utf-8', signal }),
|
|
54
|
+
readFile(modifiedPath, { encoding: 'utf-8', signal }),
|
|
55
55
|
]);
|
|
56
56
|
const patchObj = await new Promise((resolve) => {
|
|
57
|
-
structuredPatch(
|
|
57
|
+
structuredPatch(basename(originalPath), basename(modifiedPath), originalContent, modifiedContent, undefined, undefined, {
|
|
58
58
|
...(args.context !== undefined ? { context: args.context } : {}),
|
|
59
59
|
ignoreWhitespace: args.ignoreWhitespace,
|
|
60
60
|
stripTrailingCr: args.stripTrailingCr,
|
|
@@ -65,7 +65,7 @@ async function handleDiffFiles(args, signal, resourceStore) {
|
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
if (!patchObj) {
|
|
68
|
-
throw new McpError(ErrorCode.
|
|
68
|
+
throw new McpError(ErrorCode.TIMEOUT, `Diff timed out or too complex.`, originalPath);
|
|
69
69
|
}
|
|
70
70
|
const isIdentical = patchObj.hunks.length === 0;
|
|
71
71
|
const diffText = isIdentical ? '' : formatPatch(patchObj);
|
|
@@ -108,18 +108,18 @@ export function registerDiffFilesTool(server, options = {}) {
|
|
|
108
108
|
timedSignal: {},
|
|
109
109
|
context: { path: args.original },
|
|
110
110
|
run: (signal) => handleDiffFiles(args, signal, options.resourceStore),
|
|
111
|
-
onError: (error) => buildToolErrorResponse(error, ErrorCode.
|
|
111
|
+
onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.original),
|
|
112
112
|
});
|
|
113
113
|
const wrappedHandler = wrapToolHandler(handler, {
|
|
114
114
|
guard: options.isInitialized,
|
|
115
115
|
progressMessage: (args) => {
|
|
116
|
-
const n1 =
|
|
117
|
-
const n2 =
|
|
116
|
+
const n1 = basename(args.original);
|
|
117
|
+
const n2 = basename(args.modified);
|
|
118
118
|
return `🕮 diff: ${n1} ⟷ ${n2}`;
|
|
119
119
|
},
|
|
120
120
|
completionMessage: (args, result) => {
|
|
121
|
-
const n1 =
|
|
122
|
-
const n2 =
|
|
121
|
+
const n1 = basename(args.original);
|
|
122
|
+
const n2 = basename(args.modified);
|
|
123
123
|
if (result.isError)
|
|
124
124
|
return `🕮 diff: ${n1} ⟷ ${n2} • failed`;
|
|
125
125
|
const sc = result.structuredContent;
|
package/dist/tools/edit-file.js
CHANGED
|
@@ -2,9 +2,11 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
2
2
|
import { basename } from 'node:path';
|
|
3
3
|
import { createTwoFilesPatch, diffLines } from 'diff';
|
|
4
4
|
import RE2 from 're2';
|
|
5
|
+
import { withAbort } from '../lib/abort.js';
|
|
5
6
|
import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
|
|
6
7
|
import { ErrorCode, McpError } from '../lib/errors.js';
|
|
7
|
-
import { atomicWriteFile
|
|
8
|
+
import { atomicWriteFile } from '../lib/fs-helpers.js';
|
|
9
|
+
import { Logger } from '../lib/logger.js';
|
|
8
10
|
import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
|
|
9
11
|
import { EditFileInputSchema, EditFileOutputSchema } from '../schemas.js';
|
|
10
12
|
import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
@@ -161,7 +163,7 @@ async function loadEditableFile(requestedPath, signal) {
|
|
|
161
163
|
assertAllowedFileAccess(requestedPath, validPath);
|
|
162
164
|
const stats = await withAbort(stat(validPath), signal);
|
|
163
165
|
if (stats.size > MAX_TEXT_FILE_SIZE) {
|
|
164
|
-
throw new McpError(ErrorCode.
|
|
166
|
+
throw new McpError(ErrorCode.TOO_LARGE, `File too large for edit (${stats.size} bytes > ${MAX_TEXT_FILE_SIZE} bytes)`, requestedPath, { size: stats.size, maxFileSize: MAX_TEXT_FILE_SIZE });
|
|
165
167
|
}
|
|
166
168
|
const content = await readFile(validPath, { encoding: 'utf-8', signal });
|
|
167
169
|
return { validPath, content };
|
|
@@ -215,13 +217,14 @@ async function handleEditFile(args, signal) {
|
|
|
215
217
|
return buildToolResponse(`Dry run complete. ${editResult.appliedEdits} edits would be applied.`, structured);
|
|
216
218
|
}
|
|
217
219
|
if (editResult.appliedEdits === 0 && editResult.unmatchedEdits.length > 0) {
|
|
218
|
-
throw new McpError(ErrorCode.
|
|
220
|
+
throw new McpError(ErrorCode.INVALID_INPUT, `All ${editResult.unmatchedEdits.length} edits failed. Verify oldText matches exact file content.`, args.path);
|
|
219
221
|
}
|
|
220
222
|
if (editResult.appliedEdits > 0) {
|
|
221
223
|
await atomicWriteFile(validPath, editResult.content, {
|
|
222
224
|
encoding: 'utf-8',
|
|
223
225
|
signal,
|
|
224
226
|
});
|
|
227
|
+
Logger.info(`edit: ${args.path} (${editResult.appliedEdits} edits, +${editResult.linesAdded}/-${editResult.linesRemoved})`);
|
|
225
228
|
}
|
|
226
229
|
return buildToolResponse(buildEditMessage(args.path, editResult), structured);
|
|
227
230
|
}
|
|
@@ -233,7 +236,7 @@ export function registerEditFileTool(server, options = {}) {
|
|
|
233
236
|
timedSignal: {},
|
|
234
237
|
context: { path: args.path },
|
|
235
238
|
run: (signal) => handleEditFile(args, signal),
|
|
236
|
-
onError: (error) => buildToolErrorResponse(error, ErrorCode.
|
|
239
|
+
onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.path),
|
|
237
240
|
});
|
|
238
241
|
const wrappedHandler = wrapToolHandler(handler, {
|
|
239
242
|
guard: options.isInitialized,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import * as path from 'node:path';
|
|
2
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { DEFAULT_EXCLUDE_PATTERNS, MAX_LIST_ENTRIES, } from '../lib/constants.js';
|
|
5
5
|
import { ErrorCode, McpError } from '../lib/errors.js';
|
|
@@ -71,7 +71,7 @@ function decodeListCursor(cursor) {
|
|
|
71
71
|
catch {
|
|
72
72
|
// fall through to throw
|
|
73
73
|
}
|
|
74
|
-
throw new McpError(ErrorCode.
|
|
74
|
+
throw new McpError(ErrorCode.INVALID_INPUT, 'Invalid or expired cursor.');
|
|
75
75
|
}
|
|
76
76
|
function resolveNextListCursor(snapshotId, offset, pageSize, totalEntries) {
|
|
77
77
|
if (!snapshotId)
|
|
@@ -166,7 +166,7 @@ async function handleListDirectory(args, signal) {
|
|
|
166
166
|
const cursor = decodeListCursor(args.cursor);
|
|
167
167
|
const snapshot = listSnapshots.get(cursor.snapshotId);
|
|
168
168
|
if (snapshot?.fingerprint !== fingerprint) {
|
|
169
|
-
throw new McpError(ErrorCode.
|
|
169
|
+
throw new McpError(ErrorCode.INVALID_INPUT, 'Invalid or expired cursor.');
|
|
170
170
|
}
|
|
171
171
|
const { offset, snapshotId: storedSnapshotId } = cursor;
|
|
172
172
|
cursorOffset = offset;
|
|
@@ -200,13 +200,13 @@ export function registerListDirectoryTool(server, options = {}) {
|
|
|
200
200
|
outputSchema: ListDirectoryOutputSchema,
|
|
201
201
|
context: { path: args.path ?? '.' },
|
|
202
202
|
run: (signal) => handleListDirectory(args, signal),
|
|
203
|
-
onError: (error) => buildToolErrorResponse(error, ErrorCode.
|
|
203
|
+
onError: (error) => buildToolErrorResponse(error, ErrorCode.NOT_DIRECTORY, args.path ?? '.'),
|
|
204
204
|
});
|
|
205
205
|
const wrappedHandler = wrapToolHandler(handler, {
|
|
206
206
|
guard: options.isInitialized,
|
|
207
|
-
progressMessage: (args) => `≣ ls: ${args.path ?
|
|
207
|
+
progressMessage: (args) => `≣ ls: ${args.path ? basename(args.path) : '.'}`,
|
|
208
208
|
completionMessage: (args, result) => {
|
|
209
|
-
const base = args.path ?
|
|
209
|
+
const base = args.path ? basename(args.path) : '.';
|
|
210
210
|
if (result.isError)
|
|
211
211
|
return `≣ ls: ${base} • failed`;
|
|
212
212
|
const sc = result.structuredContent;
|
package/dist/tools/move-file.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import { cp, mkdir, rename, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { withAbort } from '../lib/abort.js';
|
|
3
4
|
import { ErrorCode, formatUnknownErrorMessage, isNodeError, McpError, } from '../lib/errors.js';
|
|
4
|
-
import {
|
|
5
|
+
import { Logger } from '../lib/logger.js';
|
|
5
6
|
import { assertAllowedFileAccess, validateExistingPath, validatePathForWrite, } from '../lib/paths.js';
|
|
6
7
|
import { MoveFileInputSchema, MoveFileOutputSchema } from '../schemas.js';
|
|
7
8
|
import { buildStructuredError, buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
|
|
@@ -18,103 +19,119 @@ export const MOVE_FILE_TOOL = {
|
|
|
18
19
|
],
|
|
19
20
|
taskSupport: 'forbidden',
|
|
20
21
|
};
|
|
21
|
-
function toMoveFailure(source, error, defaultCode = ErrorCode.
|
|
22
|
+
function toMoveFailure(source, error, defaultCode = ErrorCode.UNKNOWN) {
|
|
22
23
|
return {
|
|
23
24
|
source,
|
|
24
25
|
error: buildStructuredError(error, defaultCode, source),
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
|
-
async function
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
async function handleMoveError(error, src, validSource, targetPath, movedSources, failed, signal) {
|
|
29
|
+
if (isNodeError(error) && error.code === 'EXDEV') {
|
|
30
|
+
try {
|
|
31
|
+
await withAbort(cp(validSource, targetPath, { recursive: true }), signal);
|
|
32
|
+
}
|
|
33
|
+
catch (copyError) {
|
|
34
|
+
failed.push(toMoveFailure(src, copyError));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await withAbort(rm(validSource, { recursive: true, force: true }), signal);
|
|
39
|
+
movedSources.push(validSource);
|
|
40
|
+
}
|
|
41
|
+
catch (deleteError) {
|
|
42
|
+
try {
|
|
43
|
+
await rm(targetPath, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
failed.push(toMoveFailure(src, new McpError(ErrorCode.UNKNOWN, `Cross-device move partial: data at both source and destination. ${formatUnknownErrorMessage(deleteError)}`, src)));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
failed.push(toMoveFailure(src, new McpError(ErrorCode.UNKNOWN, `Cross-device move failed: source not removed. ${formatUnknownErrorMessage(deleteError)}`, src)));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
failed.push(toMoveFailure(src, error));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function processSingleMove(src, destIsDirectory, validDest, movedSources, failed, signal) {
|
|
57
|
+
let validSource;
|
|
58
|
+
try {
|
|
59
|
+
validSource = await validateExistingPath(src, signal);
|
|
60
|
+
assertAllowedFileAccess(src, validSource);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
failed.push(toMoveFailure(src, error, ErrorCode.ACCESS_DENIED));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const targetPath = destIsDirectory
|
|
67
|
+
? join(validDest, basename(validSource))
|
|
68
|
+
: validDest;
|
|
69
|
+
if (resolve(validSource) === resolve(targetPath)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (resolve(targetPath).startsWith(resolve(validSource) + sep)) {
|
|
73
|
+
failed.push(toMoveFailure(src, new McpError(ErrorCode.INVALID_INPUT, 'Cannot move a directory into its own subdirectory', src), ErrorCode.INVALID_INPUT));
|
|
74
|
+
return;
|
|
31
75
|
}
|
|
32
|
-
const validDest = await validatePathForWrite(args.destination, signal);
|
|
33
|
-
// Check if destination exists and is a directory
|
|
34
|
-
let destIsDirectory = false;
|
|
35
76
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
77
|
+
await withAbort(rename(validSource, targetPath), signal);
|
|
78
|
+
movedSources.push(validSource);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
await handleMoveError(error, src, validSource, targetPath, movedSources, failed, signal);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function getDestinationStatus(validDest) {
|
|
85
|
+
try {
|
|
86
|
+
const stats = await stat(validDest);
|
|
87
|
+
return stats.isDirectory();
|
|
38
88
|
}
|
|
39
89
|
catch (error) {
|
|
40
90
|
if (isNodeError(error) && error.code !== 'ENOENT') {
|
|
41
91
|
throw error;
|
|
42
92
|
}
|
|
43
93
|
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
function formatMoveMessage(moved, failed, destination) {
|
|
97
|
+
const movedItemStr = `item${moved === 1 ? '' : 's'}`;
|
|
98
|
+
const failedItemStr = `item${failed === 1 ? '' : 's'}`;
|
|
99
|
+
if (failed > 0) {
|
|
100
|
+
return `Moved ${moved} ${movedItemStr}; failed to move ${failed} ${failedItemStr}`;
|
|
101
|
+
}
|
|
102
|
+
return `Successfully moved ${moved} ${movedItemStr} to ${destination}`;
|
|
103
|
+
}
|
|
104
|
+
async function handleMoveFile(args, signal) {
|
|
105
|
+
const sources = args.sources ?? (args.source ? [args.source] : []);
|
|
106
|
+
if (sources.length === 0) {
|
|
107
|
+
throw new McpError(ErrorCode.INVALID_INPUT, 'No sources provided.');
|
|
108
|
+
}
|
|
109
|
+
const validDest = await validatePathForWrite(args.destination, signal);
|
|
110
|
+
const destIsDirectory = await getDestinationStatus(validDest);
|
|
44
111
|
if (sources.length > 1 && !destIsDirectory) {
|
|
45
|
-
throw new McpError(ErrorCode.
|
|
112
|
+
throw new McpError(ErrorCode.INVALID_INPUT, 'Destination must be an existing directory for multiple sources.');
|
|
46
113
|
}
|
|
47
|
-
// Ensure destination parent directory exists if it's not an existing directory
|
|
48
114
|
if (!destIsDirectory) {
|
|
49
|
-
await withAbort(
|
|
115
|
+
await withAbort(mkdir(dirname(validDest), { recursive: true }), signal);
|
|
50
116
|
}
|
|
51
117
|
const movedSources = [];
|
|
52
118
|
const failed = [];
|
|
53
119
|
for (const src of sources) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
? path.join(validDest, path.basename(validSource))
|
|
65
|
-
: validDest;
|
|
66
|
-
// Prevent moving a file onto itself
|
|
67
|
-
if (path.resolve(validSource) === path.resolve(targetPath)) {
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
// Prevent moving a directory into its own subdirectory
|
|
71
|
-
// Fixes "Missing validation for moving directory into its own subdirectory" finding
|
|
72
|
-
if (path.resolve(targetPath).startsWith(path.resolve(validSource) + path.sep)) {
|
|
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));
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
try {
|
|
77
|
-
await withAbort(fs.rename(validSource, targetPath), signal);
|
|
78
|
-
movedSources.push(validSource);
|
|
79
|
-
}
|
|
80
|
-
catch (error) {
|
|
81
|
-
if (isNodeError(error) && error.code === 'EXDEV') {
|
|
82
|
-
// Cross-device link, fallback to copy + delete
|
|
83
|
-
try {
|
|
84
|
-
await withAbort(fs.cp(validSource, targetPath, { recursive: true }), signal);
|
|
85
|
-
}
|
|
86
|
-
catch (copyError) {
|
|
87
|
-
failed.push(toMoveFailure(src, copyError));
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
// Copy succeeded — now remove source
|
|
91
|
-
try {
|
|
92
|
-
await withAbort(fs.rm(validSource, { recursive: true, force: true }), signal);
|
|
93
|
-
movedSources.push(validSource);
|
|
94
|
-
}
|
|
95
|
-
catch (deleteError) {
|
|
96
|
-
// Source delete failed after copy — rollback by removing the copy
|
|
97
|
-
try {
|
|
98
|
-
await fs.rm(targetPath, { recursive: true, force: true });
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
// Rollback failed — data exists in both locations
|
|
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)));
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
failed.push(toMoveFailure(src, new McpError(ErrorCode.E_UNKNOWN, `Cross-device move failed: could not remove source. ${formatUnknownErrorMessage(deleteError)}`, src)));
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
failed.push(toMoveFailure(src, error));
|
|
110
|
-
}
|
|
120
|
+
await processSingleMove(src, destIsDirectory, validDest, movedSources, failed, signal);
|
|
121
|
+
}
|
|
122
|
+
const message = formatMoveMessage(movedSources.length, failed.length, args.destination);
|
|
123
|
+
const movedSource = sources.length === 1 ? movedSources[0] : undefined;
|
|
124
|
+
const failedSuffix = failed.length > 0 ? ` (${failed.length} failed)` : '';
|
|
125
|
+
Logger.info(`mv: ${movedSources.length} item(s) → ${args.destination}${failedSuffix}`);
|
|
126
|
+
if (movedSources.length === 0 && failed.length > 0) {
|
|
127
|
+
const firstFailure = failed[0];
|
|
128
|
+
if (firstFailure) {
|
|
129
|
+
throw new McpError(firstFailure.error.code, message, firstFailure.error.path);
|
|
111
130
|
}
|
|
112
131
|
}
|
|
113
|
-
const message = failed.length > 0
|
|
114
|
-
? `Moved ${movedSources.length} item${movedSources.length === 1 ? '' : 's'}; failed to move ${failed.length} item${failed.length === 1 ? '' : 's'}`
|
|
115
|
-
: `Successfully moved ${movedSources.length} item${movedSources.length === 1 ? '' : 's'} to ${args.destination}`;
|
|
116
132
|
return buildToolResponse(message, {
|
|
117
133
|
ok: failed.length === 0,
|
|
134
|
+
...(movedSource ? { source: movedSource } : {}),
|
|
118
135
|
sources: movedSources,
|
|
119
136
|
destination: validDest,
|
|
120
137
|
...(failed.length > 0 ? { failed } : {}),
|
|
@@ -128,22 +145,22 @@ export function registerMoveFileTool(server, options = {}) {
|
|
|
128
145
|
timedSignal: {},
|
|
129
146
|
context: { path: args.source ?? args.sources?.[0] },
|
|
130
147
|
run: (signal) => handleMoveFile(args, signal),
|
|
131
|
-
onError: (error) => buildToolErrorResponse(error, ErrorCode.
|
|
148
|
+
onError: (error) => buildToolErrorResponse(error, ErrorCode.UNKNOWN, args.source ?? args.sources?.[0]),
|
|
132
149
|
});
|
|
133
150
|
const wrappedHandler = wrapToolHandler(handler, {
|
|
134
151
|
guard: options.isInitialized,
|
|
135
152
|
progressMessage: (args) => {
|
|
136
|
-
const dest =
|
|
153
|
+
const dest = basename(args.destination);
|
|
137
154
|
if (args.source && !args.sources?.length) {
|
|
138
|
-
return `🛠 mv: ${
|
|
155
|
+
return `🛠 mv: ${basename(args.source)} → ${dest}`;
|
|
139
156
|
}
|
|
140
157
|
const count = (args.source ? 1 : 0) + (args.sources?.length ?? 0);
|
|
141
158
|
return `🛠 mv: ${count} items → ${dest}`;
|
|
142
159
|
},
|
|
143
160
|
completionMessage: (args, result) => {
|
|
144
|
-
const dest =
|
|
161
|
+
const dest = basename(args.destination);
|
|
145
162
|
if (args.source && !args.sources?.length) {
|
|
146
|
-
const src =
|
|
163
|
+
const src = basename(args.source);
|
|
147
164
|
if (result.isError)
|
|
148
165
|
return `🛠 mv: ${src} → ${dest} • failed`;
|
|
149
166
|
return `🛠 mv: ${src} → ${dest}`;
|