From c2897ba68bedcb6d876fb39679831b2c82583363 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Thu, 13 Aug 2026 14:09:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(mcp):=20AI=20=E4=B8=80=E9=80=A3=E4=B8=8A?= =?UTF-8?q?=E5=B0=B1=E7=9F=A5=E9=81=93=E3=80=8C=E9=80=99=E8=A3=A1=E6=9C=89?= =?UTF-8?q?=E4=B8=BB=E4=BA=BA=E7=9A=84=E7=9F=A5=E8=AD=98=E5=BA=AB=E3=80=8D?= =?UTF-8?q?=EF=BC=8B=E3=80=8C=E8=AE=80=E4=B8=8D=E5=88=B0=E3=80=8D=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=AC=9B=E6=88=90=E3=80=8C=E4=B8=8D=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 2026-08-13:「它應該是**你的知識來源**⋯⋯**沒有它你是瞎的**, 從你對 Arcrun 的認知就知道你的內建 Memory 是沒用的。」 ## 病灶一:指路的話被綁在一個會無聲消失的段落上 instructions 裡唯一提到「這裡有知識可查」的,是 buildLibraryMapInstructions 拼上去的【藏書地圖】——而那段是**選配的**(1500ms 逾時、catch 回 null、失敗無聲)。 必定出現的那段靜態文字(startHere)從頭到尾只講工作流、零件、recipe、邊的語法, `kbdb_search`/`kbdb_get_map` 一個字都沒有。 實害:同一天,一條 portal 連線的 session 為了回答「Arcrun 是什麼」去讀了 682 行 原始碼——而 `kbdb_search(q="Arcrun 是什麼")` 當下回得出 33 筆真答案。 修法: - 新增 `KNOWLEDGE_FIRST` 靜態段(與 startHere 同級,KBDB 掛了照樣出現), 排在最前面:這條連線後面有主人的知識庫、第一個動作是 kbdb_search、 工具怎麼呼叫、以及「沒查到/沒查/地圖沒取到」三件事不可以講成同一句。 - 地圖失敗**不再靜默**:拿不到就印【藏書地圖:這次沒取到】, stale token 另印身分版(要使用者重新連線)——「沒取到」與「這裡沒有知識」 不可以長得一樣(Arcrun#109 同族)。鐵律不變:失敗仍然不擋連線。 - 抽出 `buildServerInstructions(env, identity)` 供測試印出完整字串。 ## 病灶二:一個 401 被逐字翻譯成「不存在」 `arcrun_get_skill('write_intent_workflow')` 回「skill ... 不存在」, 但 `kbdb_search` 撈得到 `page_name: "skill-write_intent_workflow"` (entry_type: agent-skill、source: installer-seed)——**與本工具查的鍵逐字相符**。 真兇是 `kbdbGetByPageName` 的 `if (!resp.ok) return null;`。 而 401 的來源是該實例的 arcrun-mcp 沒有 KBDB_INTERNAL_TOKEN (kbdb-client 沒 token 就匿名送出)=部署面的事,但這支 code 把它講成了假話。 修法: - 非 2xx 一律拋 KbdbAccessError(帶真 status)→ kbdb_unauthorized/kbdb_unreachable, 訊息明說「這是讀不到,不是不存在」,並交出還走得通的那條路(kbdb_search)。 - `not_found` 只留給「KBDB 正常回應但真的沒這張卡」,並說明是「這台實例沒 seed」。 - 五支工具吃 identity:stale → identity_missing;portal 撞 401 時額外說明 「是這批工具還走服務憑據,不是你的帳號讀不到」。 - instructions 步驟 4 不再指名 `arcrun_get_skill('INDEX')`(leo21c 實測根本沒 seed 這支)——改成先 `arcrun_list_skills()` 看這台實例真的有哪幾支。 ## 驗 - `mcp` 測試 130 passed(新增 mcp-instructions 7 項+skills-examples-honesty 10 項),tsc exit 0。 - 三種情境(地圖抓得到/抓不到/舊 token)各印一份完整 instructions 實測, 三份的開場都指向 kbdb_search。 ⚠️ 未部署(部署是 leo 的手動閘)。上線後才會到用戶手上。 Co-Authored-By: Claude Opus 5 --- mcp/src/mcp-handler.ts | 102 +++++++++- mcp/src/tools/arcrun_skills_examples.ts | 167 ++++++++++++---- mcp/src/tools/registry.ts | 6 +- mcp/tests/unit/mcp-instructions.test.ts | 130 ++++++++++++ .../tools/skills-examples-honesty.test.ts | 186 ++++++++++++++++++ 5 files changed, 541 insertions(+), 50 deletions(-) create mode 100644 mcp/tests/unit/mcp-instructions.test.ts create mode 100644 mcp/tests/unit/tools/skills-examples-honesty.test.ts diff --git a/mcp/src/mcp-handler.ts b/mcp/src/mcp-handler.ts index 8497568..1b5eae8 100644 --- a/mcp/src/mcp-handler.ts +++ b/mcp/src/mcp-handler.ts @@ -5,20 +5,86 @@ import { buildLibraryMapInstructions } from "./lib/library-map.js"; import type { KnowledgeIdentity } from "./lib/portal-client.js"; import { Env } from "./types.js"; -export async function handleMcpRequest( - request: Request, +/** + * 【這條連線上有主人的知識庫】——**靜態、必定出現**的指路段(2026-08-13,leo)。 + * + * leo 原話:「它應該是**你的知識來源**⋯⋯**沒有它你是瞎的**,從你對 Arcrun 的認知就知道 + * 你的內建 Memory 是沒用的。」 + * + * 為什麼要獨立成一段、而且**不准依賴任何查詢**: + * 這條連線本來就查得到答案——`kbdb_search(q="Arcrun 是什麼")` 當天實測回 33 筆真答案。 + * 但同一天,一個連著這條 MCP 的 session 為了回答同一題,去讀了 682 行原始碼—— + * **因為它不知道這裡有知識庫可查**。instructions 裡唯一提到知識的,是下面那段【藏書地圖】, + * 而那段是**選配的**(1500ms 逾時、失敗回 null);那條 portal 連線就沒收到它。 + * ⇒ **「這裡有知識庫、怎麼查」這句話本身被綁在一個會無聲消失的段落上,才是 AI 開場全瞎的成因。** + * 所以它跟下面的 Arcrun 指路段同級:靜態常數,KBDB 掛了、地圖抓不到,它照樣出現。 + */ +const KNOWLEDGE_FIRST = [ + "【這條連線上有主人的知識庫——先查它,再查別的】", + "", + "這條 MCP 連線後面接著一個 **KBDB 知識庫**:這台實例的主人長期累積的筆記、決策、", + "踩過的坑、專案現況、skill 與工作流紀錄,都在裡面。**你不是從零開始的**——", + "你對這些專案的內建印象多半是錯的或過時的,庫裡那份才是主人認的版本。", + "", + "🔴 **有人問你「X 是什麼/為什麼這樣做/之前怎麼決定的/現在做到哪」——", + "你的第一個動作是 `kbdb_search`,不是 grep 原始碼、不是上網搜、不是回答「我不知道」。**", + "", + '- `kbdb_search({ q: "Arcrun 是什麼" })` — 關鍵字查(預設 `mode:\'keyword\'`,基本盤永遠可用)。', + " 換幾組講法再放棄;想要語義相似度用 `mode:'semantic'`。**這一支是你的第一站。**", + "- `kbdb_get_map()` — 不知道該進哪個庫時先看藏書地圖(下面若有【藏書地圖】就是它的快照)。", + '- `kbdb_graph_neighbors({ subject: "Arcrun" })` — 查某個東西跟誰有關係(三元組遍歷)。', + "- `kbdb_list_templates` / `kbdb_query` — 按 template 取整批結構化資料。", + "", + "🔴 **這三件事不可以講成同一句**(講成同一句就是在騙人):", + "① 「知識庫裡沒有」 ② 「我沒查」 ③ 「地圖沒取到/某庫顯示 0」。", + "查過真的沒有 → 明說「知識庫裡查不到,以下是我從原始碼/網路推的」,再去讀 code 或上網。", + "**沒查就回答=拿你的猜測冒充主人的知識,那是這條連線上最嚴重的錯。**", + "", + "🔴 **地圖是索引,不是庫存清單**:某庫顯示 `0 triplets`、或下面整段【藏書地圖】沒出現,", + "都**不代表**沒有知識(可能只是還沒重算、或這次沒抓到)。要知道有沒有,只有一個方法:`kbdb_search` 查過。", + "同理,任何工具回 401/連不上/沒權限,那是**讀不到**,不是**不存在**——照它給的 next_actions 修,", + "別把它改口講成「這裡沒有」(`arcrun_get_skill` 曾把 KBDB 的 401 講成「skill 不存在」,就是這個病)。", +].join("\n"); + +/** 地圖沒拿到時的**明講**(不可靜默):「沒取到」和「這裡沒有知識」不可以長得一樣。 */ +const MAP_UNAVAILABLE_NOTE = [ + "【藏書地圖:這次沒取到】", + "地圖抓取逾時/回錯/或它回報的清單是空的(也可能只是還沒重算過)。", + "🔴 **這是「地圖沒拿到」,不是「這裡沒有知識」。** 上面那條規則照舊:", + "要知道庫裡有什麼,直接 `kbdb_search`;想再抓一次地圖呼叫 `kbdb_get_map()`(它會回報真正的原因)。", +].join("\n"); + +/** 舊 token(stale):拿不到地圖是**身分問題**,同樣要明講,並給可執行的修法。 */ +const MAP_STALE_NOTE = [ + "【藏書地圖:拿不到,因為這條連線是舊版簽發的 token】", + "這條連線的 token 沒帶登入者身分,`kbdb_*` 會回 `identity_missing`。", + "🔴 **這不代表知識庫是空的**——是這條連線還沒認得你。", + "仍然先呼叫一次 `kbdb_search` 確認錯誤碼;若真的是 `identity_missing`,", + "請使用者到 claude.ai → Settings → Connectors 把這個 connector 重新連線一次(重新輸入 Portal 帳密),", + "**不要改口說「查不到資料」或自己去猜答案。**", +].join("\n"); + +/** + * 組這條連線的 server instructions(`initialize` 時送出,**AI 沒辦法自己再要一次** ⇒ 必須一次到位)。 + * + * 匯出給測試:三種情境(地圖抓得到/抓不到/舊 token)各印一份完整字串,逐份確認 + * 「一個什麼都不知道的 AI 讀完,下一個動作會不會是去查知識庫」。 + */ +export async function buildServerInstructions( env: Env, - orgNamespace: string, - partnerToken: string, identity: KnowledgeIdentity, -): Promise { +): Promise { // library-map SDD M4(design §4/§6):連線時把全館藏書地圖嵌進 server instructions, // session 一開就知道館裡有哪些庫(push 零查詢)。builder 內建 timeout+isolate TTL 快取 - //(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 靜默略過,絕不擋 MCP 連線(鐵律)。 + //(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 絕不擋 MCP 連線(鐵律)。 // // 🔴 2026-08-12:以帳密連線時**改用登入者的身分**組地圖——否則 instructions 會把 // 整個知識庫的庫名一次推給一個可能只有部分權限的帳號(地圖本身就是情報)。 // 快取也因此改成 per-session key(見 lib/library-map.ts)。 + // + // 🔴 2026-08-13:失敗**不再靜默略過**。原本 null → 整段消失,於是「地圖沒取到」與 + // 「這裡沒有知識」在 AI 眼裡長得一模一樣(Arcrun#109 同一族:保險拒絕了 vs 程式碼不存在, + // 畫面上都是沉默)。現在改成印一句明話。鐵律沒變——**失敗仍然不擋連線**,只是不再無聲。 const mapInstructions = await buildLibraryMapInstructions(env, identity); // 2026-07-30(leo 問「人類說『幫我用 arcrun 寫 xxx』,Haiku 會知道要用這些資源嗎? @@ -39,9 +105,13 @@ export async function handleMcpRequest( "", "1. `arcrun_get_skill('write_intent_workflow')` — **必讀第一支**。", " 教你用 `>>` 寫「意圖工作流」。你**不需要先知道有哪些零件**,先寫意圖。", + " ⚠️ 這支若回錯(401/連不上/`kbdb_unreachable`),那是**這條連線讀不到 KBDB**,", + " **不是 skill 不存在**——同一份內容用 `kbdb_search({ q: 'skill-write_intent_workflow' })` 撈得到。", "2. `arcrun_whoami()` — 確認連到哪個帳號(勿自行 curl 猜帳號 URL)。", "3. 把意圖丟 `POST /cypher/search` 或 `arcrun_validate_yaml` — 系統告訴你哪些零件存在。", - "4. 卡住/不知道該查什麼 → `arcrun_get_skill('INDEX')`(全館導航:什麼問題查哪裡+已知的坑)。", + "4. 卡住/不知道該查什麼 → 先 `arcrun_list_skills()` **看這台實例真的有哪幾支**,再挑一支讀。", + " (2026-08-13 實測:不同實例 seed 的 skill 不一樣,有的實例只有兩支、連 `INDEX` 都沒有。", + " **不要照教材直接指名一個 slug** ——先列清單,或 `kbdb_search({ q: 'skill' })` 直接在庫裡找。)", "5. 缺零件時:缺 API → 寫 recipe(`arcrun_recipe_push`);缺能力 → 投稿零件 PR。", " 🔴 **不要因為查不到零件就改寫成 `code` 節點**——那叫「腹語術」(表面用 Arcrun、", " 實際全寫 JS)。`code` 只用於局部整形(例:剝掉 LLM 回應的雜訊)。", @@ -58,7 +128,23 @@ export async function handleMcpRequest( "第一個節點固定是 `input`。", ].join("\n"); - const instructions = mapInstructions ? `${startHere}\n\n---\n\n${mapInstructions}` : startHere; + // 地圖那段永遠有東西可印:拿到 → 印地圖;沒拿到 → 印「沒拿到」,不是消失。 + // stale 與「抓失敗」分開講,因為修法不同(前者要使用者重新連線,後者只是這次沒抓到)。 + const mapSection = + mapInstructions ?? (identity.kind === "stale" ? MAP_STALE_NOTE : MAP_UNAVAILABLE_NOTE); + + // 知識段排在 Arcrun 指路段之前:AI 最常被問的是「X 是什麼」,那題的正解是查庫,不是查零件。 + return [KNOWLEDGE_FIRST, startHere, mapSection].join("\n\n---\n\n"); +} + +export async function handleMcpRequest( + request: Request, + env: Env, + orgNamespace: string, + partnerToken: string, + identity: KnowledgeIdentity, +): Promise { + const instructions = await buildServerInstructions(env, identity); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); const server = new McpServer( diff --git a/mcp/src/tools/arcrun_skills_examples.ts b/mcp/src/tools/arcrun_skills_examples.ts index 5ac1539..f075678 100644 --- a/mcp/src/tools/arcrun_skills_examples.ts +++ b/mcp/src/tools/arcrun_skills_examples.ts @@ -30,6 +30,79 @@ 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 { @@ -45,14 +118,25 @@ interface KbdbBlock { 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 Error(`KBDB list entry_type=${entryType} HTTP ${resp.status}`); + 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) return null; + 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; } @@ -67,7 +151,7 @@ function parseTags(tagsJson?: string): string[] { } } -export function registerListSkills(server: McpServer, env: Env) { +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) 拿完整內文。", @@ -75,6 +159,7 @@ export function registerListSkills(server: McpServer, env: Env) { 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 @@ -101,20 +186,19 @@ export function registerListSkills(server: McpServer, env: Env) { skills.length === 0 ? "沒有 skill 命中。試 list_skills() 不帶 tag 看全部" : "call arcrun_get_skill(slug) 拿單個 skill 完整 markdown", + // 誠實:這裡回的是**這台實例被 seed 進去的那幾支**,不是「全世界的 skill 目錄」。 + // 上面清單沒有的名字(例如 'INDEX')就是這台沒有——別照舊教材去猜一個 slug。 + "🔴 只用上面清單裡真的有的 slug;清單沒有=這台實例沒 seed 進去,不要硬猜名字", ], ); } catch (e) { - return errorResponse( - "fetch_failed", - e instanceof Error ? e.message : String(e), - ["稍後重試", "若持續失敗,告訴 leo"], - ); + return kbdbFailure(e, "改用 kbdb_search({ q: 'skill' }) 直接在知識庫裡找 skill 卡片(那條路走的是另一組憑據)", identity); } }, ); } -export function registerGetSkill(server: McpServer, env: Env) { +export function registerGetSkill(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( toolName("get_skill"), "拿單一 agent-skill 完整 markdown playbook。slug 從 list_skills 取得。", @@ -122,15 +206,19 @@ export function registerGetSkill(server: McpServer, env: Env) { 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", - `skill "${slug}" 不存在`, + `KBDB 正常回應,但沒有 page_name="${pageName}" 這張卡——這台實例沒有 seed 這支 skill。` + + "(不同實例 seed 的 skill 不一樣,別照舊教材假設某個名字一定在。)", [ - "call arcrun_list_skills() 看可用 slug", + "call arcrun_list_skills() 看**這台實例真的有**哪幾支", + `kbdb_search({ q: '${slug}' }) 看內容是不是被存成別的名字`, "確認拼字正確(不需要 'skill-' prefix)", ], ); @@ -142,17 +230,17 @@ export function registerGetSkill(server: McpServer, env: Env) { tags: parseTags(block.tags_json), }); } catch (e) { - return errorResponse( - "fetch_failed", - e instanceof Error ? e.message : String(e), - ["稍後重試"], + return kbdbFailure( + e, + `改用 kbdb_search({ q: 'skill-${slug}' }) 撈同一張卡片(skill 就住在知識庫的 entries 裡,那條路走另一組憑據)`, + identity, ); } }, ); } -export function registerListExamples(server: McpServer, env: Env) { +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 拿細節。", @@ -160,6 +248,7 @@ export function registerListExamples(server: McpServer, env: Env) { 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 @@ -183,17 +272,13 @@ export function registerListExamples(server: McpServer, env: Env) { ], ); } catch (e) { - return errorResponse( - "fetch_failed", - e instanceof Error ? e.message : String(e), - ["稍後重試"], - ); + return kbdbFailure(e, "改用 kbdb_search({ q: 'example' }) 直接在知識庫裡找 example 卡片(那條路走另一組憑據)", identity); } }, ); } -export function registerGetExample(server: McpServer, env: Env) { +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。", @@ -201,16 +286,19 @@ export function registerGetExample(server: McpServer, env: Env) { 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", - `example "${slug}" 不存在`, + `KBDB 正常回應,但沒有 page_name="${pageName}" 這張卡——這台實例沒 seed 這個 example。`, [ - "call arcrun_list_examples() 看可用 slug", - "或 arcrun_search_examples(use_case) 用自然語言找", + "call arcrun_list_examples() 看**這台實例真的有**哪些 slug", + "或 arcrun_search_examples(use_case) 用關鍵字找", + `kbdb_search({ q: '${slug}' }) 看內容是不是被存成別的名字`, ], ); } @@ -232,17 +320,17 @@ export function registerGetExample(server: McpServer, env: Env) { "看 description_md 了解設計意圖 / 改造方向", ]); } catch (e) { - return errorResponse( - "fetch_failed", - e instanceof Error ? e.message : String(e), - ["稍後重試"], + return kbdbFailure( + e, + `改用 kbdb_search({ q: 'example-${slug}' }) 撈同一張卡片(example 就住在知識庫的 entries 裡)`, + identity, ); } }, ); } -export function registerSearchExamples(server: McpServer, env: Env) { +export function registerSearchExamples(server: McpServer, env: Env, identity: KnowledgeIdentity) { server.tool( toolName("search_examples"), "用 use case 關鍵字搜 workflow examples,回最相關 N 個。" + @@ -253,6 +341,7 @@ export function registerSearchExamples(server: McpServer, env: Env) { 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(); @@ -309,20 +398,16 @@ export function registerSearchExamples(server: McpServer, env: Env) { ], ); } catch (e) { - return errorResponse( - "internal_error", - e instanceof Error ? e.message : String(e), - ["重試一次"], - ); + return kbdbFailure(e, `改用 kbdb_search({ q: '${query.trim()}' }) 直接查知識庫(那條路走另一組憑據)`, identity); } }, ); } -export function registerAllSkillExampleTools(server: McpServer, env: Env) { - registerListSkills(server, env); - registerGetSkill(server, env); - registerListExamples(server, env); - registerGetExample(server, env); - registerSearchExamples(server, env); +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); } diff --git a/mcp/src/tools/registry.ts b/mcp/src/tools/registry.ts index 363d384..91ab42a 100644 --- a/mcp/src/tools/registry.ts +++ b/mcp/src/tools/registry.ts @@ -55,7 +55,11 @@ export function registerAllTools( registerAllWorkflowCrudTools(server, env); // LI SDD M3.2: skills + examples lookup(KBDB-backed) // 走 sync-registry-to-kbdb.py 把 registry/{skills,examples} 同步進 KBDB - registerAllSkillExampleTools(server, env); + // 2026-08-13:吃 identity 只為了「把話講對」——舊 token 誠實回 identity_missing, + // 且 401 時能告訴登入者「是這批工具還走服務憑據,不是你的帳號讀不到」。 + // ⚠️ 這批**尚未**改走 portal 資料面(portal 沒有 by-entry_type 的 listing 端點; + // 要接得補 API,不是在這層拼裝——rule 07 §3.1)。見該檔檔頭。 + registerAllSkillExampleTools(server, env, identity); // kbdb-base §7.5.i: recipe 公庫/私庫工具(與 CLI 六能力對齊,rule 07 §5 MCP 不落後) registerAllRecipeTools(server, env); // kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/search,HANDOFF §2) diff --git a/mcp/tests/unit/mcp-instructions.test.ts b/mcp/tests/unit/mcp-instructions.test.ts new file mode 100644 index 0000000..f993463 --- /dev/null +++ b/mcp/tests/unit/mcp-instructions.test.ts @@ -0,0 +1,130 @@ +/** + * server instructions(`initialize` 時送出的那份,AI **沒辦法自己再要一次**)。 + * + * 這份測試守的是 2026-08-13 leo 那句話: + * 「它應該是**你的知識來源**⋯⋯**沒有它你是瞎的**。」 + * + * 病灶(實測):instructions 裡唯一提到「這裡有知識可查」的,是**選配的**【藏書地圖】段 + * (1500ms 逾時、失敗回 null、無聲消失)。一條 portal 連線就沒收到它 ⇒ 那個 session + * 為了回答「Arcrun 是什麼」去讀了 682 行原始碼,而 `kbdb_search` 當下回得出 33 筆真答案。 + * + * 所以本檔的驗收判準只有一句: + * **不管地圖抓不抓得到、身分是哪一種,讀完這份 instructions 的下一個動作都必須是「去查知識庫」。** + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { buildServerInstructions } from "../../src/mcp-handler.js"; +import { __resetLibraryMapInstructionsCacheForTests } from "../../src/lib/library-map.js"; +import type { Env } from "../../src/types.js"; +import type { KnowledgeIdentity } from "../../src/lib/portal-client.js"; + +const SERVICE: KnowledgeIdentity = { kind: "service" }; +const STALE: KnowledgeIdentity = { kind: "stale" }; + +/** 假 KBDB service binding(服務級憑據路徑)。 */ +function makeEnv(respond: () => Response | Promise): Env { + return { KBDB: { fetch: async () => respond() } } as unknown as Env; +} + +const MAP_OK = () => + new Response( + JSON.stringify({ + success: true, + libraries: [ + { library: "kb", narrative: "leo 的知識庫主庫", top_entities: ["Arcrun"], triplet_count: 1854 }, + ], + count: 1, + }), + ); + +/** + * 「一個什麼都不知道的 AI 讀完,下一個動作會是什麼」的機械化判準。 + * 三份 instructions 都必須通過同一組斷言——通不過就是還沒修好。 + */ +function expectPointsAtKnowledge(text: string) { + // ① 明講這條連線後面有主人的知識庫 + expect(text).toContain("知識庫"); + // ② 指名工具與呼叫法(AI 不必猜工具名) + expect(text).toContain("kbdb_search"); + expect(text).toContain("Arcrun 是什麼"); + // ③ 明確排除三條錯路 + expect(text).toContain("不是 grep 原始碼"); + expect(text).toContain("不是上網搜"); + expect(text).toContain("不是回答「我不知道」"); + // ④ 「沒查到」與「沒有」不可以混為一談 + expect(text).toContain("那是**讀不到**,不是**不存在**"); +} + +describe("buildServerInstructions — 三種情境都必須指向知識庫", () => { + beforeEach(() => __resetLibraryMapInstructionsCacheForTests()); + + it("① 地圖抓得到:地圖照舊注入,且知識段仍在", async () => { + const text = await buildServerInstructions(makeEnv(MAP_OK), SERVICE); + expectPointsAtKnowledge(text); + expect(text).toContain("【藏書地圖】"); + expect(text).toContain("kb:leo 的知識庫主庫"); + // 地圖成功時不該同時出現「沒取到」的話 + expect(text).not.toContain("【藏書地圖:這次沒取到】"); + }); + + it("② 地圖抓不到(HTTP 500):明講「沒取到」,不靜默、也不等於沒有知識", async () => { + const text = await buildServerInstructions(makeEnv(() => new Response("boom", { status: 500 })), SERVICE); + expectPointsAtKnowledge(text); + expect(text).toContain("【藏書地圖:這次沒取到】"); + expect(text).toContain("這是「地圖沒拿到」,不是「這裡沒有知識」"); + }); + + it("② 地圖逾時/binding 爆炸:同樣明講,不擋連線(不 throw)", async () => { + const env = { + KBDB: { + fetch: async () => { + throw new Error("kbdb down"); + }, + }, + } as unknown as Env; + const text = await buildServerInstructions(env, SERVICE); + expectPointsAtKnowledge(text); + expect(text).toContain("【藏書地圖:這次沒取到】"); + }); + + it("② 地圖回空清單:也算沒取到,不可以長得像「這裡沒有知識」", async () => { + const text = await buildServerInstructions( + makeEnv(() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 }))), + SERVICE, + ); + expect(text).toContain("【藏書地圖:這次沒取到】"); + expect(text).toContain("不是「這裡沒有知識」"); + }); + + it("③ 舊 token(stale):講成身分問題+給可執行的修法,仍指向知識庫", async () => { + const text = await buildServerInstructions(makeEnv(MAP_OK), STALE); + expectPointsAtKnowledge(text); + expect(text).toContain("舊版簽發的 token"); + expect(text).toContain("identity_missing"); + expect(text).toContain("重新連線"); + // 🔴 不可以講成「知識庫是空的」 + expect(text).toContain("這不代表知識庫是空的"); + }); + + it("stale 不去打 KBDB(地圖本身就是情報,舊 token 不給)", async () => { + let called = 0; + const env = { + KBDB: { + fetch: async () => { + called += 1; + return MAP_OK(); + }, + }, + } as unknown as Env; + await buildServerInstructions(env, STALE); + expect(called).toBe(0); + }); + + it("Arcrun 指路段照舊存在(知識段是新增的,不是取代)", async () => { + const text = await buildServerInstructions(makeEnv(MAP_OK), SERVICE); + expect(text).toContain("# Arcrun — 你已經配備了這套工具,別上網找"); + expect(text).toContain("【先讀這裡】"); + // 步驟 4 不再指名一個可能沒被 seed 的 slug(leo21c 實測連 INDEX 都沒有) + expect(text).toContain("arcrun_list_skills()"); + expect(text).not.toContain("arcrun_get_skill('INDEX')"); + }); +}); diff --git a/mcp/tests/unit/tools/skills-examples-honesty.test.ts b/mcp/tests/unit/tools/skills-examples-honesty.test.ts new file mode 100644 index 0000000..2cabab9 --- /dev/null +++ b/mcp/tests/unit/tools/skills-examples-honesty.test.ts @@ -0,0 +1,186 @@ +/** + * skill/example 工具的**誠實性**測試(2026-08-13,Arcrun#100/#109 同一族)。 + * + * 真實事故:`arcrun_get_skill('write_intent_workflow')` 回「skill ... **不存在**」, + * 但同一台實例的 `kbdb_search` 撈得到 `page_name: "skill-write_intent_workflow"` + *(`entry_type: agent-skill`,`source: installer-seed`)——**查的鍵逐字相符**。 + * 真兇是 `if (!resp.ok) return null;`:一個 HTTP 401 被翻譯成「不存在」。 + * + * 這個謊有實害:instructions 叫 AI 第一步先讀 skill,它照做、收到「不存在」, + * 於是結論「這裡沒有 skill」⇒ 去猜/grep repo/上網。 + * + * 判準:**讀不到(401/403/5xx/連不上)與不存在(KBDB 正常回應但沒這張卡)必須長得不一樣**, + * 且讀不到時要交出還走得通的下一步(`kbdb_search`)。 + */ +import { describe, it, expect } from "vitest"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Env } from "../../../src/types.js"; +import { + registerGetSkill, + registerListSkills, + registerGetExample, +} from "../../../src/tools/arcrun_skills_examples.js"; +import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js"; + +const SERVICE: KnowledgeIdentity = { kind: "service" }; +const PORTAL: KnowledgeIdentity = { + kind: "portal", + portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] }, +}; +const STALE: KnowledgeIdentity = { kind: "stale" }; + +type ToolHandler = (args: Record) => Promise<{ + content: { type: string; text: string }[]; + isError?: boolean; +}>; + +function fakeServer() { + const tools = new Map(); + const server = { + tool: (name: string, _desc: string, _schema: unknown, handler: ToolHandler) => { + tools.set(name, handler); + }, + } as unknown as McpServer; + return { server, tools }; +} + +function makeEnv(respond: (url: string) => Response): Env { + return { + KBDB_INTERNAL_TOKEN: "", + KBDB: { fetch: async (url: string) => respond(url) }, + } as unknown as Env; +} + +const parse = (res: { content: { text: string }[] }) => JSON.parse(res.content[0].text); + +describe("arcrun_get_skill — 401 不准講成「不存在」", () => { + it("KBDB 回 401 → kbdb_unauthorized,訊息明說「讀不到不是不存在」並給 kbdb_search 這條路", async () => { + const { server, tools } = fakeServer(); + registerGetSkill(server, makeEnv(() => new Response("unauthorized", { status: 401 })), SERVICE); + const res = await tools.get("arcrun_get_skill")!({ slug: "write_intent_workflow" }); + + expect(res.isError).toBe(true); + const body = parse(res); + expect(body.error_code).toBe("kbdb_unauthorized"); + // 🔴 這句是本次事故的核心:不可以再出現舊版那句「skill "x" 不存在」的結論 + expect(body.human_message).not.toMatch(/skill "[^"]*" 不存在/); + expect(body.human_message).toContain("這是「讀不到」,不是「不存在」"); + expect(body.human_message).toContain("讀不到"); + expect(body.human_message).toContain("HTTP 401"); + // 還走得通的那條路要交到 AI 手上(實測 kbdb_search 撈得到同一張卡) + expect(body.next_actions.join("\n")).toContain("kbdb_search({ q: 'skill-write_intent_workflow' })"); + // 並明白禁止它改口 + expect(body.next_actions.join("\n")).toContain("不准把這個錯誤回報成"); + }); + + it("以帳密登入的連線收到 401 → additionally 說清楚「不是你的帳號讀不到」", async () => { + const { server, tools } = fakeServer(); + registerGetSkill(server, makeEnv(() => new Response("unauthorized", { status: 401 })), PORTAL); + const body = parse(await tools.get("arcrun_get_skill")!({ slug: "write_recipe" })); + expect(body.human_message).toContain("服務內部憑據"); + expect(body.human_message).toContain("不代表你的帳號讀不到"); + }); + + it("KBDB 5xx → kbdb_unreachable(連不上,也不是不存在)", async () => { + const { server, tools } = fakeServer(); + registerGetSkill(server, makeEnv(() => new Response("boom", { status: 503 })), SERVICE); + const body = parse(await tools.get("arcrun_get_skill")!({ slug: "whatever" })); + expect(body.error_code).toBe("kbdb_unreachable"); + expect(body.human_message).toContain("HTTP 503"); + }); + + it("binding 直接爆炸 → 也回 kbdb_unreachable,不吞成 not_found", async () => { + const { server, tools } = fakeServer(); + const env = { + KBDB: { + fetch: async () => { + throw new Error("kbdb down"); + }, + }, + } as unknown as Env; + registerGetSkill(server, env, SERVICE); + const body = parse(await tools.get("arcrun_get_skill")!({ slug: "x" })); + expect(body.error_code).toBe("kbdb_unreachable"); + expect(body.human_message).toContain("kbdb down"); + }); + + it("KBDB 正常回應但真的沒這張卡 → not_found,且說明是「這台實例沒 seed」不是全世界沒有", async () => { + const { server, tools } = fakeServer(); + registerGetSkill( + server, + makeEnv(() => new Response(JSON.stringify({ entries: [] }))), + SERVICE, + ); + const body = parse(await tools.get("arcrun_get_skill")!({ slug: "INDEX" })); + expect(body.error_code).toBe("not_found"); + expect(body.human_message).toContain("KBDB 正常回應"); + expect(body.human_message).toContain("skill-INDEX"); + expect(body.next_actions.join("\n")).toContain("arcrun_list_skills()"); + }); + + it("卡片真的在 → 照舊回內容(沒改壞正路)", async () => { + const { server, tools } = fakeServer(); + let seen = ""; + registerGetSkill( + server, + makeEnv((url) => { + seen = url; + return new Response( + JSON.stringify({ + entries: [{ id: "e1", page_name: "skill-write_intent_workflow", content: "# 意圖工作流", tags_json: '["skill:core"]' }], + }), + ); + }), + SERVICE, + ); + const body = parse(await tools.get("arcrun_get_skill")!({ slug: "write_intent_workflow" })); + expect(body.ok).toBe(true); + expect(body.data.content).toBe("# 意圖工作流"); + expect(seen).toContain("page_name=skill-write_intent_workflow"); + }); + + it("舊 token(stale)→ identity_missing(誠實要求重新連線,不謊稱找不到)", async () => { + const { server, tools } = fakeServer(); + registerGetSkill(server, makeEnv(() => new Response("{}")), STALE); + const body = parse(await tools.get("arcrun_get_skill")!({ slug: "x" })); + expect(body.error_code).toBe("identity_missing"); + }); +}); + +describe("arcrun_list_skills / arcrun_get_example — 同一條規則", () => { + it("list_skills 撞 401 → kbdb_unauthorized(舊版是 fetch_failed 一句技術話)", async () => { + const { server, tools } = fakeServer(); + registerListSkills(server, makeEnv(() => new Response("nope", { status: 401 })), SERVICE); + const body = parse(await tools.get("arcrun_list_skills")!({})); + expect(body.error_code).toBe("kbdb_unauthorized"); + expect(body.next_actions.join("\n")).toContain("kbdb_search"); + }); + + it("list_skills 成功時提醒「只用清單裡真的有的 slug」(別再猜 INDEX)", async () => { + const { server, tools } = fakeServer(); + registerListSkills( + server, + makeEnv(() => + new Response( + JSON.stringify({ + entries: [{ id: "e1", page_name: "skill-write_recipe", content: "x", tags_json: "[]" }], + }), + ), + ), + SERVICE, + ); + const body = parse(await tools.get("arcrun_list_skills")!({})); + expect(body.ok).toBe(true); + expect(body.data.count).toBe(1); + expect(body.hints.join("\n")).toContain("不要硬猜名字"); + }); + + it("get_example 撞 401 → 同樣是讀不到,不是「example 不存在」", async () => { + const { server, tools } = fakeServer(); + registerGetExample(server, makeEnv(() => new Response("nope", { status: 401 })), SERVICE); + const body = parse(await tools.get("arcrun_get_example")!({ slug: "rag-search-answer" })); + expect(body.error_code).toBe("kbdb_unauthorized"); + expect(body.human_message).not.toMatch(/example "[^"]*" 不存在/); + expect(body.next_actions.join("\n")).toContain("kbdb_search({ q: 'example-rag-search-answer' })"); + }); +});