// capmcp_unit: table checks, drift checks against ../cap.md and ../mcp.md, // the MCP vault tables, the normalizer and the served endpoint (in process). // Run from taicpp/: build/capmcp/capmcp_unit #include #include #include #include #include #include "cap_agent_manifest.h" #include "cap_binding.h" #include "cap_constants.h" #include "cap_error.h" #include "cap_event.h" #include "cap_manifest.h" #include "cap_stream_json.h" #include "mcp_constants.h" #include "mcp_error.h" #include "mcp_normalizer.h" #include "mcp_serve_handler.h" #include "mcp_sqlite_store.h" using namespace detz; using detz::wire::Json; static int failures = 0; #define CHECK(cond) \ do { \ if (!(cond)) { \ std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \ ++failures; \ } \ } while (0) static std::string slurp(const char* path) { std::ifstream in(path, std::ios::binary); return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); } class FakeAgent : public mcp::McpServeAgent { public: std::string dispatch(const std::string& name, const Json& args) override { if (name == "echo") return args.dumps(); if (name == "deny") throw mcp::McpToolDenied(); return "error: unknown tool " + name; } std::string runTurn(const std::string& message, int steps, double) override { return "reply:" + message + ":" + std::to_string(steps); } }; class FakeBackend : public mcp::McpServeBackend { public: std::vector toolSchemas() override { return {Json::object({{"type", Json("function")}, {"function", Json::object({{"name", Json("echo")}, {"description", Json("d")}, {"parameters", Json::object({{"type", Json("object")}})}})}})}; } std::shared_ptr createAgent() override { return std::make_shared(); } int maxSteps() override { return 25; } std::string complete(const std::vector&) override { return "done"; } }; class TestStore : public mcp::McpSqliteStore { public: using McpSqliteStore::McpSqliteStore; std::optional loadSecret(const std::string&, const std::string&) override { return std::nullopt; } void saveSecret(const std::string&, const std::string&, const Json&, const std::string&) override {} void deleteSecret(const std::string&, const std::string&) override {} Json secretMeta(const std::string&, const std::string&) override { return Json::object(); } std::string spillRecord(const std::string&, const std::string&, const std::string&) override { return "mem:1"; } void auditEvent(const std::string&, const std::string&, const std::string&, const std::string&, const std::string&) override {} }; int main() { // --- CAP tables CHECK(cap::CAP_ERROR_CODES.size() == 15); CHECK(cap::CapError("cap_budget_exceeded", "x").numeric == -32004); CHECK(cap::CapError("cap_custom").numeric == 0); CHECK(std::string(cap::CapError("cap_custom").what()) == "cap_custom"); CHECK(cap::CapEvent::KINDS().size() == 19); for (const auto& kind : cap::CapEvent::KINDS()) CHECK(cap::CapEvent::DIRECTIONS(kind) != nullptr); Json ev = cap::CapEvent::make("cap.text_chunk", Json::object({{"message_id", Json("m1")}, {"text", Json("hi")}, {"channel", Json("assistant")}})); CHECK(ev.dumps() == "{\"kind\": \"cap.text_chunk\", \"message_id\": \"m1\", \"text\": \"hi\", \"channel\": \"assistant\"}"); bool threw = false; try { cap::CapEvent::make("cap.text_chunk", Json::object({{"message_id", Json("m1")}, {"text", Json("hi")}})); } catch (const wire::ValueError&) { threw = true; } CHECK(threw); CHECK(cap::CapBinding::select(Json::object({{"fast_path", Json::object({{"grpc", Json(true)}})}})) == "pty"); cap::CapManifest manifest = cap::CapManifest::fromToml(cap::CapAgentManifest::TAI_CAP_MANIFEST(), "own"); CHECK(manifest.name() == "tai"); CHECK(manifest.binding() == "stream_json"); CHECK(manifest.get("parse.idle").items().size() == 1); CHECK(cap::CapStreamJson::parseUserText("{\"type\":\"user\",\"message\":\"x\"}") == "x"); // --- drift check against ../cap.md const std::string capDoc = slurp("../cap.md"); if (!capDoc.empty()) { for (const auto& kind : cap::CapEvent::KINDS()) CHECK(capDoc.find("`" + kind + "`") != std::string::npos); for (const auto& entry : cap::CAP_ERROR_CODES) CHECK(capDoc.find(std::string(entry.second)) != std::string::npos); for (const auto& code : cap::CAP_ERROR_STRINGS) CHECK(capDoc.find("`" + std::string(code) + "`") != std::string::npos); CHECK(capDoc.find("\xE2\x80\x94") == std::string::npos); } else { std::printf("note: ../cap.md not found, doc drift check skipped\n"); } const std::string mcpDoc = slurp("../mcp.md"); if (!mcpDoc.empty()) { for (const char* name : {"MCP_CONNECT_TIMEOUT", "MCP_CALL_TIMEOUT", "MCP_RESULT_MAX", "MCP_HTTP_MAX_BODY", "MCP_MAX_SERVERS", "MCP_MAX_TOOLS", "MCP_SESSION_IDLE", "MCP_CHAT_TIMEOUT", "MCP_RESOLVER_TIMEOUT", "MCP_RESOLVER_STEPS", "MCP_OAUTH_TIMEOUT"}) CHECK(mcpDoc.find(name) != std::string::npos); } // --- normalizer CHECK(mcp::McpNormalizer::mcpSanitizeName("Foo Bar!") == "foo_bar"); CHECK(mcp::McpNormalizer::mcpSanitizeName("!!") == "server"); auto expanded = mcp::McpNormalizer::mcpExpandVars("a ${X} ${Y:-d} ${Z}", [](const std::string& n) -> std::optional { if (n == "X") return std::string("1"); return std::nullopt; }); CHECK(expanded.first == "a 1 d ${Z}"); CHECK(expanded.second.size() == 1 && expanded.second[0] == "Z"); auto norm = mcp::McpNormalizer::mcpNormalize("{\"mcpServers\": {\"s\": {\"command\": \"npx\", \"args\": [\"-y\", \"p\"]}}}"); CHECK(norm.entry.get("transport").str() == "stdio"); CHECK(norm.entry.get("source").str() == "config-json"); threw = false; try { mcp::McpNormalizer::mcpNormalize("https://github.com/o/r"); } catch (const mcp::McpError&) { threw = true; } CHECK(threw); CHECK(mcp::McpNormalizer::mcpBridgeName("My Srv", "do-it") == "mcp_my_srv_do_it"); // --- vault tables sqlite3* db = nullptr; sqlite3_open(":memory:", &db); mcp::McpPlainSeal seal; TestStore store(db, &seal); store.ensureSchema(); store.mcpSaveServer(Json::object({{"name", Json("srv")}, {"transport", Json("http")}, {"url", Json("https://x.test/mcp")}, {"headers", Json::object({{"Authorization", Json("Bearer s")}})}})); CHECK(store.mcpGetServer("srv").get("url").str() == "https://x.test/mcp"); CHECK(!store.mcpServerEnabled("srv", "t")); store.mcpSetEnabled("srv", "t", true); CHECK(store.mcpEnabledServers("t").size() == 1); store.mcpStoreTools("srv", {Json::object({{"name", Json("b")}, {"description", Json("bee")}}), Json::object({{"name", Json("a")}})}); CHECK(store.mcpCachedTools("srv")[0].get("tool").str() == "a"); CHECK(store.mcpToolPins("srv").size() == 2); threw = false; try { store.mcpSaveServer(Json::object({{"name", Json("bad name!")}})); } catch (const wire::ValueError&) { threw = true; } CHECK(threw); store.mcpRemoveServer("srv"); CHECK(store.mcpGetServer("srv").isNull()); sqlite3_close(db); // --- served endpoint, in process FakeBackend backend; mcp::McpServeContext ctx(backend, "k"); mcp::McpServeHandler handler(ctx); wire::WireHttpServer::Request req; wire::WireHttpServer::Response resp; req.method = "POST"; req.path = "/mcp"; req.headers["authorization"] = "Bearer k"; req.body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"; req.headers["content-length"] = std::to_string(req.body.size()); CHECK(handler.handle(req, resp) && resp.status == 200); CHECK(Json::parse(resp.body).get("result").get("protocolVersion").str() == "2025-11-25"); std::string sid; for (const auto& h : resp.headers) if (h.first == "Mcp-Session-Id") sid = h.second; CHECK(sid.size() == 32); req.headers["mcp-session-id"] = sid; req.body = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"deny\",\"arguments\":{}}}"; req.headers["content-length"] = std::to_string(req.body.size()); handler.handle(req, resp); CHECK(Json::parse(resp.body).get("result").get("isError").truthy()); req.headers["authorization"] = "Bearer wrong"; handler.handle(req, resp); CHECK(resp.status == 401); std::printf(failures ? "%d FAILURES\n" : "capmcp_unit: all checks passed\n", failures); return failures ? 1 : 0; }