// Unit tests for yams::mcp::ModeRouter. Exercises the shared dispatch loop with // synthetic op tables and fake dispatch callbacks — no MCPServer instance required. #include #include #include #include #include #include #include #include #include #include #include using namespace std::chrono_literals; using json = nlohmann::json; using Catch::Matchers::ContainsSubstring; using yams::mcp::ModeRouter; using yams::mcp::ModeRouterConfig; using yams::mcp::ModeRouterNormalizeResult; using yams::mcp::ModeRouterStepResult; namespace { json runRouter(const ModeRouter& router, const json& args, std::string_view proto = "2024-11-05", bool limitDup = false, std::chrono::milliseconds timeout = 3s) { auto& io = yams::daemon::GlobalIOContext::instance().get_io_context(); auto fut = boost::asio::co_spawn( io, [&]() -> boost::asio::awaitable { co_return co_await router.handle(args, proto, limitDup); }, boost::asio::use_future); if (fut.wait_for(timeout) != std::future_status::ready) { return json{{"error", "timeout"}}; } return fut.get(); } ModeRouterConfig makeExecuteCfg(ModeRouterConfig::DispatchFn dispatch, ModeRouterConfig::NormalizeFn normalize = {}) { ModeRouterConfig cfg; cfg.kind = "write"; cfg.stepsKey = "operations"; cfg.summaryPrefix = "Execute"; cfg.invalidOpHint = "Use 'query' for reads."; cfg.opTable = {{"add", "add"}, {"update", "update"}}; cfg.normalize = std::move(normalize); cfg.dispatch = std::move(dispatch); cfg.stepProjector = yams::mcp::projections::executeStepProjection; cfg.finalResultBuilder = yams::mcp::projections::executeFinalResult; return cfg; } ModeRouterStepResult makeOk(std::string op, std::size_t i, json data = json::object()) { ModeRouterStepResult r; r.stepIndex = i; r.op = std::move(op); r.isError = false; r.data = std::move(data); return r; } ModeRouterStepResult makeErr(std::string op, std::size_t i, std::string msg) { ModeRouterStepResult r; r.stepIndex = i; r.op = std::move(op); r.isError = true; r.primaryError = msg; r.data = json{{"error", std::move(msg)}}; return r; } } // namespace TEST_CASE("ModeRouter rejects missing stepsKey", "[mcp][mode_router]") { ModeRouter router( makeExecuteCfg([](std::string, std::string, json, std::size_t) -> boost::asio::awaitable { co_return makeOk("add", 0); })); auto result = runRouter(router, json::object()); REQUIRE(result.value("isError", false)); const auto& text = result["content"][0]["text"].get_ref(); REQUIRE_THAT(text, ContainsSubstring("operations")); } TEST_CASE("ModeRouter rejects empty steps array", "[mcp][mode_router]") { ModeRouter router( makeExecuteCfg([](std::string, std::string, json, std::size_t) -> boost::asio::awaitable { co_return makeOk("add", 0); })); auto result = runRouter(router, json{{"operations", json::array()}}); REQUIRE(result.value("isError", false)); } TEST_CASE("ModeRouter dispatches valid batch and aggregates", "[mcp][mode_router]") { std::vector calls; auto dispatch = [&calls](std::string op, std::string /*target*/, json /*params*/, std::size_t i) -> boost::asio::awaitable { calls.push_back(op); co_return makeOk(op, i, json{{"ok", true}}); }; ModeRouter router(makeExecuteCfg(dispatch)); json args = {{"operations", json::array({ json{{"op", "add"}, {"params", json{{"path", "a"}}}}, json{{"op", "update"}, {"params", json{{"path", "b"}}}}, })}}; auto result = runRouter(router, args); REQUIRE_FALSE(result.value("isError", false)); REQUIRE(calls.size() == 2); const auto& data = result["structuredContent"]["data"]; REQUIRE(data["succeeded"].get() == 2); REQUIRE(data["failed"].get() == 0); REQUIRE(data["totalOps"].get() == 2); REQUIRE(data["results"].size() == 2); REQUIRE(data["results"][0]["success"].get()); REQUIRE(data["results"][0]["op"].get() == "add"); } TEST_CASE("ModeRouter flags unknown op with kind-specific hint", "[mcp][mode_router]") { ModeRouter router( makeExecuteCfg([](std::string, std::string, json, std::size_t) -> boost::asio::awaitable { co_return makeOk("add", 0); })); json args = {{"operations", json::array({json{{"op", "search"}, {"params", json::object()}}})}}; auto result = runRouter(router, args); REQUIRE(result.value("isError", false)); const auto& data = result["structuredContent"]["data"]; REQUIRE(data["failed"].get() == 1); const auto& err = data["results"][0]["error"].get_ref(); REQUIRE_THAT(err, ContainsSubstring("'search' is not a write operation")); REQUIRE_THAT(err, ContainsSubstring("Use 'query' for reads.")); } TEST_CASE("ModeRouter short-circuits on normalize error", "[mcp][mode_router]") { bool dispatched = false; auto normalize = [](const std::string&, json p) { return ModeRouterNormalizeResult{std::move(p), std::string("bad input")}; }; auto dispatch = [&dispatched](std::string op, std::string, json, std::size_t i) -> boost::asio::awaitable { dispatched = true; co_return makeOk(op, i); }; ModeRouter router(makeExecuteCfg(dispatch, normalize)); json args = {{"operations", json::array({json{{"op", "add"}, {"params", json::object()}}})}}; auto result = runRouter(router, args); REQUIRE(result.value("isError", false)); REQUIRE_FALSE(dispatched); REQUIRE(result["structuredContent"]["data"]["results"][0]["error"].get() == "bad input"); } TEST_CASE("ModeRouter halts on first failure by default", "[mcp][mode_router]") { int calls = 0; auto dispatch = [&calls](std::string op, std::string, json, std::size_t i) -> boost::asio::awaitable { ++calls; if (op == "add") { co_return makeErr("add", i, "boom"); } co_return makeOk(op, i); }; ModeRouter router(makeExecuteCfg(dispatch)); json args = {{"operations", json::array({ json{{"op", "add"}, {"params", json::object()}}, json{{"op", "update"}, {"params", json::object()}}, })}}; auto result = runRouter(router, args); REQUIRE(result.value("isError", false)); REQUIRE(calls == 1); REQUIRE(result["structuredContent"]["data"]["results"].size() == 1); } TEST_CASE("ModeRouter continues on error when asked", "[mcp][mode_router]") { int calls = 0; auto dispatch = [&calls](std::string op, std::string, json, std::size_t i) -> boost::asio::awaitable { ++calls; if (op == "add") { co_return makeErr("add", i, "boom"); } co_return makeOk(op, i); }; ModeRouter router(makeExecuteCfg(dispatch)); json args = {{"operations", json::array({ json{{"op", "add"}, {"params", json::object()}}, json{{"op", "update"}, {"params", json::object()}}, })}, {"continueOnError", true}}; auto result = runRouter(router, args); REQUIRE(calls == 2); const auto& data = result["structuredContent"]["data"]; REQUIRE(data["succeeded"].get() == 1); REQUIRE(data["failed"].get() == 1); } TEST_CASE("ModeRouter query projection returns steps/totalSteps/completedSteps", "[mcp][mode_router]") { ModeRouterConfig cfg; cfg.kind = "read"; cfg.stepsKey = "steps"; cfg.summaryPrefix = "Pipeline"; cfg.opTable = {{"search", "search"}}; cfg.dispatch = [](std::string op, std::string, json, std::size_t i) -> boost::asio::awaitable { co_return makeOk(op, i, json{{"hits", 0}}); }; cfg.stepProjector = yams::mcp::projections::queryStepProjection; cfg.finalResultBuilder = yams::mcp::projections::queryFinalResult; ModeRouter router(std::move(cfg)); json args = {{"steps", json::array({json{{"op", "search"}, {"params", json::object()}}})}}; auto result = runRouter(router, args); REQUIRE_FALSE(result.value("isError", false)); const auto& data = result["structuredContent"]["data"]; REQUIRE(data.contains("steps")); REQUIRE(data["totalSteps"].get() == 1); REQUIRE(data["completedSteps"].get() == 1); REQUIRE_FALSE(data["steps"][0]["isError"].get()); } TEST_CASE("ModeRouter query projection preserves partial results on dispatch failure", "[mcp][mode_router]") { ModeRouterConfig cfg; cfg.kind = "read"; cfg.stepsKey = "steps"; cfg.summaryPrefix = "Pipeline"; cfg.opTable = {{"grep", "grep"}, {"search", "search"}}; cfg.dispatchErrorsAreFatal = false; cfg.dispatch = [](std::string op, std::string, json, std::size_t i) -> boost::asio::awaitable { if (op == "grep") { co_return makeOk(op, i, json{{"match_count", 0}}); } co_return makeErr(op, i, "[ipc:timeout] Read timeout"); }; cfg.stepProjector = yams::mcp::projections::queryStepProjection; cfg.finalResultBuilder = yams::mcp::projections::queryFinalResult; ModeRouter router(std::move(cfg)); json args = {{"steps", json::array({{{"op", "grep"}, {"params", json::object()}}, {{"op", "search"}, {"params", json::object()}}})}}; auto result = runRouter(router, args); REQUIRE_FALSE(result.value("isError", false)); const auto& text = result["content"][0]["text"].get_ref(); REQUIRE_THAT(text, ContainsSubstring("1 succeeded, 1 failed")); REQUIRE_THAT(text, ContainsSubstring("[ipc:timeout] Read timeout")); const auto& data = result["structuredContent"]["data"]; REQUIRE(data["totalSteps"].get() == 2); REQUIRE(data["completedSteps"].get() == 2); REQUIRE_FALSE(data["steps"][0]["isError"].get()); REQUIRE(data["steps"][1]["isError"].get()); REQUIRE(data["steps"][1]["result"]["error"].get() == "[ipc:timeout] Read timeout"); } TEST_CASE("ModeRouter query validation failures remain fatal", "[mcp][mode_router]") { ModeRouterConfig cfg; cfg.kind = "read"; cfg.stepsKey = "steps"; cfg.summaryPrefix = "Pipeline"; cfg.opTable = {{"search", "search"}}; cfg.dispatchErrorsAreFatal = false; cfg.dispatch = [](std::string op, std::string, json, std::size_t i) -> boost::asio::awaitable { co_return makeOk(op, i, json{{"hits", 0}}); }; cfg.stepProjector = yams::mcp::projections::queryStepProjection; cfg.finalResultBuilder = yams::mcp::projections::queryFinalResult; ModeRouter router(std::move(cfg)); auto result = runRouter(router, json{{"steps", json::array({json{{"params", json::object()}}})}}); REQUIRE(result.value("isError", false)); const auto& text = result["content"][0]["text"].get_ref(); REQUIRE_THAT(text, ContainsSubstring("missing 'op' field")); const auto& data = result["structuredContent"]["data"]; REQUIRE(data["completedSteps"].get() == 1); REQUIRE(data["steps"][0]["isError"].get()); }