# Directory Structure ``` ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── src │ ├── editor.ts │ ├── server.ts │ ├── types.ts │ └── utils.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # mcp-editor This is a direct port of [Anthropic's filesystem editing tools](https://github.com/anthropics/anthropic-quickstarts/blob/main/computer-use-demo/computer_use_demo/tools/edit.py) from their computer use demos to a TypeScript MCP server. It was written largely by Claude Sonnet 3.5 on Roo Cline (now Roo Code) with probably not quite enough direct supervision. I checked over the code and use this server every day, but there may be mistakes or AI weirdness. I recommend using this server along with [mcp-server-commands](https://github.com/g0t4/mcp-server-commands) <a href="https://glama.ai/mcp/servers/lnfcd9is5i"><img width="380" height="200" src="https://glama.ai/mcp/servers/lnfcd9is5i/badge" alt="mcp-editor MCP server" /></a> ### ***WARNING: This MCP server has NO access controls and relies entirely on your client's approval mechanisms. Use at your own risk. DO NOT automatically approve write operations, doing so basically gives the LLM permission to destroy your computer.*** ### ***WARNING: This MCP server is NOT actively maintained, and is provided for reference (for example creating your own MCP server with proper access controls). I may update it occasionally.*** ## Usage Get the files on your computer. Run: ``` npm install npm build ``` If you're using the Claude desktop app, paste this into your config under "mcpServers", and edit the path to match where you put mcp-editor: ```json { "mcpServers": ... your existing servers ... "mcp-editor": { "command": "node", "args": ["/absolute/path/to/mcp-editor/dist/server.js"] } } } ``` If you're using [MCP Installer](https://github.com/anaisbetts/mcp-installer), you just need to provide your LLM with the path on your disk to mcp-editor. ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2020", "module": "ES2020", "moduleResolution": "node", "esModuleInterop": true, "outDir": "./dist", "rootDir": "./src", "strict": true }, "include": ["src/**/*"] } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "mcp-editor", "version": "1.0.0", "description": "MCP server for file editing", "main": "dist/server.js", "type": "module", "scripts": { "build": "tsc", "start": "node dist/server.js", "dev": "tsc -w" }, "dependencies": { "@modelcontextprotocol/sdk": "latest" }, "devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.0.0" } } ``` -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- ```typescript export type Command = "view" | "create" | "string_replace" | "insert" | "undo_edit"; export interface FileHistory { [path: string]: string[]; } export class ToolError extends Error { constructor(message: string) { super(message); this.name = "ToolError"; } } export interface ViewArgs extends Record<string, unknown> { path: string; view_range?: [number, number]; } export interface CreateArgs extends Record<string, unknown> { path: string; file_text: string; } export interface StringReplaceArgs extends Record<string, unknown> { path: string; old_str: string; new_str?: string; } export interface InsertArgs extends Record<string, unknown> { path: string; insert_line: number; new_str: string; } export interface UndoEditArgs extends Record<string, unknown> { path: string; } export function isViewArgs(args: Record<string, unknown>): args is ViewArgs { return typeof args.path === "string" && (args.view_range === undefined || (Array.isArray(args.view_range) && args.view_range.length === 2 && args.view_range.every(n => typeof n === "number"))); } export function isCreateArgs(args: Record<string, unknown>): args is CreateArgs { return typeof args.path === "string" && typeof args.file_text === "string"; } export function isStrReplaceArgs(args: Record<string, unknown>): args is StringReplaceArgs { return typeof args.path === "string" && typeof args.old_str === "string" && (args.new_str === undefined || typeof args.new_str === "string"); } export function isInsertArgs(args: Record<string, unknown>): args is InsertArgs { return typeof args.path === "string" && typeof args.insert_line === "number" && typeof args.new_str === "string"; } export function isUndoEditArgs(args: Record<string, unknown>): args is UndoEditArgs { return typeof args.path === "string"; } ``` -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- ```typescript import { promises as fs } from 'fs'; import * as path from 'path'; import {ToolError} from "./types.js"; export const SNIPPET_LINES = 4; export async function readFile(filePath: string): Promise<string> { try { return await fs.readFile(filePath, 'utf8'); } catch (e) { const error = e instanceof Error ? e : new Error('Unknown error'); throw new Error(`Failed to read ${filePath}: ${error.message}`); } } export async function writeFile(filePath: string, content: string): Promise<void> { try { await fs.writeFile(filePath, content, 'utf8'); } catch (e) { const error = e instanceof Error ? e : new Error('Unknown error'); throw new Error(`Failed to write to ${filePath}: ${error.message}`); } } export function makeOutput( fileContent: string, fileDescriptor: string, initLine: number = 1, expandTabs: boolean = true ): string { if (expandTabs) { fileContent = fileContent.replace(/\t/g, ' '); } const lines = fileContent.split('\n'); const numberedLines = lines.map((line, i) => `${(i + initLine).toString().padStart(6)}\t${line}` ).join('\n'); return `Here's the result of running \`cat -n\` on ${fileDescriptor}:\n${numberedLines}\n`; } export async function validatePath(command: string, filePath: string): Promise<void> { const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath); if (!path.isAbsolute(filePath)) { throw new ToolError( `The path ${filePath} is not an absolute path, it should start with '/'. Maybe you meant ${absolutePath}?` ); } try { const stats = await fs.stat(filePath); if (stats.isDirectory() && command !== 'view') { throw new ToolError( `The path ${filePath} is a directory and only the \`view\` command can be used on directories` ); } if (command === 'create' && stats.isFile()) { throw new ToolError( `File already exists at: ${filePath}. Cannot overwrite files using command \`create\`` ); } } catch (e: unknown) { const error = e instanceof Error ? e : new Error('Unknown error'); if ('code' in error && error.code === 'ENOENT' && command !== 'create') { throw new ToolError(`The path ${filePath} does not exist. Please provide a valid path.`); } if (command !== 'create') { throw error; } } } export function truncateText(text: string, maxLength: number = 1000): string { if (text.length <= maxLength) return text; return text.slice(0, maxLength) + '... (truncated)'; } ``` -------------------------------------------------------------------------------- /src/editor.ts: -------------------------------------------------------------------------------- ```typescript import * as path from 'path'; import { promises as fs } from 'fs'; import { exec } from 'child_process'; import { promisify } from 'util'; import { FileHistory, ToolError, ViewArgs, CreateArgs, StringReplaceArgs, InsertArgs, UndoEditArgs } from './types.js'; import { SNIPPET_LINES, readFile, writeFile, makeOutput, validatePath, truncateText } from './utils.js'; const execAsync = promisify(exec); export class FileEditor { private fileHistory: FileHistory = {}; async view(args: ViewArgs): Promise<string> { await validatePath('view', args.path); if (await this.isDirectory(args.path)) { if (args.view_range) { throw new ToolError( 'The `view_range` parameter is not allowed when `path` points to a directory.' ); } const { stdout, stderr } = await execAsync( `find "${args.path}" -maxdepth 2 -not -path '*/\\.*'` ); if (stderr) throw new ToolError(stderr); return `Here's the files and directories up to 2 levels deep in ${args.path}, excluding hidden items:\n${stdout}\n`; } const fileContent = await readFile(args.path); let initLine = 1; if (args.view_range) { const fileLines = fileContent.split('\n'); const nLinesFile = fileLines.length; const [start, end] = args.view_range; if (start < 1 || start > nLinesFile) { throw new ToolError( `Invalid \`view_range\`: ${args.view_range}. Its first element \`${start}\` should be within the range of lines of the file: [1, ${nLinesFile}]` ); } if (end !== -1) { if (end > nLinesFile) { throw new ToolError( `Invalid \`view_range\`: ${args.view_range}. Its second element \`${end}\` should be smaller than the number of lines in the file: \`${nLinesFile}\`` ); } if (end < start) { throw new ToolError( `Invalid \`view_range\`: ${args.view_range}. Its second element \`${end}\` should be larger or equal than its first \`${start}\`` ); } } const selectedLines = end === -1 ? fileLines.slice(start - 1) : fileLines.slice(start - 1, end); return makeOutput(selectedLines.join('\n'), String(args.path), start); } return makeOutput(fileContent, String(args.path)); } async create(args: CreateArgs): Promise<string> { await validatePath('create', args.path); await writeFile(args.path, args.file_text); if (!this.fileHistory[args.path]) { this.fileHistory[args.path] = []; } this.fileHistory[args.path].push(args.file_text); return `File created successfully at: ${args.path}`; } async strReplace(args: StringReplaceArgs): Promise<string> { await validatePath('string_replace', args.path); const fileContent = await readFile(args.path); const oldStr = args.old_str.replace(/\t/g, ' '); const newStr = args.new_str?.replace(/\t/g, ' ') ?? ''; const occurrences = fileContent.split(oldStr).length - 1; if (occurrences === 0) { throw new ToolError( `No replacement was performed, old_str \`${args.old_str}\` did not appear verbatim in ${args.path}.` ); } if (occurrences > 1) { const lines = fileContent.split('\n') .map((line, idx) => line.includes(oldStr) ? idx + 1 : null) .filter((idx): idx is number => idx !== null); throw new ToolError( `No replacement was performed. Multiple occurrences of old_str \`${args.old_str}\` in lines ${lines}. Please ensure it is unique` ); } const newContent = fileContent.replace(oldStr, newStr); await writeFile(args.path, newContent); if (!this.fileHistory[args.path]) { this.fileHistory[args.path] = []; } this.fileHistory[args.path].push(fileContent); const replacementLine = fileContent.split(oldStr)[0].split('\n').length; const startLine = Math.max(0, replacementLine - SNIPPET_LINES); const endLine = replacementLine + SNIPPET_LINES + newStr.split('\n').length; const snippet = newContent.split('\n').slice(startLine, endLine + 1).join('\n'); let successMsg = `The file ${args.path} has been edited. `; successMsg += makeOutput(snippet, `a snippet of ${args.path}`, startLine + 1); successMsg += 'Review the changes and make sure they are as expected. Edit the file again if necessary.'; return successMsg; } async insert(args: InsertArgs): Promise<string> { await validatePath('insert', args.path); const fileContent = await readFile(args.path); const newStr = args.new_str.replace(/\t/g, ' '); const fileLines = fileContent.split('\n'); const nLinesFile = fileLines.length; if (args.insert_line < 0 || args.insert_line > nLinesFile) { throw new ToolError( `Invalid \`insert_line\` parameter: ${args.insert_line}. It should be within the range of lines of the file: [0, ${nLinesFile}]` ); } const newStrLines = newStr.split('\n'); const newFileLines = [ ...fileLines.slice(0, args.insert_line), ...newStrLines, ...fileLines.slice(args.insert_line) ]; const snippetLines = [ ...fileLines.slice(Math.max(0, args.insert_line - SNIPPET_LINES), args.insert_line), ...newStrLines, ...fileLines.slice(args.insert_line, args.insert_line + SNIPPET_LINES) ]; const newFileContent = newFileLines.join('\n'); const snippet = snippetLines.join('\n'); await writeFile(args.path, newFileContent); if (!this.fileHistory[args.path]) { this.fileHistory[args.path] = []; } this.fileHistory[args.path].push(fileContent); let successMsg = `The file ${args.path} has been edited. `; successMsg += makeOutput( snippet, 'a snippet of the edited file', Math.max(1, args.insert_line - SNIPPET_LINES + 1) ); successMsg += 'Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). Edit the file again if necessary.'; return successMsg; } async undoEdit(args: UndoEditArgs): Promise<string> { await validatePath('undo_edit', args.path); if (!this.fileHistory[args.path] || this.fileHistory[args.path].length === 0) { throw new ToolError(`No edit history found for ${args.path}.`); } const oldText = this.fileHistory[args.path].pop()!; await writeFile(args.path, oldText); return `Last edit to ${args.path} undone successfully. ${makeOutput(oldText, String(args.path))}`; } private async isDirectory(filePath: string): Promise<boolean> { try { const stats = await fs.stat(filePath); return stats.isDirectory(); } catch (error) { return false; } } } ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { FileEditor } from "./editor.js"; import {CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { CreateArgs, isCreateArgs, isInsertArgs, isStrReplaceArgs, isUndoEditArgs, isViewArgs, ToolError, ViewArgs } from "./types.js"; class EditorServer { private server: Server; private editor: FileEditor; constructor() { this.server = new Server({ name: "mcp-editor", version: "1.0.0" }, { capabilities: { tools: {} } }); this.editor = new FileEditor(); this.setupTools(); } private setupTools(): void { // Set up all our editing tools to match the original EditTool's functionality this.server.setRequestHandler( ListToolsRequestSchema, async () => ({ tools: [ { name: "view", description: "View file contents or directory listing", inputSchema: { type: "object", properties: { path: { type: "string", description: "Absolute path to the file or directory" }, view_range: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2, description: "Optional range of lines to view [start, end]" } }, required: ["path"] } }, { name: "create", description: "Create a new file with specified content", inputSchema: { type: "object", properties: { path: { type: "string", description: "Absolute path where file should be created" }, file_text: { type: "string", description: "Content to write to the file" } }, required: ["path", "file_text"] } }, { name: "string_replace", description: "Replace a string in a file with a new string", inputSchema: { type: "object", properties: { path: { type: "string", description: "Absolute path to the file" }, old_str: { type: "string", description: "String to replace" }, new_str: { type: "string", description: "Replacement string (empty string if omitted)" } }, required: ["path", "old_str"] } }, { name: "insert", description: "Insert text at a specific line in the file", inputSchema: { type: "object", properties: { path: { type: "string", description: "Absolute path to the file" }, insert_line: { type: "number", description: "Line number where text should be inserted" }, new_str: { type: "string", description: "Text to insert" } }, required: ["path", "insert_line", "new_str"] } }, { name: "undo_edit", description: "Undo the last edit to a file", inputSchema: { type: "object", properties: { path: { type: "string", description: "Absolute path to the file" } }, required: ["path"] } } ] }) ); this.server.setRequestHandler( CallToolRequestSchema, async (request) => { try { let result: string; switch (request.params.name) { case "view": if (!request.params.arguments || !isViewArgs(request.params.arguments)) { throw new ToolError("Invalid arguments for view command"); // At least this one was right lol } result = await this.editor.view(request.params.arguments); break; case "create": if (!request.params.arguments || !isCreateArgs(request.params.arguments)) { throw new ToolError("Invalid arguments for create command"); // Fixed } result = await this.editor.create(request.params.arguments); break; case "string_replace": if (!request.params.arguments || !isStrReplaceArgs(request.params.arguments)) { throw new ToolError("Invalid arguments for string_replace command"); // Fixed } result = await this.editor.strReplace(request.params.arguments); break; case "insert": if (!request.params.arguments || !isInsertArgs(request.params.arguments)) { throw new ToolError("Invalid arguments for insert command"); // Fixed } result = await this.editor.insert(request.params.arguments); break; case "undo_edit": if (!request.params.arguments || !isUndoEditArgs(request.params.arguments)) { throw new ToolError("Invalid arguments for undo_edit command"); // Fixed } result = await this.editor.undoEdit(request.params.arguments); break; default: throw new ToolError(`Unknown tool: ${request.params.name}`); // This should be ToolError too } return { content: [{ type: "text", text: result }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: errorMessage }], isError: true }; } } ); } async run(): Promise<void> { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error("Editor MCP server running on stdio"); } } // Start the server const server = new EditorServer(); server.run().catch(console.error); ```