filesystem-tools.ts
1 import fs from "fs/promises"; 2 3 import path from "path"; 4 5 import { minimatch } from "minimatch"; 6 7 import { zodToJsonSchema } from "zod-to-json-schema"; 8 9 import { ToolSchema } from "@modelcontextprotocol/sdk/types.js"; 10 11 import { expandHome, normalizePath } from "../utils/path-utils.js"; 12 13 import { isPathWithinAllowedDirectories } from "../utils/path-validation.js"; 14 15 import { 16 MakeDirectoryArgsSchema, 17 ListDirectoryArgsSchema, 18 ListDirectoryWithSizesArgsSchema, 19 DirectoryTreeArgsSchema, 20 MoveFileArgsSchema, 21 GetFileInfoArgsSchema, 22 RegisterDirectoryArgsSchema, 23 FileOperationsArgsSchema, 24 DeleteFilesArgsSchema, 25 type MakeDirectoryArgs, 26 type ListDirectoryArgs, 27 type ListDirectoryWithSizesArgs, 28 type DirectoryTreeArgs, 29 type MoveFileArgs, 30 type GetFileInfoArgs, 31 type RegisterDirectoryArgs, 32 type FileOperationsArgs, 33 type DeleteFilesArgs, 34 } from "../types/index.js"; 35 36 import { 37 validatePath, 38 getFileStats, 39 formatSize, 40 getAllowedDirectories, 41 setAllowedDirectories, 42 shouldIgnoreFolder, 43 getIgnoredFolders, 44 } from "../utils/lib.js"; 45 import { 46 createEmptyObjectSchema, 47 createPathArraySchema, 48 sanitizeToolInputSchema, 49 } from "../utils/tool-schema.js"; 50 51 const ToolInputSchema = ToolSchema.shape.inputSchema; 52 53 type ToolInput = any; 54 55 // Internal interfaces for unified list_directory implementation 56 interface FileEntry { 57 name: string; 58 path: string; 59 isDirectory: boolean; 60 size: number; 61 modifiedTime: Date; 62 children?: FileEntry[]; 63 } 64 65 interface ListingResult { 66 entries: FileEntry[]; 67 excludedByPatterns: number; 68 excludedByIgnoreRules: number; 69 } 70 71 export function getFileSystemTools() { 72 // Get current allowed directories for dynamic descriptions 73 const currentAllowedDirs = getAllowedDirectories(); 74 75 // Generate dynamic text for pre-approved directories 76 const generateApprovedDirsText = (): string => { 77 if (currentAllowedDirs.length === 0) { 78 return "\n\nCURRENTLY ACCESSIBLE DIRECTORIES: None. Use this tool to register directories for access."; 79 } 80 81 const dirList = currentAllowedDirs.map((dir) => ` - ${dir}`).join("\n"); 82 return `\n\nPRE-APPROVED DIRECTORIES (already accessible, DO NOT register these):\n${dirList}\n\nIMPORTANT: These directories and their subdirectories are ALREADY accessible to all filesystem tools. Do NOT use register_directory for these paths or any subdirectories within them.`; 83 }; 84 85 return [ 86 { 87 name: "make_directory", 88 description: 89 "Create single or multiple directories with recursive parent creation " + 90 "(like Unix 'mkdir -p'). Idempotent - won't error if directories exist. " + 91 "Only works within allowed directories.", 92 inputSchema: { 93 type: "object", 94 properties: { 95 paths: createPathArraySchema( 96 "Directory paths to create. For maximum MCP client compatibility, provide an array even when creating a single directory." 97 ), 98 }, 99 required: ["paths"], 100 additionalProperties: false, 101 } as ToolInput, 102 }, 103 { 104 name: "list_directory", 105 description: 106 "List directory contents with flexible output formats. Replaces the previous " + 107 "list_directory, list_directory_with_sizes, and directory_tree tools. " + 108 "Supports simple listings, detailed views with sizes/timestamps, hierarchical " + 109 "tree display, and structured JSON output. Automatically filters globally " + 110 "configured ignored folders. Only works within allowed directories.", 111 inputSchema: sanitizeToolInputSchema( 112 zodToJsonSchema(ListDirectoryArgsSchema) as ToolInput 113 ), 114 }, 115 { 116 name: "move_file", 117 description: 118 "Relocate or rename files and directories in a single atomic operation. " + 119 "Supports cross-directory moves with simultaneous renaming when needed. " + 120 "Fails safely if the destination path already exists to prevent accidental overwrites. " + 121 "Can also perform simple same-directory renames. " + 122 "Both source and destination must be within allowed directories.", 123 inputSchema: sanitizeToolInputSchema( 124 zodToJsonSchema(MoveFileArgsSchema) as ToolInput 125 ), 126 }, 127 { 128 name: "get_file_info", 129 description: 130 "Extract comprehensive metadata and statistics for files or directories. " + 131 "Provides detailed information including size, timestamps (creation and last modification), permissions, and entry type. " + 132 "Perfect for inspecting file properties and attributes without accessing the actual content. " + 133 "Only works within allowed directories.", 134 inputSchema: sanitizeToolInputSchema( 135 zodToJsonSchema(GetFileInfoArgsSchema) as ToolInput 136 ), 137 }, 138 { 139 name: "register_directory", 140 description: 141 "Register a directory for access. This allows the AI to dynamically gain access " + 142 "to directories specified by the human user during conversation. The directory " + 143 "and all its subdirectories will become accessible for all filesystem operations." + 144 generateApprovedDirsText(), 145 inputSchema: sanitizeToolInputSchema( 146 zodToJsonSchema(RegisterDirectoryArgsSchema) as ToolInput 147 ), 148 }, 149 { 150 name: "list_allowed_directories", 151 description: 152 "Display all directories currently accessible to the server. " + 153 "Note that subdirectories within listed paths are implicitly accessible as well. " + 154 "Use this to determine available filesystem scope and plan operations accordingly before attempting file access." + 155 generateApprovedDirsText(), 156 inputSchema: createEmptyObjectSchema() as ToolInput, 157 }, 158 { 159 name: "file_operations", 160 description: 161 "Perform bulk file operations (move, copy, rename) on single or multiple files and directories concurrently. " + 162 "All operations are validated for security before execution. Supports conflict resolution " + 163 "strategies for existing destinations. Maximum 100 files per operation for performance.", 164 inputSchema: { 165 type: "object", 166 properties: { 167 operation: { 168 type: "string", 169 enum: ["move", "copy", "rename"], 170 description: "The type of file operation to perform", 171 }, 172 files: { 173 type: "array", 174 items: { 175 type: "object", 176 properties: { 177 source: { 178 type: "string", 179 description: "Source file or directory path", 180 }, 181 destination: { 182 type: "string", 183 description: "Destination file or directory path", 184 }, 185 }, 186 required: ["source", "destination"], 187 additionalProperties: false, 188 }, 189 minItems: 1, 190 maxItems: 100, 191 description: "Array of source-destination file pairs", 192 }, 193 onConflict: { 194 type: "string", 195 enum: ["skip", "overwrite", "error"], 196 description: "How to handle destination conflicts", 197 default: "error", 198 }, 199 }, 200 required: ["operation", "files"], 201 additionalProperties: false, 202 } as ToolInput, 203 }, 204 { 205 name: "delete_files", 206 description: 207 "Delete single or multiple files and directories securely. " + 208 "Supports recursive directory deletion with safety controls. " + 209 "All paths are validated before deletion begins. " + 210 "Operations are processed concurrently for performance. " + 211 "Maximum 100 paths per operation. " + 212 "Only works within allowed directories.", 213 inputSchema: sanitizeToolInputSchema( 214 zodToJsonSchema(DeleteFilesArgsSchema) as ToolInput 215 ), 216 }, 217 ]; 218 } 219 220 // ============================================================================ 221 // UNIFIED LIST_DIRECTORY IMPLEMENTATION 222 // ============================================================================ 223 224 /** 225 * Helper: Collect file entry metadata 226 */ 227 async function collectFileEntry( 228 entryPath: string, 229 dirent: any, 230 ): Promise<FileEntry> { 231 try { 232 const stats = await fs.stat(entryPath); 233 return { 234 name: dirent.name, 235 path: entryPath, 236 isDirectory: dirent.isDirectory(), 237 size: stats.size, 238 modifiedTime: stats.mtime, 239 }; 240 } catch (error) { 241 // Return minimal entry on error 242 return { 243 name: dirent.name, 244 path: entryPath, 245 isDirectory: dirent.isDirectory(), 246 size: 0, 247 modifiedTime: new Date(0), 248 }; 249 } 250 } 251 252 async function filterAndCollectEntries( 253 rawEntries: any[], 254 255 basePath: string, 256 257 args: ListDirectoryArgs, 258 ): Promise<ListingResult> { 259 let excludedByPatterns = 0; 260 261 let excludedByIgnoreRules = 0; 262 263 const entries: FileEntry[] = []; 264 265 for (const dirent of rawEntries) { 266 // Check global ignore rules 267 268 if (dirent.isDirectory() && shouldIgnoreFolder(dirent.name)) { 269 excludedByIgnoreRules++; 270 271 continue; 272 } 273 274 if (args.excludePatterns && args.excludePatterns.length > 0) { 275 const shouldExclude = args.excludePatterns.some((pattern: string) => { 276 return minimatch(dirent.name, pattern, { dot: true }); 277 }); 278 279 if (shouldExclude) { 280 excludedByPatterns++; 281 282 continue; 283 } 284 } 285 286 // Collect entry with metadata 287 288 const entryPath = path.join(basePath, dirent.name); 289 290 const entry = await collectFileEntry(entryPath, dirent); 291 292 entries.push(entry); 293 } 294 295 return { entries, excludedByPatterns, excludedByIgnoreRules }; 296 } 297 298 /** 299 * Helper: Recursively expand directory entries for tree/json formats 300 */ 301 async function recursivelyExpandEntries( 302 entries: FileEntry[], 303 args: ListDirectoryArgs, 304 ): Promise<void> { 305 for (const entry of entries) { 306 if (entry.isDirectory) { 307 try { 308 const subEntries = await fs.readdir(entry.path, { 309 withFileTypes: true, 310 }); 311 const { entries: children } = await filterAndCollectEntries( 312 subEntries, 313 entry.path, 314 args, 315 ); 316 entry.children = children; 317 318 // Recurse 319 await recursivelyExpandEntries(children, args); 320 } catch (error) { 321 entry.children = []; 322 } 323 } 324 } 325 } 326 327 /** 328 * Helper: Sort entries (Gemini-inspired - always dirs first, then by criterion) 329 */ 330 function sortEntries(entries: FileEntry[], sortBy: string): FileEntry[] { 331 return [...entries].sort((a, b) => { 332 // Always group directories first (Gemini best practice) 333 if (a.isDirectory && !b.isDirectory) return -1; 334 if (!a.isDirectory && b.isDirectory) return 1; 335 336 // Then apply sort criterion 337 if (sortBy === "size") { 338 return b.size - a.size; // Descending by size 339 } 340 341 // Default: alphabetical by name 342 return a.name.localeCompare(b.name); 343 }); 344 } 345 346 /** 347 * Helper: Count files recursively 348 */ 349 function countFiles(entries: FileEntry[]): number { 350 let count = 0; 351 for (const entry of entries) { 352 if (!entry.isDirectory) { 353 count++; 354 } 355 if (entry.children) { 356 count += countFiles(entry.children); 357 } 358 } 359 return count; 360 } 361 362 /** 363 * Helper: Count directories recursively 364 */ 365 function countDirectories(entries: FileEntry[]): number { 366 let count = 0; 367 for (const entry of entries) { 368 if (entry.isDirectory) { 369 count++; 370 if (entry.children) { 371 count += countDirectories(entry.children); 372 } 373 } 374 } 375 return count; 376 } 377 378 /** 379 * Helper: Calculate total size recursively 380 */ 381 function calculateTotalSize(entries: FileEntry[]): number { 382 let total = 0; 383 for (const entry of entries) { 384 if (!entry.isDirectory) { 385 total += entry.size; 386 } 387 if (entry.children) { 388 total += calculateTotalSize(entry.children); 389 } 390 } 391 return total; 392 } 393 394 /** 395 * Format: Simple (default) 396 */ 397 function formatSimple( 398 entries: FileEntry[], 399 excludedByPatterns: number, 400 excludedByIgnoreRules: number, 401 ): { content: any[] } { 402 const lines = entries.map((entry) => { 403 const prefix = entry.isDirectory ? "[DIR]" : "[FILE]"; 404 return `${prefix} ${entry.name}`; 405 }); 406 407 // Summary 408 const totalFiles = entries.filter((e) => !e.isDirectory).length; 409 const totalDirs = entries.filter((e) => e.isDirectory).length; 410 lines.push(""); 411 lines.push(`Total: ${totalFiles} files, ${totalDirs} directories`); 412 413 // Show exclusion counts 414 const totalExcluded = excludedByPatterns + excludedByIgnoreRules; 415 if (totalExcluded > 0) { 416 lines.push(`(${totalExcluded} filtered by ignore rules)`); 417 } 418 419 return { content: [{ type: "text", text: lines.join("\n") }] }; 420 } 421 422 /** 423 * Format: Detailed (with sizes and metadata) 424 */ 425 function formatDetailed( 426 entries: FileEntry[], 427 excludedByPatterns: number, 428 excludedByIgnoreRules: number, 429 ): { content: any[] } { 430 const header = "Type Name Size Modified"; 431 const separator = "-".repeat(70); 432 433 const lines = entries.map((entry) => { 434 const type = entry.isDirectory ? "[DIR]" : "[FILE]"; 435 const name = entry.name.padEnd(20); 436 const size = entry.isDirectory 437 ? "-".padStart(11) 438 : formatSize(entry.size).padStart(11); 439 const mtime = entry.modifiedTime 440 .toISOString() 441 .slice(0, 19) 442 .replace("T", " "); 443 444 return `${type} ${name} ${size} ${mtime}`; 445 }); 446 447 // Summary 448 const totalFiles = entries.filter((e) => !e.isDirectory).length; 449 const totalDirs = entries.filter((e) => e.isDirectory).length; 450 const totalSize = entries.reduce( 451 (sum, e) => sum + (e.isDirectory ? 0 : e.size), 452 0, 453 ); 454 455 const output = [ 456 header, 457 separator, 458 ...lines, 459 "", 460 `Total: ${totalFiles} files, ${totalDirs} directories`, 461 `Combined size: ${formatSize(totalSize)}`, 462 ]; 463 464 const totalExcluded = excludedByPatterns + excludedByIgnoreRules; 465 if (totalExcluded > 0) { 466 output.push(`(${totalExcluded} filtered by ignore rules)`); 467 } 468 469 return { content: [{ type: "text", text: output.join("\n") }] }; 470 } 471 472 /** 473 * Format: Tree (hierarchical text tree) 474 */ 475 function formatTree( 476 entries: FileEntry[], 477 excludedByPatterns: number, 478 prefix: string = "", 479 isRoot: boolean = true, 480 ): { content: any[] } { 481 const lines: string[] = []; 482 483 if (isRoot) { 484 lines.push("."); 485 } 486 487 entries.forEach((entry, index) => { 488 const isLast = index === entries.length - 1; 489 const connector = isLast ? "└── " : "├── "; 490 const suffix = entry.isDirectory ? "/" : ""; 491 492 lines.push(`${prefix}${connector}${entry.name}${suffix}`); 493 494 if (entry.children && entry.children.length > 0) { 495 const childPrefix = prefix + (isLast ? " " : "│ "); 496 const childResult = formatTree(entry.children, 0, childPrefix, false); 497 lines.push( 498 ...childResult.content[0].text.split("\n").filter((l: string) => l), 499 ); 500 } 501 }); 502 503 if (isRoot) { 504 const totalFiles = countFiles(entries); 505 const totalDirs = countDirectories(entries); 506 lines.push(""); 507 lines.push(`${totalDirs} directories, ${totalFiles} files`); 508 509 if (excludedByPatterns > 0) { 510 lines.push(`(${excludedByPatterns} entries excluded by patterns)`); 511 } 512 } 513 514 return { content: [{ type: "text", text: lines.join("\n") }] }; 515 } 516 517 /** 518 * Format: JSON (structured data) 519 */ 520 function formatJson( 521 entries: FileEntry[], 522 basePath: string, 523 excludedByPatterns: number, 524 excludedByIgnoreRules: number, 525 ): { content: any[] } { 526 const totalFiles = countFiles(entries); 527 const totalDirs = countDirectories(entries); 528 const totalSize = calculateTotalSize(entries); 529 530 const output = { 531 path: basePath, 532 entries: entries.map((e) => ({ 533 name: e.name, 534 type: e.isDirectory ? "directory" : "file", 535 path: e.path, 536 isDirectory: e.isDirectory, 537 size: e.size, 538 modifiedTime: e.modifiedTime.toISOString(), 539 ...(e.children && { children: e.children }), 540 })), 541 summary: { 542 totalFiles, 543 totalDirectories: totalDirs, 544 totalSize, 545 totalSizeFormatted: formatSize(totalSize), 546 excludedByPatterns, 547 excludedByIgnoreRules, 548 }, 549 }; 550 551 return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] }; 552 } 553 554 /** 555 * Format output based on selected format 556 */ 557 function formatOutput( 558 entries: FileEntry[], 559 args: ListDirectoryArgs, 560 excludedByPatterns: number, 561 excludedByIgnoreRules: number, 562 ): { content: any[] } { 563 switch (args.format) { 564 case "simple": 565 return formatSimple(entries, excludedByPatterns, excludedByIgnoreRules); 566 case "detailed": 567 return formatDetailed(entries, excludedByPatterns, excludedByIgnoreRules); 568 case "tree": 569 return formatTree(entries, excludedByPatterns); 570 case "json": 571 return formatJson( 572 entries, 573 args.path, 574 excludedByPatterns, 575 excludedByIgnoreRules, 576 ); 577 default: 578 return formatSimple(entries, excludedByPatterns, excludedByIgnoreRules); 579 } 580 } 581 582 /** 583 * Main unified list_directory implementation 584 */ 585 async function listDirectory( 586 args: ListDirectoryArgs, 587 ): Promise<{ content: any[] }> { 588 // Step 1: Validate path 589 const validPath = await validatePath(args.path); 590 591 // Step 2: Read directory 592 const rawEntries = await fs.readdir(validPath, { withFileTypes: true }); 593 594 // Step 3: Apply filtering and collect metadata 595 const { entries, excludedByPatterns, excludedByIgnoreRules } = 596 await filterAndCollectEntries(rawEntries, validPath, args); 597 598 // Step 4: Handle recursive formats (tree and json) 599 if (args.format === "tree" || args.format === "json") { 600 await recursivelyExpandEntries(entries, args); 601 } 602 603 // Step 5: Apply sorting (always dirs first, then by sortBy) 604 const sorted = sortEntries(entries, args.sortBy || "name"); 605 606 // Step 6: Format output 607 return formatOutput(sorted, args, excludedByPatterns, excludedByIgnoreRules); 608 } 609 610 // ============================================================================ 611 // END UNIFIED LIST_DIRECTORY IMPLEMENTATION 612 // ============================================================================ 613 614 export async function handleFileSystemTool(name: string, args: any) { 615 switch (name) { 616 case "make_directory": { 617 const parsed = MakeDirectoryArgsSchema.safeParse(args); 618 if (!parsed.success) { 619 throw new Error( 620 `Invalid arguments for make_directory: ${parsed.error}`, 621 ); 622 } 623 624 // Defensive handling for MCP clients that may stringify arrays 625 // Some MCP clients (e.g., Claude Desktop) incorrectly serialize array parameters 626 // as stringified JSON instead of proper arrays. This workaround detects and fixes that. 627 let pathsInput = parsed.data.paths; 628 629 // If paths is a string that looks like a JSON array, try to parse it 630 if (typeof pathsInput === "string" && pathsInput.trim().startsWith("[")) { 631 try { 632 const parsedArray = JSON.parse(pathsInput); 633 if (Array.isArray(parsedArray)) { 634 pathsInput = parsedArray; 635 // Log for diagnostics - helps identify which clients have serialization issues 636 console.error( 637 "[INFO] make_directory: Detected and corrected stringified array parameter", 638 ); 639 } 640 } catch { 641 // If parsing fails, treat as single path (existing behavior) 642 // This handles edge cases like paths literally named "[something]" 643 } 644 } 645 646 // Normalize to array (single path or multiple paths) 647 const pathsToCreate = Array.isArray(pathsInput) 648 ? pathsInput 649 : [pathsInput]; 650 651 // Validate all paths first (atomic - fail before any creation) 652 const allowedDirs = getAllowedDirectories(); 653 const validatedPaths = pathsToCreate.map((dirPath) => { 654 const expandedPath = expandHome(dirPath); 655 // Resolve to absolute path - required by isPathWithinAllowedDirectories 656 const absolutePath = path.isAbsolute(expandedPath) 657 ? path.resolve(expandedPath) 658 : path.resolve(process.cwd(), expandedPath); 659 const normalized = normalizePath(absolutePath); 660 661 // Use secure path validation function to prevent prefix collision attacks 662 // (CVE-2025-54794 pattern: ensures path separator is required, not just prefix match) 663 if (!isPathWithinAllowedDirectories(normalized, allowedDirs)) { 664 throw new Error( 665 `Access denied: Path ${dirPath} is not within allowed directories`, 666 ); 667 } 668 669 return { original: dirPath, normalized }; 670 }); 671 672 // All validated - now create them concurrently 673 const results = await Promise.all( 674 validatedPaths.map(async ({ original, normalized }) => { 675 await fs.mkdir(normalized, { recursive: true }); 676 return original; 677 }), 678 ); 679 680 // Format response based on single vs batch 681 const message = 682 results.length === 1 683 ? `Successfully created directory ${results[0]}` 684 : `Successfully created ${results.length} directories:\n${results 685 .map((p) => ` - ${p}`) 686 .join("\n")}`; 687 688 return { 689 content: [ 690 { 691 type: "text", 692 text: message, 693 }, 694 ], 695 }; 696 } 697 698 case "list_directory": { 699 const parsed = ListDirectoryArgsSchema.safeParse(args); 700 if (!parsed.success) { 701 throw new Error( 702 `Invalid arguments for list_directory: ${parsed.error}`, 703 ); 704 } 705 return await listDirectory(parsed.data); 706 } 707 708 case "move_file": { 709 const parsed = MoveFileArgsSchema.safeParse(args); 710 if (!parsed.success) { 711 throw new Error(`Invalid arguments for move_file: ${parsed.error}`); 712 } 713 const validSourcePath = await validatePath(parsed.data.source); 714 const validDestPath = await validatePath(parsed.data.destination); 715 await fs.rename(validSourcePath, validDestPath); 716 return { 717 content: [ 718 { 719 type: "text", 720 text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}`, 721 }, 722 ], 723 }; 724 } 725 726 case "get_file_info": { 727 const parsed = GetFileInfoArgsSchema.safeParse(args); 728 if (!parsed.success) { 729 throw new Error(`Invalid arguments for get_file_info: ${parsed.error}`); 730 } 731 const validPath = await validatePath(parsed.data.path); 732 const info = await getFileStats(validPath); 733 return { 734 content: [ 735 { 736 type: "text", 737 text: Object.entries(info) 738 .map(([key, value]) => `${key}: ${value}`) 739 .join("\n"), 740 }, 741 ], 742 }; 743 } 744 745 case "register_directory": { 746 const parsed = RegisterDirectoryArgsSchema.safeParse(args); 747 if (!parsed.success) { 748 throw new Error( 749 `Invalid arguments for register_directory: ${parsed.error}`, 750 ); 751 } 752 753 const expandedPath = expandHome(parsed.data.path); 754 const absolutePath = path.resolve(expandedPath); 755 const normalizedPath = normalizePath(absolutePath); 756 757 // Validate that the path exists and is a directory 758 try { 759 const stats = await fs.stat(absolutePath); 760 if (!stats.isDirectory()) { 761 throw new Error(`Path ${absolutePath} is not a directory`); 762 } 763 } catch (error) { 764 if ((error as NodeJS.ErrnoException).code === "ENOENT") { 765 throw new Error(`Directory ${absolutePath} does not exist`); 766 } 767 throw error; 768 } 769 770 // Add to allowed directories 771 const currentDirs = getAllowedDirectories(); 772 if (!currentDirs.includes(normalizedPath)) { 773 setAllowedDirectories([...currentDirs, normalizedPath]); 774 return { 775 content: [ 776 { 777 type: "text", 778 text: `Successfully registered directory: ${parsed.data.path} (${normalizedPath})`, 779 }, 780 ], 781 }; 782 } else { 783 return { 784 content: [ 785 { 786 type: "text", 787 text: `Directory already registered: ${parsed.data.path} (${normalizedPath})`, 788 }, 789 ], 790 }; 791 } 792 } 793 794 case "list_allowed_directories": { 795 return { 796 content: [ 797 { 798 type: "text", 799 800 text: `Allowed directories:\n${getAllowedDirectories().join("\n")}`, 801 }, 802 ], 803 }; 804 } 805 806 case "file_operations": { 807 const parsed = FileOperationsArgsSchema.safeParse(args); 808 809 if (!parsed.success) { 810 throw new Error( 811 `Invalid arguments for file_operations: ${parsed.error}`, 812 ); 813 } 814 815 // Phase 1: Path Validation 816 const validationPromises = parsed.data.files.map( 817 async (file: FileOperationsArgs["files"][number], index: number) => { 818 try { 819 const validSource = await validatePath(file.source); 820 821 const validDest = await validatePath(file.destination); 822 823 return { 824 index, 825 826 source: file.source, 827 828 destination: file.destination, 829 830 validSource, 831 832 validDest, 833 834 success: true, 835 }; 836 } catch (error) { 837 return { 838 index, 839 source: file.source, 840 841 destination: file.destination, 842 843 success: false, 844 845 error: error instanceof Error ? error.message : String(error), 846 }; 847 } 848 }, 849 ); 850 851 const validatedFiles = await Promise.all(validationPromises); 852 853 // Check for validation errors 854 855 const validationErrors = validatedFiles.filter((f) => !f.success); 856 857 if (validationErrors.length > 0) { 858 const errorMessages = validationErrors 859 860 .map( 861 (f) => 862 `${f.source} → ${f.destination}: ${f.error || "Unknown error"}`, 863 ) 864 865 .join("\n"); 866 867 throw new Error(`Path validation failed:\n${errorMessages}`); 868 } 869 870 // Phase 2: Conflict Detection 871 872 const conflictChecks = await Promise.all( 873 validatedFiles.map(async (file) => { 874 try { 875 await fs.access(file.validDest!); 876 877 return { 878 ...file, 879 880 hasConflict: true, 881 }; 882 } catch { 883 return { 884 ...file, 885 886 hasConflict: false, 887 }; 888 } 889 }), 890 ); 891 892 // Handle conflicts based on strategy 893 894 const filesToProcess = conflictChecks.filter((file) => { 895 if (file.hasConflict) { 896 switch (parsed.data.onConflict) { 897 case "skip": 898 return false; 899 900 case "error": 901 throw new Error( 902 `Destination already exists: ${file.destination}`, 903 ); 904 905 case "overwrite": 906 return true; 907 } 908 } 909 910 return true; 911 }); 912 913 // Phase 3: Execute Operations 914 915 const operationPromises = filesToProcess.map(async (file) => { 916 try { 917 switch (parsed.data.operation) { 918 case "move": 919 920 case "rename": 921 await fs.rename(file.validSource!, file.validDest!); 922 923 break; 924 925 case "copy": 926 const stats = await fs.stat(file.validSource!); 927 928 if (stats.isDirectory()) { 929 await copyDirectoryRecursive( 930 file.validSource!, 931 932 file.validDest!, 933 ); 934 } else { 935 await fs.copyFile(file.validSource!, file.validDest!); 936 } 937 break; 938 } 939 return { 940 index: file.index, 941 942 source: file.source, 943 944 destination: file.destination, 945 946 success: true, 947 948 operation: parsed.data.operation, 949 }; 950 } catch (error) { 951 return { 952 index: file.index, 953 954 source: file.source, 955 956 destination: file.destination, 957 958 success: false, 959 960 error: error instanceof Error ? error.message : String(error), 961 962 operation: parsed.data.operation, 963 }; 964 } 965 }); 966 967 const results = await Promise.allSettled(operationPromises); 968 969 const processedResults = results.map((result, index) => { 970 if (result.status === "fulfilled") { 971 return result.value; 972 } else { 973 return { 974 index, 975 976 source: filesToProcess[index].source, 977 978 destination: filesToProcess[index].destination, 979 980 success: false, 981 982 error: 983 result.reason instanceof Error 984 ? result.reason.message 985 : String(result.reason), 986 987 operation: parsed.data.operation, 988 }; 989 } 990 }); 991 992 // Prepare response 993 994 const successful = processedResults.filter((r) => r.success); 995 996 const failed = processedResults.filter((r) => !r.success); 997 998 const successDetails = successful 999 1000 .map((r) => `✓ ${r.source} → ${r.destination}`) 1001 1002 .join("\n"); 1003 1004 const failureDetails = 1005 failed.length > 0 1006 ? failed 1007 1008 .map((r) => `✗ ${r.source} → ${r.destination}: ${r.error}`) 1009 1010 .join("\n") 1011 : ""; 1012 1013 return { 1014 content: [ 1015 { 1016 type: "text", 1017 1018 text: 1019 `Successfully performed ${parsed.data.operation} operations:\n\n` + 1020 `Total operations: ${processedResults.length}\n` + 1021 `Successful: ${successful.length}\n` + 1022 `Failed: ${failed.length}\n\n` + 1023 (failed.length > 0 1024 ? `Failed operations:\n${failureDetails}\n\n` 1025 : "") + 1026 `Processed files:\n${successDetails}`, 1027 }, 1028 ], 1029 }; 1030 } 1031 1032 case "delete_files": { 1033 const parsed = DeleteFilesArgsSchema.safeParse(args); 1034 if (!parsed.success) { 1035 throw new Error(`Invalid arguments for delete_files: ${parsed.error}`); 1036 } 1037 1038 // Phase 1: Path Validation 1039 const validationPromises = parsed.data.paths.map( 1040 async (filePath, index) => { 1041 try { 1042 const validPath = await validatePath(filePath); 1043 return { 1044 index, 1045 originalPath: filePath, 1046 validPath, 1047 success: true, 1048 }; 1049 } catch (error) { 1050 return { 1051 index, 1052 originalPath: filePath, 1053 success: false, 1054 error: error instanceof Error ? error.message : String(error), 1055 }; 1056 } 1057 }, 1058 ); 1059 1060 const validatedPaths = await Promise.all(validationPromises); 1061 1062 // Check for validation errors 1063 const validationErrors = validatedPaths.filter((p) => !p.success); 1064 if (validationErrors.length > 0) { 1065 const errorMessages = validationErrors 1066 .map((p) => `${p.originalPath}: ${p.error || "Unknown error"}`) 1067 .join("\n"); 1068 throw new Error(`Path validation failed:\n${errorMessages}`); 1069 } 1070 1071 // Phase 2: Pre-deletion Checks 1072 const preCheckPromises = validatedPaths.map(async (item) => { 1073 try { 1074 const stats = await fs.stat(item.validPath!); 1075 return { 1076 ...item, 1077 exists: true, 1078 isDirectory: stats.isDirectory(), 1079 }; 1080 } catch (error) { 1081 return { 1082 ...item, 1083 exists: false, 1084 isDirectory: false, 1085 error: `File does not exist: ${item.originalPath}`, 1086 }; 1087 } 1088 }); 1089 1090 const checkedPaths = await Promise.all(preCheckPromises); 1091 1092 // Filter out non-existent paths 1093 const pathsToDelete = checkedPaths.filter((p) => p.exists); 1094 1095 if (pathsToDelete.length === 0) { 1096 throw new Error( 1097 "No valid paths to delete - all paths either don't exist or failed validation", 1098 ); 1099 } 1100 1101 // Phase 3: Execute Deletions 1102 const deletionPromises = pathsToDelete.map(async (item) => { 1103 try { 1104 if (item.isDirectory) { 1105 if (parsed.data.recursive) { 1106 // Recursive directory deletion 1107 await fs.rm(item.validPath!, { 1108 recursive: true, 1109 force: parsed.data.force, 1110 }); 1111 } else { 1112 // Non-recursive - only delete empty directories 1113 await fs.rmdir(item.validPath!); 1114 } 1115 } else { 1116 // File deletion 1117 await fs.unlink(item.validPath!); 1118 } 1119 return { 1120 index: item.index, 1121 path: item.originalPath, 1122 success: true, 1123 isDirectory: item.isDirectory, 1124 }; 1125 } catch (error) { 1126 const errorMessage = 1127 error instanceof Error ? error.message : String(error); 1128 // Provide helpful error messages 1129 let friendlyError = errorMessage; 1130 if ( 1131 errorMessage.includes("ENOTEMPTY") || 1132 errorMessage.includes("directory not empty") 1133 ) { 1134 friendlyError = `Directory not empty. Use recursive: true to delete non-empty directories.`; 1135 } else if ( 1136 errorMessage.includes("EACCES") || 1137 errorMessage.includes("EPERM") 1138 ) { 1139 friendlyError = `Permission denied. ${ 1140 parsed.data.force 1141 ? "Insufficient permissions even with force enabled." 1142 : "Try using force: true if appropriate." 1143 }`; 1144 } 1145 1146 return { 1147 index: item.index, 1148 path: item.originalPath, 1149 success: false, 1150 error: friendlyError, 1151 isDirectory: item.isDirectory || false, 1152 }; 1153 } 1154 }); 1155 1156 const results = await Promise.allSettled(deletionPromises); 1157 1158 // Process results 1159 const processedResults = results.map((result, index) => { 1160 if (result.status === "fulfilled") { 1161 return result.value; 1162 } else { 1163 return { 1164 index, 1165 path: pathsToDelete[index].originalPath, 1166 success: false, 1167 isDirectory: pathsToDelete[index].isDirectory || false, 1168 error: 1169 result.reason instanceof Error 1170 ? result.reason.message 1171 : String(result.reason), 1172 }; 1173 } 1174 }); 1175 1176 // Prepare response 1177 const successful = processedResults.filter((r) => r.success); 1178 const failed = processedResults.filter((r) => !r.success); 1179 1180 const successDetails = successful 1181 .map((r) => `✓ ${r.path}${r.isDirectory ? " (directory)" : ""}`) 1182 .join("\n"); 1183 1184 const failureDetails = 1185 failed.length > 0 1186 ? failed.map((r) => `✗ ${r.path}: ${r.error}`).join("\n") 1187 : ""; 1188 1189 // Build response message 1190 const responseLines = [ 1191 `Successfully deleted ${successful.length} of ${processedResults.length} paths:`, 1192 "", 1193 `Total paths: ${processedResults.length}`, 1194 `Successful: ${successful.length}`, 1195 `Failed: ${failed.length}`, 1196 "", 1197 ]; 1198 1199 if (failed.length > 0) { 1200 responseLines.push(`Failed deletions:`, failureDetails, ""); 1201 } 1202 1203 if (successful.length > 0) { 1204 responseLines.push(`Deleted paths:`, successDetails); 1205 } 1206 1207 return { 1208 content: [ 1209 { 1210 type: "text", 1211 text: responseLines.join("\n"), 1212 }, 1213 ], 1214 }; 1215 } 1216 1217 default: 1218 throw new Error(`Unknown filesystem tool: ${name}`); 1219 } 1220 } 1221 1222 // Helper function for recursive directory copying 1223 async function copyDirectoryRecursive( 1224 source: string, 1225 destination: string, 1226 ): Promise<void> { 1227 // Create destination directory 1228 await fs.mkdir(destination, { recursive: true }); 1229 1230 // Read source directory 1231 const entries = await fs.readdir(source, { withFileTypes: true }); 1232 1233 // Copy all entries 1234 for (const entry of entries) { 1235 const sourcePath = path.join(source, entry.name); 1236 const destPath = path.join(destination, entry.name); 1237 1238 if (entry.isDirectory()) { 1239 await copyDirectoryRecursive(sourcePath, destPath); 1240 } else { 1241 await fs.copyFile(sourcePath, destPath); 1242 } 1243 } 1244 }