Files
Arcrun/mcp/src/tools/arcrun_skills_examples.ts
T
uncle6me-web b9bf3ec3d5 fix(mcp,kbdb): LI M3 skills/examples 改打基本盤 /entries(修死 route 假綠)
skills/examples 整條從舊 v3 /blocks /search 改打 KBDB 基本盤 /entries
(entry_type 對應)。5 個已上線 MCP 工具原本對死 route 回 404(假綠),
現修正;sync-registry-to-kbdb.py 改打 /entries idempotent upsert。
誠實降級:基本盤無語義 search → LIKE 關鍵字(embed 模組上線再換回語義)。
順手 gitignore scripts/__pycache__/。

對應 kbdb-base tasks 9.4 / llm-interface M3.2/M3.4。mcp + kbdb tsc exit 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:12:11 +08:00

328 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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=Ybase 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 { z } from "zod";
import type { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
import { errorResponse, successResponse } from "../lib/cypher-client.js";
// 基本盤 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<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}`);
const data = await resp.json<{ entries?: KbdbBlock[] }>();
return data.entries ?? [];
}
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;
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) {
server.tool(
"arcrun_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 }) => {
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",
],
);
} catch (e) {
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試", "若持續失敗,告訴 leo"],
);
}
},
);
}
export function registerGetSkill(server: McpServer, env: Env) {
server.tool(
"arcrun_get_skill",
"拿單一 agent-skill 完整 markdown playbook。slug 從 list_skills 取得。",
{
slug: z.string().describe("skill slug,例如 'build_watcher_workflow' / 'rag_with_arcrun'"),
},
async ({ slug }) => {
try {
const pageName = slug.startsWith("skill-") ? slug : `skill-${slug}`;
const block = await kbdbGetByPageName(env, pageName);
if (!block) {
return errorResponse(
"not_found",
`skill "${slug}" 不存在`,
[
"call arcrun_list_skills() 看可用 slug",
"確認拼字正確(不需要 'skill-' prefix",
],
);
}
return successResponse({
slug,
page_name: block.page_name,
content: block.content,
tags: parseTags(block.tags_json),
});
} catch (e) {
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試"],
);
}
},
);
}
export function registerListExamples(server: McpServer, env: Env) {
server.tool(
"arcrun_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 }) => {
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 errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試"],
);
}
},
);
}
export function registerGetExample(server: McpServer, env: Env) {
server.tool(
"arcrun_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 }) => {
try {
const pageName = slug.startsWith("example-") ? slug : `example-${slug}`;
const block = await kbdbGetByPageName(env, pageName);
if (!block) {
return errorResponse(
"not_found",
`example "${slug}" 不存在`,
[
"call arcrun_list_examples() 看可用 slug",
"或 arcrun_search_examples(use_case) 用自然語言找",
],
);
}
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 errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試"],
);
}
},
);
}
export function registerSearchExamples(server: McpServer, env: Env) {
server.tool(
"arcrun_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 }) => {
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 命中 +1slug/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 errorResponse(
"internal_error",
e instanceof Error ? e.message : String(e),
["重試一次"],
);
}
},
);
}
export function registerAllSkillExampleTools(server: McpServer, env: Env) {
registerListSkills(server, env);
registerGetSkill(server, env);
registerListExamples(server, env);
registerGetExample(server, env);
registerSearchExamples(server, env);
}