Problem: the README said setup enables "tracked ... OpenGrep pre-push hooks", but only .githooks/pre-commit is tracked, so fresh clones get no pre-push hook. docs/research/prior-art.md pointed to prior-art-full.md, which has never been in the repository. test_mcp_stdio's TempDir destructor called the throwing remove_all overload; a failure during unwinding terminates the test binary and leaves .fold-mcp-test-* directories behind. Fix: the README names only the tracked pre-commit hook. The pre-push hook is not committed: it runs `opengrep --config auto`, which needs the network. The dead reference is removed. TempDir uses the error_code remove_all overload. It stays under the working directory, because the MCP server rejects paths outside its workspace root (moving it to temp_directory_path broke three MCP tests). Test: mcp_stdio passes.
814 lines
33 KiB
C++
814 lines
33 KiB
C++
#include <catch2/catch_test_macros.hpp>
|
|
#include <chrono>
|
|
#include <deque>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <memory>
|
|
#include <nlohmann/json.hpp>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#include "fold/mcp/server.hpp"
|
|
#include "fold/mcp/transport.hpp"
|
|
|
|
namespace {
|
|
|
|
// In-memory transport for tests: scripted inputs in a queue, captured
|
|
// outputs in a vector. Returns std::nullopt on empty queue (simulates EOF).
|
|
class ScriptedTransport final : public fold::mcp::Transport {
|
|
public:
|
|
void enqueue(const nlohmann::json& msg) { in_.push_back(msg.dump()); }
|
|
|
|
std::optional<std::string> readMessage() override {
|
|
if (in_.empty())
|
|
return std::nullopt;
|
|
std::string m = in_.front();
|
|
in_.pop_front();
|
|
return m;
|
|
}
|
|
void writeMessage(const nlohmann::json& msg) override { out_.push_back(msg); }
|
|
const std::vector<nlohmann::json>& out() const { return out_; }
|
|
|
|
private:
|
|
std::deque<std::string> in_;
|
|
std::vector<nlohmann::json> out_;
|
|
};
|
|
|
|
std::string writeFixtureDb(const std::string& fixtures_dir) {
|
|
namespace fs = std::filesystem;
|
|
fs::path dir(fixtures_dir);
|
|
nlohmann::json db = nlohmann::json::array();
|
|
for (const auto& f : {"clones/exact.cpp", "clones/renamed.cpp", "clones/near_miss.cpp"}) {
|
|
fs::path file = dir / f;
|
|
db.push_back({
|
|
{"directory", dir.string()},
|
|
{"command", std::string("clang++ -std=c++20 -c ") + file.string()},
|
|
{"file", file.string()},
|
|
});
|
|
}
|
|
fs::path out = dir / "compile_commands.json";
|
|
std::ofstream(out) << db.dump();
|
|
return dir.string();
|
|
}
|
|
|
|
struct TempDir {
|
|
std::filesystem::path path;
|
|
|
|
TempDir()
|
|
// Must stay under the working directory: the MCP server rejects paths
|
|
// outside its workspace root.
|
|
: path(std::filesystem::current_path() /
|
|
(".fold-mcp-test-" +
|
|
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()))) {
|
|
std::filesystem::create_directories(path);
|
|
}
|
|
|
|
// Non-throwing: a destructor that throws during unwinding terminates.
|
|
~TempDir() {
|
|
std::error_code ec;
|
|
std::filesystem::remove_all(path, ec);
|
|
}
|
|
};
|
|
|
|
void writeFile(const std::filesystem::path& path, const std::string& content) {
|
|
std::ofstream(path) << content;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("MCP: initialize then tools/list returns the fold tools", "[mcp][handshake]") {
|
|
auto transport = std::make_unique<ScriptedTransport>();
|
|
auto* t = transport.get();
|
|
t->enqueue({{"jsonrpc", "2.0"},
|
|
{"id", 1},
|
|
{"method", "initialize"},
|
|
{"params", nlohmann::json::object()}});
|
|
t->enqueue({{"jsonrpc", "2.0"}, {"id", 2}, {"method", "tools/list"}});
|
|
|
|
fold::mcp::Server server(std::move(transport));
|
|
REQUIRE(server.run() == 0);
|
|
|
|
REQUIRE(t->out().size() == 2);
|
|
|
|
const auto& init = t->out()[0];
|
|
REQUIRE(init["jsonrpc"] == "2.0");
|
|
REQUIRE(init["id"] == 1);
|
|
REQUIRE(init["result"]["protocolVersion"] == "2024-11-05");
|
|
REQUIRE(init["result"]["serverInfo"]["name"] == "fold");
|
|
|
|
const auto& list = t->out()[1];
|
|
REQUIRE(list["id"] == 2);
|
|
auto tools = list["result"]["tools"];
|
|
REQUIRE(tools.is_array());
|
|
std::vector<std::string> names;
|
|
for (const auto& tool : tools) {
|
|
names.push_back(tool["name"]);
|
|
REQUIRE(tool["inputSchema"]["additionalProperties"] == false);
|
|
}
|
|
const std::vector<std::string> expected_names = {
|
|
"fold.scan",
|
|
"fold.scan_start",
|
|
"fold.scan_status",
|
|
"fold.scan_jobs",
|
|
"fold.scan_job_findings",
|
|
"fold.scan_activate",
|
|
"fold.get_scan_context",
|
|
"fold.get_findings",
|
|
"fold.query_findings",
|
|
"fold.suggest_merge",
|
|
"fold.explain",
|
|
};
|
|
REQUIRE(names == expected_names);
|
|
|
|
const auto scan_tool = std::find_if(
|
|
tools.begin(), tools.end(), [](const auto& tool) { return tool["name"] == "fold.scan"; });
|
|
REQUIRE(scan_tool != tools.end());
|
|
std::vector<std::string> scan_properties;
|
|
for (const auto& [key, value] : (*scan_tool)["inputSchema"]["properties"].items()) {
|
|
(void)value;
|
|
scan_properties.push_back(key);
|
|
}
|
|
const std::vector<std::string> expected_scan_properties = {
|
|
"binary",
|
|
"binary_detectors",
|
|
"binary_max_functions",
|
|
"budget_seconds",
|
|
"clone_max_body_len",
|
|
"clone_max_pairs",
|
|
"compile_db",
|
|
"configuration_profile",
|
|
"detectors",
|
|
"hybrid_binary_functions",
|
|
"hybrid_source_candidates",
|
|
"hybrid_strategy",
|
|
"min_body_stmts",
|
|
"mode",
|
|
"nullable_allocations",
|
|
"paths",
|
|
"per_query_ms",
|
|
"processes",
|
|
"source_detectors",
|
|
};
|
|
REQUIRE(scan_properties == expected_scan_properties);
|
|
|
|
const auto scan_start_tool = std::find_if(tools.begin(), tools.end(), [](const auto& tool) {
|
|
return tool["name"] == "fold.scan_start";
|
|
});
|
|
REQUIRE(scan_start_tool != tools.end());
|
|
std::vector<std::string> scan_start_properties;
|
|
for (const auto& [key, value] : (*scan_start_tool)["inputSchema"]["properties"].items()) {
|
|
(void)value;
|
|
scan_start_properties.push_back(key);
|
|
}
|
|
REQUIRE(scan_start_properties == expected_scan_properties);
|
|
REQUIRE((*scan_start_tool)["inputSchema"] == (*scan_tool)["inputSchema"]);
|
|
|
|
const auto query_tool = std::find_if(tools.begin(), tools.end(), [](const auto& tool) {
|
|
return tool["name"] == "fold.query_findings";
|
|
});
|
|
REQUIRE(query_tool != tools.end());
|
|
const auto& query_properties = (*query_tool)["inputSchema"]["properties"];
|
|
REQUIRE(query_properties["fields"]["maxItems"] == 6);
|
|
REQUIRE(query_properties["limit"]["minimum"] == 1);
|
|
REQUIRE(query_properties["limit"]["maximum"] == 500);
|
|
REQUIRE_FALSE(query_properties.contains("regex"));
|
|
}
|
|
|
|
TEST_CASE("MCP: rejects invalid tool arguments before dispatch", "[mcp][contract][policy]") {
|
|
auto transport = std::make_unique<ScriptedTransport>();
|
|
fold::mcp::Server server(std::move(transport));
|
|
|
|
auto init_resp =
|
|
server.handleOne(nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"method", "initialize"}});
|
|
REQUIRE(init_resp.has_value());
|
|
REQUIRE_FALSE(init_resp->contains("error"));
|
|
|
|
const std::vector<nlohmann::json> cases = {
|
|
{{"name", "fold.get_scan_context"}, {"arguments", {{"definitely_not_an_option", true}}}},
|
|
{{"name", "fold.scan"}, {"arguments", {{"mode", 7}}}},
|
|
{{"name", "fold.scan"}, {"arguments", {{"mode", "invented"}}}},
|
|
{{"name", "fold.scan"}, {"arguments", {{"processes", -1}}}},
|
|
{{"name", "fold.scan"}, {"arguments", {{"processes", 33}}}},
|
|
{{"name", "fold.scan_start"}, {"arguments", {{"queue_depth", 0}}}},
|
|
{{"name", "fold.scan"}, {"arguments", {{"binary", std::string("bad\0path", 8)}}}},
|
|
{{"name", "fold.scan"}, {"arguments", {{"compile_db", "../outside"}}}},
|
|
{{"name", "fold.scan"}, {"arguments", {{"rust_allow_build_code", true}}}},
|
|
{{"name", "fold.scan"},
|
|
{"arguments", {{"extra_args", nlohmann::json::array({"-Xclang", "-load"})}}}},
|
|
{{"name", "fold.query_findings"}, {"arguments", {{"offset", -1}}}},
|
|
{{"name", "fold.query_findings"},
|
|
{"arguments", {{"fields", nlohmann::json::array({"source"})}}}},
|
|
{{"name", "fold.query_findings"}, {"arguments", {{"regex", "(a+)+$"}}}},
|
|
{{"name", "fold.scan"},
|
|
{"arguments",
|
|
{{"nullable_allocations", true}, {"null_deref_nullable_allocations", true}}}},
|
|
};
|
|
for (std::size_t index = 0; index < cases.size(); ++index) {
|
|
auto response = server.handleOne(nlohmann::json{{"jsonrpc", "2.0"},
|
|
{"id", 2 + index},
|
|
{"method", "tools/call"},
|
|
{"params", cases[index]}});
|
|
REQUIRE(response.has_value());
|
|
REQUIRE((*response)["error"]["code"] == -32602);
|
|
REQUIRE((*response)["error"]["message"].get<std::string>().find("field `") !=
|
|
std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("MCP: tools/list before initialize returns ServerNotInitialized", "[mcp][errors]") {
|
|
auto transport = std::make_unique<ScriptedTransport>();
|
|
auto* t = transport.get();
|
|
t->enqueue({{"jsonrpc", "2.0"}, {"id", 1}, {"method", "tools/list"}});
|
|
|
|
fold::mcp::Server server(std::move(transport));
|
|
server.run();
|
|
REQUIRE(t->out().size() == 1);
|
|
REQUIRE(t->out()[0]["error"]["code"] == -32002);
|
|
}
|
|
|
|
TEST_CASE("MCP: tools/call runs the clone detector against a fixture DB", "[mcp][e2e]") {
|
|
const char* env = std::getenv("FOLD_FIXTURES_DIR");
|
|
std::string fixtures = env ? env : "../fixtures";
|
|
if (!std::filesystem::exists(fixtures + "/clones/exact.cpp")) {
|
|
fixtures = std::filesystem::current_path().string() + "/../fixtures";
|
|
}
|
|
REQUIRE(std::filesystem::exists(fixtures + "/clones/exact.cpp"));
|
|
writeFixtureDb(fixtures);
|
|
|
|
auto transport = std::make_unique<ScriptedTransport>();
|
|
auto* t = transport.get();
|
|
t->enqueue({{"jsonrpc", "2.0"}, {"id", 1}, {"method", "initialize"}});
|
|
t->enqueue({{"jsonrpc", "2.0"},
|
|
{"id", 2},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{
|
|
{"name", "fold.find_duplicates"},
|
|
{"arguments", {{"compile_db", fixtures}}},
|
|
}}});
|
|
|
|
fold::mcp::Server server(std::move(transport));
|
|
REQUIRE(server.run() == 0);
|
|
REQUIRE(t->out().size() == 2);
|
|
const auto& resp = t->out()[1];
|
|
REQUIRE(resp["id"] == 2);
|
|
REQUIRE_FALSE(resp.contains("error"));
|
|
const auto& structured = resp["result"]["structuredContent"];
|
|
REQUIRE(structured["context"]["total_findings"].get<std::size_t>() >= 1);
|
|
REQUIRE(structured["deprecation"]["alias"] == "fold.find_duplicates");
|
|
REQUIRE(structured["deprecation"]["replacement"] == "fold.scan");
|
|
}
|
|
|
|
TEST_CASE("MCP: suggest_merge looks up a cached finding", "[mcp][cache]") {
|
|
const char* env = std::getenv("FOLD_FIXTURES_DIR");
|
|
std::string fixtures = env ? env : "../fixtures";
|
|
if (!std::filesystem::exists(fixtures + "/clones/exact.cpp")) {
|
|
fixtures = std::filesystem::current_path().string() + "/../fixtures";
|
|
}
|
|
writeFixtureDb(fixtures);
|
|
|
|
auto transport = std::make_unique<ScriptedTransport>();
|
|
fold::mcp::Server server(std::move(transport));
|
|
|
|
// Process initialize.
|
|
auto init_req = nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"method", "initialize"}};
|
|
auto init_resp = server.handleOne(init_req);
|
|
REQUIRE(init_resp.has_value());
|
|
REQUIRE_FALSE(init_resp->contains("error"));
|
|
|
|
// Run the scan to populate the session cache.
|
|
auto scan_req = nlohmann::json{{"jsonrpc", "2.0"},
|
|
{"id", 2},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{
|
|
{"name", "fold.find_duplicates"},
|
|
{"arguments", {{"compile_db", fixtures}}},
|
|
}}};
|
|
auto scan_resp = server.handleOne(scan_req);
|
|
REQUIRE(scan_resp.has_value());
|
|
REQUIRE((*scan_resp)["result"]["structuredContent"]["deprecation"]["alias"] ==
|
|
"fold.find_duplicates");
|
|
auto findings_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 22},
|
|
{"method", "tools/call"},
|
|
{"params", {{"name", "fold.get_findings"}, {"arguments", {{"limit", 50}}}}},
|
|
});
|
|
REQUIRE(findings_resp.has_value());
|
|
auto findings = (*findings_resp)["result"]["structuredContent"]["findings"];
|
|
REQUIRE(findings.is_array());
|
|
REQUIRE(findings.size() >= 1);
|
|
std::string fid = findings[0]["id"].get<std::string>();
|
|
REQUIRE_FALSE(fid.empty());
|
|
|
|
// Look up the cached finding — same server instance, populated cache.
|
|
auto merge_req = nlohmann::json{{"jsonrpc", "2.0"},
|
|
{"id", 3},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{
|
|
{"name", "fold.suggest_merge"},
|
|
{"arguments", {{"finding_id", fid}}},
|
|
}}};
|
|
auto merge_resp = server.handleOne(merge_req);
|
|
REQUIRE(merge_resp.has_value());
|
|
REQUIRE_FALSE(merge_resp->contains("error"));
|
|
REQUIRE((*merge_resp)["result"]["structuredContent"]["finding_id"] == fid);
|
|
REQUIRE((*merge_resp)["result"]["structuredContent"]["source"].is_string());
|
|
}
|
|
|
|
TEST_CASE("MCP: router scan stores context and pages findings", "[mcp][context]") {
|
|
const char* env = std::getenv("FOLD_FIXTURES_DIR");
|
|
std::string fixtures = env ? env : "../fixtures";
|
|
if (!std::filesystem::exists(fixtures + "/clones/exact.cpp")) {
|
|
fixtures = std::filesystem::current_path().string() + "/../fixtures";
|
|
}
|
|
writeFixtureDb(fixtures);
|
|
|
|
auto transport = std::make_unique<ScriptedTransport>();
|
|
fold::mcp::Server server(std::move(transport));
|
|
|
|
auto init_resp =
|
|
server.handleOne(nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"method", "initialize"}});
|
|
REQUIRE(init_resp.has_value());
|
|
REQUIRE_FALSE(init_resp->contains("error"));
|
|
|
|
auto scan_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 2},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.scan"},
|
|
{"arguments",
|
|
{{"mode", "source"},
|
|
{"compile_db", fixtures},
|
|
{"detectors", nlohmann::json::array({"clone"})},
|
|
{"null_deref_nullable_allocations", true}}}}},
|
|
});
|
|
REQUIRE(scan_resp.has_value());
|
|
REQUIRE_FALSE(scan_resp->contains("error"));
|
|
REQUIRE((*scan_resp)["result"]["structuredContent"]["deprecations"][0]["alias"] ==
|
|
"null_deref_nullable_allocations");
|
|
REQUIRE((*scan_resp)["result"]["structuredContent"]["deprecations"][0]["replacement"] ==
|
|
"nullable_allocations");
|
|
const auto& context = (*scan_resp)["result"]["structuredContent"]["context"];
|
|
REQUIRE(context["mode"] == "source");
|
|
REQUIRE(context["total_findings"].get<std::size_t>() >= 1);
|
|
|
|
auto context_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 3},
|
|
{"method", "tools/call"},
|
|
{"params", {{"name", "fold.get_scan_context"}, {"arguments", nlohmann::json::object()}}}});
|
|
REQUIRE(context_resp.has_value());
|
|
REQUIRE_FALSE(context_resp->contains("error"));
|
|
REQUIRE((*context_resp)["result"]["structuredContent"]["context"]["context_id"] ==
|
|
context["context_id"]);
|
|
|
|
auto findings_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 4},
|
|
{"method", "tools/call"},
|
|
{"params", {{"name", "fold.get_findings"}, {"arguments", {{"offset", 0}, {"limit", 2}}}}}});
|
|
REQUIRE(findings_resp.has_value());
|
|
REQUIRE_FALSE(findings_resp->contains("error"));
|
|
auto findings = (*findings_resp)["result"]["structuredContent"]["findings"];
|
|
REQUIRE(findings.is_array());
|
|
REQUIRE(findings.size() >= 1);
|
|
|
|
auto query_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 5},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.query_findings"},
|
|
{"arguments",
|
|
{{"pattern", "fold.clone"},
|
|
{"fields", nlohmann::json::array({"rule"})},
|
|
{"backend", "source"},
|
|
{"path_glob", "*clones*"},
|
|
{"limit", 1}}}}},
|
|
});
|
|
REQUIRE(query_resp.has_value());
|
|
REQUIRE_FALSE(query_resp->contains("error"));
|
|
const auto& query = (*query_resp)["result"]["structuredContent"];
|
|
REQUIRE(query["total_input"] == context["total_findings"]);
|
|
REQUIRE(query["total_matches"].get<std::size_t>() >= 1);
|
|
REQUIRE(query["total_actionable_matches"] == query["total_matches"]);
|
|
REQUIRE(query["findings"].size() == 1);
|
|
REQUIRE(query["findings"][0]["evidence"]["backend"] == "source");
|
|
REQUIRE(query["has_more"].is_boolean());
|
|
}
|
|
|
|
TEST_CASE("MCP: router scan auto-detects Rust paths", "[mcp][rust][router]") {
|
|
TempDir dir;
|
|
const auto source = dir.path / "unsafe.rs";
|
|
writeFile(source, "pub unsafe fn read_raw(p: *const i32) -> i32 { *p }\n"
|
|
"pub fn safe_ref(r: &i32) -> i32 { *r }\n");
|
|
|
|
auto transport = std::make_unique<ScriptedTransport>();
|
|
fold::mcp::Server server(std::move(transport));
|
|
|
|
auto init_resp =
|
|
server.handleOne(nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"method", "initialize"}});
|
|
REQUIRE(init_resp.has_value());
|
|
REQUIRE_FALSE(init_resp->contains("error"));
|
|
|
|
auto scan_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 2},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.scan"},
|
|
{"arguments",
|
|
{{"paths", nlohmann::json::array({source.string()})},
|
|
{"detectors", nlohmann::json::array({"null-deref"})}}}}},
|
|
});
|
|
REQUIRE(scan_resp.has_value());
|
|
REQUIRE_FALSE(scan_resp->contains("error"));
|
|
const auto& context = (*scan_resp)["result"]["structuredContent"]["context"];
|
|
REQUIRE(context["mode"] == "rust");
|
|
REQUIRE(context["source_ran"].get<bool>());
|
|
REQUIRE(context["total_findings"].get<std::size_t>() >= 1);
|
|
|
|
auto findings_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 3},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.get_findings"}, {"arguments", {{"offset", 0}, {"limit", 10}}}}}});
|
|
REQUIRE(findings_resp.has_value());
|
|
REQUIRE_FALSE(findings_resp->contains("error"));
|
|
auto findings = (*findings_resp)["result"]["structuredContent"]["findings"];
|
|
REQUIRE(findings.size() >= 1);
|
|
REQUIRE(findings[0]["kind"] == "null-deref");
|
|
REQUIRE(findings[0]["evidence"]["backend"] == "rust");
|
|
}
|
|
|
|
TEST_CASE("MCP: queued scan job can be polled and paged", "[mcp][queue]") {
|
|
const char* env = std::getenv("FOLD_FIXTURES_DIR");
|
|
std::string fixtures = env ? env : "../fixtures";
|
|
if (!std::filesystem::exists(fixtures + "/clones/exact.cpp")) {
|
|
fixtures = std::filesystem::current_path().string() + "/../fixtures";
|
|
}
|
|
writeFixtureDb(fixtures);
|
|
|
|
auto transport = std::make_unique<ScriptedTransport>();
|
|
fold::mcp::Server server(std::move(transport));
|
|
|
|
auto init_resp =
|
|
server.handleOne(nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"method", "initialize"}});
|
|
REQUIRE(init_resp.has_value());
|
|
REQUIRE_FALSE(init_resp->contains("error"));
|
|
|
|
auto start_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 2},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.scan_start"},
|
|
{"arguments",
|
|
{{"mode", "source"},
|
|
{"compile_db", fixtures},
|
|
{"detectors", nlohmann::json::array({"clone"})}}}}},
|
|
});
|
|
REQUIRE(start_resp.has_value());
|
|
REQUIRE_FALSE(start_resp->contains("error"));
|
|
const std::string job_id =
|
|
(*start_resp)["result"]["structuredContent"]["job"]["job_id"].get<std::string>();
|
|
REQUIRE_FALSE(job_id.empty());
|
|
|
|
nlohmann::json job;
|
|
for (int attempt = 0; attempt < 50; ++attempt) {
|
|
auto status_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 3},
|
|
{"method", "tools/call"},
|
|
{"params", {{"name", "fold.scan_status"}, {"arguments", {{"job_id", job_id}}}}}});
|
|
REQUIRE(status_resp.has_value());
|
|
REQUIRE_FALSE(status_resp->contains("error"));
|
|
job = (*status_resp)["result"]["structuredContent"]["job"];
|
|
if (job["status"] == "completed")
|
|
break;
|
|
REQUIRE(job["status"] != "failed");
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
|
}
|
|
REQUIRE(job["status"] == "completed");
|
|
REQUIRE(job["findings"].get<std::size_t>() >= 1);
|
|
|
|
auto findings_resp = server.handleOne(
|
|
nlohmann::json{{"jsonrpc", "2.0"},
|
|
{"id", 4},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.scan_job_findings"},
|
|
{"arguments", {{"job_id", job_id}, {"offset", 0}, {"limit", 2}}}}}});
|
|
REQUIRE(findings_resp.has_value());
|
|
REQUIRE_FALSE(findings_resp->contains("error"));
|
|
REQUIRE((*findings_resp)["result"]["structuredContent"]["findings"].size() >= 1);
|
|
|
|
auto query_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 40},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.query_findings"},
|
|
{"arguments", {{"job_id", job_id}, {"rule", "fold.clone"}, {"limit", 2}}}}},
|
|
});
|
|
REQUIRE(query_resp.has_value());
|
|
REQUIRE_FALSE(query_resp->contains("error"));
|
|
REQUIRE((*query_resp)["result"]["structuredContent"]["job_id"] == job_id);
|
|
REQUIRE((*query_resp)["result"]["structuredContent"]["total_matches"].get<std::size_t>() >= 1);
|
|
|
|
auto alias_findings_resp = server.handleOne(
|
|
nlohmann::json{{"jsonrpc", "2.0"},
|
|
{"id", 5},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.get_job_findings"},
|
|
{"arguments", {{"job_id", job_id}, {"offset", 0}, {"limit", 2}}}}}});
|
|
REQUIRE(alias_findings_resp.has_value());
|
|
REQUIRE_FALSE(alias_findings_resp->contains("error"));
|
|
REQUIRE((*alias_findings_resp)["result"]["structuredContent"]["findings"].size() >= 1);
|
|
REQUIRE((*alias_findings_resp)["result"]["structuredContent"]["deprecation"]["replacement"] ==
|
|
"fold.scan_job_findings");
|
|
|
|
auto alias_activate_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 6},
|
|
{"method", "tools/call"},
|
|
{"params", {{"name", "fold.activate_scan_job"}, {"arguments", {{"job_id", job_id}}}}},
|
|
});
|
|
REQUIRE(alias_activate_resp.has_value());
|
|
REQUIRE_FALSE(alias_activate_resp->contains("error"));
|
|
REQUIRE((*alias_activate_resp)["result"]["structuredContent"]["activated"].get<bool>());
|
|
REQUIRE((*alias_activate_resp)["result"]["structuredContent"]["deprecation"]["replacement"] ==
|
|
"fold.scan_activate");
|
|
|
|
auto activate_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 7},
|
|
{"method", "tools/call"},
|
|
{"params", {{"name", "fold.scan_activate"}, {"arguments", {{"job_id", job_id}}}}}});
|
|
REQUIRE(activate_resp.has_value());
|
|
REQUIRE_FALSE(activate_resp->contains("error"));
|
|
REQUIRE((*activate_resp)["result"]["structuredContent"]["activated"].get<bool>());
|
|
|
|
auto context_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 8},
|
|
{"method", "tools/call"},
|
|
{"params", {{"name", "fold.get_scan_context"}, {"arguments", nlohmann::json::object()}}}});
|
|
REQUIRE(context_resp.has_value());
|
|
REQUIRE_FALSE(context_resp->contains("error"));
|
|
REQUIRE((*context_resp)["result"]["structuredContent"]["context"]["total_findings"]
|
|
.get<std::size_t>() >= 1);
|
|
|
|
auto background_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 9},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.scan"},
|
|
{"arguments",
|
|
{{"background", true},
|
|
{"mode", "source"},
|
|
{"compile_db", fixtures},
|
|
{"detectors", nlohmann::json::array({"clone"})}}}}},
|
|
});
|
|
REQUIRE(background_resp.has_value());
|
|
REQUIRE_FALSE(background_resp->contains("error"));
|
|
const auto& deprecation = (*background_resp)["result"]["structuredContent"]["deprecation"];
|
|
REQUIRE(deprecation["alias"] == "fold.scan background=true");
|
|
REQUIRE(deprecation["replacement"] == "fold.scan_start");
|
|
}
|
|
|
|
TEST_CASE("MCP: fold.scan_rust scans .rs files directly", "[mcp][rust][scan_rust]") {
|
|
TempDir dir;
|
|
const auto source = dir.path / "unsafe.rs";
|
|
writeFile(source, "pub unsafe fn read_raw(p: *const i32) -> i32 { *p }\n"
|
|
"pub fn safe_ref(r: &i32) -> i32 { *r }\n");
|
|
|
|
auto transport = std::make_unique<ScriptedTransport>();
|
|
fold::mcp::Server server(std::move(transport));
|
|
|
|
auto init_resp =
|
|
server.handleOne(nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"method", "initialize"}});
|
|
REQUIRE(init_resp.has_value());
|
|
REQUIRE_FALSE(init_resp->contains("error"));
|
|
|
|
auto scan_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 2},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.scan_rust"},
|
|
{"arguments",
|
|
{{"paths", nlohmann::json::array({source.string()})},
|
|
{"detectors", nlohmann::json::array({"null-deref"})}}}}},
|
|
});
|
|
REQUIRE(scan_resp.has_value());
|
|
REQUIRE_FALSE(scan_resp->contains("error"));
|
|
const auto& structured = (*scan_resp)["result"]["structuredContent"];
|
|
REQUIRE(structured["deprecation"]["alias"] == "fold.scan_rust");
|
|
const auto& context = structured["context"];
|
|
REQUIRE(context["mode"] == "rust");
|
|
REQUIRE(context["total_findings"].get<std::size_t>() >= 1);
|
|
|
|
auto findings_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 3},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.get_findings"}, {"arguments", {{"offset", 0}, {"limit", 10}}}}}});
|
|
REQUIRE(findings_resp.has_value());
|
|
REQUIRE_FALSE(findings_resp->contains("error"));
|
|
auto findings = (*findings_resp)["result"]["structuredContent"]["findings"];
|
|
REQUIRE(findings.size() >= 1);
|
|
REQUIRE(findings[0]["evidence"]["backend"] == "rust");
|
|
}
|
|
|
|
TEST_CASE("MCP: fold.get_findings filters by backend", "[mcp][backend-filter]") {
|
|
const char* env = std::getenv("FOLD_FIXTURES_DIR");
|
|
std::string fixtures = env ? env : "../fixtures";
|
|
if (!std::filesystem::exists(fixtures + "/clones/exact.cpp")) {
|
|
fixtures = std::filesystem::current_path().string() + "/../fixtures";
|
|
}
|
|
writeFixtureDb(fixtures);
|
|
|
|
auto transport = std::make_unique<ScriptedTransport>();
|
|
fold::mcp::Server server(std::move(transport));
|
|
|
|
auto init_resp =
|
|
server.handleOne(nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"method", "initialize"}});
|
|
REQUIRE(init_resp.has_value());
|
|
REQUIRE_FALSE(init_resp->contains("error"));
|
|
|
|
// Run a source scan to populate findings.
|
|
auto scan_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 2},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.scan"},
|
|
{"arguments",
|
|
{{"mode", "source"},
|
|
{"compile_db", fixtures},
|
|
{"detectors", nlohmann::json::array({"clone"})}}}}},
|
|
});
|
|
REQUIRE(scan_resp.has_value());
|
|
REQUIRE_FALSE(scan_resp->contains("error"));
|
|
|
|
// Fetch all findings (no filter).
|
|
auto all_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 3},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.get_findings"}, {"arguments", {{"offset", 0}, {"limit", 50}}}}}});
|
|
REQUIRE(all_resp.has_value());
|
|
REQUIRE_FALSE(all_resp->contains("error"));
|
|
auto all_findings = (*all_resp)["result"]["structuredContent"]["findings"];
|
|
REQUIRE(all_findings.size() >= 1);
|
|
auto all_total = (*all_resp)["result"]["structuredContent"]["total"];
|
|
|
|
// Filter by backend=source — should match everything since scan was source-only.
|
|
auto source_resp = server.handleOne(
|
|
nlohmann::json{{"jsonrpc", "2.0"},
|
|
{"id", 4},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.get_findings"},
|
|
{"arguments", {{"offset", 0}, {"limit", 50}, {"backend", "source"}}}}}});
|
|
REQUIRE(source_resp.has_value());
|
|
REQUIRE_FALSE(source_resp->contains("error"));
|
|
auto source_findings = (*source_resp)["result"]["structuredContent"]["findings"];
|
|
auto source_total = (*source_resp)["result"]["structuredContent"]["total"];
|
|
REQUIRE(source_findings.size() >= 1);
|
|
REQUIRE(source_total == all_total);
|
|
for (const auto& f : source_findings) {
|
|
REQUIRE(f["evidence"]["backend"] == "source");
|
|
}
|
|
|
|
// Filter by backend=binary — should return nothing (source-only scan).
|
|
auto binary_resp = server.handleOne(
|
|
nlohmann::json{{"jsonrpc", "2.0"},
|
|
{"id", 5},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.get_findings"},
|
|
{"arguments", {{"offset", 0}, {"limit", 50}, {"backend", "binary"}}}}}});
|
|
REQUIRE(binary_resp.has_value());
|
|
REQUIRE_FALSE(binary_resp->contains("error"));
|
|
auto binary_total = (*binary_resp)["result"]["structuredContent"]["total"];
|
|
REQUIRE(binary_total.get<std::size_t>() == 0);
|
|
}
|
|
|
|
TEST_CASE("MCP: fold.scan with explicit mode=swift routes to Swift backend",
|
|
"[mcp][swift][router]") {
|
|
TempDir dir;
|
|
const auto source = dir.path / "test.swift";
|
|
writeFile(source, "func greet() { print(\"hello\") }\n");
|
|
|
|
auto transport = std::make_unique<ScriptedTransport>();
|
|
fold::mcp::Server server(std::move(transport));
|
|
|
|
auto init_resp =
|
|
server.handleOne(nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"method", "initialize"}});
|
|
REQUIRE(init_resp.has_value());
|
|
REQUIRE_FALSE(init_resp->contains("error"));
|
|
|
|
auto scan_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 2},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.scan"},
|
|
{"arguments", {{"mode", "swift"}, {"paths", nlohmann::json::array({source.string()})}}}}},
|
|
});
|
|
REQUIRE(scan_resp.has_value());
|
|
// Swift scan may fail if fold-swift binary is not available, but routing must work.
|
|
const auto& context = (*scan_resp)["result"]["structuredContent"]["context"];
|
|
REQUIRE(context["mode"] == "swift");
|
|
}
|
|
|
|
TEST_CASE("MCP: fold.scan_swift routes .swift files through Swift backend",
|
|
"[mcp][swift][scan_swift]") {
|
|
TempDir dir;
|
|
const auto source = dir.path / "test.swift";
|
|
writeFile(source, "func greet() { print(\"hello\") }\n");
|
|
|
|
auto transport = std::make_unique<ScriptedTransport>();
|
|
fold::mcp::Server server(std::move(transport));
|
|
|
|
auto init_resp =
|
|
server.handleOne(nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"method", "initialize"}});
|
|
REQUIRE(init_resp.has_value());
|
|
REQUIRE_FALSE(init_resp->contains("error"));
|
|
|
|
auto scan_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 2},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.scan_swift"},
|
|
{"arguments", {{"paths", nlohmann::json::array({source.string()})}}}}},
|
|
});
|
|
REQUIRE(scan_resp.has_value());
|
|
// Swift scan may fail if fold-swift binary is not available, but routing must work.
|
|
const auto& structured = (*scan_resp)["result"]["structuredContent"];
|
|
REQUIRE(structured["deprecation"]["alias"] == "fold.scan_swift");
|
|
REQUIRE(structured["context"]["mode"] == "swift");
|
|
}
|
|
|
|
TEST_CASE("MCP: fold.scan_start with Rust mode queues a Rust scan job", "[mcp][rust][queue]") {
|
|
TempDir dir;
|
|
const auto source = dir.path / "unsafe.rs";
|
|
writeFile(source, "pub unsafe fn read_raw(p: *const i32) -> i32 { *p }\n"
|
|
"pub fn safe_ref(r: &i32) -> i32 { *r }\n");
|
|
|
|
auto transport = std::make_unique<ScriptedTransport>();
|
|
fold::mcp::Server server(std::move(transport));
|
|
|
|
auto init_resp =
|
|
server.handleOne(nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"method", "initialize"}});
|
|
REQUIRE(init_resp.has_value());
|
|
REQUIRE_FALSE(init_resp->contains("error"));
|
|
|
|
auto start_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 2},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "fold.scan_start"},
|
|
{"arguments",
|
|
{{"mode", "rust"},
|
|
{"paths", nlohmann::json::array({source.string()})},
|
|
{"detectors", nlohmann::json::array({"null-deref"})}}}}},
|
|
});
|
|
REQUIRE(start_resp.has_value());
|
|
REQUIRE_FALSE(start_resp->contains("error"));
|
|
const std::string job_id =
|
|
(*start_resp)["result"]["structuredContent"]["job"]["job_id"].get<std::string>();
|
|
REQUIRE_FALSE(job_id.empty());
|
|
|
|
// Poll until completed or failed.
|
|
nlohmann::json job;
|
|
for (int attempt = 0; attempt < 50; ++attempt) {
|
|
auto status_resp = server.handleOne(nlohmann::json{
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 3},
|
|
{"method", "tools/call"},
|
|
{"params", {{"name", "fold.scan_status"}, {"arguments", {{"job_id", job_id}}}}}});
|
|
REQUIRE(status_resp.has_value());
|
|
REQUIRE_FALSE(status_resp->contains("error"));
|
|
job = (*status_resp)["result"]["structuredContent"]["job"];
|
|
if (job["status"] == "completed" || job["status"] == "failed")
|
|
break;
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
|
}
|
|
REQUIRE((job["status"] == "completed" || job["status"] == "failed"));
|
|
}
|