UNPKG

mcp-server-text-editor

Version:

An open source implementation of the Claude built-in text editor tool

243 lines (242 loc) 10.4 kB
import { execSync } from 'child_process'; import * as fsSync from 'fs'; import * as fs from 'fs/promises'; import * as path from 'path'; import { z } from 'zod'; const OUTPUT_LIMIT = 10 * 1024; // 10KB limit // Store file states for undo functionality const fileStateHistory = {}; export const toolParameters = { command: z .enum(['view', 'create', 'str_replace', 'insert', 'undo_edit']) .describe('The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.'), path: z .string() .describe('Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.'), file_text: z .string() .optional() .describe('Required parameter of `create` command, with the content of the file to be created.'), insert_line: z .number() .optional() .describe('Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.'), new_str: z .string() .optional() .describe('Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.'), old_str: z .string() .optional() .describe('Required parameter of `str_replace` command containing the string in `path` to replace.'), view_range: z .array(z.number()) .optional() .describe('Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.'), description: z .string() .describe('The reason you are using the text editor (max 80 chars)'), }; // eslint-disable-next-line unused-imports/no-unused-vars const parameterSchema = z.object(toolParameters); // eslint-disable-next-line unused-imports/no-unused-vars const returnSchema = z.object({ success: z.boolean(), message: z.string(), content: z.string().optional(), }); export const summarizeEdit = (input) => { return `${input.command} operation on "${input.path}", ${input.description}`; }; const buildContentResponse = (result) => { return { content: [ { type: 'text', text: JSON.stringify(result), }, ], }; }; export const textEditorExecute = async (parameters) => { try { const result = await textEditorInternal(parameters); return buildContentResponse(result); } catch (error) { return buildContentResponse({ success: false, message: error instanceof Error ? error.message : 'Unknown error', content: 'undefined', }); } }; const textEditorInternal = async ({ command, path: filePath, file_text, insert_line, new_str, old_str, view_range, }) => { if (!path.isAbsolute(filePath)) { throw new Error('Path must be absolute'); } switch (command) { case 'view': { // Check if path is a directory const stats = await fs.stat(filePath).catch(() => null); if (!stats) { throw new Error(`File or directory not found: ${filePath}`); } if (stats.isDirectory()) { // List directory contents up to 2 levels deep try { const output = execSync(`find "${filePath}" -type f -not -path "*/\\.*" -maxdepth 2 | sort`, { encoding: 'utf8' }); return { success: true, message: `Directory listing for ${filePath}:`, content: output, }; } catch (error) { throw new Error(`Error listing directory: ${error}`); } } else { // Read file content const content = await fs.readFile(filePath, 'utf8'); const lines = content.split('\n'); // Apply view range if specified let displayContent = content; if (view_range && view_range.length === 2) { const [start, end] = view_range; const startLine = Math.max(1, start || 1) - 1; // Convert to 0-indexed const endLine = end === -1 ? lines.length : end; displayContent = lines.slice(startLine, endLine).join('\n'); } // Add line numbers const startLineNum = view_range && view_range.length === 2 ? view_range[0] : 1; const numberedContent = displayContent .split('\n') .map((line, i) => `${(startLineNum || 1) + i}: ${line}`) .join('\n'); // Truncate if too large if (numberedContent.length > OUTPUT_LIMIT) { const truncatedContent = numberedContent.substring(0, OUTPUT_LIMIT); return { success: true, message: `File content (truncated):`, content: `${truncatedContent}\n<response clipped>`, }; } return { success: true, message: `File content:`, content: numberedContent, }; } } case 'create': { if (!file_text) { throw new Error('file_text parameter is required for create command'); } // Create parent directories if they don't exist await fs.mkdir(path.dirname(filePath), { recursive: true }); // Check if file already exists const fileExists = fsSync.existsSync(filePath); if (fileExists) { // Save current state for undo if file exists const currentContent = await fs.readFile(filePath, 'utf8'); if (!fileStateHistory[filePath]) { fileStateHistory[filePath] = []; } fileStateHistory[filePath].push(currentContent); } else { // Initialize history for new files fileStateHistory[filePath] = []; } // Create or overwrite the file await fs.writeFile(filePath, file_text, 'utf8'); return { success: true, message: fileExists ? `File overwritten: ${filePath}` : `File created: ${filePath}`, }; } case 'str_replace': { if (!old_str) { throw new Error('old_str parameter is required for str_replace command'); } // Ensure the file exists if (!fsSync.existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); } // Read the current content const content = await fs.readFile(filePath, 'utf8'); // Check if old_str exists uniquely in the file const occurrences = content.split(old_str).length - 1; if (occurrences === 0) { throw new Error(`The specified old_str was not found in the file`); } if (occurrences > 1) { throw new Error(`Found ${occurrences} occurrences of old_str, expected exactly 1`); } // Save current state for undo if (!fileStateHistory[filePath]) { fileStateHistory[filePath] = []; } fileStateHistory[filePath].push(content); // Replace the content const updatedContent = content.replace(old_str, new_str || ''); await fs.writeFile(filePath, updatedContent, 'utf8'); return { success: true, message: `Successfully replaced text in ${filePath}`, }; } case 'insert': { if (insert_line === undefined) { throw new Error('insert_line parameter is required for insert command'); } if (!new_str) { throw new Error('new_str parameter is required for insert command'); } // Ensure the file exists if (!fsSync.existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); } // Read the current content const content = await fs.readFile(filePath, 'utf8'); const lines = content.split('\n'); // Validate line number if (insert_line < 0 || insert_line > lines.length) { throw new Error(`Invalid line number: ${insert_line}. File has ${lines.length} lines.`); } // Save current state for undo if (!fileStateHistory[filePath]) { fileStateHistory[filePath] = []; } fileStateHistory[filePath].push(content); // Insert the new content after the specified line lines.splice(insert_line, 0, new_str); const updatedContent = lines.join('\n'); await fs.writeFile(filePath, updatedContent, 'utf8'); return { success: true, message: `Successfully inserted text after line ${insert_line} in ${filePath}`, }; } case 'undo_edit': { // Check if we have history for this file if (!fileStateHistory[filePath] || fileStateHistory[filePath].length === 0) { throw new Error(`No edit history found for ${filePath}`); } // Get the previous state const previousState = fileStateHistory[filePath].pop(); await fs.writeFile(filePath, previousState, 'utf8'); return { success: true, message: `Successfully reverted last edit to ${filePath}`, }; } default: throw new Error(`Unknown command: ${command}`); } };