#include #include #include #include #include #include #include #include #include #include #include #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 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& out() const { return out_; } private: std::deque in_; std::vector 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(); 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 names; for (const auto& tool : tools) { names.push_back(tool["name"]); REQUIRE(tool["inputSchema"]["additionalProperties"] == false); } const std::vector 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 scan_properties; for (const auto& [key, value] : (*scan_tool)["inputSchema"]["properties"].items()) { (void)value; scan_properties.push_back(key); } const std::vector 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 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(); 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 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().find("field `") != std::string::npos); } } TEST_CASE("MCP: tools/list before initialize returns ServerNotInitialized", "[mcp][errors]") { auto transport = std::make_unique(); 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(); 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() >= 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(); 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(); 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(); 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() >= 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() >= 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(); 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()); REQUIRE(context["total_findings"].get() >= 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(); 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(); 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() >= 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() >= 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()); 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()); 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() >= 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(); 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() >= 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(); 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() == 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(); 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(); 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(); 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(); 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")); }