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 <noreply@anthropic.com>
This commit is contained in:
Bill
2026-02-11 05:59:02 +00:00
parent 06706aeacf
commit 1131fdf73d
8 changed files with 817 additions and 2 deletions

View File

@@ -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)

View File

@@ -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;
}

300
editor/src/FileOperations.h Normal file
View File

@@ -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 <string>
#include <vector>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <algorithm>
#include <functional>
#include "FileTree.h"
namespace fs = std::filesystem;
// --- Path security ---
// Resolves relative paths against workspace root.
// Rejects paths that escape the workspace.
inline std::pair<bool, std::string> 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<bool, std::string, int> 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<std::string> 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<bool, std::string, size_t> 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<bool, std::string> 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 <iostream>\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<std::string, int, int> 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::string> {
std::vector<std::string> 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<FileOpsEntry> fileOpsListWorkspace(
const std::string& workspaceRoot,
const std::string& globPattern = "*") {
std::vector<FileOpsEntry> 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<void(const FileNode&)> 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;
}

View File

@@ -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");
}

View File

@@ -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"

View File

@@ -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();
}
};

View File

@@ -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 <iostream>
#include <cassert>
#include <string>
#include <filesystem>
#include <fstream>
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;
}

View File

@@ -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)