file-organizer-mcp 2.1.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 +603 -0
- package/package.json +34 -0
- package/server.js +668 -0
- package/server.json +48 -0
- package/start.bat +3 -0
- package/test_security.js +205 -0
package/server.js
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import fs from "fs/promises";
|
|
10
|
+
import { createReadStream } from "fs";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import crypto from "crypto";
|
|
13
|
+
|
|
14
|
+
class FileOrganizerServer {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.server = new Server(
|
|
17
|
+
{
|
|
18
|
+
name: "file-organizer",
|
|
19
|
+
version: "2.1.0",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
capabilities: {
|
|
23
|
+
tools: {},
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Security constants
|
|
29
|
+
this.MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
|
30
|
+
this.MAX_FILES = 10000;
|
|
31
|
+
this.MAX_DEPTH = 10;
|
|
32
|
+
|
|
33
|
+
// File type categories
|
|
34
|
+
this.categories = {
|
|
35
|
+
Executables: [".exe", ".msi", ".bat", ".cmd", ".sh"],
|
|
36
|
+
Videos: [".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".webm", ".m4v"],
|
|
37
|
+
Documents: [".pdf", ".doc", ".docx", ".txt", ".rtf", ".odt"],
|
|
38
|
+
Presentations: [".ppt", ".pptx", ".odp", ".key"],
|
|
39
|
+
Spreadsheets: [".xls", ".xlsx", ".csv", ".ods"],
|
|
40
|
+
Images: [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg", ".ico", ".webp"],
|
|
41
|
+
Audio: [".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma", ".m4a"],
|
|
42
|
+
Archives: [".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", ".xz"],
|
|
43
|
+
Code: [".py", ".js", ".ts", ".java", ".cpp", ".c", ".html", ".css", ".php", ".rb", ".go", ".json"],
|
|
44
|
+
Installers: [".dmg", ".pkg", ".deb", ".rpm", ".apk"],
|
|
45
|
+
Ebooks: [".epub", ".mobi", ".azw", ".azw3"],
|
|
46
|
+
Fonts: [".ttf", ".otf", ".woff", ".woff2"],
|
|
47
|
+
Others: [],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
this.setupHandlers();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setupHandlers() {
|
|
54
|
+
// List available tools
|
|
55
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
56
|
+
tools: [
|
|
57
|
+
{
|
|
58
|
+
name: "list_files",
|
|
59
|
+
description: "List all files in a directory with basic information",
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: {
|
|
63
|
+
directory: {
|
|
64
|
+
type: "string",
|
|
65
|
+
description: "Full path to the directory to list files from",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: ["directory"],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "scan_directory",
|
|
73
|
+
description: "Scan directory and get detailed file information including size, dates, and extensions",
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: {
|
|
77
|
+
directory: {
|
|
78
|
+
type: "string",
|
|
79
|
+
description: "Full path to the directory to scan",
|
|
80
|
+
},
|
|
81
|
+
include_subdirs: {
|
|
82
|
+
type: "boolean",
|
|
83
|
+
description: "Include subdirectories in the scan",
|
|
84
|
+
default: false,
|
|
85
|
+
},
|
|
86
|
+
max_depth: {
|
|
87
|
+
type: "number",
|
|
88
|
+
description: "Maximum depth to scan (0 = current directory only, -1 = unlimited)",
|
|
89
|
+
default: -1,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
required: ["directory"],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "categorize_by_type",
|
|
97
|
+
description: "Categorize files by their type and show statistics for each category",
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: {
|
|
101
|
+
directory: {
|
|
102
|
+
type: "string",
|
|
103
|
+
description: "Full path to the directory to categorize",
|
|
104
|
+
},
|
|
105
|
+
include_subdirs: {
|
|
106
|
+
type: "boolean",
|
|
107
|
+
description: "Include subdirectories in categorization",
|
|
108
|
+
default: false,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
required: ["directory"],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "find_largest_files",
|
|
116
|
+
description: "Find the largest files in a directory",
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: "object",
|
|
119
|
+
properties: {
|
|
120
|
+
directory: {
|
|
121
|
+
type: "string",
|
|
122
|
+
description: "Full path to the directory to search",
|
|
123
|
+
},
|
|
124
|
+
include_subdirs: {
|
|
125
|
+
type: "boolean",
|
|
126
|
+
description: "Include subdirectories in search",
|
|
127
|
+
default: false,
|
|
128
|
+
},
|
|
129
|
+
top_n: {
|
|
130
|
+
type: "number",
|
|
131
|
+
description: "Number of largest files to return",
|
|
132
|
+
default: 10,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
required: ["directory"],
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "find_duplicate_files",
|
|
140
|
+
description: "Find duplicate files in a directory based on their content",
|
|
141
|
+
inputSchema: {
|
|
142
|
+
type: "object",
|
|
143
|
+
properties: {
|
|
144
|
+
directory: {
|
|
145
|
+
type: "string",
|
|
146
|
+
description: "Full path to the directory to search for duplicates",
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
required: ["directory"],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "organize_files",
|
|
154
|
+
description: "Automatically organize files into categorized folders (Executables, Videos, Documents, etc.)",
|
|
155
|
+
inputSchema: {
|
|
156
|
+
type: "object",
|
|
157
|
+
properties: {
|
|
158
|
+
directory: {
|
|
159
|
+
type: "string",
|
|
160
|
+
description: "Full path to the directory to organize",
|
|
161
|
+
},
|
|
162
|
+
dry_run: {
|
|
163
|
+
type: "boolean",
|
|
164
|
+
description: "If true, only simulate the organization without moving files",
|
|
165
|
+
default: false,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
required: ["directory"],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
// Handle tool calls
|
|
175
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
176
|
+
const { name, arguments: args } = request.params;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
switch (name) {
|
|
180
|
+
case "list_files":
|
|
181
|
+
return await this.listFiles(args.directory);
|
|
182
|
+
case "scan_directory":
|
|
183
|
+
return await this.scanDirectory(args.directory, args.include_subdirs, args.max_depth);
|
|
184
|
+
case "categorize_by_type":
|
|
185
|
+
return await this.categorizeByType(args.directory, args.include_subdirs);
|
|
186
|
+
case "find_largest_files":
|
|
187
|
+
return await this.findLargestFiles(args.directory, args.include_subdirs, args.top_n);
|
|
188
|
+
case "find_duplicate_files":
|
|
189
|
+
return await this.findDuplicateFiles(args.directory);
|
|
190
|
+
case "organize_files":
|
|
191
|
+
return await this.organizeFiles(args.directory, args.dry_run);
|
|
192
|
+
default:
|
|
193
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
194
|
+
}
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return {
|
|
197
|
+
content: [
|
|
198
|
+
{
|
|
199
|
+
type: "text",
|
|
200
|
+
text: `Error: ${this.sanitizeError(error)}`,
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async listFiles(directory) {
|
|
209
|
+
await this.validatePath(directory);
|
|
210
|
+
const files = await fs.readdir(directory, { withFileTypes: true });
|
|
211
|
+
const fileList = files
|
|
212
|
+
.filter((f) => f.isFile())
|
|
213
|
+
.map((f) => ({
|
|
214
|
+
name: f.name,
|
|
215
|
+
path: path.join(directory, f.name),
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
content: [
|
|
220
|
+
{
|
|
221
|
+
type: "text",
|
|
222
|
+
text: JSON.stringify(
|
|
223
|
+
{
|
|
224
|
+
directory,
|
|
225
|
+
total_files: fileList.length,
|
|
226
|
+
files: fileList,
|
|
227
|
+
},
|
|
228
|
+
null,
|
|
229
|
+
2
|
|
230
|
+
),
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async scanDirectory(directory, includeSubdirs = false, maxDepth = -1) {
|
|
237
|
+
await this.validatePath(directory);
|
|
238
|
+
const results = [];
|
|
239
|
+
|
|
240
|
+
const scanDir = async (dir, currentDepth = 0) => {
|
|
241
|
+
// Enforce limits
|
|
242
|
+
if (maxDepth !== -1 && currentDepth > maxDepth) return;
|
|
243
|
+
if (currentDepth > this.MAX_DEPTH) {
|
|
244
|
+
console.error(`Warning: Max depth ${this.MAX_DEPTH} reached at ${dir}`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const items = await fs.readdir(dir, { withFileTypes: true });
|
|
249
|
+
|
|
250
|
+
for (const item of items) {
|
|
251
|
+
if (item.name.startsWith(".")) continue;
|
|
252
|
+
|
|
253
|
+
const fullPath = path.join(dir, item.name);
|
|
254
|
+
|
|
255
|
+
if (item.isFile()) {
|
|
256
|
+
// Enforce max files
|
|
257
|
+
if (results.length >= this.MAX_FILES) {
|
|
258
|
+
throw new Error(`Maximum file limit (${this.MAX_FILES}) reached`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const stats = await fs.stat(fullPath);
|
|
262
|
+
results.push({
|
|
263
|
+
name: item.name,
|
|
264
|
+
path: fullPath,
|
|
265
|
+
size: stats.size,
|
|
266
|
+
extension: path.extname(item.name),
|
|
267
|
+
created: stats.birthtime,
|
|
268
|
+
modified: stats.mtime,
|
|
269
|
+
});
|
|
270
|
+
} else if (item.isDirectory() && includeSubdirs) {
|
|
271
|
+
await scanDir(fullPath, currentDepth + 1);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
await scanDir(directory);
|
|
277
|
+
|
|
278
|
+
const totalSize = results.reduce((sum, file) => sum + file.size, 0);
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
content: [
|
|
282
|
+
{
|
|
283
|
+
type: "text",
|
|
284
|
+
text: JSON.stringify(
|
|
285
|
+
{
|
|
286
|
+
directory,
|
|
287
|
+
total_files: results.length,
|
|
288
|
+
total_size: totalSize,
|
|
289
|
+
total_size_readable: this.formatBytes(totalSize),
|
|
290
|
+
files: results,
|
|
291
|
+
},
|
|
292
|
+
null,
|
|
293
|
+
2
|
|
294
|
+
),
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async categorizeByType(directory, includeSubdirs = false) {
|
|
301
|
+
await this.validatePath(directory);
|
|
302
|
+
const files = await this.getAllFiles(directory, includeSubdirs);
|
|
303
|
+
const categorized = {};
|
|
304
|
+
|
|
305
|
+
for (const category in this.categories) {
|
|
306
|
+
categorized[category] = {
|
|
307
|
+
count: 0,
|
|
308
|
+
total_size: 0,
|
|
309
|
+
files: [],
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const file of files) {
|
|
314
|
+
const ext = path.extname(file.name).toLowerCase();
|
|
315
|
+
const category = this.getCategory(ext);
|
|
316
|
+
|
|
317
|
+
categorized[category].count++;
|
|
318
|
+
categorized[category].total_size += file.size;
|
|
319
|
+
categorized[category].files.push(file.name);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Remove empty categories
|
|
323
|
+
for (const category in categorized) {
|
|
324
|
+
if (categorized[category].count === 0) {
|
|
325
|
+
delete categorized[category];
|
|
326
|
+
} else {
|
|
327
|
+
categorized[category].total_size_readable = this.formatBytes(
|
|
328
|
+
categorized[category].total_size
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
content: [
|
|
335
|
+
{
|
|
336
|
+
type: "text",
|
|
337
|
+
text: JSON.stringify(
|
|
338
|
+
{
|
|
339
|
+
directory,
|
|
340
|
+
categories: categorized,
|
|
341
|
+
},
|
|
342
|
+
null,
|
|
343
|
+
2
|
|
344
|
+
),
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async findLargestFiles(directory, includeSubdirs = false, topN = 10) {
|
|
351
|
+
await this.validatePath(directory);
|
|
352
|
+
const files = await this.getAllFiles(directory, includeSubdirs);
|
|
353
|
+
const sorted = files.sort((a, b) => b.size - a.size).slice(0, topN);
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
content: [
|
|
357
|
+
{
|
|
358
|
+
type: "text",
|
|
359
|
+
text: JSON.stringify(
|
|
360
|
+
{
|
|
361
|
+
directory,
|
|
362
|
+
largest_files: sorted.map((f) => ({
|
|
363
|
+
name: f.name,
|
|
364
|
+
path: f.path,
|
|
365
|
+
size: f.size,
|
|
366
|
+
size_readable: this.formatBytes(f.size),
|
|
367
|
+
})),
|
|
368
|
+
},
|
|
369
|
+
null,
|
|
370
|
+
2
|
|
371
|
+
),
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async findDuplicateFiles(directory) {
|
|
378
|
+
await this.validatePath(directory);
|
|
379
|
+
const files = await this.getAllFiles(directory, false);
|
|
380
|
+
const hashMap = {};
|
|
381
|
+
|
|
382
|
+
// Calculate hash for each file
|
|
383
|
+
for (const file of files) {
|
|
384
|
+
try {
|
|
385
|
+
// Skip files that are too large
|
|
386
|
+
if (file.size > this.MAX_FILE_SIZE) {
|
|
387
|
+
console.error(`Skipping large file: ${file.name} (${this.formatBytes(file.size)})`);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const hash = await this.calculateFileHash(file.path);
|
|
392
|
+
if (!hashMap[hash]) {
|
|
393
|
+
hashMap[hash] = [];
|
|
394
|
+
}
|
|
395
|
+
hashMap[hash].push(file);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
console.error(`Error hashing ${file.name}: ${error.message}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Filter only duplicates
|
|
402
|
+
const duplicates = Object.values(hashMap).filter((group) => group.length > 1);
|
|
403
|
+
|
|
404
|
+
const totalDuplicateSize = duplicates.reduce((sum, group) => {
|
|
405
|
+
return sum + group[0].size * (group.length - 1);
|
|
406
|
+
}, 0);
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
content: [
|
|
410
|
+
{
|
|
411
|
+
type: "text",
|
|
412
|
+
text: JSON.stringify(
|
|
413
|
+
{
|
|
414
|
+
directory,
|
|
415
|
+
duplicate_groups: duplicates.length,
|
|
416
|
+
total_duplicate_files: duplicates.reduce((sum, g) => sum + g.length, 0),
|
|
417
|
+
wasted_space: this.formatBytes(totalDuplicateSize),
|
|
418
|
+
duplicates: duplicates.map((group) => ({
|
|
419
|
+
count: group.length,
|
|
420
|
+
size: this.formatBytes(group[0].size),
|
|
421
|
+
files: group.map((f) => ({ name: f.name, path: f.path })),
|
|
422
|
+
})),
|
|
423
|
+
},
|
|
424
|
+
null,
|
|
425
|
+
2
|
|
426
|
+
),
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async organizeFiles(directory, dryRun = false) {
|
|
433
|
+
await this.validatePath(directory);
|
|
434
|
+
const files = await this.getAllFiles(directory, false);
|
|
435
|
+
const stats = {};
|
|
436
|
+
const actions = [];
|
|
437
|
+
const errors = [];
|
|
438
|
+
|
|
439
|
+
// Create category folders
|
|
440
|
+
for (const category in this.categories) {
|
|
441
|
+
const categoryPath = path.join(directory, category);
|
|
442
|
+
if (!dryRun) {
|
|
443
|
+
try {
|
|
444
|
+
await fs.mkdir(categoryPath, { recursive: true });
|
|
445
|
+
} catch (error) {
|
|
446
|
+
if (error.code !== "EEXIST") {
|
|
447
|
+
errors.push(`Failed to create folder ${category}: ${error.message}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
stats[category] = 0;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Organize files
|
|
455
|
+
for (const file of files) {
|
|
456
|
+
try {
|
|
457
|
+
const ext = path.extname(file.name).toLowerCase();
|
|
458
|
+
const category = this.getCategory(ext);
|
|
459
|
+
const destFolder = path.join(directory, category);
|
|
460
|
+
let destPath = path.join(destFolder, file.name);
|
|
461
|
+
|
|
462
|
+
// Handle duplicates
|
|
463
|
+
let counter = 1;
|
|
464
|
+
while (await this.fileExists(destPath)) {
|
|
465
|
+
const baseName = path.basename(file.name, ext);
|
|
466
|
+
const newName = `${baseName}_${counter}${ext}`;
|
|
467
|
+
destPath = path.join(destFolder, newName);
|
|
468
|
+
counter++;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (!dryRun) {
|
|
472
|
+
await fs.rename(file.path, destPath);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
stats[category]++;
|
|
476
|
+
actions.push({
|
|
477
|
+
file: file.name,
|
|
478
|
+
from: file.path,
|
|
479
|
+
to: destPath,
|
|
480
|
+
category,
|
|
481
|
+
});
|
|
482
|
+
} catch (error) {
|
|
483
|
+
errors.push(`Failed to move ${file.name}: ${error.message}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Clean up empty folders
|
|
488
|
+
if (!dryRun) {
|
|
489
|
+
for (const category in this.categories) {
|
|
490
|
+
if (stats[category] === 0) {
|
|
491
|
+
const categoryPath = path.join(directory, category);
|
|
492
|
+
try {
|
|
493
|
+
await fs.rmdir(categoryPath);
|
|
494
|
+
} catch (error) {
|
|
495
|
+
// Ignore errors for non-empty or non-existent directories
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
content: [
|
|
503
|
+
{
|
|
504
|
+
type: "text",
|
|
505
|
+
text: JSON.stringify(
|
|
506
|
+
{
|
|
507
|
+
directory,
|
|
508
|
+
dry_run: dryRun,
|
|
509
|
+
total_files: files.length,
|
|
510
|
+
statistics: stats,
|
|
511
|
+
actions: actions,
|
|
512
|
+
errors: errors,
|
|
513
|
+
},
|
|
514
|
+
null,
|
|
515
|
+
2
|
|
516
|
+
),
|
|
517
|
+
},
|
|
518
|
+
],
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Helper methods
|
|
523
|
+
async validatePath(requestedPath) {
|
|
524
|
+
const cwd = process.cwd();
|
|
525
|
+
|
|
526
|
+
// Step 1: Normalize and sanitize input
|
|
527
|
+
const normalized = path.normalize(requestedPath).replace(/^(\.\.(\/|\\|$))+/, '');
|
|
528
|
+
|
|
529
|
+
// Step 2: Resolve to absolute path
|
|
530
|
+
const absolutePath = path.resolve(cwd, normalized);
|
|
531
|
+
|
|
532
|
+
// Step 3: Resolve symlinks (use try-catch since file might not exist yet)
|
|
533
|
+
let realPath;
|
|
534
|
+
try {
|
|
535
|
+
realPath = await fs.realpath(absolutePath);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
// If file doesn't exist, validate parent directory instead
|
|
538
|
+
const parentDir = path.dirname(absolutePath);
|
|
539
|
+
try {
|
|
540
|
+
const realParent = await fs.realpath(parentDir);
|
|
541
|
+
if (!realParent.startsWith(cwd + path.sep) && realParent !== cwd) {
|
|
542
|
+
throw new Error(`Access denied: Parent directory is outside allowed directory`);
|
|
543
|
+
}
|
|
544
|
+
realPath = path.join(realParent, path.basename(absolutePath));
|
|
545
|
+
} catch {
|
|
546
|
+
// If parent doesn't exist either, check grand-parent or just simple check
|
|
547
|
+
// For simplicity in this fix, we fall back to absolute path check if realpath fails widely
|
|
548
|
+
// preventing deeper traversal if parent is also missing
|
|
549
|
+
if (!absolutePath.startsWith(cwd + path.sep) && absolutePath !== cwd) {
|
|
550
|
+
throw new Error(`Access denied: Path is outside allowed directory`);
|
|
551
|
+
}
|
|
552
|
+
return absolutePath;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Step 4: Strict containment check
|
|
557
|
+
if (!realPath.startsWith(cwd + path.sep) && realPath !== cwd) {
|
|
558
|
+
throw new Error(`Access denied: Path '${requestedPath}' is outside the allowed directory.`);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return realPath;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async getAllFiles(directory, includeSubdirs = false) {
|
|
565
|
+
const results = [];
|
|
566
|
+
|
|
567
|
+
const scanDir = async (dir, depth = 0) => {
|
|
568
|
+
if (includeSubdirs && depth > this.MAX_DEPTH) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const items = await fs.readdir(dir, { withFileTypes: true });
|
|
573
|
+
|
|
574
|
+
for (const item of items) {
|
|
575
|
+
if (item.name.startsWith(".")) continue;
|
|
576
|
+
|
|
577
|
+
const fullPath = path.join(dir, item.name);
|
|
578
|
+
|
|
579
|
+
if (item.isFile()) {
|
|
580
|
+
// Enforce max files
|
|
581
|
+
if (results.length >= this.MAX_FILES) {
|
|
582
|
+
throw new Error(`Maximum file limit (${this.MAX_FILES}) reached`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const stats = await fs.stat(fullPath);
|
|
586
|
+
results.push({
|
|
587
|
+
name: item.name,
|
|
588
|
+
path: fullPath,
|
|
589
|
+
size: stats.size,
|
|
590
|
+
});
|
|
591
|
+
} else if (item.isDirectory() && includeSubdirs) {
|
|
592
|
+
await scanDir(fullPath, depth + 1);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
await scanDir(directory);
|
|
598
|
+
return results;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
getCategory(extension) {
|
|
602
|
+
for (const [category, extensions] of Object.entries(this.categories)) {
|
|
603
|
+
if (extensions.includes(extension)) {
|
|
604
|
+
return category;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return "Others";
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async calculateFileHash(filePath) {
|
|
611
|
+
// Check file size first
|
|
612
|
+
const stats = await fs.stat(filePath);
|
|
613
|
+
if (stats.size > this.MAX_FILE_SIZE) {
|
|
614
|
+
throw new Error(`File exceeds maximum size for hashing (${this.formatBytes(this.MAX_FILE_SIZE)})`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return new Promise((resolve, reject) => {
|
|
618
|
+
const hash = crypto.createHash("sha256");
|
|
619
|
+
const stream = createReadStream(filePath, {
|
|
620
|
+
highWaterMark: 64 * 1024 // 64KB chunks
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
624
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
625
|
+
stream.on("error", reject);
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async fileExists(filePath) {
|
|
630
|
+
try {
|
|
631
|
+
await fs.access(filePath);
|
|
632
|
+
return true;
|
|
633
|
+
} catch {
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
formatBytes(bytes) {
|
|
639
|
+
if (bytes === 0) return "0 Bytes";
|
|
640
|
+
const k = 1024;
|
|
641
|
+
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
|
642
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
643
|
+
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
sanitizeError(error) {
|
|
647
|
+
return error.message
|
|
648
|
+
.replace(/\/[^\s]+/g, '[PATH]')
|
|
649
|
+
.replace(/[A-Z]:\\[^\s]+/g, '[PATH]');
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async run() {
|
|
653
|
+
const transport = new StdioServerTransport();
|
|
654
|
+
await this.server.connect(transport);
|
|
655
|
+
console.error("File Organizer MCP Server running on stdio");
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Start the server
|
|
660
|
+
// Export the class for testing
|
|
661
|
+
export { FileOrganizerServer };
|
|
662
|
+
|
|
663
|
+
// Start the server if running directly
|
|
664
|
+
import { fileURLToPath } from 'url';
|
|
665
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
666
|
+
const server = new FileOrganizerServer();
|
|
667
|
+
server.run().catch(console.error);
|
|
668
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://registry.modelcontextprotocol.io/schemas/server-bundle.json",
|
|
3
|
+
"name": "io.github.kridaydave/file-organizer",
|
|
4
|
+
"version": "2.1.0",
|
|
5
|
+
"description": "Security-hardened MCP server for intelligent file organization with duplicate detection, smart categorization, and comprehensive safety features",
|
|
6
|
+
"icon": "📁",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"vendor": {
|
|
9
|
+
"name": "kridaydave",
|
|
10
|
+
"url": "https://github.com/kridaydave"
|
|
11
|
+
},
|
|
12
|
+
"sourceCode": "https://github.com/kridaydave/File-Organizer-MCP",
|
|
13
|
+
"distribution": {
|
|
14
|
+
"type": "npm",
|
|
15
|
+
"package": "file-organizer-mcp"
|
|
16
|
+
},
|
|
17
|
+
"transports": [
|
|
18
|
+
"stdio"
|
|
19
|
+
],
|
|
20
|
+
"resources": [],
|
|
21
|
+
"prompts": [],
|
|
22
|
+
"tools": [
|
|
23
|
+
{
|
|
24
|
+
"name": "list_files",
|
|
25
|
+
"description": "List all files in a directory"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "scan_directory",
|
|
29
|
+
"description": "Detailed directory scan with file information"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "categorize_by_type",
|
|
33
|
+
"description": "Group files by category with statistics"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "find_largest_files",
|
|
37
|
+
"description": "Find the largest files in a directory"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "find_duplicate_files",
|
|
41
|
+
"description": "Identify duplicate files using SHA-256 hashing"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "organize_files",
|
|
45
|
+
"description": "Automatically organize files into categorized folders"
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|