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/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
+ }