Newer
Older
TelosDB / tests / test_mcp_client.mjs
/**
 * 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 } });
    const isError = badItemRes?.result?.isError === true || badItemRes?.error != null;
    if (!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();