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:
271
editor/tests/step247_test.cpp
Normal file
271
editor/tests/step247_test.cpp
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user