/ src / tools / write-tools.ts
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  }