@j0hanz/filesystem-mcp 1.9.0 → 1.10.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/README.md +15 -15
- package/dist/completions.js +140 -115
- package/dist/lib/constants.d.ts +1 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/file-operations/metadata.d.ts +5 -1
- package/dist/lib/file-operations/metadata.js +14 -1
- package/dist/lib/file-operations/search.d.ts +7 -5
- package/dist/lib/file-operations/search.js +64 -32
- package/dist/lib/fs-helpers.d.ts +3 -1
- package/dist/lib/fs-helpers.js +63 -0
- package/dist/lib/paths.d.ts +8 -0
- package/dist/lib/paths.js +119 -64
- package/dist/lib/resource-store.d.ts +2 -0
- package/dist/lib/resource-store.js +58 -17
- package/dist/prompts.d.ts +2 -0
- package/dist/prompts.js +51 -0
- package/dist/resources/generated-instructions.js +36 -9
- package/dist/resources/tool-catalog.js +30 -7
- package/dist/resources/tool-info.d.ts +4 -0
- package/dist/resources/tool-info.js +21 -3
- package/dist/resources/workflows.js +17 -5
- package/dist/schemas.d.ts +47 -12
- package/dist/schemas.js +75 -18
- package/dist/server/bootstrap.js +103 -91
- package/dist/server/roots-manager.d.ts +3 -0
- package/dist/server/roots-manager.js +15 -3
- package/dist/tools/apply-patch.js +135 -31
- package/dist/tools/calculate-hash.js +13 -8
- package/dist/tools/create-directory.js +14 -3
- package/dist/tools/delete-file.js +1 -0
- package/dist/tools/diff-files.js +26 -8
- package/dist/tools/edit-file.js +11 -8
- package/dist/tools/list-directory.js +1 -6
- package/dist/tools/move-file.js +39 -7
- package/dist/tools/read-multiple.js +9 -1
- package/dist/tools/read.js +38 -6
- package/dist/tools/replace-in-files.js +72 -25
- package/dist/tools/roots.js +1 -0
- package/dist/tools/search-content.js +76 -48
- package/dist/tools/search-files.js +6 -7
- package/dist/tools/shared.d.ts +2 -1
- package/dist/tools/shared.js +36 -20
- package/dist/tools/stat-many.js +1 -1
- package/dist/tools/stat.js +4 -0
- package/dist/tools/task-support.js +4 -12
- package/dist/tools/tree.js +4 -0
- package/dist/tools/write-file.js +4 -2
- package/package.json +17 -8
|
@@ -3,7 +3,7 @@ import { InitializedNotificationSchema, RootsListChangedNotificationSchema, } fr
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { formatUnknownErrorMessage } from '../lib/errors.js';
|
|
5
5
|
import { assertNotAborted, createTimedAbortSignal, withAbort, } from '../lib/fs-helpers.js';
|
|
6
|
-
import {
|
|
6
|
+
import { getValidRootDirectories, isPathWithinDirectories, normalizePath, resolveAllowedDirectoriesState, setAllowedDirectoriesStateResolved, } from '../lib/paths.js';
|
|
7
7
|
import { isRecord } from '../lib/utils.js';
|
|
8
8
|
import { logToMcp } from './bootstrap.js';
|
|
9
9
|
const ROOTS_TIMEOUT_MS = 5000;
|
|
@@ -83,6 +83,10 @@ async function filterRootsWithinBaseline(roots, baseline, signal) {
|
|
|
83
83
|
export class RootsManager {
|
|
84
84
|
rootsUpdateTimeout;
|
|
85
85
|
rootDirectories = [];
|
|
86
|
+
allowedDirectoriesState = {
|
|
87
|
+
primary: [],
|
|
88
|
+
expanded: [],
|
|
89
|
+
};
|
|
86
90
|
clientInitialized = false;
|
|
87
91
|
// Set to true when an update is in progress, to prevent concurrent executions. If a change arrives while true, we queue a single retry after completion to ensure the last-known state is applied. This
|
|
88
92
|
updatingRoots = false;
|
|
@@ -103,8 +107,14 @@ export class RootsManager {
|
|
|
103
107
|
this.rootsUpdateTimeout = undefined;
|
|
104
108
|
}
|
|
105
109
|
}
|
|
110
|
+
getAllowedDirectoriesState() {
|
|
111
|
+
return {
|
|
112
|
+
primary: [...this.allowedDirectoriesState.primary],
|
|
113
|
+
expanded: [...this.allowedDirectoriesState.expanded],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
106
116
|
logMissingDirectoriesIfNeeded(server) {
|
|
107
|
-
if (
|
|
117
|
+
if (this.allowedDirectoriesState.expanded.length === 0) {
|
|
108
118
|
this.logMissingDirectories(server);
|
|
109
119
|
}
|
|
110
120
|
}
|
|
@@ -130,7 +140,9 @@ export class RootsManager {
|
|
|
130
140
|
? await filterRootsWithinBaseline(this.rootDirectories, baseline, signal)
|
|
131
141
|
: this.rootDirectories;
|
|
132
142
|
const combined = [...baseline, ...rootsToInclude];
|
|
133
|
-
await
|
|
143
|
+
const nextState = await resolveAllowedDirectoriesState(combined, signal);
|
|
144
|
+
this.allowedDirectoriesState = nextState;
|
|
145
|
+
setAllowedDirectoriesStateResolved(nextState);
|
|
134
146
|
}
|
|
135
147
|
finally {
|
|
136
148
|
cleanup();
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import { applyPatch } from 'diff';
|
|
3
|
+
import { applyPatch, parsePatch } from 'diff';
|
|
4
4
|
import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
|
|
5
|
-
import { ErrorCode, McpError } from '../lib/errors.js';
|
|
5
|
+
import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
|
|
6
6
|
import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
|
|
7
7
|
import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
|
|
8
8
|
import { ApplyPatchInputSchema, ApplyPatchOutputSchema } from '../schemas.js';
|
|
@@ -11,13 +11,18 @@ import { registerToolTaskIfAvailable } from './task-support.js';
|
|
|
11
11
|
export const APPLY_PATCH_TOOL = {
|
|
12
12
|
name: 'apply_patch',
|
|
13
13
|
title: 'Apply Patch',
|
|
14
|
-
description: 'Apply a unified diff patch to
|
|
14
|
+
description: 'Apply a unified diff patch to one or more files. ' +
|
|
15
|
+
'Single-file: throws on failure. Multi-file: best-effort per file with `results[]`. ' +
|
|
15
16
|
'Workflow: `diff_files` \u2192 `apply_patch(dryRun:true)` \u2192 `apply_patch`. ' +
|
|
16
17
|
'On failure, regenerate the patch from current file content.',
|
|
17
18
|
inputSchema: ApplyPatchInputSchema,
|
|
18
19
|
outputSchema: ApplyPatchOutputSchema,
|
|
19
20
|
annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
|
|
21
|
+
nuances: [
|
|
22
|
+
'Multi-file patches use `path` as base directory; per-file results in `results[]`.',
|
|
23
|
+
],
|
|
20
24
|
gotchas: ['Patch must include valid hunk headers; use `dryRun=true` first.'],
|
|
25
|
+
taskSupport: 'optional',
|
|
21
26
|
};
|
|
22
27
|
function assertPatchTargetSizeWithinLimit(filePath, size, maxFileSize) {
|
|
23
28
|
if (size <= maxFileSize)
|
|
@@ -33,37 +38,138 @@ function assertPatchHasHunks(patch) {
|
|
|
33
38
|
throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch must include unified hunk headers (e.g., @@ -1,2 +1,2 @@).');
|
|
34
39
|
}
|
|
35
40
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
function countStructuredPatchStats(diff) {
|
|
42
|
+
let linesAdded = 0;
|
|
43
|
+
let linesRemoved = 0;
|
|
44
|
+
for (const hunk of diff.hunks) {
|
|
45
|
+
for (const line of hunk.lines) {
|
|
46
|
+
if (line.startsWith('+'))
|
|
47
|
+
linesAdded++;
|
|
48
|
+
else if (line.startsWith('-'))
|
|
49
|
+
linesRemoved++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { hunksApplied: diff.hunks.length, linesAdded, linesRemoved };
|
|
53
|
+
}
|
|
54
|
+
function stripGitPrefix(fileName) {
|
|
55
|
+
return fileName.startsWith('a/') || fileName.startsWith('b/')
|
|
56
|
+
? fileName.slice(2)
|
|
57
|
+
: fileName;
|
|
58
|
+
}
|
|
59
|
+
function extractPatchTargetPath(diff) {
|
|
60
|
+
if (diff.newFileName)
|
|
61
|
+
return stripGitPrefix(diff.newFileName);
|
|
62
|
+
if (diff.oldFileName)
|
|
63
|
+
return stripGitPrefix(diff.oldFileName);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
async function applyPatchToFile(filePath, diff, options, signal) {
|
|
67
|
+
const validPath = await validateExistingPath(filePath, signal);
|
|
68
|
+
assertAllowedFileAccess(filePath, validPath);
|
|
40
69
|
const stats = await withAbort(fs.stat(validPath), signal);
|
|
41
|
-
assertPatchTargetSizeWithinLimit(validPath, stats.size,
|
|
70
|
+
assertPatchTargetSizeWithinLimit(validPath, stats.size, MAX_TEXT_FILE_SIZE);
|
|
42
71
|
const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
fuzzFactor,
|
|
47
|
-
autoConvertLineEndings: args.autoConvertLineEndings,
|
|
72
|
+
const patched = applyPatch(content, diff, {
|
|
73
|
+
fuzzFactor: options.fuzzFactor,
|
|
74
|
+
autoConvertLineEndings: options.autoConvertLineEndings,
|
|
48
75
|
});
|
|
49
76
|
if (patched === false) {
|
|
50
|
-
|
|
77
|
+
return {
|
|
78
|
+
path: validPath,
|
|
79
|
+
applied: false,
|
|
80
|
+
error: 'Patch application failed',
|
|
81
|
+
};
|
|
51
82
|
}
|
|
52
83
|
if (patched === content) {
|
|
53
|
-
|
|
84
|
+
return { path: validPath, applied: false, error: 'Patch had no effect' };
|
|
54
85
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
86
|
+
const patchStats = countStructuredPatchStats(diff);
|
|
87
|
+
if (!options.dryRun) {
|
|
88
|
+
await atomicWriteFile(validPath, patched, { encoding: 'utf-8', signal });
|
|
89
|
+
}
|
|
90
|
+
return { path: validPath, applied: true, ...patchStats };
|
|
91
|
+
}
|
|
92
|
+
async function handleMultiFilePatch(basePath, parsed, options, signal) {
|
|
93
|
+
const validBase = await validateExistingPath(basePath, signal);
|
|
94
|
+
const results = [];
|
|
95
|
+
for (const diff of parsed) {
|
|
96
|
+
const fileName = extractPatchTargetPath(diff);
|
|
97
|
+
if (!fileName) {
|
|
98
|
+
results.push({
|
|
99
|
+
path: '<unknown>',
|
|
100
|
+
applied: false,
|
|
101
|
+
error: 'Missing file name in patch header',
|
|
102
|
+
});
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const filePath = path.resolve(validBase, fileName);
|
|
106
|
+
try {
|
|
107
|
+
const result = await applyPatchToFile(filePath, diff, options, signal);
|
|
108
|
+
results.push({ ...result, path: fileName });
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
results.push({
|
|
112
|
+
path: fileName,
|
|
113
|
+
applied: false,
|
|
114
|
+
error: formatUnknownErrorMessage(error),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const totals = results.reduce((acc, r) => {
|
|
119
|
+
if (r.applied) {
|
|
120
|
+
acc.applied++;
|
|
121
|
+
acc.hunks += r.hunksApplied ?? 0;
|
|
122
|
+
acc.added += r.linesAdded ?? 0;
|
|
123
|
+
acc.removed += r.linesRemoved ?? 0;
|
|
124
|
+
}
|
|
125
|
+
return acc;
|
|
126
|
+
}, { applied: 0, hunks: 0, added: 0, removed: 0 });
|
|
127
|
+
const label = options.dryRun ? ' (dry run)' : '';
|
|
128
|
+
const text = `Applied ${totals.applied}/${parsed.length} file patches${label}`;
|
|
129
|
+
return buildToolResponse(text, {
|
|
130
|
+
ok: totals.applied === parsed.length,
|
|
131
|
+
path: basePath,
|
|
132
|
+
applied: totals.applied > 0,
|
|
133
|
+
hunksApplied: totals.hunks,
|
|
134
|
+
linesAdded: totals.added,
|
|
135
|
+
linesRemoved: totals.removed,
|
|
136
|
+
results,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async function handleApplyPatch(args, signal) {
|
|
140
|
+
assertPatchHasHunks(args.patch);
|
|
141
|
+
const fuzzFactor = args.fuzzFactor ?? 0;
|
|
142
|
+
const parsed = parsePatch(args.patch);
|
|
143
|
+
const options = {
|
|
144
|
+
dryRun: args.dryRun,
|
|
145
|
+
fuzzFactor,
|
|
146
|
+
autoConvertLineEndings: args.autoConvertLineEndings,
|
|
147
|
+
};
|
|
148
|
+
// Multi-file patch: best-effort per file
|
|
149
|
+
if (parsed.length > 1) {
|
|
150
|
+
return handleMultiFilePatch(args.path, parsed, options, signal);
|
|
151
|
+
}
|
|
152
|
+
// Single-file patch: delegate to shared helper, then assert success
|
|
153
|
+
const diff = parsed[0];
|
|
154
|
+
if (!diff) {
|
|
155
|
+
throw new McpError(ErrorCode.E_INVALID_INPUT, 'No patch content found.');
|
|
156
|
+
}
|
|
157
|
+
const result = await applyPatchToFile(args.path, diff, options, signal);
|
|
158
|
+
if (!result.applied) {
|
|
159
|
+
throw new McpError(ErrorCode.E_INVALID_INPUT, result.error === 'Patch had no effect'
|
|
160
|
+
? '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.'
|
|
161
|
+
: '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.');
|
|
61
162
|
}
|
|
62
|
-
|
|
63
|
-
|
|
163
|
+
const text = args.dryRun
|
|
164
|
+
? 'Dry run successful. Patch can be applied.'
|
|
165
|
+
: `Successfully patched ${args.path}`;
|
|
166
|
+
return buildToolResponse(text, {
|
|
64
167
|
ok: true,
|
|
65
|
-
path:
|
|
168
|
+
path: result.path,
|
|
66
169
|
applied: true,
|
|
170
|
+
hunksApplied: result.hunksApplied,
|
|
171
|
+
linesAdded: result.linesAdded,
|
|
172
|
+
linesRemoved: result.linesRemoved,
|
|
67
173
|
});
|
|
68
174
|
}
|
|
69
175
|
export function registerApplyPatchTool(server, options = {}) {
|
|
@@ -79,20 +185,18 @@ export function registerApplyPatchTool(server, options = {}) {
|
|
|
79
185
|
guard: options.isInitialized,
|
|
80
186
|
progressMessage: (args) => {
|
|
81
187
|
const name = path.basename(args.path);
|
|
82
|
-
return args.dryRun
|
|
83
|
-
? `🛠 apply_patch: ${name} [dry run]`
|
|
84
|
-
: `🛠 apply_patch: ${name}`;
|
|
188
|
+
return args.dryRun ? `🛠 patch: ${name} [dry run]` : `🛠 patch: ${name}`;
|
|
85
189
|
},
|
|
86
190
|
completionMessage: (args, result) => {
|
|
87
191
|
const name = path.basename(args.path);
|
|
88
192
|
if (result.isError)
|
|
89
|
-
return `🛠
|
|
193
|
+
return `🛠 patch: ${name} • failed`;
|
|
90
194
|
const sc = result.structuredContent;
|
|
91
195
|
if (!sc.ok)
|
|
92
|
-
return `🛠
|
|
196
|
+
return `🛠 patch: ${name} • failed`;
|
|
93
197
|
if (args.dryRun)
|
|
94
|
-
return `🛠
|
|
95
|
-
return `🛠
|
|
198
|
+
return `🛠 patch: ${name} • dry run OK`;
|
|
199
|
+
return `🛠 patch: ${name} • applied`;
|
|
96
200
|
},
|
|
97
201
|
});
|
|
98
202
|
const validatedHandler = withValidatedArgs(ApplyPatchInputSchema, wrappedHandler);
|
|
@@ -23,6 +23,7 @@ export const CALCULATE_HASH_TOOL = {
|
|
|
23
23
|
nuances: [
|
|
24
24
|
'Directory hashing respects root `.gitignore` and sorts paths for stable output.',
|
|
25
25
|
],
|
|
26
|
+
taskSupport: 'optional',
|
|
26
27
|
};
|
|
27
28
|
async function hashFile(filePath, encoding, signal) {
|
|
28
29
|
assertNotAborted(signal);
|
|
@@ -87,6 +88,7 @@ async function hashDirectory(dirPath, options = {}) {
|
|
|
87
88
|
const concurrency = Math.min(PARALLEL_CONCURRENCY, 8);
|
|
88
89
|
const entries = [];
|
|
89
90
|
let filesHashed = 0;
|
|
91
|
+
const totalFiles = filteredPaths.length;
|
|
90
92
|
for (let i = 0; i < filteredPaths.length; i += concurrency) {
|
|
91
93
|
assertNotAborted(signal);
|
|
92
94
|
const batch = filteredPaths.slice(i, i + concurrency);
|
|
@@ -96,10 +98,14 @@ async function hashDirectory(dirPath, options = {}) {
|
|
|
96
98
|
}));
|
|
97
99
|
entries.push(...batchResults);
|
|
98
100
|
filesHashed += batchResults.length;
|
|
99
|
-
reportPeriodicProgress(onProgress, filesHashed, {
|
|
101
|
+
reportPeriodicProgress(onProgress, filesHashed, {
|
|
102
|
+
throttleModulo: 25,
|
|
103
|
+
total: totalFiles,
|
|
104
|
+
});
|
|
100
105
|
}
|
|
101
106
|
reportPeriodicProgress(onProgress, filesHashed, {
|
|
102
107
|
throttleModulo: 25,
|
|
108
|
+
total: totalFiles,
|
|
103
109
|
force: true,
|
|
104
110
|
});
|
|
105
111
|
assertNotAborted(signal);
|
|
@@ -158,13 +164,12 @@ export function registerCalculateHashTool(server, options = {}) {
|
|
|
158
164
|
context: { path: args.path },
|
|
159
165
|
run: async (signal) => {
|
|
160
166
|
const baseName = path.basename(args.path);
|
|
161
|
-
const progress = createToolProgressSession(extra, `🕮
|
|
167
|
+
const progress = createToolProgressSession(extra, `🕮 hash: ${baseName}`);
|
|
162
168
|
const progressWithMessage = ({ current, total, }) => {
|
|
163
|
-
const fileWord = current === 1 ? 'file' : 'files';
|
|
164
169
|
progress.update({
|
|
165
170
|
current,
|
|
166
171
|
...(total !== undefined ? { total } : {}),
|
|
167
|
-
message: `🕮
|
|
172
|
+
message: `🕮 hash: ${baseName} [${current} files]`,
|
|
168
173
|
});
|
|
169
174
|
};
|
|
170
175
|
try {
|
|
@@ -177,16 +182,16 @@ export function registerCalculateHashTool(server, options = {}) {
|
|
|
177
182
|
suffix = 'failed';
|
|
178
183
|
}
|
|
179
184
|
else if (sc.fileCount !== undefined && sc.fileCount > 1) {
|
|
180
|
-
suffix = `${sc.fileCount} files • ${(sc.hash ?? '').slice(0, 8)}
|
|
185
|
+
suffix = `${sc.fileCount} files • ${(sc.hash ?? '').slice(0, 8)}…`;
|
|
181
186
|
}
|
|
182
187
|
else {
|
|
183
|
-
suffix = `${(sc.hash ?? '').slice(0, 8)}
|
|
188
|
+
suffix = `${(sc.hash ?? '').slice(0, 8)}…`;
|
|
184
189
|
}
|
|
185
|
-
progress.complete(`🕮
|
|
190
|
+
progress.complete(`🕮 hash: ${baseName} • ${suffix}`, finalCurrent);
|
|
186
191
|
return result;
|
|
187
192
|
}
|
|
188
193
|
catch (error) {
|
|
189
|
-
progress.fail(`🕮
|
|
194
|
+
progress.fail(`🕮 hash: ${baseName} • failed`);
|
|
190
195
|
throw error;
|
|
191
196
|
}
|
|
192
197
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
2
3
|
import { ErrorCode, McpError } from '../lib/errors.js';
|
|
3
4
|
import { withAbort } from '../lib/fs-helpers.js';
|
|
4
5
|
import { validatePathForWrite } from '../lib/paths.js';
|
|
@@ -13,6 +14,7 @@ export const CREATE_DIRECTORY_TOOL = {
|
|
|
13
14
|
outputSchema: CreateDirectoryOutputSchema,
|
|
14
15
|
annotations: IDEMPOTENT_WRITE_TOOL_ANNOTATIONS,
|
|
15
16
|
nuances: ['Succeeds silently if the directory already exists (idempotent).'],
|
|
17
|
+
taskSupport: 'optional',
|
|
16
18
|
};
|
|
17
19
|
export async function handleCreateDirectory(args, signal) {
|
|
18
20
|
const allPaths = [];
|
|
@@ -42,14 +44,23 @@ export function registerCreateDirectoryTool(server, options = {}) {
|
|
|
42
44
|
const wrappedHandler = wrapToolHandler(handler, {
|
|
43
45
|
guard: options.isInitialized,
|
|
44
46
|
progressMessage: (args) => {
|
|
47
|
+
if (args.path && !args.paths?.length) {
|
|
48
|
+
return `🛠 mkdir: ${path.basename(args.path)}`;
|
|
49
|
+
}
|
|
45
50
|
const count = (args.path ? 1 : 0) + (args.paths?.length ?? 0);
|
|
46
|
-
return `🛠 mkdir: ${count}
|
|
51
|
+
return `🛠 mkdir: ${count} directories`;
|
|
47
52
|
},
|
|
48
53
|
completionMessage: (args, result) => {
|
|
54
|
+
if (args.path && !args.paths?.length) {
|
|
55
|
+
const name = path.basename(args.path);
|
|
56
|
+
if (result.isError)
|
|
57
|
+
return `🛠 mkdir: ${name} • failed`;
|
|
58
|
+
return `🛠 mkdir: ${name} • created`;
|
|
59
|
+
}
|
|
49
60
|
const count = (args.path ? 1 : 0) + (args.paths?.length ?? 0);
|
|
50
61
|
if (result.isError)
|
|
51
|
-
return `🛠 mkdir: ${count} • failed`;
|
|
52
|
-
return `🛠 mkdir: ${count} • created`;
|
|
62
|
+
return `🛠 mkdir: ${count} directories • failed`;
|
|
63
|
+
return `🛠 mkdir: ${count} directories • created`;
|
|
53
64
|
},
|
|
54
65
|
});
|
|
55
66
|
const validatedHandler = withValidatedArgs(CreateDirectoryInputSchema, wrappedHandler);
|
|
@@ -17,6 +17,7 @@ export const DELETE_FILE_TOOL = {
|
|
|
17
17
|
'No undo — deletion is permanent.',
|
|
18
18
|
'Non-empty directories require `recursive=true`.',
|
|
19
19
|
],
|
|
20
|
+
taskSupport: 'optional',
|
|
20
21
|
};
|
|
21
22
|
async function handleDeleteFile(args, signal) {
|
|
22
23
|
const validPath = await validatePathForWrite(args.path, signal);
|
package/dist/tools/diff-files.js
CHANGED
|
@@ -18,7 +18,22 @@ export const DIFF_FILES_TOOL = {
|
|
|
18
18
|
outputSchema: DiffFilesOutputSchema,
|
|
19
19
|
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
|
20
20
|
gotchas: ['`isIdentical=true` means no hunks (`@@`) and empty diff.'],
|
|
21
|
+
taskSupport: 'optional',
|
|
21
22
|
};
|
|
23
|
+
function computeDiffStats(patch) {
|
|
24
|
+
let linesAdded = 0;
|
|
25
|
+
let linesRemoved = 0;
|
|
26
|
+
let hunksCount = 0;
|
|
27
|
+
for (const line of patch.split('\n')) {
|
|
28
|
+
if (line.startsWith('@@'))
|
|
29
|
+
hunksCount++;
|
|
30
|
+
else if (line.startsWith('+') && !line.startsWith('+++'))
|
|
31
|
+
linesAdded++;
|
|
32
|
+
else if (line.startsWith('-') && !line.startsWith('---'))
|
|
33
|
+
linesRemoved++;
|
|
34
|
+
}
|
|
35
|
+
return { linesAdded, linesRemoved, hunksCount };
|
|
36
|
+
}
|
|
22
37
|
function assertDiffFileSizeWithinLimit(filePath, size, maxFileSize) {
|
|
23
38
|
if (size <= maxFileSize)
|
|
24
39
|
return;
|
|
@@ -47,6 +62,7 @@ async function handleDiffFiles(args, signal, resourceStore) {
|
|
|
47
62
|
});
|
|
48
63
|
const isIdentical = !patch.includes('@@');
|
|
49
64
|
const diffText = isIdentical ? '' : patch;
|
|
65
|
+
const stats = isIdentical ? undefined : computeDiffStats(patch);
|
|
50
66
|
const externalized = maybeExternalizeTextContent(resourceStore, diffText, {
|
|
51
67
|
name: 'diff:patch',
|
|
52
68
|
mimeType: 'text/x-diff',
|
|
@@ -56,6 +72,7 @@ async function handleDiffFiles(args, signal, resourceStore) {
|
|
|
56
72
|
ok: true,
|
|
57
73
|
diff: diffText,
|
|
58
74
|
isIdentical,
|
|
75
|
+
...(stats ?? {}),
|
|
59
76
|
});
|
|
60
77
|
}
|
|
61
78
|
const { preview, entry } = externalized;
|
|
@@ -63,6 +80,7 @@ async function handleDiffFiles(args, signal, resourceStore) {
|
|
|
63
80
|
ok: true,
|
|
64
81
|
diff: preview,
|
|
65
82
|
isIdentical,
|
|
83
|
+
...(stats ?? {}),
|
|
66
84
|
truncated: true,
|
|
67
85
|
resourceUri: entry.uri,
|
|
68
86
|
}, [
|
|
@@ -71,6 +89,7 @@ async function handleDiffFiles(args, signal, resourceStore) {
|
|
|
71
89
|
name: entry.name,
|
|
72
90
|
mimeType: entry.mimeType,
|
|
73
91
|
description: 'Full diff content',
|
|
92
|
+
expiresAt: entry.expiresAt,
|
|
74
93
|
}),
|
|
75
94
|
]);
|
|
76
95
|
}
|
|
@@ -86,22 +105,21 @@ export function registerDiffFilesTool(server, options = {}) {
|
|
|
86
105
|
const wrappedHandler = wrapToolHandler(handler, {
|
|
87
106
|
guard: options.isInitialized,
|
|
88
107
|
progressMessage: (args) => {
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
return `🕮
|
|
108
|
+
const n1 = path.basename(args.original);
|
|
109
|
+
const n2 = path.basename(args.modified);
|
|
110
|
+
return `🕮 diff: ${n1} ⟷ ${n2}`;
|
|
92
111
|
},
|
|
93
112
|
completionMessage: (args, result) => {
|
|
94
113
|
const n1 = path.basename(args.original);
|
|
95
114
|
const n2 = path.basename(args.modified);
|
|
96
115
|
if (result.isError)
|
|
97
|
-
return `🕮
|
|
116
|
+
return `🕮 diff: ${n1} ⟷ ${n2} • failed`;
|
|
98
117
|
const sc = result.structuredContent;
|
|
99
118
|
if (!sc.ok)
|
|
100
|
-
return `🕮
|
|
119
|
+
return `🕮 diff: ${n1} ⟷ ${n2} • failed`;
|
|
101
120
|
if (sc.isIdentical)
|
|
102
|
-
return `🕮
|
|
103
|
-
|
|
104
|
-
return `🕮 diff_files: ${n1} ⟷ ${n2} • ${hunks} hunk${hunks !== 1 ? 's' : ''}`;
|
|
121
|
+
return `🕮 diff: ${n1} ⟷ ${n2} • identical`;
|
|
122
|
+
return `🕮 diff: ${n1} ⟷ ${n2} • changed`;
|
|
105
123
|
},
|
|
106
124
|
});
|
|
107
125
|
const validatedHandler = withValidatedArgs(DiffFilesInputSchema, wrappedHandler);
|
package/dist/tools/edit-file.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
+
import { createTwoFilesPatch } from 'diff';
|
|
3
4
|
import RE2 from 're2';
|
|
4
5
|
import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
|
|
5
6
|
import { ErrorCode, McpError } from '../lib/errors.js';
|
|
@@ -19,6 +20,7 @@ export const EDIT_FILE_TOOL = {
|
|
|
19
20
|
annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
|
|
20
21
|
nuances: ['Each edit applies to the output of the previous edit.'],
|
|
21
22
|
gotchas: ['Unmatched `oldText` entries listed in `unmatchedEdits`.'],
|
|
23
|
+
taskSupport: 'optional',
|
|
22
24
|
};
|
|
23
25
|
function escapeRegExp(string) {
|
|
24
26
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -99,6 +101,9 @@ export async function handleEditFile(args, signal) {
|
|
|
99
101
|
...(lineRange ? { lineRange } : {}),
|
|
100
102
|
};
|
|
101
103
|
if (args.dryRun) {
|
|
104
|
+
if (appliedEdits > 0) {
|
|
105
|
+
structured.diff = createTwoFilesPatch(path.basename(validPath), path.basename(validPath), content, newContent, 'Original', 'Modified');
|
|
106
|
+
}
|
|
102
107
|
return buildToolResponse(`Dry run complete. ${appliedEdits} edits would be applied.`, structured);
|
|
103
108
|
}
|
|
104
109
|
if (appliedEdits > 0) {
|
|
@@ -127,8 +132,9 @@ export function registerEditFileTool(server, options = {}) {
|
|
|
127
132
|
guard: options.isInitialized,
|
|
128
133
|
progressMessage: (args) => {
|
|
129
134
|
const name = path.basename(args.path);
|
|
130
|
-
const
|
|
131
|
-
|
|
135
|
+
const count = args.edits.length;
|
|
136
|
+
const tag = args.dryRun ? ' [dry run]' : '';
|
|
137
|
+
return `🛠 edit: ${name} [${count} ${count === 1 ? 'edit' : 'edits'}]${tag}`;
|
|
132
138
|
},
|
|
133
139
|
completionMessage: (args, result) => {
|
|
134
140
|
const name = path.basename(args.path);
|
|
@@ -139,14 +145,11 @@ export function registerEditFileTool(server, options = {}) {
|
|
|
139
145
|
return `🛠 edit: ${name} • failed`;
|
|
140
146
|
const applied = sc.appliedEdits ?? 0;
|
|
141
147
|
const unmatched = sc.unmatchedEdits?.length ?? 0;
|
|
142
|
-
const
|
|
148
|
+
const dry = args.dryRun ? 'dry run — ' : '';
|
|
143
149
|
if (unmatched > 0) {
|
|
144
|
-
return `🛠 edit: ${name} • ${
|
|
145
|
-
}
|
|
146
|
-
if (sc.lineRange) {
|
|
147
|
-
return `🛠 edit: ${name} • ${dryPrefix}lines ${sc.lineRange[0]}–${sc.lineRange[1]}`;
|
|
150
|
+
return `🛠 edit: ${name} • ${dry}${applied} applied, ${unmatched} unmatched`;
|
|
148
151
|
}
|
|
149
|
-
return `🛠 edit: ${name} • ${
|
|
152
|
+
return `🛠 edit: ${name} • ${dry}${applied} applied`;
|
|
150
153
|
},
|
|
151
154
|
});
|
|
152
155
|
const validatedHandler = withValidatedArgs(EditFileInputSchema, wrappedHandler);
|
|
@@ -112,12 +112,7 @@ export function registerListDirectoryTool(server, options = {}) {
|
|
|
112
112
|
});
|
|
113
113
|
const wrappedHandler = wrapToolHandler(handler, {
|
|
114
114
|
guard: options.isInitialized,
|
|
115
|
-
progressMessage: (args) => {
|
|
116
|
-
if (args.path) {
|
|
117
|
-
return `≣ ls: ${path.basename(args.path)}`;
|
|
118
|
-
}
|
|
119
|
-
return '≣ ls';
|
|
120
|
-
},
|
|
115
|
+
progressMessage: (args) => `≣ ls: ${args.path ? path.basename(args.path) : '.'}`,
|
|
121
116
|
completionMessage: (args, result) => {
|
|
122
117
|
const base = args.path ? path.basename(args.path) : '.';
|
|
123
118
|
if (result.isError)
|
package/dist/tools/move-file.js
CHANGED
|
@@ -17,6 +17,7 @@ export const MOVE_FILE_TOOL = {
|
|
|
17
17
|
gotchas: [
|
|
18
18
|
'On POSIX, an existing destination is silently overwritten; on Windows, rename fails with EEXIST if destination exists.',
|
|
19
19
|
],
|
|
20
|
+
taskSupport: 'optional',
|
|
20
21
|
};
|
|
21
22
|
export async function handleMoveFile(args, signal) {
|
|
22
23
|
const sources = args.sources ?? (args.source ? [args.source] : []);
|
|
@@ -82,14 +83,36 @@ export async function handleMoveFile(args, signal) {
|
|
|
82
83
|
// Cross-device link, fallback to copy + delete
|
|
83
84
|
try {
|
|
84
85
|
await withAbort(fs.cp(validSource, targetPath, { recursive: true }), signal);
|
|
85
|
-
await withAbort(fs.rm(validSource, { recursive: true, force: true }), signal);
|
|
86
|
-
movedSources.push(validSource);
|
|
87
86
|
}
|
|
88
87
|
catch (copyError) {
|
|
89
88
|
failed.push({
|
|
90
89
|
source: src,
|
|
91
90
|
error: formatUnknownErrorMessage(copyError),
|
|
92
91
|
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Copy succeeded — now remove source
|
|
95
|
+
try {
|
|
96
|
+
await withAbort(fs.rm(validSource, { recursive: true, force: true }), signal);
|
|
97
|
+
movedSources.push(validSource);
|
|
98
|
+
}
|
|
99
|
+
catch (deleteError) {
|
|
100
|
+
// Source delete failed after copy — rollback by removing the copy
|
|
101
|
+
try {
|
|
102
|
+
await fs.rm(targetPath, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// 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
|
+
});
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
failed.push({
|
|
113
|
+
source: src,
|
|
114
|
+
error: `Cross-device move failed: could not remove source. ${formatUnknownErrorMessage(deleteError)}`,
|
|
115
|
+
});
|
|
93
116
|
}
|
|
94
117
|
}
|
|
95
118
|
else {
|
|
@@ -122,16 +145,25 @@ export function registerMoveFileTool(server, options = {}) {
|
|
|
122
145
|
const wrappedHandler = wrapToolHandler(handler, {
|
|
123
146
|
guard: options.isInitialized,
|
|
124
147
|
progressMessage: (args) => {
|
|
125
|
-
const count = (args.source ? 1 : 0) + (args.sources?.length ?? 0);
|
|
126
148
|
const dest = path.basename(args.destination);
|
|
127
|
-
|
|
149
|
+
if (args.source && !args.sources?.length) {
|
|
150
|
+
return `🛠 mv: ${path.basename(args.source)} → ${dest}`;
|
|
151
|
+
}
|
|
152
|
+
const count = (args.source ? 1 : 0) + (args.sources?.length ?? 0);
|
|
153
|
+
return `🛠 mv: ${count} items → ${dest}`;
|
|
128
154
|
},
|
|
129
155
|
completionMessage: (args, result) => {
|
|
130
|
-
const count = (args.source ? 1 : 0) + (args.sources?.length ?? 0);
|
|
131
156
|
const dest = path.basename(args.destination);
|
|
157
|
+
if (args.source && !args.sources?.length) {
|
|
158
|
+
const src = path.basename(args.source);
|
|
159
|
+
if (result.isError)
|
|
160
|
+
return `🛠 mv: ${src} → ${dest} • failed`;
|
|
161
|
+
return `🛠 mv: ${src} → ${dest} • moved`;
|
|
162
|
+
}
|
|
163
|
+
const count = (args.source ? 1 : 0) + (args.sources?.length ?? 0);
|
|
132
164
|
if (result.isError)
|
|
133
|
-
return `🛠 mv: ${count} → ${dest} • failed`;
|
|
134
|
-
return `🛠 mv: ${count} → ${dest} • moved`;
|
|
165
|
+
return `🛠 mv: ${count} items → ${dest} • failed`;
|
|
166
|
+
return `🛠 mv: ${count} items → ${dest} • moved`;
|
|
135
167
|
},
|
|
136
168
|
});
|
|
137
169
|
const validatedHandler = withValidatedArgs(MoveFileInputSchema, wrappedHandler);
|
|
@@ -16,7 +16,7 @@ export const READ_MULTIPLE_FILES_TOOL = {
|
|
|
16
16
|
taskSupport: 'optional',
|
|
17
17
|
nuances: ['Total read budget is capped by `MAX_READ_MANY_TOTAL_SIZE`.'],
|
|
18
18
|
gotchas: [
|
|
19
|
-
'Per-file `truncationReason` can be `head`, `range`, or `externalized`.',
|
|
19
|
+
'Per-file `truncationReason` can be `head`, `tail`, `range`, or `externalized`.',
|
|
20
20
|
],
|
|
21
21
|
};
|
|
22
22
|
function toStructuredReadManyResult(result) {
|
|
@@ -31,6 +31,8 @@ function toStructuredReadManyResult(result) {
|
|
|
31
31
|
structured.resourceUri = result.resourceUri;
|
|
32
32
|
if (result.head !== undefined)
|
|
33
33
|
structured.head = result.head;
|
|
34
|
+
if (result.tail !== undefined)
|
|
35
|
+
structured.tail = result.tail;
|
|
34
36
|
if (result.startLine !== undefined)
|
|
35
37
|
structured.startLine = result.startLine;
|
|
36
38
|
if (result.endLine !== undefined)
|
|
@@ -39,6 +41,8 @@ function toStructuredReadManyResult(result) {
|
|
|
39
41
|
structured.hasMoreLines = result.hasMoreLines;
|
|
40
42
|
if (result.totalLines !== undefined)
|
|
41
43
|
structured.totalLines = result.totalLines;
|
|
44
|
+
if (result.linesRead !== undefined)
|
|
45
|
+
structured.linesRead = result.linesRead;
|
|
42
46
|
if (result.truncationReason) {
|
|
43
47
|
structured.truncationReason = result.truncationReason;
|
|
44
48
|
}
|
|
@@ -50,6 +54,7 @@ async function handleReadMultipleFiles(args, signal, resourceStore, onReadComple
|
|
|
50
54
|
const options = {
|
|
51
55
|
...(signal ? { signal } : {}),
|
|
52
56
|
...(args.head !== undefined ? { head: args.head } : {}),
|
|
57
|
+
...(args.tail !== undefined ? { tail: args.tail } : {}),
|
|
53
58
|
...(args.startLine !== undefined ? { startLine: args.startLine } : {}),
|
|
54
59
|
...(args.endLine !== undefined ? { endLine: args.endLine } : {}),
|
|
55
60
|
...(onReadComplete ? { onReadComplete } : {}),
|
|
@@ -61,6 +66,9 @@ async function handleReadMultipleFiles(args, signal, resourceStore, onReadComple
|
|
|
61
66
|
if (result.truncated && result.readMode === 'head') {
|
|
62
67
|
baseTruncationReason = 'head';
|
|
63
68
|
}
|
|
69
|
+
else if (result.truncated && result.readMode === 'tail') {
|
|
70
|
+
baseTruncationReason = 'tail';
|
|
71
|
+
}
|
|
64
72
|
else if (result.truncated && result.readMode === 'range') {
|
|
65
73
|
baseTruncationReason = 'range';
|
|
66
74
|
}
|