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:
@@ -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
300
editor/src/FileOperations.h
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user