Files
tai/taicpp/tests/capmcp_unit.cpp
T

197 lines
8.8 KiB
C++

// 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 <sqlite3.h>
#include <cstdio>
#include <fstream>
#include <iterator>
#include <sstream>
#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<char>(in), std::istreambuf_iterator<char>());
}
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<Json> 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<mcp::McpServeAgent> createAgent() override { return std::make_shared<FakeAgent>(); }
int maxSteps() override { return 25; }
std::string complete(const std::vector<Json>&) override { return "done"; }
};
class TestStore : public mcp::McpSqliteStore {
public:
using McpSqliteStore::McpSqliteStore;
std::optional<std::string> 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<std::string> {
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;
}