Files
Arcrun/mcp/src/tools/arcrun_skills_examples.ts
T
Claude 5fc7277c3c refactor(mcp): u6u_* tool 全面 rename 為 arcrun_* + 前綴收斂單一來源
零 u6u 洩漏(leo 要求 1):connector tool 清單只剩 arcrun_*,並移除壞掉/
重複的 u6u_ workflow tool(deploy/execute/get/list_workflows 由 arcrun_
push/run/get/list_workflows 取代);MCP server name 也由 u6u-mcp-server
改 arcrun-mcp-server。

前綴單一真相源(leo 要求 2):新增 mcp/src/brand.ts(TOOL_PREFIX + toolName()
helper),所有 server.tool() 註冊一律走 toolName("suffix"),前綴不再明碼散寫。
未來 rebrand 只改 brand.ts 一行。

- 12 個獨有能力 tool rename(component/tag/gui/search_workflows)
- 4 個重複/壞掉 tool 移除
- inter-tool hint、描述字串、註解內 u6u_ 名字全數更新
- tsc exit 0;vitest 19/19 綠

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
2026-07-07 05:21:34 +00:00

329 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 { 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";
// 基本盤 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(
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 }) => {
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(
toolName("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(
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 }) => {
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(
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 }) => {
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(
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 }) => {
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);
}