Skip to main content
Glama

edit

Modify file contents by performing batch operations like replace, insert, or update text after reading the file first.

Instructions

Edit file contents with batch operations (requires prior read)

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
pathYesFile path to edit
editsYesArray of edit operations to perform in sequence

Implementation Reference

  • The EditTool class provides the core implementation of the 'edit' tool. It implements the Tool interface with getName(), getDefinition(), and execute() methods. The execute() method (lines 87-101) parses input using EditToolInputSchema and delegates to handleBatchEdit() (lines 578-723), which applies batch edit operations like replace and insert to the file.
    export class EditTool {
      private static sessions = new Map<string, SessionData>();
      private static readonly SESSION_TIMEOUT = 5 * 60 * 1000; // 5 minutes
    
      private normalizeLineEndings(text: string): string {
        return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
      }
    
      getName(): string {
        return "edit";
      }
    
      getDefinition(): Tool {
        return {
          name: "edit",
          description: "Edit file contents with batch operations (requires prior read)",
          inputSchema: {
            type: "object",
            properties: {
              path: {
                type: "string",
                description: "File path to edit",
              },
              edits: {
                type: "array",
                description: "Array of edit operations to perform in sequence",
                items: {
                  type: "object",
                  properties: {
                    oldString: {
                      type: "string",
                      description: "String to replace (for replace operation)",
                    },
                    newString: {
                      type: "string",
                      description: "Replacement string (for replace operation)",
                    },
                    lineNumber: {
                      type: "number",
                      description: "Line number for insertion (1-based, for insert operation)",
                    },
                    content: {
                      type: "string",
                      description: "Content to insert (for insert operation)",
                    },
                    replaceAll: {
                      type: "boolean",
                      description: "Replace all occurrences (default: false)",
                      default: false,
                    },
                  },
                },
              },
            },
            required: ["path", "edits"],
          },
        };
      }
    
      async execute(args: any): Promise<any> {
        try {
          // Validate and parse input using Zod schema
          const validatedArgs = EditToolInputSchema.parse(args);
          const { path: filePath, edits } = validatedArgs;
    
          return await this.handleBatchEdit(filePath, edits);
        } catch (error) {
          // Handle Zod validation errors
          if (error instanceof Error && error.name === "ZodError") {
            throw ToolError.createValidationError("input", args, `Invalid input: ${error.message}`);
          }
          throw error;
        }
      }
    
      private async handleReplace(
        filePath: string,
        oldString: string,
        newString: string,
        matchIndex?: number[],
        replaceAll?: boolean,
        sessionId?: string
      ): Promise<any> {
        // Same string check (like built-in Edit tool)
        if (oldString === newString) {
          throw ToolError.createValidationError(
            "replacement",
            { oldString, newString },
            "No changes to make: oldString and newString are exactly the same"
          );
        }
    
        // Check if file exists
        try {
          await access(filePath);
        } catch {
          throw ToolError.createValidationError("filePath", filePath, "File not found");
        }
    
        // Check if binary file
        if (await PathSecurity.isBinaryFile(filePath)) {
          throw ToolError.createValidationError("filePath", filePath, "Binary files are not supported");
        }
    
        const rawFileContent = await readFile(filePath, "utf-8");
        const fileContent = this.normalizeLineEndings(rawFileContent);
    
        ToolValidation.validateContentSize(fileContent, "edit operation");
    
        // Normalize search strings
        const normalizedOldString = this.normalizeLineEndings(oldString);
        const normalizedNewString = this.normalizeLineEndings(newString);
    
        // Handle session-based replacement
        if (sessionId) {
          return await this.handleSessionReplace(filePath, sessionId, matchIndex, replaceAll);
        }
    
        // Try flexible matching first
        const flexibleResult = await this.tryFlexibleMatch(
          fileContent,
          filePath,
          normalizedOldString,
          normalizedNewString
        );
        if (flexibleResult) {
          return flexibleResult;
        }
    
        // Fall back to original exact matching
        const matches = this.findMatches(fileContent, normalizedOldString);
    
        if (matches.length === 0) {
          const suggestions = this.findSimilarStrings(fileContent, oldString);
          let errorMessage = `No matches found for "${oldString}" in ${filePath}`;
          if (suggestions.length > 0) {
            errorMessage += `\nSimilar strings found:\n${suggestions.map((s) => `- "${s.text}" (Line ${s.line})`).join("\n")}`;
          }
          throw new Error(errorMessage);
        }
    
        if (matches.length === 1) {
          // Single match - replace directly
          const match = matches[0];
          if (!match) {
            throw new Error("Unexpected error: match not found");
          }
          const newContent = this.replaceAtMatch(fileContent, match, newString);
          await writeFile(filePath, newContent, "utf-8");
    
          // Generate result display showing the changed line
          const resultDisplay = await this.generateResultDisplay(
            filePath,
            "Successfully replaced 1 occurrence",
            match.lineNumber
          );
    
          return ResultFormatter.createResponse(resultDisplay);
        }
    
        // Multiple matches - create session
        const newSessionId = this.generateSessionId();
        const sessionData: SessionData = {
          sessionId: newSessionId,
          filePath,
          oldString,
          newString,
          matches,
          timestamp: Date.now(),
        };
    
        EditTool.sessions.set(newSessionId, sessionData);
    
        // Display matches with context
        const matchDisplay = matches
          .map((match, index) => {
            const contextLines = [
              ...match.contextBefore.map((line) => `    ${line}`),
              `→   ${match.matchedText}`,
              ...match.contextAfter.map((line) => `    ${line}`),
            ].join("\n");
    
            return `[${index}] Line ${match.lineNumber}:\n${contextLines}`;
          })
          .join("\n\n");
    
        const message = `Multiple matches found for "${oldString}" in ${filePath} (Session: ${newSessionId}):\n\n${matchDisplay}\n\nAvailable options:\n- Single/Multiple: matchIndex=[0] or matchIndex=[0,2]\n- All: replaceAll=true\n- Re-run with: edit(path="${filePath}", oldString="${oldString}", newString="${newString}", matchIndex=[0], _sessionId="${newSessionId}")`;
    
        return {
          content: [
            {
              type: "text",
              text: message,
            },
          ],
        };
      }
    
      private async handleInsert(filePath: string, content: string, lineNumber: number): Promise<any> {
        if (lineNumber < 1) {
          throw ToolError.createValidationError("lineNumber", lineNumber, "must be 1 or greater");
        }
    
        try {
          await access(filePath);
        } catch {
          throw ToolError.createValidationError("filePath", filePath, "File not found");
        }
    
        const fileContent = await readFile(filePath, "utf-8");
        const lines = fileContent.split("\n");
    
        if (lineNumber > lines.length + 1) {
          throw ToolError.createValidationError(
            "lineNumber",
            lineNumber,
            `Invalid line number. File has ${lines.length} lines (can insert at line ${lines.length + 1} to append)`
          );
        }
    
        lines.splice(lineNumber - 1, 0, content);
        const newContent = lines.join("\n");
    
        await writeFile(filePath, newContent, "utf-8");
    
        const resultDisplay = await this.generateResultDisplay(
          filePath,
          "Successfully inserted content",
          lineNumber
        );
    
        return {
          content: [
            {
              type: "text",
              text: resultDisplay,
            },
          ],
        };
      }
    
      // Add file to read tracking when read operations complete
      static markFileAsRead(filePath: string): void {
        fileReadTracker.markFileAsRead(filePath);
      }
    
      // [Copy all the helper methods from write.ts]
      private async generateResultDisplay(
        filePath: string,
        operation: string,
        lineNumber?: number
      ): Promise<string> {
        try {
          const content = await readFile(filePath, "utf-8");
          const lines = content.split("\n");
    
          if (lineNumber && lineNumber > 0 && lineNumber <= lines.length) {
            const start = Math.max(0, lineNumber - 3);
            const end = Math.min(lines.length, lineNumber + 2);
            const contextLines = lines
              .slice(start, end)
              .map((line, index) => {
                const actualLineNum = start + index + 1;
                const marker = actualLineNum === lineNumber ? "→" : " ";
                return `  ${marker}${actualLineNum}: ${line}`;
              })
              .join("\n");
    
            return `${operation}. Updated content:\n${contextLines}`;
          } else {
            const totalLines = lines.length;
            const preview = lines
              .slice(0, 3)
              .map((line, i) => `  ${i + 1}: ${line}`)
              .join("\n");
            return `${operation}. File now has ${totalLines} lines:\n${preview}${totalLines > 3 ? "\n  ..." : ""}`;
          }
        } catch {
          return `${operation}.`;
        }
      }
    
      private cleanExpiredSessions(): void {
        const now = Date.now();
        for (const [sessionId, sessionData] of EditTool.sessions) {
          if (now - sessionData.timestamp > EditTool.SESSION_TIMEOUT) {
            EditTool.sessions.delete(sessionId);
          }
        }
      }
    
      private generateSessionId(): string {
        return Math.random().toString(36).substring(2, 11);
      }
    
      private findMatches(content: string, searchString: string): MatchInfo[] {
        const matches: MatchInfo[] = [];
        const lines = content.split("\n");
        let currentPos = 0;
    
        for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
          const line = lines[lineIndex] || "";
    
          let searchPos = 0;
          while (true) {
            const matchPos = line.indexOf(searchString, searchPos);
            if (matchPos === -1) break;
    
            const contextBefore = lines.slice(Math.max(0, lineIndex - 2), lineIndex);
            const contextAfter = lines.slice(lineIndex + 1, Math.min(lines.length, lineIndex + 3));
    
            matches.push({
              index: matches.length,
              lineNumber: lineIndex + 1,
              startPos: currentPos + matchPos,
              endPos: currentPos + matchPos + searchString.length,
              contextBefore,
              contextAfter,
              matchedText: line,
            });
    
            searchPos = matchPos + 1;
          }
    
          currentPos += line.length + 1;
        }
    
        return matches;
      }
    
      private replaceAtMatch(content: string, match: MatchInfo, replacement: string): string {
        return content.substring(0, match.startPos) + replacement + content.substring(match.endPos);
      }
    
      private findSimilarStrings(
        content: string,
        target: string
      ): Array<{ text: string; line: number }> {
        const lines = content.split("\n");
        const suggestions: Array<{ text: string; line: number }> = [];
    
        for (let i = 0; i < lines.length; i++) {
          const line = lines[i];
          if (!line) continue;
    
          const words = line.match(/\w+/g) || [];
          for (const word of words) {
            if (
              Math.abs(word.length - target.length) <= 2 &&
              this.calculateSimilarity(word, target) > 0.6
            ) {
              suggestions.push({ text: word, line: i + 1 });
            }
          }
        }
    
        return suggestions.slice(0, 3);
      }
    
      private calculateSimilarity(a: string, b: string): number {
        const longer = a.length > b.length ? a : b;
        const shorter = a.length > b.length ? b : a;
    
        if (longer.length === 0) return 1.0;
    
        const editDistance = this.levenshteinDistance(longer, shorter);
        return (longer.length - editDistance) / longer.length;
      }
    
      private levenshteinDistance(a: string, b: string): number {
        const matrix: number[][] = Array(b.length + 1)
          .fill(null)
          .map(() => Array(a.length + 1).fill(0));
    
        for (let i = 0; i <= a.length; i++) matrix[0]![i] = i;
        for (let j = 0; j <= b.length; j++) matrix[j]![0] = j;
    
        for (let j = 1; j <= b.length; j++) {
          for (let i = 1; i <= a.length; i++) {
            const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1;
            const row = matrix[j]!;
            const prevRow = matrix[j - 1]!;
            row[i] = Math.min(row[i - 1]! + 1, prevRow[i]! + 1, prevRow[i - 1]! + substitutionCost);
          }
        }
    
        return matrix[b.length]![a.length]!;
      }
    
      private async tryFlexibleMatch(
        fileContent: string,
        filePath: string,
        oldString: string,
        newString: string
      ): Promise<any | null> {
        // 1. Check for complete line match first (for deletion)
        const lines = fileContent.split("\n");
        const lineIndex = lines.findIndex((line) => line === oldString);
    
        if (lineIndex !== -1 && newString === "") {
          // Complete line deletion - remove the line entirely
          lines.splice(lineIndex, 1);
          const newContent = lines.join("\n");
          await writeFile(filePath, newContent, "utf-8");
    
          const resultDisplay = await this.generateResultDisplay(
            filePath,
            "Successfully deleted 1 line (complete line match)"
          );
          return ResultFormatter.createResponse(resultDisplay);
        }
    
        // 2. Try exact match
        if (fileContent.includes(oldString)) {
          const newContent = fileContent.replace(oldString, newString);
          await writeFile(filePath, newContent, "utf-8");
    
          const resultDisplay = await this.generateResultDisplay(
            filePath,
            "Successfully replaced 1 occurrence (exact match)"
          );
          return ResultFormatter.createResponse(resultDisplay);
        }
    
        // 2. Try line-by-line matching with whitespace flexibility
        const oldLines = oldString.split("\n");
        const contentLines = fileContent.split("\n");
    
        for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
          const potentialMatch = contentLines.slice(i, i + oldLines.length);
    
          // Compare lines with normalized whitespace
          const isMatch = oldLines.every((oldLine, j) => {
            const contentLine = potentialMatch[j];
            return oldLine.trim() === contentLine?.trim();
          });
    
          if (isMatch) {
            // Preserve original indentation
            const originalIndent = contentLines[i]?.match(/^\s*/)?.[0] || "";
            const newLines = newString.split("\n").map((line, j) => {
              if (j === 0) return originalIndent + line.trimStart();
    
              // For subsequent lines, preserve relative indentation
              const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || "";
              const newIndent = line.match(/^\s*/)?.[0] || "";
    
              if (oldIndent && newIndent) {
                // Calculate relative indentation
                const relativeIndent = originalIndent + newIndent.slice(oldIndent.length);
                return relativeIndent + line.trimStart();
              }
    
              return originalIndent + line.trimStart();
            });
    
            // Replace the matched lines
            const beforeLines = contentLines.slice(0, i);
            const afterLines = contentLines.slice(i + oldLines.length);
            const newContent = [...beforeLines, ...newLines, ...afterLines].join("\n");
    
            await writeFile(filePath, newContent, "utf-8");
    
            const resultDisplay = await this.generateResultDisplay(
              filePath,
              "Successfully replaced 1 occurrence (flexible match)",
              i + 1
            );
            return {
              content: [
                {
                  type: "text",
                  text: resultDisplay,
                },
              ],
            };
          }
        }
    
        return null; // No flexible match found
      }
    
      private async handleSessionReplace(
        filePath: string,
        sessionId: string,
        matchIndex?: number[],
        replaceAll?: boolean
      ): Promise<any> {
        const sessionData = EditTool.sessions.get(sessionId);
        if (!sessionData) {
          throw new Error(`Invalid or expired session: ${sessionId}`);
        }
    
        if (sessionData.filePath !== filePath) {
          throw new Error(`Session file path mismatch. Expected: ${sessionData.filePath}`);
        }
    
        const currentContent = await readFile(filePath, "utf-8");
    
        let replacementCount = 0;
        let newContent = currentContent;
    
        if (replaceAll) {
          newContent = newContent.replaceAll(sessionData.oldString, sessionData.newString);
          replacementCount = sessionData.matches.length;
        } else if (matchIndex && matchIndex.length > 0) {
          const sortedIndices = [...matchIndex].sort((a, b) => b - a);
    
          for (const index of sortedIndices) {
            if (index < 0 || index >= sessionData.matches.length) {
              throw new Error(
                `Invalid match index: ${index}. Valid range: 0-${sessionData.matches.length - 1}`
              );
            }
    
            const match = sessionData.matches[index];
            if (match) {
              const before = newContent.substring(0, match.startPos);
              const after = newContent.substring(match.endPos);
              newContent = before + sessionData.newString + after;
              replacementCount++;
    
              const lengthDiff = sessionData.newString.length - sessionData.oldString.length;
              for (let i = 0; i < sessionData.matches.length; i++) {
                const otherMatch = sessionData.matches[i];
                if (otherMatch && otherMatch.startPos > match.startPos) {
                  otherMatch.startPos += lengthDiff;
                  otherMatch.endPos += lengthDiff;
                }
              }
            }
          }
        } else {
          throw new Error(
            "Either replaceAll=true or matchIndex array must be specified for session replacement"
          );
        }
    
        await writeFile(filePath, newContent, "utf-8");
    
        const resultDisplay = await this.generateResultDisplay(
          filePath,
          `Successfully replaced ${replacementCount} occurrence(s)`
        );
    
        EditTool.sessions.delete(sessionId);
    
        return ResultFormatter.createResponse(resultDisplay);
      }
    
      private async handleBatchEdit(filePath: string, edits: any[]): Promise<any> {
        const resolvedPath = resolve(filePath);
    
        // Security validation
        await ToolValidation.validateFileAccess(resolvedPath, true);
    
        // Read file once
        const rawFileContent = await readFile(resolvedPath, "utf-8");
        let currentContent = this.normalizeLineEndings(rawFileContent);
        let totalChanges = 0;
        const results: string[] = [];
    
        // Apply each edit sequentially
        for (let i = 0; i < edits.length; i++) {
          const edit = edits[i];
          const editNum = i + 1;
    
          try {
            const beforeContent = currentContent;
    
            // Determine edit type
            const isReplace = edit.oldString !== undefined && edit.newString !== undefined;
            const isInsert = edit.lineNumber !== undefined && edit.content !== undefined;
    
            if (!isReplace && !isInsert) {
              const editLabel = edits.length === 1 ? "❌ Invalid" : `Edit ${editNum}: ❌ Invalid`;
              results.push(
                `\n${editLabel} - must specify either (oldString+newString) or (lineNumber+content)\n`
              );
              continue;
            }
    
            if (isReplace && isInsert) {
              const editLabel = edits.length === 1 ? "❌ Invalid" : `Edit ${editNum}: ❌ Invalid`;
              results.push(`\n${editLabel} - cannot specify both replace and insert operations\n`);
              continue;
            }
    
            if (isReplace) {
              const normalizedOld = this.normalizeLineEndings(edit.oldString);
              const normalizedNew = this.normalizeLineEndings(edit.newString);
    
              // Check for complete line match first (for deletion)
              const lines = currentContent.split("\n");
              const lineIndex = lines.findIndex((line) => line === normalizedOld);
    
              if (lineIndex !== -1 && normalizedNew === "") {
                // Complete line deletion - remove the line entirely
                lines.splice(lineIndex, 1);
                currentContent = lines.join("\n");
                totalChanges++;
    
                const editLabel =
                  edits.length === 1 ? "✅ Deleted line" : `Edit ${editNum}: ✅ Deleted line`;
                const contextDisplay = await this.generateEditContext(
                  resolvedPath,
                  lineIndex + 1,
                  normalizedOld,
                  ""
                );
                results.push(`\n${editLabel}\n\n${contextDisplay}\n`);
              }
              // Try flexible matching
              else if (currentContent.includes(normalizedOld)) {
                if (edit.replaceAll) {
                  currentContent = currentContent.replaceAll(normalizedOld, normalizedNew);
                  const count = (
                    beforeContent.match(new RegExp(this.escapeRegex(normalizedOld), "g")) || []
                  ).length;
                  totalChanges += count;
                  const editLabel = edits.length === 1 ? "✅ Replaced" : `Edit ${editNum}: ✅ Replaced`;
                  results.push(`\n${editLabel} ${count} occurrences of "${edit.oldString}"\n`);
                } else {
                  // Find line number before replacement
                  const matchLine = normalizedOld.includes("\n")
                    ? this.findMultilineMatch(currentContent, normalizedOld)
                    : this.findLineNumber(currentContent, normalizedOld);
                  currentContent = currentContent.replace(normalizedOld, normalizedNew);
                  totalChanges++;
    
                  // Generate detailed context display with diff
                  const contextDisplay = await this.generateEditContext(
                    resolvedPath,
                    matchLine,
                    normalizedOld,
                    normalizedNew
                  );
                  const editLabel = edits.length === 1 ? "✅ Replaced" : `Edit ${editNum}: ✅ Replaced`;
                  const editDisplay =
                    edits.length === 1
                      ? `\n${editLabel}\n\n${contextDisplay}\n`
                      : `${editLabel} at line ${matchLine}\n\n${contextDisplay}\n`;
                  results.push(editDisplay);
                }
              } else {
                const editLabel =
                  edits.length === 1 ? "❌ No matches found" : `Edit ${editNum}: ❌ No matches found`;
                results.push(`\n${editLabel} for "${edit.oldString}"\n`);
              }
            } else if (isInsert) {
              // Insert operation
              const lines = currentContent.split("\n");
    
              if (edit.lineNumber < 1 || edit.lineNumber > lines.length + 1) {
                const editLabel =
                  edits.length === 1
                    ? "❌ Invalid line number"
                    : `Edit ${editNum}: ❌ Invalid line number`;
                results.push(`\n${editLabel} ${edit.lineNumber} (file has ${lines.length} lines)\n`);
                continue;
              }
    
              lines.splice(edit.lineNumber - 1, 0, edit.content);
              currentContent = lines.join("\n");
              totalChanges++;
    
              // Generate detailed context display for insertion
              const contextDisplay = await this.generateEditContext(resolvedPath, edit.lineNumber);
              const editLabel = edits.length === 1 ? "✅ Inserted" : `Edit ${editNum}: ✅ Inserted`;
              results.push(`${editLabel} at line ${edit.lineNumber}\n\n${contextDisplay}\n`);
            }
          } catch (error: any) {
            const editLabel = edits.length === 1 ? "❌ Error" : `Edit ${editNum}: ❌ Error`;
            results.push(`\n${editLabel} - ${error.message}\n`);
          }
        }
    
        // Write file once if there were changes
        if (totalChanges > 0) {
          await writeFile(resolvedPath, currentContent, "utf-8");
        }
    
        // Generate summary
        const summary =
          edits.length === 1
            ? `Edit completed: ${totalChanges} change applied to ${resolvedPath}`
            : `Batch edit completed: ${totalChanges} changes applied across ${edits.length} operations to ${resolvedPath}`;
        const detailedResults = results.join("\n");
    
        const responseText =
          edits.length === 1
            ? `path: ${resolvedPath}\n${detailedResults}`
            : `${summary}\n\nDetails:\n${detailedResults}`;
    
        return ResultFormatter.createResponse(responseText);
      }
    
      private escapeRegex(string: string): string {
        return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
      }
    
      private findLineNumber(content: string, searchString: string): number {
        const lines = content.split("\n");
        for (let i = 0; i < lines.length; i++) {
          if (lines[i]?.includes(searchString)) {
            return i + 1; // 1-based line number
          }
        }
        return 1; // Default to line 1 if not found
      }
    
      private findMultilineMatch(content: string, searchString: string): number {
        // For multiline strings, find the actual starting line
        const index = content.indexOf(searchString);
        if (index === -1) return 1;
    
        const beforeMatch = content.substring(0, index);
        const lineNumber = beforeMatch.split("\n").length;
        return lineNumber;
      }
    
      private async generateEditContext(
        filePath: string,
        lineNumber: number,
        oldText?: string,
        newText?: string
      ): Promise<string> {
        try {
          const content = await readFile(filePath, "utf-8");
          const lines = content.split("\n");
    
          if (lineNumber > 0 && lineNumber <= lines.length) {
            const start = Math.max(0, lineNumber - 3);
            const end = Math.min(lines.length, lineNumber + 3);
            const contextLines = [];
    
            // Calculate max line number width for right alignment
            const maxLineNum = Math.min(lines.length, lineNumber + 3);
            const lineNumWidth = maxLineNum.toString().length;
    
            for (let i = start; i < end; i++) {
              const actualLineNum = i + 1;
              const line = lines[i] || "";
              const paddedLineNum = actualLineNum.toString().padStart(lineNumWidth, " ");
    
              if (actualLineNum === lineNumber) {
                if (oldText && newText === "") {
                  // Show deletion - only old text with - marker
                  contextLines.push(`${paddedLineNum}:- ${oldText}`);
                } else if (oldText && newText) {
                  // Show diff for replaced content - handle multiline
                  const oldLines = oldText.split("\n");
                  const newLines = newText.split("\n");
    
                  // Show old lines with - marker
                  oldLines.forEach((oldLine, idx) => {
                    const lineNum = (actualLineNum + idx).toString().padStart(lineNumWidth, " ");
                    contextLines.push(`${lineNum}:- ${oldLine}`);
                  });
    
                  // Show new lines with + marker
                  newLines.forEach((newLine, idx) => {
                    const lineNum = (actualLineNum + idx).toString().padStart(lineNumWidth, " ");
                    contextLines.push(`${lineNum}:+ ${newLine}`);
                  });
                } else {
                  // Show inserted/modified line
                  contextLines.push(`${paddedLineNum}:+ ${line}`);
                }
              } else {
                // Show context lines
                contextLines.push(`${paddedLineNum}:  ${line}`);
              }
            }
    
            return contextLines.join("\n");
          } else {
            return `    Line ${lineNumber}: (context unavailable)`;
          }
        } catch {
          return `    Line ${lineNumber}: (error reading context)`;
        }
      }
    }
  • Zod schemas defining the input structure for the 'edit' tool, including EditOperationSchema for individual operations and EditToolInputSchema for the tool arguments. Ensures either replace (oldString+newString) or insert (lineNumber+content).
    // Edit operation schemas
    export const EditOperationSchema = z
      .object({
        oldString: z.string().optional(),
        newString: z.string().optional(),
        lineNumber: OptionalLineNumberSchema,
        content: z.string().optional(),
        replaceAll: BooleanFlagSchema,
      })
      .refine(
        (data) => {
          const hasReplace = data.oldString !== undefined && data.newString !== undefined;
          const hasInsert = data.lineNumber !== undefined && data.content !== undefined;
          return hasReplace !== hasInsert; // XOR: exactly one must be true
        },
        {
          message: "Must specify either (oldString+newString) or (lineNumber+content), but not both",
        }
      );
    
    // Edit tool input schema
    export const EditToolInputSchema = z.object({
      path: FilePathSchema,
      edits: z.array(EditOperationSchema).min(1, "At least one edit operation is required"),
    });
    
    // Find tool input schema
    export const FindToolInputSchema = z.object({
      path: FilePathSchema,
      pattern: z.string().optional(),
      depth: z.number().int().min(0).default(0),
      includeIgnored: BooleanFlagSchema,
    });
    
    // Grep tool input schema
    export const GrepToolInputSchema = z.object({
      pattern: z.string().min(1, "Pattern cannot be empty"),
      path: FilePathSchema.default("."),
      include: z.string().optional(),
      beforeContext: z.number().int().min(0).default(0),
      afterContext: z.number().int().min(0).default(0),
      context: z.number().int().min(0).optional(),
      multiline: BooleanFlagSchema,
      maxCount: z.number().int().min(1).default(5),
    });
    
    // Move tool input schema
    export const MoveToolInputSchema = z.object({
      from: FilePathSchema,
      to: FilePathSchema,
      overwrite: BooleanFlagSchema,
    });
    
    // Copy tool input schema
    export const CopyToolInputSchema = z.object({
      from: FilePathSchema,
      to: FilePathSchema,
      overwrite: BooleanFlagSchema,
    });
    
    // Type exports for better TypeScript integration
    export type ReadToolInput = z.infer<typeof ReadToolInputSchema>;
    export type WriteToolInput = z.infer<typeof WriteToolInputSchema>;
    export type EditOperationInput = z.infer<typeof EditOperationSchema>;
    export type EditToolInput = z.infer<typeof EditToolInputSchema>;
  • The 'edit' tool is registered in FileMCPServer: imported at line 11, instantiated at line 85, definition added to tool list at line 96, and executed via dispatch at lines 118-119.
    class FileMCPServer extends MCPServer {
      private findTool: FindTool;
      private readTool: ReadTool;
      private grepTool: GrepTool;
      private writeTool: WriteTool;
      private editTool: EditTool;
      private moveTool: MoveTool;
      private copyTool: CopyTool;
    
      constructor() {
        super("file-mcp", "1.0.0");
        this.findTool = new FindTool();
        this.readTool = new ReadTool();
        this.grepTool = new GrepTool();
        this.writeTool = new WriteTool();
        this.editTool = new EditTool();
        this.moveTool = new MoveTool();
        this.copyTool = new CopyTool();
      }
    
      protected getTools(): Tool[] {
        return [
          this.readTool.getDefinition(),
          this.findTool.getDefinition(),
          this.grepTool.getDefinition(),
          this.writeTool.getDefinition(),
          this.editTool.getDefinition(),
          this.moveTool.getDefinition(),
          this.copyTool.getDefinition(),
        ];
      }
    
      protected async handleToolCall(
        name: string,
        args: unknown
      ): Promise<{ content: Array<{ type: string; text: string }> }> {
        try {
          const toolName = name as ToolName;
    
          switch (toolName) {
            case "read":
              return await this.readTool.execute(args);
            case "find":
              return await this.findTool.execute(args);
            case "grep":
              return await this.grepTool.execute(args);
            case "write":
              return await this.writeTool.execute(args);
            case "edit":
              return await this.editTool.execute(args);
            case "move":
              return await this.moveTool.execute(args);
            case "copy":
              return await this.copyTool.execute(args);
    
            default:
              throw ToolError.createValidationError(
                "toolName",
                name,
                `Unknown tool: ${name}. Available tools: ${this.getTools()
                  .map((t) => t.name)
                  .join(", ")}`
              );
          }
        } catch (error) {
          // Return error as proper MCP response instead of throwing
          const errorMessage = error instanceof Error ? error.message : String(error);
    
          return {
            content: [
              {
                type: "text",
                text: `❌ Error: ${errorMessage}`,
              },
            ],
          };
        }
      }
Behavior2/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries full burden but offers minimal behavioral insight. It mentions 'batch operations' and 'requires prior read', hinting at sequential edits and a prerequisite, but lacks details on permissions, error handling, atomicity of edits, or what happens if the file doesn't exist. For a mutation tool with zero annotation coverage, this is inadequate.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is a single, efficient sentence with zero waste. It front-loads the core action ('edit file contents'), adds key context ('batch operations'), and includes a prerequisite ('requires prior read')—all essential information without redundancy.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool's complexity (mutation with batch operations) and lack of annotations/output schema, the description is insufficient. It doesn't explain return values, error conditions, or behavioral nuances like edit ordering or rollback. For a tool that modifies files, more context is needed to ensure safe and correct usage.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100%, providing full documentation for both parameters ('path' and 'edits') and their nested properties. The description adds no parameter-specific information beyond what's in the schema, such as format examples or constraints. Baseline 3 is appropriate since the schema does the heavy lifting.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the verb ('edit') and resource ('file contents') with the specific capability of 'batch operations'. It distinguishes from siblings like 'read' (view only) and 'write' (full overwrite) by focusing on targeted modifications. However, it doesn't explicitly contrast with 'copy' or 'move' which handle files rather than content.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description provides some guidance with 'requires prior read', implying this tool should be used after inspecting file content. However, it doesn't specify when to choose this over 'write' (for full replacements) or other editing alternatives, nor does it mention when not to use it (e.g., for simple appends). The context is implied but not explicit.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/d-issy/mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server