@j0hanz/filesystem-mcp 1.1.0 → 1.1.2
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 -0
- package/dist/lib/constants.js +18 -0
- package/dist/schemas.d.ts +1 -0
- package/dist/schemas.js +4 -0
- package/dist/tools/apply-patch.js +1 -1
- package/dist/tools/delete-file.js +32 -4
- package/dist/tools/edit-file.js +34 -2
- package/dist/tools/search-content.js +11 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -137,6 +137,15 @@ The server communicates via `stdio`. Ensure your MCP client is configured to run
|
|
|
137
137
|
| `apply_patch` | Apply unified patch | `path`, `patch` |
|
|
138
138
|
| `search_and_replace` | Search & replace across files | `filePattern`, `searchPattern`, `replacement` |
|
|
139
139
|
|
|
140
|
+
### Behavioral Notes
|
|
141
|
+
|
|
142
|
+
- `rm` with `recursive: false`:
|
|
143
|
+
- Deletes files and empty directories.
|
|
144
|
+
- Returns `E_INVALID_INPUT` for non-empty directories with guidance to use `recursive: true`.
|
|
145
|
+
- `includeIgnored: false` (default) for navigation/search tools:
|
|
146
|
+
- Excludes common generated/vendor directories such as `node_modules`, `dist`, `.git`, and similar patterns.
|
|
147
|
+
- Set `includeIgnored: true` to include those entries.
|
|
148
|
+
|
|
140
149
|
### Resources
|
|
141
150
|
|
|
142
151
|
| URI Pattern | Description |
|
|
@@ -252,6 +261,12 @@ args = ["-y", "@j0hanz/filesystem-mcp@latest", "${workspaceFolder}"]
|
|
|
252
261
|
- **Path Restrictions**: All file operations are strictly validated against the allowed root directories provided at startup.
|
|
253
262
|
- **Path Validation**: Uses `isPathWithinDirectories` to prevent path traversal attacks.
|
|
254
263
|
- **Hidden Files**: Hidden files (starting with `.`) are excluded by default in listings and searches unless explicitly requested.
|
|
264
|
+
- **Ignored Directories**: Ignored directories (for example `node_modules`, `.git`, `dist`) are excluded by default unless `includeIgnored=true`.
|
|
265
|
+
|
|
266
|
+
## Testing Notes
|
|
267
|
+
|
|
268
|
+
- For protocol-level validation, prefer an MCP SDK client (`listTools`, `listResources`, `listPrompts`, `callTool`, `readResource`) as source-of-truth.
|
|
269
|
+
- Some third-party MCP CLIs may have URI parsing limitations when reading resources; if this happens, verify resource behavior through SDK client calls.
|
|
255
270
|
|
|
256
271
|
## Development Workflow
|
|
257
272
|
|
package/dist/lib/constants.js
CHANGED
|
@@ -146,24 +146,42 @@ export const KNOWN_BINARY_EXTENSIONS = new Set([
|
|
|
146
146
|
'.dat',
|
|
147
147
|
]);
|
|
148
148
|
export const DEFAULT_EXCLUDE_PATTERNS = [
|
|
149
|
+
'**/node_modules',
|
|
149
150
|
'**/node_modules/**',
|
|
151
|
+
'**/dist',
|
|
150
152
|
'**/dist/**',
|
|
153
|
+
'**/build',
|
|
151
154
|
'**/build/**',
|
|
155
|
+
'**/coverage',
|
|
152
156
|
'**/coverage/**',
|
|
157
|
+
'**/.git',
|
|
153
158
|
'**/.git/**',
|
|
159
|
+
'**/.vscode',
|
|
154
160
|
'**/.vscode/**',
|
|
161
|
+
'**/.idea',
|
|
155
162
|
'**/.idea/**',
|
|
156
163
|
'**/.DS_Store',
|
|
164
|
+
'**/.next',
|
|
157
165
|
'**/.next/**',
|
|
166
|
+
'**/.nuxt',
|
|
158
167
|
'**/.nuxt/**',
|
|
168
|
+
'**/.output',
|
|
159
169
|
'**/.output/**',
|
|
170
|
+
'**/.svelte-kit',
|
|
160
171
|
'**/.svelte-kit/**',
|
|
172
|
+
'**/.cache',
|
|
161
173
|
'**/.cache/**',
|
|
174
|
+
'**/.yarn',
|
|
162
175
|
'**/.yarn/**',
|
|
176
|
+
'**/jspm_packages',
|
|
163
177
|
'**/jspm_packages/**',
|
|
178
|
+
'**/bower_components',
|
|
164
179
|
'**/bower_components/**',
|
|
180
|
+
'**/out',
|
|
165
181
|
'**/out/**',
|
|
182
|
+
'**/tmp',
|
|
166
183
|
'**/tmp/**',
|
|
184
|
+
'**/.temp',
|
|
167
185
|
'**/.temp/**',
|
|
168
186
|
'**/npm-debug.log',
|
|
169
187
|
'**/yarn-debug.log',
|
package/dist/schemas.d.ts
CHANGED
|
@@ -403,6 +403,7 @@ export declare const EditFileOutputSchema: z.ZodObject<{
|
|
|
403
403
|
ok: z.ZodBoolean;
|
|
404
404
|
path: z.ZodOptional<z.ZodString>;
|
|
405
405
|
appliedEdits: z.ZodOptional<z.ZodNumber>;
|
|
406
|
+
lineRange: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
|
|
406
407
|
unmatchedEdits: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
407
408
|
error: z.ZodOptional<z.ZodObject<{
|
|
408
409
|
code: z.ZodEnum<{
|
package/dist/schemas.js
CHANGED
|
@@ -510,6 +510,10 @@ export const EditFileOutputSchema = z.object({
|
|
|
510
510
|
ok: z.boolean(),
|
|
511
511
|
path: z.string().optional(),
|
|
512
512
|
appliedEdits: z.number().optional(),
|
|
513
|
+
lineRange: z
|
|
514
|
+
.tuple([z.number(), z.number()])
|
|
515
|
+
.optional()
|
|
516
|
+
.describe('Line range modified [start, end] (1-based)'),
|
|
513
517
|
unmatchedEdits: z
|
|
514
518
|
.array(z.string())
|
|
515
519
|
.optional()
|
|
@@ -45,7 +45,7 @@ async function handleApplyPatch(args, signal) {
|
|
|
45
45
|
autoConvertLineEndings: args.autoConvertLineEndings,
|
|
46
46
|
});
|
|
47
47
|
if (patched === false) {
|
|
48
|
-
throw new McpError(ErrorCode.E_INVALID_INPUT,
|
|
48
|
+
throw new McpError(ErrorCode.E_INVALID_INPUT, '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 (fuzzy=true or fuzzFactor).');
|
|
49
49
|
}
|
|
50
50
|
if (args.dryRun) {
|
|
51
51
|
return buildToolResponse('Dry run successful. Patch can be applied.', {
|
|
@@ -19,10 +19,32 @@ const DELETE_FILE_TOOL = {
|
|
|
19
19
|
};
|
|
20
20
|
async function handleDeleteFile(args, signal) {
|
|
21
21
|
const validPath = await validatePathForWrite(args.path, signal);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
22
|
+
let stats;
|
|
23
|
+
try {
|
|
24
|
+
stats = await withAbort(fs.lstat(validPath), signal);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (isNodeError(error) &&
|
|
28
|
+
error.code === 'ENOENT' &&
|
|
29
|
+
args.ignoreIfNotExists) {
|
|
30
|
+
return buildToolResponse(`Successfully deleted: ${args.path}`, {
|
|
31
|
+
ok: true,
|
|
32
|
+
path: validPath,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
if (stats.isDirectory() && !args.recursive) {
|
|
38
|
+
// Use rmdir for non-recursive directory deletes so non-empty directories
|
|
39
|
+
// consistently return ENOTEMPTY-style errors with actionable guidance.
|
|
40
|
+
await withAbort(fs.rmdir(validPath), signal);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
await withAbort(fs.rm(validPath, {
|
|
44
|
+
recursive: args.recursive,
|
|
45
|
+
force: args.ignoreIfNotExists,
|
|
46
|
+
}), signal);
|
|
47
|
+
}
|
|
26
48
|
return buildToolResponse(`Successfully deleted: ${args.path}`, {
|
|
27
49
|
ok: true,
|
|
28
50
|
path: validPath,
|
|
@@ -45,6 +67,12 @@ export function registerDeleteFileTool(server, options = {}) {
|
|
|
45
67
|
if (error.code === 'ENOTEMPTY') {
|
|
46
68
|
return buildToolErrorResponse(new Error(`Directory is not empty: ${args.path}. Use recursive: true to delete non-empty directories.`), ErrorCode.E_INVALID_INPUT, args.path);
|
|
47
69
|
}
|
|
70
|
+
if (error.code === 'EISDIR') {
|
|
71
|
+
return buildToolErrorResponse(new Error(`Path is a directory: ${args.path}. Use recursive: true to delete directories.`), ErrorCode.E_INVALID_INPUT, args.path);
|
|
72
|
+
}
|
|
73
|
+
if (error.code === 'EEXIST') {
|
|
74
|
+
return buildToolErrorResponse(new Error(`Directory is not empty: ${args.path}. Use recursive: true to delete non-empty directories.`), ErrorCode.E_INVALID_INPUT, args.path);
|
|
75
|
+
}
|
|
48
76
|
if (error.code === 'EPERM' || error.code === 'EACCES') {
|
|
49
77
|
return buildToolErrorResponse(error, ErrorCode.E_PERMISSION_DENIED, args.path);
|
|
50
78
|
}
|
package/dist/tools/edit-file.js
CHANGED
|
@@ -21,25 +21,45 @@ function applyEdits(content, edits) {
|
|
|
21
21
|
let newContent = content;
|
|
22
22
|
let appliedEdits = 0;
|
|
23
23
|
const unmatchedEdits = [];
|
|
24
|
+
let minLine;
|
|
25
|
+
let maxLine;
|
|
24
26
|
for (const edit of edits) {
|
|
25
27
|
if (!newContent.includes(edit.oldText)) {
|
|
26
28
|
unmatchedEdits.push(edit.oldText);
|
|
27
29
|
continue;
|
|
28
30
|
}
|
|
31
|
+
const index = newContent.indexOf(edit.oldText);
|
|
32
|
+
const linesBefore = newContent.slice(0, index).split('\n').length;
|
|
33
|
+
const newTextLines = edit.newText.split('\n').length;
|
|
34
|
+
const startLine = linesBefore;
|
|
35
|
+
const endLine = linesBefore + newTextLines - 1;
|
|
36
|
+
if (minLine === undefined || startLine < minLine)
|
|
37
|
+
minLine = startLine;
|
|
38
|
+
if (maxLine === undefined || endLine > maxLine)
|
|
39
|
+
maxLine = endLine;
|
|
29
40
|
newContent = newContent.replace(edit.oldText, edit.newText);
|
|
30
41
|
appliedEdits += 1;
|
|
31
42
|
}
|
|
32
|
-
|
|
43
|
+
const result = {
|
|
44
|
+
content: newContent,
|
|
45
|
+
appliedEdits,
|
|
46
|
+
unmatchedEdits,
|
|
47
|
+
};
|
|
48
|
+
if (minLine !== undefined && maxLine !== undefined) {
|
|
49
|
+
result.lineRange = [minLine, maxLine];
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
33
52
|
}
|
|
34
53
|
async function handleEditFile(args, signal) {
|
|
35
54
|
const validPath = await validateExistingPath(args.path, signal);
|
|
36
55
|
const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
|
|
37
|
-
const { content: newContent, appliedEdits, unmatchedEdits, } = applyEdits(content, args.edits);
|
|
56
|
+
const { content: newContent, appliedEdits, unmatchedEdits, lineRange, } = applyEdits(content, args.edits);
|
|
38
57
|
const structured = {
|
|
39
58
|
ok: true,
|
|
40
59
|
path: validPath,
|
|
41
60
|
appliedEdits,
|
|
42
61
|
...(unmatchedEdits.length > 0 ? { unmatchedEdits } : {}),
|
|
62
|
+
...(lineRange ? { lineRange } : {}),
|
|
43
63
|
};
|
|
44
64
|
if (args.dryRun) {
|
|
45
65
|
return buildToolResponse(`Dry run complete. ${appliedEdits} edits would be applied.`, structured);
|
|
@@ -68,5 +88,17 @@ export function registerEditFileTool(server, options = {}) {
|
|
|
68
88
|
const name = path.basename(args.path);
|
|
69
89
|
return `🛠 edit: ${name} (${args.edits.length} edits)`;
|
|
70
90
|
},
|
|
91
|
+
completionMessage: (args, result) => {
|
|
92
|
+
const name = path.basename(args.path);
|
|
93
|
+
if (result.isError)
|
|
94
|
+
return `🛠 edit: ${name} ➟ Failed`;
|
|
95
|
+
const sc = result.structuredContent;
|
|
96
|
+
if (!sc.ok)
|
|
97
|
+
return `🛠 edit: ${name} ➟ Failed`;
|
|
98
|
+
if (sc.lineRange) {
|
|
99
|
+
return `🛠 edit: ${name} ➟ [${sc.lineRange[0]}-${sc.lineRange[1]}]`;
|
|
100
|
+
}
|
|
101
|
+
return `🛠 edit: ${name} ➟ (${sc.appliedEdits ?? 0} edits)`;
|
|
102
|
+
},
|
|
71
103
|
}));
|
|
72
104
|
}
|
|
@@ -202,7 +202,17 @@ export function registerSearchContentTool(server, options = {}) {
|
|
|
202
202
|
});
|
|
203
203
|
const result = await handleSearchContent(normalizedArgs, extra.signal, options.resourceStore, createProgressReporter(extra));
|
|
204
204
|
const sc = result.structuredContent;
|
|
205
|
-
const
|
|
205
|
+
const count = sc.ok && sc.totalMatches ? sc.totalMatches : 0;
|
|
206
|
+
let suffix;
|
|
207
|
+
if (count === 0) {
|
|
208
|
+
suffix = 'No matches';
|
|
209
|
+
}
|
|
210
|
+
else if (count === 1) {
|
|
211
|
+
suffix = '1 match';
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
suffix = `${count} matches`;
|
|
215
|
+
}
|
|
206
216
|
const finalCurrent = (sc.filesScanned ?? 0) + 1;
|
|
207
217
|
notifyProgress(extra, {
|
|
208
218
|
current: finalCurrent,
|