From 1131fdf73dec6e9280e37d7accf003a76b2b806e Mon Sep 17 00:00:00 2001 From: Bill Date: Wed, 11 Feb 2026 05:59:02 +0000 Subject: [PATCH] Step 247: file operation tools for MCP (read, write, create, diff, list) Adds 5 workspace-aware file I/O tools to the MCP server so agents can manage files without a GUI. All paths are resolved against --workspace root with escape-rejection security. 12/12 tests pass, tool count now 15. Co-Authored-By: Claude Opus 4.6 --- editor/CMakeLists.txt | 11 + editor/src/AgentPermissionPolicy.h | 9 +- editor/src/FileOperations.h | 300 +++++++++++++++++++++++++++ editor/src/HeadlessAgentRPCHandler.h | 127 ++++++++++++ editor/src/HeadlessEditorState.h | 1 + editor/src/MCPServer.h | 71 +++++++ editor/tests/step247_test.cpp | 271 ++++++++++++++++++++++++ progress.md | 29 +++ 8 files changed, 817 insertions(+), 2 deletions(-) create mode 100644 editor/src/FileOperations.h create mode 100644 editor/tests/step247_test.cpp diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 6941e6e..c172330 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1391,4 +1391,15 @@ target_link_libraries(step246_test PRIVATE tree_sitter_java tree_sitter_rust tree_sitter_go tree_sitter_org) +# Step 247: File operation tools for MCP +add_executable(step247_test tests/step247_test.cpp) +target_include_directories(step247_test PRIVATE src) +target_link_libraries(step247_test PRIVATE + nlohmann_json::nlohmann_json + unofficial::tree-sitter::tree-sitter + tree_sitter_python tree_sitter_cpp tree_sitter_elisp + tree_sitter_javascript tree_sitter_typescript + tree_sitter_java tree_sitter_rust tree_sitter_go + tree_sitter_org) + # Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies) diff --git a/editor/src/AgentPermissionPolicy.h b/editor/src/AgentPermissionPolicy.h index e4c321c..6cd8318 100644 --- a/editor/src/AgentPermissionPolicy.h +++ b/editor/src/AgentPermissionPolicy.h @@ -41,7 +41,10 @@ struct AgentPermissionPolicy { method == "runPipeline" || method == "parseSource" || method == "generateFromAST" || - method == "projectLanguage") { + method == "projectLanguage" || + method == "fileRead" || + method == "workspaceList" || + method == "fileDiff") { return true; } @@ -49,7 +52,9 @@ struct AgentPermissionPolicy { if (method == "generateCode" || method == "applyMutation" || method == "applyAnnotationSuggestion" || - method == "applyBatch") { + method == "applyBatch" || + method == "fileWrite" || + method == "fileCreate") { return role == AgentRole::Refactor || role == AgentRole::Generator; } diff --git a/editor/src/FileOperations.h b/editor/src/FileOperations.h new file mode 100644 index 0000000..bc71eae --- /dev/null +++ b/editor/src/FileOperations.h @@ -0,0 +1,300 @@ +#pragma once +// Step 247: File Operation Tools for MCP +// +// Standalone header implementing file operations for the headless path. +// No GUI dependencies. All operations respect the workspace root. + +#include +#include +#include +#include +#include +#include +#include +#include "FileTree.h" + +namespace fs = std::filesystem; + +// --- Path security --- +// Resolves relative paths against workspace root. +// Rejects paths that escape the workspace. +inline std::pair fileOpsResolvePath( + const std::string& workspaceRoot, const std::string& path) { + if (workspaceRoot.empty()) + return {false, "No workspace root configured"}; + + fs::path wsRoot = fs::weakly_canonical(fs::path(workspaceRoot)); + fs::path resolved; + + if (fs::path(path).is_absolute()) { + resolved = fs::weakly_canonical(fs::path(path)); + } else { + resolved = fs::weakly_canonical(wsRoot / path); + } + + std::string resolvedStr = resolved.string(); + std::string wsStr = wsRoot.string(); + if (resolvedStr.find(wsStr) != 0) + return {false, "Path escapes workspace: " + path}; + + return {true, resolvedStr}; +} + +// --- Read file --- +// Returns {success, content, lineCount} +inline std::tuple fileOpsRead( + const std::string& resolvedPath, int startLine = 0, int endLine = 0) { + std::ifstream f(resolvedPath); + if (!f.is_open()) + return {false, "Cannot open file: " + resolvedPath, 0}; + + std::vector lines; + std::string line; + while (std::getline(f, line)) + lines.push_back(line); + + int totalLines = (int)lines.size(); + + if (startLine <= 0 && endLine <= 0) { + std::string content; + for (size_t i = 0; i < lines.size(); ++i) { + if (i > 0) content += "\n"; + content += lines[i]; + } + return {true, content, totalLines}; + } + + // Line range (1-based) + int s = std::max(1, startLine); + int e = endLine > 0 ? std::min(endLine, totalLines) : totalLines; + + std::string content; + for (int i = s; i <= e; ++i) { + if (i > s) content += "\n"; + content += lines[i - 1]; + } + return {true, content, totalLines}; +} + +// --- Write file --- +// Returns {success, message, bytesWritten} +inline std::tuple fileOpsWrite( + const std::string& resolvedPath, const std::string& content) { + fs::path parent = fs::path(resolvedPath).parent_path(); + if (!parent.empty() && !fs::exists(parent)) { + std::error_code ec; + fs::create_directories(parent, ec); + if (ec) + return {false, "Cannot create directories: " + ec.message(), 0}; + } + + std::ofstream f(resolvedPath, std::ios::binary); + if (!f.is_open()) + return {false, "Cannot write file: " + resolvedPath, 0}; + + f.write(content.data(), (std::streamsize)content.size()); + f.close(); + return {true, resolvedPath, content.size()}; +} + +// --- Create file --- +// Returns {success, message} +inline std::pair fileOpsCreate( + const std::string& resolvedPath, + const std::string& language = "", + const std::string& templateName = "") { + fs::path parent = fs::path(resolvedPath).parent_path(); + if (!parent.empty() && !fs::exists(parent)) { + std::error_code ec; + fs::create_directories(parent, ec); + if (ec) + return {false, "Cannot create directories: " + ec.message()}; + } + + if (fs::exists(resolvedPath)) + return {false, "File already exists: " + resolvedPath}; + + std::string content; + std::string lang = language; + if (lang.empty()) { + std::string ext = fs::path(resolvedPath).extension().string(); + if (ext == ".py") lang = "python"; + else if (ext == ".cpp" || ext == ".h" || ext == ".hpp") lang = "cpp"; + else if (ext == ".rs") lang = "rust"; + else if (ext == ".go") lang = "go"; + else if (ext == ".java") lang = "java"; + else if (ext == ".js") lang = "javascript"; + else if (ext == ".ts") lang = "typescript"; + } + + if (!templateName.empty() && templateName != "empty") { + if (lang == "python") { + std::string modName = fs::path(resolvedPath).stem().string(); + content = "\"\"\"" + modName + " module.\"\"\"\n\n\n" + "if __name__ == \"__main__\":\n pass\n"; + } else if (lang == "cpp") { + std::string ext = fs::path(resolvedPath).extension().string(); + if (ext == ".h" || ext == ".hpp") { + content = "#pragma once\n\n"; + } else { + content = "#include \n\nint main() {\n" + " return 0;\n}\n"; + } + } else if (lang == "rust") { + content = "fn main() {\n println!(\"Hello, world!\");\n}\n"; + } else if (lang == "go") { + content = "package main\n\nfunc main() {\n}\n"; + } else if (lang == "java") { + std::string cls = fs::path(resolvedPath).stem().string(); + content = "public class " + cls + + " {\n public static void main(String[] args) {\n }\n}\n"; + } else if (lang == "javascript" || lang == "typescript") { + content = "// " + + fs::path(resolvedPath).filename().string() + "\n\n"; + } + } + + std::ofstream f(resolvedPath, std::ios::binary); + if (!f.is_open()) + return {false, "Cannot create file: " + resolvedPath}; + + if (!content.empty()) + f.write(content.data(), (std::streamsize)content.size()); + f.close(); + return {true, resolvedPath}; +} + +// --- Diff --- +// Simple line-by-line unified diff between string content and a file on disk. +// Returns {diffText, linesAdded, linesRemoved} +inline std::tuple fileOpsDiff( + const std::string& currentContent, const std::string& diskPath) { + std::ifstream f(diskPath); + std::string diskContent; + if (f.is_open()) { + std::ostringstream ss; + ss << f.rdbuf(); + diskContent = ss.str(); + } + + auto splitLines = [](const std::string& s) -> std::vector { + std::vector lines; + std::istringstream iss(s); + std::string line; + while (std::getline(iss, line)) + lines.push_back(line); + return lines; + }; + + auto diskLines = splitLines(diskContent); + auto curLines = splitLines(currentContent); + + int added = 0, removed = 0; + std::ostringstream diff; + diff << "--- " << diskPath << "\n"; + diff << "+++ (buffer)\n"; + + size_t di = 0, ci = 0; + while (di < diskLines.size() || ci < curLines.size()) { + if (di < diskLines.size() && ci < curLines.size() && + diskLines[di] == curLines[ci]) { + diff << " " << diskLines[di] << "\n"; + ++di; ++ci; + } else { + bool found = false; + for (size_t look = 1; look < 5 && !found; ++look) { + if (ci + look < curLines.size() && + di < diskLines.size() && + curLines[ci + look] == diskLines[di]) { + for (size_t j = 0; j < look; ++j) { + diff << "+" << curLines[ci + j] << "\n"; + ++added; + } + ci += look; + found = true; + } + if (!found && di + look < diskLines.size() && + ci < curLines.size() && + diskLines[di + look] == curLines[ci]) { + for (size_t j = 0; j < look; ++j) { + diff << "-" << diskLines[di + j] << "\n"; + ++removed; + } + di += look; + found = true; + } + } + if (!found) { + if (di < diskLines.size()) { + diff << "-" << diskLines[di] << "\n"; + ++removed; ++di; + } + if (ci < curLines.size()) { + diff << "+" << curLines[ci] << "\n"; + ++added; ++ci; + } + } + } + } + + return {diff.str(), added, removed}; +} + +// --- List workspace files --- +struct FileOpsEntry { + std::string path; + size_t size = 0; + bool isDir = false; +}; + +inline std::vector fileOpsListWorkspace( + const std::string& workspaceRoot, + const std::string& globPattern = "*") { + std::vector results; + if (workspaceRoot.empty() || !fs::exists(workspaceRoot)) + return results; + + FileTree tree; + FileNode root = tree.build(workspaceRoot); + + auto matchGlob = [](const std::string& name, + const std::string& pattern) -> bool { + if (pattern == "*") return true; + if (pattern.empty()) return true; + if (pattern.size() > 1 && pattern[0] == '*') { + std::string suffix = pattern.substr(1); + return name.size() >= suffix.size() && + name.compare(name.size() - suffix.size(), + suffix.size(), suffix) == 0; + } + return name == pattern; + }; + + std::function collect = + [&](const FileNode& node) { + if (node.path == workspaceRoot) { + for (const auto& child : node.children) + collect(child); + return; + } + std::string relPath = fs::relative( + fs::path(node.path), fs::path(workspaceRoot)).string(); + if (matchGlob(node.name, globPattern)) { + FileOpsEntry entry; + entry.path = relPath; + entry.isDir = node.isDir; + if (!node.isDir && fs::exists(node.path)) { + std::error_code ec; + entry.size = (size_t)fs::file_size(node.path, ec); + } + results.push_back(entry); + } + if (node.isDir) { + for (const auto& child : node.children) + collect(child); + } + }; + + collect(root); + return results; +} diff --git a/editor/src/HeadlessAgentRPCHandler.h b/editor/src/HeadlessAgentRPCHandler.h index 833e7a3..b9b4886 100644 --- a/editor/src/HeadlessAgentRPCHandler.h +++ b/editor/src/HeadlessAgentRPCHandler.h @@ -554,5 +554,132 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state, }); } + // --- fileRead --- + if (method == "fileRead") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string path = params.value("path", ""); + if (path.empty()) + return headlessRpcError(id, -32602, "Missing path parameter"); + auto [ok, resolved] = + fileOpsResolvePath(state.workspaceRoot, path); + if (!ok) + return headlessRpcError(id, -32040, resolved); + int startLine = params.value("startLine", 0); + int endLine = params.value("endLine", 0); + auto [success, content, lineCount] = + fileOpsRead(resolved, startLine, endLine); + if (!success) + return headlessRpcError(id, -32041, content); + return headlessRpcResult(id, { + {"content", content}, {"lineCount", lineCount}, + {"path", resolved} + }); + } + + // --- fileWrite --- + if (method == "fileWrite") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string path = params.value("path", ""); + if (path.empty()) + return headlessRpcError(id, -32602, "Missing path parameter"); + std::string content = params.value("content", ""); + auto [ok, resolved] = + fileOpsResolvePath(state.workspaceRoot, path); + if (!ok) + return headlessRpcError(id, -32040, resolved); + auto [success, msg, bytes] = fileOpsWrite(resolved, content); + if (!success) + return headlessRpcError(id, -32041, msg); + return headlessRpcResult(id, { + {"success", true}, {"path", resolved}, + {"bytesWritten", bytes} + }); + } + + // --- fileCreate --- + if (method == "fileCreate") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string path = params.value("path", ""); + if (path.empty()) + return headlessRpcError(id, -32602, "Missing path parameter"); + auto [ok, resolved] = + fileOpsResolvePath(state.workspaceRoot, path); + if (!ok) + return headlessRpcError(id, -32040, resolved); + std::string language = params.value("language", ""); + std::string tmpl = params.value("template", ""); + auto [success, msg] = fileOpsCreate(resolved, language, tmpl); + if (!success) + return headlessRpcError(id, -32041, msg); + return headlessRpcResult(id, { + {"success", true}, {"path", resolved} + }); + } + + // --- fileDiff --- + if (method == "fileDiff") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string path = params.value("path", ""); + std::string bufContent; + std::string diskPath; + if (!path.empty()) { + auto [ok, resolved] = + fileOpsResolvePath(state.workspaceRoot, path); + if (!ok) + return headlessRpcError(id, -32040, resolved); + diskPath = resolved; + auto it = state.bufferStates.find(path); + if (it != state.bufferStates.end()) + bufContent = it->second->editBuf; + else { + auto [rok, content, lc] = fileOpsRead(resolved); + if (rok) bufContent = content; + } + } else if (state.active()) { + diskPath = state.active()->path; + bufContent = state.active()->editBuf; + auto [ok2, resolved2] = + fileOpsResolvePath(state.workspaceRoot, diskPath); + if (ok2) diskPath = resolved2; + } else { + return headlessRpcError(id, -32000, "No active buffer"); + } + auto [diffText, added, removed] = + fileOpsDiff(bufContent, diskPath); + return headlessRpcResult(id, { + {"diff", diffText}, {"linesAdded", added}, + {"linesRemoved", removed} + }); + } + + // --- workspaceList --- + if (method == "workspaceList") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string glob = params.value("glob", "*"); + auto entries = fileOpsListWorkspace(state.workspaceRoot, glob); + json files = json::array(); + for (const auto& e : entries) + files.push_back({ + {"path", e.path}, {"size", e.size}, + {"isDir", e.isDir} + }); + return headlessRpcResult(id, {{"files", files}}); + } + return headlessRpcError(id, -32601, "Method not found"); } diff --git a/editor/src/HeadlessEditorState.h b/editor/src/HeadlessEditorState.h index 4773196..b042a72 100644 --- a/editor/src/HeadlessEditorState.h +++ b/editor/src/HeadlessEditorState.h @@ -22,6 +22,7 @@ #include "AgentCodeGen.h" #include "AgentLibraryPolicy.h" #include "WorkflowRecorder.h" +#include "FileOperations.h" #include "Orchestrator.h" #include "IncrementalOptimizer.h" #include "TextASTSync.h" diff --git a/editor/src/MCPServer.h b/editor/src/MCPServer.h index 6f03654..f0f9fac 100644 --- a/editor/src/MCPServer.h +++ b/editor/src/MCPServer.h @@ -549,11 +549,82 @@ private: return {}; } + // --------------------------------------------------------------- + // Step 247: Register file operation tools + // --------------------------------------------------------------- + void registerFileTools() { + // whetstone_file_read + tools_.push_back({"whetstone_file_read", + "Read a file from the workspace. Returns file content with " + "optional line range filtering. Path is relative to workspace root.", + {{"type", "object"}, {"properties", { + {"path", {{"type", "string"}, {"description", "File path (relative to workspace)"}}}, + {"startLine", {{"type", "integer"}, {"description", "Start line (1-based, optional)"}}}, + {"endLine", {{"type", "integer"}, {"description", "End line (1-based, optional)"}}} + }}, {"required", {"path"}}} + }); + toolHandlers_["whetstone_file_read"] = [this](const json& args) { + return callWhetstone("fileRead", args); + }; + + // whetstone_file_write + tools_.push_back({"whetstone_file_write", + "Write content to a file in the workspace. Creates parent " + "directories if needed. Requires Refactor or Generator role.", + {{"type", "object"}, {"properties", { + {"path", {{"type", "string"}, {"description", "File path (relative to workspace)"}}}, + {"content", {{"type", "string"}, {"description", "Content to write"}}} + }}, {"required", {"path", "content"}}} + }); + toolHandlers_["whetstone_file_write"] = [this](const json& args) { + return callWhetstone("fileWrite", args); + }; + + // whetstone_file_create + tools_.push_back({"whetstone_file_create", + "Create a new file with optional language template. " + "Requires Refactor or Generator role.", + {{"type", "object"}, {"properties", { + {"path", {{"type", "string"}, {"description", "File path (relative to workspace)"}}}, + {"language", {{"type", "string"}, {"description", "Language (python, cpp, rust, go, java, javascript, typescript)"}}}, + {"template", {{"type", "string"}, {"description", "Template name (module, empty) or empty for blank file"}}} + }}, {"required", {"path"}}} + }); + toolHandlers_["whetstone_file_create"] = [this](const json& args) { + return callWhetstone("fileCreate", args); + }; + + // whetstone_file_diff + tools_.push_back({"whetstone_file_diff", + "Get a unified diff between the active buffer content and the " + "file on disk. Optionally specify a path, defaults to active buffer.", + {{"type", "object"}, {"properties", { + {"path", {{"type", "string"}, {"description", "File path (optional, defaults to active buffer)"}}} + }}} + }); + toolHandlers_["whetstone_file_diff"] = [this](const json& args) { + return callWhetstone("fileDiff", args); + }; + + // whetstone_workspace_list + tools_.push_back({"whetstone_workspace_list", + "List files in the workspace matching an optional glob pattern. " + "Respects .gitignore. Returns path, size, and isDir for each entry.", + {{"type", "object"}, {"properties", { + {"glob", {{"type", "string"}, {"description", "Glob pattern (default: *). E.g. *.py, *.cpp"}}} + }}} + }); + toolHandlers_["whetstone_workspace_list"] = [this](const json& args) { + return callWhetstone("workspaceList", args); + }; + } + // --------------------------------------------------------------- // Register all tools // --------------------------------------------------------------- void registerWhetstoneTools() { registerASTTools(); registerAnnotationTools(); + registerFileTools(); } }; diff --git a/editor/tests/step247_test.cpp b/editor/tests/step247_test.cpp new file mode 100644 index 0000000..9535cf9 --- /dev/null +++ b/editor/tests/step247_test.cpp @@ -0,0 +1,271 @@ +// Step 247 TDD Test: File Operation Tools for MCP +// +// 12 test cases covering path resolution, file CRUD, diff, listing, +// and permission enforcement via HeadlessEditorState + RPC dispatch. +#include "HeadlessEditorState.h" +#include "MCPBridge.h" + +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +static void expect(bool cond, const std::string& name, + int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) + << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) + << " FAIL: " << name << "\n"; + ++failed; + } +} + +static std::string tmpDir; + +static void setupTmpDir() { + tmpDir = "/tmp/whetstone_test_247_" + + std::to_string(std::chrono::steady_clock::now() + .time_since_epoch().count()); + fs::create_directories(tmpDir); +} + +static void cleanupTmpDir() { + std::error_code ec; + fs::remove_all(tmpDir, ec); +} + +int main() { + int passed = 0; + int failed = 0; + + setupTmpDir(); + + // Set up HeadlessEditorState with temp workspace + HeadlessEditorState state; + state.workspaceRoot = tmpDir; + state.defaultLanguage = "python"; + state.verbose = false; + + // Create a test file on disk for reading + std::string testContent = "line one\nline two\nline three\nline four\n"; + { + std::ofstream f(tmpDir + "/test.txt"); + f << testContent; + } + + // Open a buffer with Python source for diff tests + std::string src = "def greet(name):\n return 'hello ' + name\n"; + state.openBuffer("greet.py", src, "python"); + state.setAgentRole("test-session", AgentRole::Refactor); + + auto rpc = [&](const std::string& method, const json& params = {}) { + json request = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", method}, {"params", params}}; + return state.processAgentRequest(request, "test-session"); + }; + + // --------------------------------------------------------------- + // Test 1: Resolve path within workspace + // --------------------------------------------------------------- + { + auto [ok, resolved] = fileOpsResolvePath(tmpDir, "test.txt"); + expect(ok && resolved == tmpDir + "/test.txt", + "Resolve path within workspace", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 2: Reject path escaping workspace + // --------------------------------------------------------------- + { + auto [ok, resolved] = + fileOpsResolvePath(tmpDir, "../../../etc/passwd"); + expect(!ok && resolved.find("escapes") != std::string::npos, + "Reject path escaping workspace (../../../etc/passwd)", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 3: Create file via RPC, verify it exists on disk + // --------------------------------------------------------------- + { + json resp = rpc("fileCreate", {{"path", "new_file.txt"}}); + bool success = resp.contains("result") && + resp["result"].value("success", false); + expect(success && fs::exists(tmpDir + "/new_file.txt"), + "Create file via RPC, verify on disk", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 4: Read file via RPC, verify content matches + // --------------------------------------------------------------- + { + json resp = rpc("fileRead", {{"path", "test.txt"}}); + std::string content = ""; + if (resp.contains("result")) + content = resp["result"].value("content", ""); + expect(content.find("line one") != std::string::npos && + content.find("line four") != std::string::npos, + "Read file via RPC, content matches", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 5: Read file with line range (startLine/endLine) + // --------------------------------------------------------------- + { + json resp = rpc("fileRead", + {{"path", "test.txt"}, {"startLine", 2}, {"endLine", 3}}); + std::string content = ""; + if (resp.contains("result")) + content = resp["result"].value("content", ""); + expect(content.find("line two") != std::string::npos && + content.find("line three") != std::string::npos && + content.find("line one") == std::string::npos, + "Read file with line range (startLine/endLine)", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 6: Write file via RPC, verify content on disk + // --------------------------------------------------------------- + { + std::string writeContent = "hello from agent\nsecond line\n"; + json resp = rpc("fileWrite", + {{"path", "written.txt"}, {"content", writeContent}}); + bool success = resp.contains("result") && + resp["result"].value("success", false); + // Read back from disk + std::string diskContent; + if (success) { + std::ifstream f(tmpDir + "/written.txt"); + std::ostringstream ss; + ss << f.rdbuf(); + diskContent = ss.str(); + } + expect(success && diskContent == writeContent, + "Write file via RPC, verify content on disk", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 7: Diff active buffer vs disk after modification + // --------------------------------------------------------------- + { + // Write the original greet.py content to disk + { + auto [ok, resolved] = + fileOpsResolvePath(tmpDir, "greet.py"); + if (ok) { + std::ofstream f(resolved); + f << "def greet(name):\n return 'hi ' + name\n"; + } + } + // Buffer has 'hello', disk has 'hi' — should produce a diff + json resp = rpc("fileDiff", {{"path", "greet.py"}}); + std::string diff = ""; + if (resp.contains("result")) + diff = resp["result"].value("diff", ""); + expect(!diff.empty() && diff.find("---") != std::string::npos, + "Diff active buffer vs disk after modification", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 8: List workspace files, verify count > 0 + // --------------------------------------------------------------- + { + json resp = rpc("workspaceList", json::object()); + int count = 0; + if (resp.contains("result") && resp["result"].contains("files")) + count = (int)resp["result"]["files"].size(); + expect(count > 0, + "List workspace files, count > 0 (got " + + std::to_string(count) + ")", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 9: List workspace with glob filter + // --------------------------------------------------------------- + { + json resp = rpc("workspaceList", {{"glob", "*.txt"}}); + int count = 0; + bool allTxt = true; + if (resp.contains("result") && resp["result"].contains("files")) { + count = (int)resp["result"]["files"].size(); + for (const auto& f : resp["result"]["files"]) { + std::string p = f.value("path", ""); + if (p.find(".txt") == std::string::npos) allTxt = false; + } + } + expect(count > 0 && allTxt, + "List workspace with glob *.txt (got " + + std::to_string(count) + ")", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 10: File create with language template + // --------------------------------------------------------------- + { + json resp = rpc("fileCreate", + {{"path", "app.py"}, {"language", "python"}, + {"template", "module"}}); + bool success = resp.contains("result") && + resp["result"].value("success", false); + std::string content; + if (success) { + std::ifstream f(tmpDir + "/app.py"); + std::ostringstream ss; + ss << f.rdbuf(); + content = ss.str(); + } + expect(success && content.find("__main__") != std::string::npos, + "File create with language template (python module)", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 11: Write creates parent directories + // --------------------------------------------------------------- + { + json resp = rpc("fileWrite", + {{"path", "sub/dir/deep.txt"}, {"content", "nested"}}); + bool success = resp.contains("result") && + resp["result"].value("success", false); + expect(success && fs::exists(tmpDir + "/sub/dir/deep.txt"), + "Write creates parent directories", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 12: Permission check: Linter cannot write files + // --------------------------------------------------------------- + { + state.setAgentRole("linter-session", AgentRole::Linter); + json request = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "fileWrite"}, + {"params", {{"path", "blocked.txt"}, + {"content", "nope"}}}}; + json resp = state.processAgentRequest(request, "linter-session"); + bool denied = resp.contains("error") && + resp["error"].value("code", 0) == -32031; + expect(denied && !fs::exists(tmpDir + "/blocked.txt"), + "Permission check: Linter cannot write files", + passed, failed); + } + + cleanupTmpDir(); + + std::cout << "\n=== Step 247 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md index 5ed910e..c4192c9 100644 --- a/progress.md +++ b/progress.md @@ -45,3 +45,32 @@ that any MCP client (Claude Code, Cursor, etc.) can launch over stdio. - Default Refactor role for full MCP access - Empty scratch buffer on startup so getAST works immediately - All logging to stderr (stdout is the MCP transport) + +### Step 247: File Operation Tools for MCP +**Status:** PASS (12/12 tests) + +Adds 5 file I/O tools to the MCP server so agents can create, read, write, +diff, and list files without a GUI. All operations respect the `--workspace` +root with path-escape security checks. + +**Files created:** +- `editor/src/FileOperations.h` — Standalone file ops: resolve path, read, + write, create (with templates), unified diff, workspace listing via FileTree +- `editor/tests/step247_test.cpp` — 12 test cases: path resolution, path + escape rejection, file create/read/write via RPC, line range, diff, workspace + list, glob filter, language templates, parent dir creation, permission denial + +**Files modified:** +- `editor/src/AgentPermissionPolicy.h` — fileRead/workspaceList/fileDiff + read-only; fileWrite/fileCreate require Refactor/Generator +- `editor/src/HeadlessAgentRPCHandler.h` — 5 new RPC handlers +- `editor/src/HeadlessEditorState.h` — include FileOperations.h +- `editor/src/MCPServer.h` — registerFileTools() with 5 MCP tool definitions +- `editor/CMakeLists.txt` — step247_test target + +**Key design decisions:** +- Path security: all paths resolved against workspaceRoot, escape rejected +- Simple line-by-line unified diff (no external dependency) +- Language templates for Python, C++, Rust, Go, Java, JS/TS +- FileTree.h reuse for .gitignore-aware workspace listing +- tools/list now returns 15 tools (was 10)