197 lines
8.8 KiB
C++
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;
|
|
}
|