/** * Skills + Examples lookup MCP tools — LI SDD M3.2 / M3.4 * * 對應 docs/3-specs/llm-interface/ Milestone 3.2 + 3.4。 * * - arcrun_list_skills — 列 KBDB entry_type=agent-skill 全部 * - arcrun_get_skill — 用 slug 拿 skill markdown 全文 * - arcrun_list_examples — 列 KBDB entry_type=workflow-example 全部 * - arcrun_get_example — 用 slug 拿 example yaml + description + tags * - arcrun_search_examples — use case 關鍵字 → 命中相關 example * * Skills / examples 由 arcrun/scripts/sync-registry-to-kbdb.py 從 * arcrun/registry/{skills,examples} 同步進 KBDB。 * * 直接走 KBDB service binding(既有 pattern),不經 cypher-executor。 * * 2026-06-14 重寫:KBDB 降基本盤後(三表 entries/templates/records,無 v3 blocks 表、 * 無語義 search),原打 /blocks /search 的舊路徑全失效。改打基本盤 /entries: * - entry_type 取代 blocks 的 type 欄(entries 表原生有 entry_type/page_name/tags_json/metadata_json) * - GET /blocks?type=X → GET /entries?entry_type=X * - GET /blocks?page_name=Y → GET /entries?page_name=Y(base listEntries 加了 page_name 過濾) * - POST /search(語義) → GET /entries/search?q=(D1 LIKE 關鍵字,基本盤無語義; * 誠實降級:search_examples 現在是「關鍵字」非「語義」。embed 模組(kbdb-base Phase 1) * 上線後只換內部、工具簽名不變。 */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { toolName } from "../brand.js"; import { z } from "zod"; import type { Env } from "../types.js"; import { kbdbFetch } from "../lib/kbdb-client.js"; import { errorResponse, successResponse } from "../lib/cypher-client.js"; import { staleIdentityError, type KnowledgeIdentity } from "../lib/portal-client.js"; /** * 🔴 2026-08-13:**「讀不到」不准長得像「不存在」**(Arcrun#100/#109 同一族)。 * * 實撞(總管在 leo21c/portal-login 連線上親跑,且已對照證實): * `arcrun_get_skill('write_intent_workflow')` → 「skill "write_intent_workflow" **不存在**」 * 但 `kbdb_search` 撈得到 `page_name: "skill-write_intent_workflow"`、 * `entry_type: "agent-skill"`、`source: "installer-seed"`——**與本工具查的鍵逐字相符**。 * 真兇:下面的 `kbdbGetByPageName` 舊版寫 `if (!resp.ok) return null;` * ⇒ **一個 HTTP 401 被逐字翻譯成「不存在」**。 * * 401 從哪來(不是這支 code 的錯,但這支 code 把它講成了假話):`kbdb-client.ts` 只在 * `env.KBDB_INTERNAL_TOKEN` 存在時才掛 Authorization ⇒ 沒設就匿名送出 ⇒ KBDB 回 401。 * 而該實例的 `arcrun-mcp` 很可能根本沒拿到那把 token(安裝器的 secret 迴圈漏了它)。 * ⇒ **修 token 是部署面的事(leo 的手);這支 code 該做的是把話講對。** * * 為什麼講對很重要:instructions 叫 AI「第一步先讀 skill」。它照做、拿到「不存在」, * 於是結論是「這裡沒有 skill」⇒ 去猜、去 grep repo、去上網——**那正是「AI 是瞎的」的機械過程**。 * * 現在:非 2xx 一律拋 `KbdbAccessError`(帶真的 status),由 `kbdbFailure()` 講成它自己 * (401/403=讀不到;5xx/連不上=連不上),並把**還走得通的那條路**(`kbdb_search`)交給 AI。 * `not_found` 只保留給「KBDB 正常回應、但真的沒有這張卡」。 */ class KbdbAccessError extends Error { constructor( readonly status: number, readonly what: string, readonly detail?: string, ) { super(`KBDB ${what} HTTP ${status}`); } } /** * KBDB 讀取失敗 → 誠實的錯誤回覆。 * * `searchHint` 是「同一份內容還能從哪裡拿」——這批 registry 卡片(skill/example)本身就住在 * KBDB entries 裡,而 `kbdb_search` 走的是**另一條**路(登入身分走 portal 資料面), * 這條 401 時那條常常還活著(2026-08-13 實測即如此)。所以這不是安慰話,是真的可行的下一步。 */ function kbdbFailure(e: unknown, searchHint: string, identity: KnowledgeIdentity) { const portalNote = identity.kind === "portal" ? "你這條是帳密登入的連線,但 skill/example 這批工具目前仍走**服務內部憑據**(還沒接上登入身分)——" + "所以它讀不到,不代表你的帳號讀不到。" : ""; if (e instanceof KbdbAccessError) { const unauthorized = e.status === 401 || e.status === 403; return errorResponse( unauthorized ? "kbdb_unauthorized" : "kbdb_unreachable", (unauthorized ? `讀不到 KBDB(HTTP ${e.status}:這條連線的憑據被拒或根本沒帶)。` : `讀不到 KBDB(HTTP ${e.status})。`) + "🔴 **這是「讀不到」,不是「不存在」**——內容還在庫裡,只是這條路被擋住了。" + (portalNote ? ` ${portalNote}` : ""), [ searchHint, "kbdb_get_map() 看這台實例有哪些庫(那條走得通就更確定是這批工具的路壞了,不是庫空了)", "🔴 不准把這個錯誤回報成「找不到/沒有這個 skill」——請照實說「KBDB 這條路讀不到」", "持續失敗:告訴 leo 這台實例的 arcrun-mcp 少了 secret KBDB_INTERNAL_TOKEN", ], e.detail, ); } return errorResponse( "kbdb_unreachable", `讀不到 KBDB:${e instanceof Error ? e.message : String(e)}。` + "🔴 **這是「讀不到」,不是「不存在」**。" + (portalNote ? ` ${portalNote}` : ""), [searchHint, "稍後重試", "🔴 不准把它回報成「沒有這個 skill/example」"], ); } // 基本盤 entries row(與舊 v3 block 欄位 1:1,差別只在 type→entry_type) interface KbdbBlock { id: string; page_name?: string | null; content?: string | null; entry_type?: string; tags_json?: string; metadata_json?: string | null; source?: string | null; updated_at?: number; } async function kbdbList(env: Env, entryType: string, limit = 100): Promise { const resp = await kbdbFetch(env, `/entries?entry_type=${encodeURIComponent(entryType)}&limit=${limit}`); if (!resp.ok) { throw new KbdbAccessError(resp.status, `list entry_type=${entryType}`, await resp.text().catch(() => "")); } const data = await resp.json<{ entries?: KbdbBlock[] }>(); return data.entries ?? []; } /** * 依 page_name 取一張卡。 * * 🔴 回 `null` **只代表「KBDB 好好回答了,而它說沒有這張卡」**。 * 讀不到(401/5xx/連不上)一律拋 `KbdbAccessError`——**絕不 return null**, * 否則呼叫端會把它講成「不存在」(2026-08-13 的真實事故,見檔頭)。 */ async function kbdbGetByPageName(env: Env, pageName: string): Promise { const resp = await kbdbFetch(env, `/entries?page_name=${encodeURIComponent(pageName)}&limit=1`); if (!resp.ok) { throw new KbdbAccessError(resp.status, `get page_name=${pageName}`, await resp.text().catch(() => "")); } const data = await resp.json<{ entries?: KbdbBlock[] }>(); return data.entries?.[0] ?? null; } function parseTags(tagsJson?: string): string[] { if (!tagsJson) return []; try { const arr = JSON.parse(tagsJson); return Array.isArray(arr) ? arr : []; } catch { return []; } } export function registerListSkills(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( toolName("list_skills"), "列所有 agent-skill blocks(從 arcrun/registry/skills/ 同步進 KBDB)。每個 skill 是個 markdown playbook,描述 AI 面對 X 問題該怎麼想 + 該用哪個 example。回 [{slug, title, tags}]。call get_skill(slug) 拿完整內文。", { tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'watcher' / 'debug'"), }, async ({ tag }) => { if (identity.kind === "stale") return staleIdentityError(); try { const blocks = await kbdbList(env, "agent-skill", 100); const skills = blocks .map((b) => { const tags = parseTags(b.tags_json); let title = b.page_name?.replace(/^skill-/, "") ?? "(no title)"; try { const meta = b.metadata_json ? JSON.parse(b.metadata_json) : null; if (meta?.title) title = meta.title; } catch {} return { slug: b.page_name?.replace(/^skill-/, "") ?? "", page_name: b.page_name, title, tags, chars: (b.content ?? "").length, }; }) .filter((s) => !tag || s.tags.includes(`skill:${tag}`) || s.tags.includes(tag) || s.slug.includes(tag)); return successResponse( { count: skills.length, skills }, [ skills.length === 0 ? "沒有 skill 命中。試 list_skills() 不帶 tag 看全部" : "call arcrun_get_skill(slug) 拿單個 skill 完整 markdown", // 誠實:這裡回的是**這台實例被 seed 進去的那幾支**,不是「全世界的 skill 目錄」。 // 上面清單沒有的名字(例如 'INDEX')就是這台沒有——別照舊教材去猜一個 slug。 "🔴 只用上面清單裡真的有的 slug;清單沒有=這台實例沒 seed 進去,不要硬猜名字", ], ); } catch (e) { return kbdbFailure(e, "改用 kbdb_search({ q: 'skill' }) 直接在知識庫裡找 skill 卡片(那條路走的是另一組憑據)", identity); } }, ); } export function registerGetSkill(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( toolName("get_skill"), "拿單一 agent-skill 完整 markdown playbook。slug 從 list_skills 取得。", { slug: z.string().describe("skill slug,例如 'build_watcher_workflow' / 'rag_with_arcrun'"), }, async ({ slug }) => { if (identity.kind === "stale") return staleIdentityError(); try { const pageName = slug.startsWith("skill-") ? slug : `skill-${slug}`; const block = await kbdbGetByPageName(env, pageName); if (!block) { // 走到這裡=KBDB **有正常回答**,而它說沒有這張卡(讀不到的情況上面已經拋出去了)。 return errorResponse( "not_found", `KBDB 正常回應,但沒有 page_name="${pageName}" 這張卡——這台實例沒有 seed 這支 skill。` + "(不同實例 seed 的 skill 不一樣,別照舊教材假設某個名字一定在。)", [ "call arcrun_list_skills() 看**這台實例真的有**哪幾支", `kbdb_search({ q: '${slug}' }) 看內容是不是被存成別的名字`, "確認拼字正確(不需要 'skill-' prefix)", ], ); } return successResponse({ slug, page_name: block.page_name, content: block.content, tags: parseTags(block.tags_json), }); } catch (e) { return kbdbFailure( e, `改用 kbdb_search({ q: 'skill-${slug}' }) 撈同一張卡片(skill 就住在知識庫的 entries 裡,那條路走另一組憑據)`, identity, ); } }, ); } export function registerListExamples(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( toolName("list_examples"), "列所有 workflow-example blocks(從 arcrun/registry/examples/ 同步進 KBDB)。每個 example 是可直接 push 的 workflow YAML 範本 + description。回 [{slug, tags}]。call get_example / search_examples 拿細節。", { tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'cron' / 'llm' / 'webhook'"), }, async ({ tag }) => { if (identity.kind === "stale") return staleIdentityError(); try { const blocks = await kbdbList(env, "workflow-example", 200); const examples = blocks .map((b) => { const tags = parseTags(b.tags_json); return { slug: b.page_name?.replace(/^example-/, "") ?? "", page_name: b.page_name, tags, chars: (b.content ?? "").length, }; }) .filter((e) => !tag || e.tags.includes(tag) || e.tags.includes(`example:${tag}`) || e.slug.includes(tag)); return successResponse( { count: examples.length, examples }, [ examples.length === 0 ? "沒有 example 命中。試 list_examples() 不帶 tag 看全部" : "call arcrun_get_example(slug) 拿單個 YAML + description", ], ); } catch (e) { return kbdbFailure(e, "改用 kbdb_search({ q: 'example' }) 直接在知識庫裡找 example 卡片(那條路走另一組憑據)", identity); } }, ); } export function registerGetExample(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( toolName("get_example"), "拿單一 workflow-example 完整 YAML + description。slug 從 list_examples / search_examples 取得。可直接拿 YAML 改成你自己的 → push。", { slug: z.string().describe("example slug,例如 'rag-search-answer' / 'cron-watcher'"), }, async ({ slug }) => { if (identity.kind === "stale") return staleIdentityError(); try { const pageName = slug.startsWith("example-") ? slug : `example-${slug}`; const block = await kbdbGetByPageName(env, pageName); if (!block) { // KBDB 好好回答了,而它說沒有這張卡(讀不到的情況已在 kbdbGetByPageName 拋出)。 return errorResponse( "not_found", `KBDB 正常回應,但沒有 page_name="${pageName}" 這張卡——這台實例沒 seed 這個 example。`, [ "call arcrun_list_examples() 看**這台實例真的有**哪些 slug", "或 arcrun_search_examples(use_case) 用關鍵字找", `kbdb_search({ q: '${slug}' }) 看內容是不是被存成別的名字`, ], ); } let description_md = ""; try { const meta = block.metadata_json ? JSON.parse(block.metadata_json) : null; description_md = meta?.description_md ?? ""; } catch {} return successResponse({ slug, page_name: block.page_name, workflow_yaml: block.content, description_md, tags: parseTags(block.tags_json), }, [ "拿 workflow_yaml 改成你自己的 → call arcrun_push_workflow", "看 description_md 了解設計意圖 / 改造方向", ]); } catch (e) { return kbdbFailure( e, `改用 kbdb_search({ q: 'example-${slug}' }) 撈同一張卡片(example 就住在知識庫的 entries 裡)`, identity, ); } }, ); } export function registerSearchExamples(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( toolName("search_examples"), "用 use case 關鍵字搜 workflow examples,回最相關 N 個。" + "注意:基本盤目前是 D1 LIKE 關鍵字搜尋(非語義 embedding;語義是 kbdb-base Phase 1 的 embed 模組,尚未上)。" + "→ 用具體詞('email'、'cron'、'rag')比整句自然語言命中率高。也會比對 slug/tag。", { query: z.string().min(2).describe("use case 關鍵字,例如 'email 摘要' / 'cron 排程' / 'rag'。基本盤是關鍵字非語義,用詞要具體"), top_k: z.number().int().min(1).max(20).optional().describe("回幾個結果(預設 5)"), }, async ({ query, top_k }) => { if (identity.kind === "stale") return staleIdentityError(); try { const k = top_k ?? 5; const q = query.trim(); // 基本盤無語義 search:撈全部 workflow-example,用 query 對 content/slug/tag 做關鍵字比對排序。 // (examples 只有 ~10 筆,client 端過濾零負擔;embed 模組上線後可改打語義 search) const blocks = await kbdbList(env, "workflow-example", 200); const ql = q.toLowerCase(); const terms = ql.split(/\s+/).filter(Boolean); const scored = blocks .map((b) => { const slug = b.page_name?.replace(/^example-/, "") ?? ""; const tags = parseTags(b.tags_json); const hay = `${slug} ${tags.join(" ")} ${(b.content ?? "")}`.toLowerCase(); // 每個 term 命中 +1;slug/tag 命中額外加權 let score = 0; for (const t of terms) { if (hay.includes(t)) score += 1; if (slug.toLowerCase().includes(t)) score += 2; if (tags.some((tag) => tag.toLowerCase().includes(t))) score += 2; } return { b, slug, tags, score }; }) .filter((r) => r.score > 0) .sort((a, b) => b.score - a.score) .slice(0, k); const examples = scored.map((r) => ({ slug: r.slug, page_name: r.b.page_name, score: r.score, tags: r.tags, preview: (r.b.content ?? "").slice(0, 200), })); if (examples.length === 0) { return successResponse( { count: 0, examples: [], query: q }, [ "關鍵字沒命中(基本盤是 LIKE 非語義,換更具體/不同的詞再試)", "改用 arcrun_list_examples(tag='...') 走 tag 過濾", "或 arcrun_list_examples() 看全部清單自己挑", ], ); } return successResponse( { count: examples.length, examples, query: q, search_mode: "keyword" }, [ "call arcrun_get_example(slug) 拿完整 YAML", "score 高 = 關鍵字命中越多(slug/tag 命中加權)", "search_mode:keyword — 基本盤無語義,命中靠字面;換具體詞可改善", ], ); } catch (e) { return kbdbFailure(e, `改用 kbdb_search({ q: '${query.trim()}' }) 直接查知識庫(那條路走另一組憑據)`, identity); } }, ); } export function registerAllSkillExampleTools(server: McpServer, env: Env, identity: KnowledgeIdentity) { registerListSkills(server, env, identity); registerGetSkill(server, env, identity); registerListExamples(server, env, identity); registerGetExample(server, env, identity); registerSearchExamples(server, env, identity); }