fix(mcp): AI 一連上就知道「這裡有主人的知識庫」+「讀不到」不再講成「不存在」

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 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-13 14:09:35 +08:00
parent 9ff933ba98
commit c2897ba68b
5 changed files with 541 additions and 50 deletions
+126 -41
View File
@@ -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 同一族)。
*
* 實撞(總管在 leo21cportal-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
? `讀不到 KBDBHTTP ${e.status}:這條連線的憑據被拒或根本沒帶)。`
: `讀不到 KBDBHTTP ${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, "稍後重試", "🔴 不准把它回報成「沒有這個 skillexample」"],
);
}
// 基本盤 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<KbdbBlock[]> {
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 好好回答了,而它說沒有這張卡」**。
* 讀不到(4015xx/連不上)一律拋 `KbdbAccessError`——**絕不 return null**
* 否則呼叫端會把它講成「不存在」(2026-08-13 的真實事故,見檔頭)。
*/
async function kbdbGetByPageName(env: Env, pageName: string): Promise<KbdbBlock | null> {
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);
}