fold/tests/integration/test_mcp_stdio.cpp
trvon f0fe006d6b
fix(docs,tests): correct the pre-push hook claim, a dead link, MCP cleanup
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.
2026-09-24 20:57:32 -06:00

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