/**
* TelosDB MCP クライアントテスト。
* ビルドバージョン(package.json)と API /version(Cargo.toml 由来)の一致も検証する。
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PKG = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../package.json"), "utf8"));
/**
* 前提: MCP が 127.0.0.1:3001 で起動していること(npm run test-and-heal または test-and-heal:pro で起動+実行される)。
*
* テスト内容:
* [1] tools/list
* [2] search_text
* [3] Pro: スコアが全件 0.4 でないこと(警告のみの緩いチェック)
* [4] Pro: ベクトル化 E2E — RE-INDEX 実行 → indexing_status が idle になるまで待機 → 検索 → vector_search_used が true かつスコアが全件 0.4 でないことを検証(ここで失敗すればベクトル化が効いていない)
* [5] get_item_by_id(不正 id で isError)
* [6] get_document_count
* [7] add_item_text → list_documents → get_document → get_item_by_id → update_item → delete_item → delete_document(CRUD 一連)
*/
import axios from 'axios';
const API_BASE = "http://127.0.0.1:3001";
async function postMessage(axiosInstance, method, params = {}) {
const res = await axiosInstance.post("/messages", {
jsonrpc: "2.0",
method: method,
params: params,
id: Date.now()
});
return res.data;
}
/**
* Pro 時: 複数ヒットで全件スコア 0.4 なら LIKE フォールバックのみ=近似近傍が動いていない欠陥を検出する。
* strict が true のときは失敗とする。false のときは警告のみ(既存データに依存するため)。
*/
async function assertProVectorScoresNotAllFallback(axiosInstance, strict = false) {
const editionRes = await axiosInstance.get("/edition");
if (editionRes.data?.edition !== "pro") return;
const searchResult = await postMessage(axiosInstance, "tools/call", {
name: "search_text",
arguments: { content: "文", limit: 5 }
});
const raw = searchResult.result?.content?.[0]?.text;
if (!raw) throw new Error("Pro vector check: no search response");
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("Pro vector check: search result is not JSON");
}
const items = Array.isArray(parsed) ? parsed : (parsed?.items ?? []);
if (!Array.isArray(items) || items.length < 2) {
if (strict) throw new Error("Pro vector check: need at least 2 docs to assert scores");
return;
}
const likeFallbackScore = 0.4;
const allFallback = items.every(
(it) => Math.abs((it.similarity ?? 0) - likeFallbackScore) < 1e-5
);
if (allFallback) {
const msg = "Pro: 全件スコア 0.4(キーワード一致のみ)。RE-INDEX でベクトル化してください。";
if (strict) throw new Error(msg);
console.warn(" Pro vector check:", msg);
return;
}
console.log(" Pro vector check: at least one score != 0.4 (OK)");
}
/**
* Pro ベクトル化 E2E: RE-INDEX 実行 → 完了待ち → 検索で vector_search_used とスコアを検証。
* ベクトル化が正しく動いていることをテストする。
*/
async function testProVectorizationE2E(axiosInstance) {
const editionRes = await axiosInstance.get("/edition");
if (editionRes.data?.edition !== "pro") {
console.log(" Pro vectorization E2E: skip (not Pro)");
return;
}
console.log(" Pro vectorization E2E: calling RE-INDEX (lsa_retrain)...");
await postMessage(axiosInstance, "lsa_retrain", {});
const timeoutMs = 120_000;
const pollMs = 2_000;
const deadline = Date.now() + timeoutMs;
let status = "";
while (Date.now() < deadline) {
const r = await axiosInstance.get("/indexing_status");
status = (r.data && r.data.status) ? r.data.status : String(r.data ?? "");
if (status === "idle") break;
await new Promise((res) => setTimeout(res, pollMs));
}
if (status !== "idle") {
throw new Error(`Pro vectorization E2E: RE-INDEX did not become idle within ${timeoutMs / 1000}s (status=${status})`);
}
console.log(" Pro vectorization E2E: RE-INDEX done, searching...");
const searchResult = await postMessage(axiosInstance, "tools/call", {
name: "search_text",
arguments: { content: "文", limit: 5 }
});
const raw = searchResult.result?.content?.[0]?.text;
if (!raw) throw new Error("Pro vectorization E2E: no search response");
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("Pro vectorization E2E: search result is not JSON");
}
const items = Array.isArray(parsed) ? parsed : (parsed?.items ?? []);
const vectorSearchUsed = parsed?.vector_search_used === true;
if (!vectorSearchUsed && items.length > 0) {
throw new Error(
"Pro vectorization E2E: vector_search_used が false。ベクトル化が反映されていません(vec_items/HNSW を確認してください)。"
);
}
const likeFallbackScore = 0.4;
const allFallback = items.length >= 2 && items.every(
(it) => Math.abs((it.similarity ?? 0) - likeFallbackScore) < 1e-5
);
if (allFallback) {
throw new Error(
"Pro vectorization E2E: RE-INDEX 後も全件スコア 0.4。ベクトル検索が効いていません。"
);
}
console.log(" Pro vectorization E2E: vector_search_used=true, スコア差あり (OK)");
}
function parseResultText(result) {
const text = result?.result?.content?.[0]?.text;
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
async function testMcpCrud(axiosInstance) {
console.log("\n[5] get_item_by_id with invalid id returns error...");
const badItemRes = await postMessage(axiosInstance, "tools/call", { name: "get_item_by_id", arguments: { id: -1 } });
if (!badItemRes.isError) throw new Error("get_item_by_id(-1): expected isError true");
console.log(" get_item_by_id(-1) isError OK.");
const path = "integration-test-" + Date.now() + ".txt";
const content = "結合テスト用ドキュメントです。get_document_count / list_documents / get_document / get_item_by_id / update_item / delete_item / delete_document を検証します。";
console.log("\n[6] get_document_count...");
const countRes = await postMessage(axiosInstance, "tools/call", { name: "get_document_count", arguments: {} });
const countData = parseResultText(countRes);
const count = typeof countData === "number" ? countData : (countData?.count ?? 0);
if (typeof count !== "number" || count < 0) throw new Error("get_document_count: invalid result " + JSON.stringify(countData));
console.log(" Count:", count);
console.log("\n[7] add_item_text → list_documents → get_document → get_item_by_id → update_item → delete_item → delete_document...");
await postMessage(axiosInstance, "tools/call", { name: "add_item_text", arguments: { path, content } });
const listRes = await postMessage(axiosInstance, "tools/call", { name: "list_documents", arguments: {} });
const list = parseResultText(listRes);
const docList = Array.isArray(list) ? list : [];
const doc = docList.find((d) => d.path === path);
if (!doc || doc.id == null) throw new Error("list_documents: added doc not found or no id. list=" + JSON.stringify(docList).slice(0, 200));
const getDocRes = await postMessage(axiosInstance, "tools/call", { name: "get_document", arguments: { document_id: doc.id } });
const getDoc = parseResultText(getDocRes);
if (!getDoc || !Array.isArray(getDoc.chunks) || getDoc.chunks.length === 0) throw new Error("get_document: no chunks. " + JSON.stringify(getDoc).slice(0, 200));
const chunkId = getDoc.chunks[0].id;
const getItemRes = await postMessage(axiosInstance, "tools/call", { name: "get_item_by_id", arguments: { id: chunkId } });
const getItem = getItemRes.result && !getItemRes.isError ? getItemRes.result : null;
if (!getItem || getItem.content !== content) throw new Error("get_item_by_id: content mismatch");
const updatedContent = "更新後のチャンク内容";
await postMessage(axiosInstance, "tools/call", { name: "update_item", arguments: { id: chunkId, content: updatedContent } });
const getItem2Res = await postMessage(axiosInstance, "tools/call", { name: "get_item_by_id", arguments: { id: chunkId } });
const getItem2 = getItem2Res.result && !getItem2Res.isError ? getItem2Res.result : null;
if (!getItem2 || getItem2.content !== updatedContent) throw new Error("update_item: content not updated. got=" + (getItem2?.content ?? ""));
await postMessage(axiosInstance, "tools/call", { name: "delete_item", arguments: { id: chunkId } });
await postMessage(axiosInstance, "tools/call", { name: "delete_document", arguments: { document_id: doc.id } });
const listRes2 = await postMessage(axiosInstance, "tools/call", { name: "list_documents", arguments: {} });
const list2 = parseResultText(listRes2);
const docList2 = Array.isArray(list2) ? list2 : [];
if (docList2.some((d) => d.path === path)) throw new Error("delete_document: document still in list");
console.log(" CRUD flow OK.");
}
async function testMcp() {
console.log("=== TelosDB MCP Tool Test ===");
const axiosInstance = axios.create({ baseURL: API_BASE });
try {
console.log("\n[1] Listing tools...");
const toolsResult = await postMessage(axiosInstance, "tools/list");
const toolCount = toolsResult.result?.tools?.length ?? 0;
if (toolCount < 10) throw new Error("tools/list: expected at least 10 tools, got " + toolCount);
console.log(" Success: Found", toolCount, "tools.");
const editionRes = await axiosInstance.get("/edition");
const edition = editionRes.data?.edition;
if (edition !== "community" && edition !== "pro") throw new Error("/edition: expected community or pro, got " + JSON.stringify(editionRes.data));
console.log(" /edition:", edition);
const statusRes = await axiosInstance.get("/indexing_status");
const status = statusRes.data?.status;
if (typeof status !== "string") throw new Error("/indexing_status: expected status string, got " + JSON.stringify(statusRes.data));
console.log(" /indexing_status:", status);
const versionRes = await axiosInstance.get("/version");
if (!versionRes.data || typeof versionRes.data.version !== "string") throw new Error("/version: expected { version: string }, got " + JSON.stringify(versionRes.data));
const apiVersion = versionRes.data.version;
if (apiVersion !== PKG.version) throw new Error("/version (" + apiVersion + ") が package.json の version (" + PKG.version + ") と一致しません。Cargo.toml と package.json を揃えてください。");
console.log(" /version:", apiVersion, "(package.json と一致)");
console.log("\n[2] Testing search_text...");
const searchResult = await postMessage(axiosInstance, "tools/call", {
name: "search_text",
arguments: { content: "宝くじ", limit: 5 }
});
const content = searchResult.result?.content?.[0]?.text;
console.log(" Result:\n", content || " No results");
console.log("\n[3] Pro: assert vector search contributes (not all scores 0.4)...");
await assertProVectorScoresNotAllFallback(axiosInstance);
console.log("\n[4] Pro: vectorization E2E (RE-INDEX → 完了待ち → 検索で vector_search_used 検証)...");
await testProVectorizationE2E(axiosInstance);
await testMcpCrud(axiosInstance);
console.log("\n=== Test Finished ===");
} catch (e) {
console.error("\nTest failed:", e.message || e);
process.exit(1);
}
}
testMcp();