write-tools.ts
1 import { zodToJsonSchema } from "zod-to-json-schema"; 2 import { ToolSchema } from "@modelcontextprotocol/sdk/types.js"; 3 import path from "path"; 4 import { promises as fs } from "fs"; 5 import { 6 WriteFileArgsSchema, 7 WriteMultipleFilesArgsSchema, 8 EditFileArgsSchema, 9 EditFileRequestSchema, 10 type WriteFileArgs, 11 type WriteMultipleFilesArgs, 12 type EditFileArgs, 13 type EditFileRequest, 14 } from "../types/index.js"; 15 import { 16 validatePath, 17 writeFileContent, 18 readFileContent, 19 applyFileEdits, 20 } from "../utils/lib.js"; 21 import { 22 isHTMLContent, 23 convertHTMLToPDF, 24 convertHTMLToDOCX, 25 } from "../utils/html-to-document.js"; 26 import { sanitizeToolInputSchema } from "../utils/tool-schema.js"; 27 28 const ToolInputSchema = ToolSchema.shape.inputSchema; 29 type ToolInput = any; 30 31 interface EditFileResult { 32 path: string; 33 success: boolean; 34 strategy?: "exact" | "flexible" | "fuzzy"; 35 occurrences?: number; 36 diff?: string; 37 error?: string; 38 dryRun?: boolean; 39 } 40 41 interface EditFileResults { 42 results: EditFileResult[]; 43 summary: { 44 total: number; 45 successful: number; 46 failed: number; 47 hasFailures: boolean; 48 failFast: boolean; 49 }; 50 } 51 52 /** 53 * Helper function to write file content based on file extension 54 * Supports HTML conversion for rich formatting in PDF and DOCX files 55 * 56 * @security Path must be pre-validated via validatePath() before calling this function 57 * @param validPath - VALIDATED path (must have passed through validatePath()) 58 * @param content - File content to write 59 */ 60 async function writeFileBasedOnExtension( 61 validPath: string, 62 content: string, 63 ): Promise<void> { 64 const ext = path.extname(validPath).toLowerCase(); 65 const filename = path.basename(validPath); 66 const fileTitle = path.basename(validPath, ext); 67 68 // Detect if content is HTML 69 const isHTML = isHTMLContent(content); 70 71 if (ext === ".pdf") { 72 if (isHTML) { 73 // Use HTML-to-PDF converter for rich formatting 74 const pdfBuffer = await convertHTMLToPDF(content, { 75 title: fileTitle, 76 author: "vulcan-file-ops", 77 }); 78 // SECURITY: validPath pre-validated by validatePath() - safe from path traversal (CWE-23) 79 await fs.writeFile(validPath, pdfBuffer); 80 } else { 81 // Fallback to simple text PDF for plain text 82 const { createSimpleTextPDF } = await import("../utils/pdf-writer.js"); 83 const pdfBuffer = await createSimpleTextPDF(content); 84 // SECURITY: validPath pre-validated by validatePath() - safe from path traversal (CWE-23) 85 await fs.writeFile(validPath, pdfBuffer); 86 } 87 } else if (ext === ".docx") { 88 if (isHTML) { 89 // Use HTML-to-DOCX converter for rich formatting 90 const docxBuffer = await convertHTMLToDOCX(content, { 91 title: fileTitle, 92 author: "vulcan-file-ops", 93 }); 94 // SECURITY: validPath pre-validated by validatePath() - safe from path traversal (CWE-23) 95 await fs.writeFile(validPath, docxBuffer); 96 } else { 97 // Fallback to simple text DOCX for plain text 98 const { createSimpleDOCX } = await import("../utils/docx-writer.js"); 99 const docxBuffer = await createSimpleDOCX(content); 100 // SECURITY: validPath pre-validated by validatePath() - safe from path traversal (CWE-23) 101 await fs.writeFile(validPath, docxBuffer); 102 } 103 } else { 104 // Regular text file 105 // SECURITY: validPath pre-validated by validatePath() - writeFileContent adds additional atomic write protection 106 await writeFileContent(validPath, content); 107 } 108 } 109 110 /** 111 * Process a single file edit request with validation 112 * 113 * @security All paths validated via validatePath() before file operations 114 * @param request - Edit request with path and edits to apply 115 * @param failOnAmbiguous - Whether to fail on ambiguous matches 116 * @returns Edit result with success status and diff 117 */ 118 async function processFileEditRequest( 119 request: EditFileRequest, 120 failOnAmbiguous: boolean = true, 121 ): Promise<EditFileResult> { 122 try { 123 // SECURITY: Path validated against allowed directories, symlink targets checked, 124 // prevents CVE-2025-54794 (prefix collision), CVE-2025-53109 (symlink attacks) 125 const validPath = await validatePath(request.path); 126 const result = await applyFileEdits( 127 validPath, 128 request.edits, 129 request.dryRun || false, 130 request.matchingStrategy || "auto", 131 request.failOnAmbiguous !== undefined 132 ? request.failOnAmbiguous 133 : failOnAmbiguous, 134 true, // Return metadata 135 ); 136 137 if (typeof result === "string") { 138 throw new Error("Expected metadata but got string result"); 139 } 140 141 // Aggregate metadata from all edits 142 const totalOccurrences = result.metadata.reduce( 143 (sum, r) => sum + r.occurrences, 144 0, 145 ); 146 const usedStrategies = [...new Set(result.metadata.map((r) => r.strategy))]; 147 const finalStrategy = 148 result.metadata[result.metadata.length - 1]?.strategy || "exact"; 149 const warning = result.metadata.find((r) => r.warning)?.warning; 150 const ambiguity = result.metadata.find((r) => r.ambiguity)?.ambiguity; 151 152 return { 153 path: request.path, 154 success: true, 155 strategy: finalStrategy, 156 occurrences: totalOccurrences, 157 diff: result.diff, 158 dryRun: request.dryRun, 159 }; 160 } catch (error) { 161 return { 162 path: request.path, 163 success: false, 164 error: error instanceof Error ? error.message : String(error), 165 }; 166 } 167 } 168 169 async function processMultiFileEdits( 170 files: EditFileRequest[], 171 failFast: boolean = true, 172 ): Promise<EditFileResults> { 173 const results: EditFileResult[] = []; 174 const rollbackData: Array<{ path: string; originalContent: string }> = []; 175 let hasFailures = false; 176 177 try { 178 // Process files sequentially if failFast is true, concurrently if false 179 if (failFast) { 180 // Process sequentially to stop on first failure 181 for (const request of files) { 182 // For rollback capability, read original content before editing 183 let originalContent: string | undefined; 184 if (failFast && !request.dryRun) { 185 try { 186 originalContent = await readFileContent( 187 await validatePath(request.path), 188 ); 189 } catch (error) { 190 // If we can't read the original content, we can't provide rollback 191 // This is acceptable - the file might not exist or be readable 192 } 193 } 194 195 const result = await processFileEditRequest(request); 196 results.push(result); 197 198 // Track successful edits for potential rollback 199 if ( 200 result.success && 201 !request.dryRun && 202 originalContent !== undefined 203 ) { 204 rollbackData.push({ 205 path: request.path, 206 originalContent, 207 }); 208 } 209 210 if (!result.success) { 211 hasFailures = true; 212 // Rollback all previously successful edits 213 await performRollback(rollbackData); 214 break; // Stop processing remaining files 215 } 216 } 217 } else { 218 // Process all concurrently, collect all results (no rollback for concurrent mode) 219 const promises = files.map((request) => processFileEditRequest(request)); 220 const allResults = await Promise.allSettled(promises); 221 222 for (let i = 0; i < allResults.length; i++) { 223 const settled = allResults[i]; 224 const request = files[i]; 225 226 if (settled.status === "fulfilled") { 227 results.push(settled.value); 228 if (!settled.value.success) hasFailures = true; 229 } else { 230 // Handle unexpected promise rejections 231 results.push({ 232 path: request.path, 233 success: false, 234 error: `Unexpected error: ${settled.reason}`, 235 }); 236 hasFailures = true; 237 } 238 } 239 } 240 } catch (error) { 241 // If there's an unexpected error during processing, attempt rollback 242 if (failFast && rollbackData.length > 0) { 243 await performRollback(rollbackData); 244 } 245 throw error; 246 } 247 248 return { 249 results, 250 summary: { 251 total: files.length, 252 successful: results.filter((r) => r.success).length, 253 failed: results.filter((r) => !r.success).length, 254 hasFailures, 255 failFast, 256 }, 257 }; 258 } 259 260 async function performRollback( 261 rollbackData: Array<{ path: string; originalContent: string }>, 262 ): Promise<void> { 263 for (const item of rollbackData.reverse()) { 264 // Rollback in reverse order 265 try { 266 // Security: Re-validate path before rollback to ensure it's still within allowed directories 267 // Defense-in-depth: Even though paths were validated during edit, re-validate during rollback 268 // to protect against edge cases where allowed directories might have changed 269 const validPath = await validatePath(item.path); 270 await writeFileContent(validPath, item.originalContent); 271 } catch (rollbackError) { 272 // Log rollback failure but don't throw - we want to attempt all rollbacks 273 console.error(`Failed to rollback ${item.path}: ${rollbackError}`); 274 } 275 } 276 } 277 278 function formatMultiFileEditResults(editResults: EditFileResults): string { 279 let output = ""; 280 281 // Summary header 282 output += `Multi-File Edit Summary:\n`; 283 output += `Total files: ${editResults.summary.total}\n`; 284 output += `Successful: ${editResults.summary.successful}\n`; 285 output += `Failed: ${editResults.summary.failed}\n`; 286 output += `Mode: ${ 287 editResults.summary.failFast ? "failFast (atomic)" : "continueOnError" 288 }\n`; 289 290 if (editResults.summary.failFast && editResults.summary.hasFailures) { 291 output += `⚠️ Atomic operation failed - all successful edits were rolled back\n`; 292 } 293 294 output += `\n`; 295 296 // Individual file results 297 editResults.results.forEach((result, index) => { 298 output += `File ${index + 1}: ${result.path}\n`; 299 output += `Status: ${result.success ? "✓ SUCCESS" : "✗ FAILED"}\n`; 300 301 if (result.success) { 302 if (result.strategy) { 303 output += `Strategy: ${result.strategy}\n`; 304 } 305 if (result.occurrences !== undefined) { 306 output += `Occurrences: ${result.occurrences}\n`; 307 } 308 if (result.dryRun) { 309 output += `Mode: DRY RUN (no changes made)\n`; 310 } 311 if (result.diff) { 312 output += `\n${result.diff}\n`; 313 } 314 } else { 315 output += `Error: ${result.error}\n`; 316 } 317 318 output += "\n" + "=".repeat(50) + "\n\n"; 319 }); 320 321 return output.trim(); 322 } 323 324 export function getWriteTools() { 325 return [ 326 { 327 name: "write_file", 328 description: 329 "Create/replace files. Supports text (UTF-8), PDF, and DOCX with HTML formatting. " + 330 "\n\n" + 331 "**PDF/DOCX with HTML Formatting:**\n" + 332 "- Provide HTML content for rich formatting (headings, bold, italic, colors, tables, lists)\n" + 333 "- Supports: <h1>-<h6>, <p>, <div>, <span>, <strong>, <em>, <u>, <table>, <ul>, <ol>\n" + 334 "- CSS styling: colors, fonts, alignment, borders, margins, padding\n" + 335 "- Example: '<html><body><h1 style=\"color: #2c3e50;\">Title</h1><p>Content</p></body></html>'\n" + 336 "- Plain text fallback: If content is not HTML, creates simple formatted document\n" + 337 "\n" + 338 "**Text files:** UTF-8 encoding. " + 339 "**Overwrites without confirmation.**\n" + 340 "\n" + 341 "IMPORTANT - Multi-line Content:\n" + 342 "- Use actual newline characters in the content string, NOT escape sequences like \\n\n" + 343 "- MCP/JSON will handle the encoding automatically\n" + 344 '- Incorrect: {"content": "line1\\nline2"} - this writes literal \\n characters\n' + 345 "- Correct: Use actual line breaks in your JSON string value\n" + 346 "\n" + 347 "Only works within allowed directories.", 348 inputSchema: sanitizeToolInputSchema( 349 zodToJsonSchema(WriteFileArgsSchema) as ToolInput 350 ), 351 }, 352 { 353 name: "edit_file", 354 description: 355 "Apply precise modifications to text and code files with intelligent matching.\n\n" + 356 "**Single File Editing (mode: 'single'):**\n" + 357 "Edit one file with multiple sequential edits using exact, flexible, or fuzzy matching strategies.\n\n" + 358 "**Multi-File Editing (mode: 'multiple'):**\n" + 359 "Edit multiple files concurrently in a single operation. Each file can have its own edit configuration.\n\n" + 360 "**Matching Strategies:**\n" + 361 "1. Exact: Character-for-character match (fastest, safest)\n" + 362 "2. Flexible: Whitespace-insensitive, preserves original indentation\n" + 363 "3. Fuzzy: Token-based regex matching for maximum compatibility\n\n" + 364 "**Features:**\n" + 365 "- Concurrent processing for multi-file operations\n" + 366 "- Per-file matching strategy control\n" + 367 "- Dry-run preview mode\n" + 368 "- Detailed diff output with statistics\n" + 369 "- Atomic operations with rollback capability\n" + 370 "- Cross-platform line ending preservation\n\n" + 371 "**Maximum:** 50 files per multi-file operation\n\n" + 372 "**Best Practices:**\n" + 373 "- Include 3-5 lines of context before and after the change for reliability\n" + 374 "- Add 'instruction' field to describe the purpose of each edit\n" + 375 "- Use 'dryRun: true' to preview changes before applying\n" + 376 "- For multiple related changes, use array of edits (applied sequentially)\n" + 377 "- Set 'expectedOccurrences' to validate replacement count\n" + 378 "- Use 'matchingStrategy' to control matching behavior (defaults to 'auto')\n\n" + 379 "**CRITICAL - Multi-line Content:**\n" + 380 "- Use actual newline characters in oldText/newText strings, NOT \\n escape sequences\n" + 381 "- The MCP/JSON layer handles encoding automatically\n" + 382 "- Using \\n literally will search for/write backslash+n characters (wrong!)\n\n" + 383 "Only works within allowed directories.", 384 inputSchema: sanitizeToolInputSchema( 385 zodToJsonSchema(EditFileArgsSchema) as ToolInput 386 ), 387 }, 388 { 389 name: "write_multiple_files", 390 description: 391 "Write multiple files concurrently. Supports text, PDF, and DOCX with HTML formatting. " + 392 "File type auto-detected by extension. Failed writes for individual files " + 393 "won't stop others. Returns detailed results for each file. " + 394 "\n\n" + 395 "**PDF/DOCX with HTML:** Provide HTML content for rich formatting. " + 396 "Automatically detects HTML and applies formatting. Plain text creates simple documents.\n" + 397 "\n" + 398 "IMPORTANT - Multi-line Content:\n" + 399 "- Use actual newline characters in content strings, NOT \\n escape sequences\n" + 400 "- Each file's content will be written exactly as provided in the string\n" + 401 "\n" + 402 "Only works within allowed directories.", 403 inputSchema: sanitizeToolInputSchema( 404 zodToJsonSchema(WriteMultipleFilesArgsSchema) as ToolInput 405 ), 406 }, 407 ]; 408 } 409 410 export async function handleWriteTool(name: string, args: any) { 411 switch (name) { 412 case "write_file": { 413 const parsed = WriteFileArgsSchema.safeParse(args); 414 if (!parsed.success) { 415 throw new Error(`Invalid arguments for write_file: ${parsed.error}`); 416 } 417 // SECURITY: validatePath() enforces: 418 // 1. Canonical path resolution (path.resolve + path.normalize) 419 // 2. Allowed directory boundary checking (isPathWithinAllowedDirectories) 420 // 3. Symlink resolution and target validation (fs.realpath) 421 // 4. Parent directory validation for new files 422 // Prevents: CWE-23 (Path Traversal), CVE-2025-54794, CVE-2025-53109, CVE-2025-53110 423 const validPath = await validatePath(parsed.data.path, { 424 createParentIfMissing: true, 425 }); 426 await writeFileBasedOnExtension(validPath, parsed.data.content); 427 return { 428 content: [ 429 { type: "text", text: `Successfully wrote to ${parsed.data.path}` }, 430 ], 431 }; 432 } 433 434 case "edit_file": { 435 const parsed = EditFileArgsSchema.safeParse(args); 436 if (!parsed.success) { 437 throw new Error(`Invalid arguments for edit_file: ${parsed.error}`); 438 } 439 440 // Determine mode and route to appropriate handler 441 const mode = parsed.data.mode || "single"; 442 443 if (mode === "single") { 444 // Single file mode (backward compatible) 445 if (!parsed.data.path || !parsed.data.edits) { 446 throw new Error("Single mode requires 'path' and 'edits' fields"); 447 } 448 449 const result = await processFileEditRequest({ 450 path: parsed.data.path, 451 edits: parsed.data.edits, 452 matchingStrategy: parsed.data.matchingStrategy, 453 dryRun: parsed.data.dryRun, 454 failOnAmbiguous: parsed.data.failOnAmbiguous, 455 }); 456 457 if (!result.success) { 458 throw new Error(result.error); 459 } 460 461 return { 462 content: [{ type: "text", text: result.diff }], 463 }; 464 } else if (mode === "multiple") { 465 // Multi-file mode 466 if (!parsed.data.files) { 467 throw new Error("Multiple mode requires 'files' field"); 468 } 469 470 const editResults = await processMultiFileEdits( 471 parsed.data.files, 472 parsed.data.failFast, 473 ); 474 475 const output = formatMultiFileEditResults(editResults); 476 return { 477 content: [{ type: "text", text: output }], 478 }; 479 } else { 480 throw new Error(`Invalid mode: ${mode}`); 481 } 482 } 483 484 case "write_multiple_files": { 485 const parsed = WriteMultipleFilesArgsSchema.safeParse(args); 486 if (!parsed.success) { 487 throw new Error( 488 `Invalid arguments for write_multiple_files: ${parsed.error}`, 489 ); 490 } 491 492 // Validate all paths before any writing, auto-creating parent directories 493 const validationPromises = parsed.data.files.map(async (file) => { 494 try { 495 const validPath = await validatePath(file.path, { 496 createParentIfMissing: true, 497 }); 498 return { 499 path: file.path, 500 validPath, 501 content: file.content, 502 success: true, 503 }; 504 } catch (error) { 505 return { 506 path: file.path, 507 content: file.content, 508 success: false, 509 error: error instanceof Error ? error.message : String(error), 510 }; 511 } 512 }); 513 514 const validatedFiles = await Promise.all(validationPromises); 515 516 // Separate valid and invalid files 517 const validFiles = validatedFiles.filter((f) => f.success) as Array<{ 518 path: string; 519 validPath: string; 520 content: string; 521 success: true; 522 }>; 523 const invalidFiles = validatedFiles.filter((f) => !f.success); 524 525 // If any paths are invalid, fail the entire operation 526 if (invalidFiles.length > 0) { 527 const errorMessages = invalidFiles 528 .map((f) => `${f.path}: ${f.error || "Unknown error"}`) 529 .join("\n"); 530 throw new Error(`Invalid file paths:\n${errorMessages}`); 531 } 532 533 // Write all valid files concurrently 534 const writePromises = validFiles.map(async (file) => { 535 try { 536 await writeFileBasedOnExtension(file.validPath, file.content); 537 return { 538 path: file.path, 539 success: true, 540 size: Buffer.byteLength(file.content, "utf8"), 541 }; 542 } catch (error) { 543 return { 544 path: file.path, 545 success: false, 546 error: error instanceof Error ? error.message : String(error), 547 }; 548 } 549 }); 550 551 const results = await Promise.allSettled(writePromises); 552 const successful = results.filter( 553 (r) => r.status === "fulfilled" && r.value.success, 554 ).length; 555 const failed = results.filter( 556 (r) => r.status === "fulfilled" && !r.value.success, 557 ).length; 558 559 // Format results 560 const resultLines = results.map((result, index) => { 561 if (result.status === "rejected") { 562 return `✗ ${parsed.data.files[index].path} - Unexpected error`; 563 } 564 const file = result.value; 565 if (file && file.success) { 566 return `✓ ${file.path} (${file.size} bytes)`; 567 } else if (file) { 568 return `✗ ${file.path} - Error: ${file.error || "Unknown error"}`; 569 } else { 570 return `✗ ${parsed.data.files[index].path} - Unknown error`; 571 } 572 }); 573 574 const summary = `\nWrote ${successful} of ${parsed.data.files.length} files:`; 575 const resultText = summary + "\n" + resultLines.join("\n"); 576 577 if (failed === 0) { 578 return { 579 content: [ 580 { 581 type: "text", 582 text: resultText + "\n\nAll files written successfully.", 583 }, 584 ], 585 }; 586 } else { 587 return { 588 content: [ 589 { 590 type: "text", 591 text: 592 resultText + 593 `\n\n${successful} files succeeded, ${failed} failed.`, 594 }, 595 ], 596 }; 597 } 598 } 599 600 default: 601 throw new Error(`Unknown write tool: ${name}`); 602 } 603 }