// MCP Server async execution tests // Catch2 migration from GTest (yams-3s4 / yams-84g) #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "../../common/test_helpers_catch2.h" using namespace std::chrono_literals; using Catch::Matchers::ContainsSubstring; namespace { // Minimal no-op transport for constructing MCPServer in tests without starting loops. class NullTransport : public yams::mcp::ITransport { public: void send(const nlohmann::json&) override {} yams::mcp::MessageResult receive() override { return yams::Error{yams::ErrorCode::NotImplemented, "Null transport"}; } bool isConnected() const override { return false; } void close() override {} yams::mcp::TransportState getState() const override { return yams::mcp::TransportState::Disconnected; } }; template auto run_awaitable_with_timeout(Awaitable aw, std::chrono::milliseconds timeout) -> std::optional { using T = typename Awaitable::value_type; auto& io = yams::daemon::GlobalIOContext::instance().get_io_context(); std::promise p; auto fut = p.get_future(); boost::asio::co_spawn( io, [aw = std::move(aw), pr = std::move(p)]() mutable -> boost::asio::awaitable { T r = co_await std::move(aw); pr.set_value(std::move(r)); co_return; }, boost::asio::detached); if (fut.wait_for(timeout) != std::future_status::ready) { return std::nullopt; } return fut.get(); } class MCPAsyncExecFixture { public: yams::test::ScopedEnvVar discovery{"YAMS_ENABLE_LOCAL_MCP_DISCOVERY", std::string{"0"}}; yams::test::ScopedEnvVar autoStart{"YAMS_CLI_DISABLE_DAEMON_AUTOSTART", std::string{"1"}}; std::optional socketEnvironment; std::shared_ptr server; std::filesystem::path socketPath; MCPAsyncExecFixture() { auto transport = std::make_unique(); server = std::make_shared(std::move(transport)); const auto uniqueName = std::string("yams-mcp-test-") + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".sock"; socketPath = std::filesystem::temp_directory_path() / uniqueName; socketEnvironment.emplace("YAMS_DAEMON_SOCKET_PATH", socketPath.string()); yams::daemon::ClientConfig cfg; cfg.autoStart = false; cfg.maxRetries = 0; cfg.connectTimeout = std::chrono::milliseconds{50}; cfg.requestTimeout = std::chrono::milliseconds{50}; cfg.socketPath = socketPath; std::error_code ec; std::filesystem::remove(cfg.socketPath, ec); server->testConfigureDaemonClient(cfg); server->testSetEnsureDaemonClientHook( [](const yams::daemon::ClientConfig&) -> yams::Result { return yams::Error{yams::ErrorCode::NetworkError, "Daemon socket not found for test harness"}; }); } ~MCPAsyncExecFixture() { if (server) { server->testShutdown(); } } template void expectAsyncCompletion(Awaitable makeAwaitable, const std::string& toolName) { auto start = std::chrono::steady_clock::now(); auto result = run_awaitable_with_timeout(makeAwaitable(), std::chrono::seconds(3)); auto elapsed = std::chrono::steady_clock::now() - start; INFO(toolName + " handler timed out"); REQUIRE(result.has_value()); auto elapsedMs = std::chrono::duration_cast(elapsed); INFO(toolName + " harness took too long: " + std::to_string(elapsedMs.count()) + "ms"); CHECK(elapsedMs < 500ms); if (result->has_value()) { SUCCEED(toolName + " handler completed successfully"); } else { const auto& error = result->error(); INFO(toolName + " error message missing"); CHECK_FALSE(error.message.empty()); INFO(toolName + " unexpected error message: " + error.message); CHECK(error.message.find("test harness") != std::string::npos); } } }; } // namespace // Ensure async tool handlers return robustly (error or value), not crash/hang, // when daemon is unavailable in test environment. TEST_CASE_METHOD(MCPAsyncExecFixture, "MCP async tools return structured errors without daemon", "[mcp][async][exec][catch2]") { SECTION("search tool returns error") { expectAsyncCompletion( [&]() -> boost::asio::awaitable> { yams::mcp::MCPSearchRequest req; req.query = "foo"; co_return co_await server->testHandleSearchDocuments(req); }, "search"); } SECTION("grep tool returns error") { expectAsyncCompletion( [&]() -> boost::asio::awaitable> { yams::mcp::MCPGrepRequest req; req.pattern = "bar"; co_return co_await server->testHandleGrepDocuments(req); }, "grep"); } SECTION("retrieve tool returns error") { expectAsyncCompletion( [&]() -> boost::asio::awaitable> { yams::mcp::MCPRetrieveDocumentRequest req; req.hash = "deadbeef"; co_return co_await server->testHandleRetrieveDocument(req); }, "retrieve"); } SECTION("list tool returns error") { expectAsyncCompletion( [&]() -> boost::asio::awaitable> { yams::mcp::MCPListDocumentsRequest req; co_return co_await server->testHandleListDocuments(req); }, "list"); } SECTION("stats tool returns error") { expectAsyncCompletion( [&]() -> boost::asio::awaitable> { yams::mcp::MCPStatsRequest req; co_return co_await server->testHandleGetStats(req); }, "stats"); } SECTION("callToolAsync returns structured error") { auto syncResult = run_awaitable_with_timeout( server->testCallToolAsync("search", nlohmann::json{{"query", "foo"}}), std::chrono::seconds(3)); REQUIRE(syncResult.has_value()); REQUIRE(syncResult->is_object()); if (syncResult->contains("error")) { const auto& error = (*syncResult)["error"]; REQUIRE(error.contains("message")); CHECK_THAT(error["message"].get(), ContainsSubstring("test harness")); } else { INFO("callTool result: " << syncResult->dump()); REQUIRE(syncResult->contains("isError")); REQUIRE((*syncResult)["isError"].is_boolean()); CHECK((*syncResult)["isError"].get()); REQUIRE(syncResult->contains("content")); const auto& content = (*syncResult)["content"]; REQUIRE(content.is_array()); REQUIRE_FALSE(content.empty()); const auto& first = content.front(); REQUIRE(first.contains("text")); CHECK_THAT(first["text"].get(), ContainsSubstring("test harness")); } } }