feat(kbdb): recipe 公庫/私庫雙向機制 + UUID 身份 + KBDB Base + 市場數據

kbdb-base SDD §7.5(公庫/私庫雙向機制,richblack 2026-06-07 拍板)。

## KBDB Base worker(新)
- kbdb/:D1-only 核心三表(entries/templates/entry_values)+ CRUD + LIKE search
  + recipe-stats 端點(市場數據)+ 0001_base.sql migration(含 recipe_stat seed)

## Phase 2.3:init 建 D1 + 套 migration
- cli cf-api.ts 加 listD1Databases/ensureD1Database;init 建 arcrun-kbdb D1
- deploy.ts 部署後對 D1 套 0001_base.sql(CF /d1/query API,idempotent)+ 注入 database_id

## Phase 5.1:recipe 成功記錄(市場數據來源)
- GraphExecutor 收集本次用到的 recipe uuid(usedRecipeKeys)
- executeWebhookGraph 執行結束一次性記 per-uuid 成功/失敗到 KBDB(fire-and-forget)

## Phase 7.5:recipe UUID 身份 + app-store 模型
- recipe 領 uuid=唯一身份;canonical_id/author/公私=屬性(§7.5.5)
- recipe:{uuid} + idx:canonical/installed/hash;resolveRecipe 向後相容不破執行鏈
- POST /recipes/submit=領新 uuid 新增作者版本(非覆蓋,app-store)
- GET /public-recipes 搜尋(多作者+per-uuid 市場星數)/ :id pull(選市場最佳)
- 落空→found:false 創作引導(§7.5.6 閉環)
- POST /recipes/migrate-uuid 一次性轉舊 key(增量寫不刪舊、冪等)
- init-seed 用 UUID(author=system)

## 薄殼(rule 07 §5:CLI + MCP 覆蓋同組能力)
- CLI: acr recipe search/pull/submit-p(config 加 DEFAULT_PUBLIC_LIBRARY_URL)
- MCP: arcrun_recipe_search/pull/submit_p/push/list/delete(補齊漂移)

## 壓測修正
- api-recipe-seeds: google_sheets_append PUT→POST(:append 正確動詞,階段12)

四 worker tsc 全綠(cypher/cli/kbdb/mcp)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-06-07 16:18:10 +08:00
parent 95a1462b65
commit 6a75117ba3
28 changed files with 3438 additions and 37 deletions
+233
View File
@@ -0,0 +1,233 @@
/**
* Recipe toolskbdb-base §7.5.i)— MCP 薄殼補齊 recipe 能力。
*
* rule 07 §5CLI + MCP 覆蓋同一組 API 能力,MCP 不可長期落後。
* CLI 已有 recipe push/list/delete/search/pull/submit-pcli/src/commands/recipe.ts);
* 此檔把同六能力暴露為 MCP 工具,**薄殼**:只 cypherFetch + 格式化,無業務邏輯。
*
* 私庫操作(push/list/delete/pull-install)→ cypherFetch 打用戶 cypher= 私庫)。
* 公庫操作(search/pull-fetch/submit-p)→ 同樣經 cypherMCP 連平台 cypher = 公庫;
* self-hosted account-source 是 §5.2 已知違反,pre-existing,本檔沿用既有 cypherFetch 模式)。
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { Env } from "../types.js";
import { cypherFetch, errorResponse, successResponse } from "../lib/cypher-client.js";
const apiKeyDesc = "你(用戶)的 arcrun api_key (ak_xxx)。從 https://arcrun.dev/me 取得";
/** 註冊全部 recipe 工具(kbdb-base §7.5.i,與 CLI 六能力對齊)。 */
export function registerAllRecipeTools(server: McpServer, env: Env) {
registerRecipeSearch(server, env);
registerRecipePull(server, env);
registerRecipeSubmitP(server, env);
registerRecipePush(server, env);
registerRecipeList(server, env);
registerRecipeDelete(server, env);
}
/** arcrun_recipe_search — 搜尋公庫 recipe(同名可多作者,附市場數據)。落空回創作引導。 */
export function registerRecipeSearch(server: McpServer, env: Env) {
server.tool(
"arcrun_recipe_search",
"搜尋公庫 recipeAPI 整合配方)。同 canonical_id 可有多作者版本,各附市場數據(成功/失敗次數),依數據選最佳。找不到時會提示可自己做一個 recipe 投稿成為作者。",
{
api_key: z.string().describe(apiKeyDesc),
query: z.string().describe("搜尋詞,如「gsheets append」「telegram send」"),
},
async ({ api_key, query }) => {
try {
const res = await cypherFetch(env, "/public-recipes", {
apiKey: api_key,
query: { q: query },
});
if (!res.ok) return errorResponse("search_failed", `搜尋公庫失敗`, ["稍後再試"], await res.text());
const data = await res.json();
return successResponse(data, [
"found:false → 公庫沒有,可自己做:建 recipe → arcrun_recipe_push(私庫)→ arcrun_recipe_submit_p(投稿)",
"多作者版本依 market_stat 選成功率最高的 → arcrun_recipe_pull",
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
}
},
);
}
/** arcrun_recipe_pull — 從公庫取一份 recipe 寫進自己私庫。 */
export function registerRecipePull(server: McpServer, env: Env) {
server.tool(
"arcrun_recipe_pull",
"從公庫取一份 recipe 寫進自己私庫(按需取用,非全量同步)。不指定 author 取市場最佳版本。取回後可在 workflow 用 component: <canonical_id>。",
{
api_key: z.string().describe(apiKeyDesc),
canonical_id: z.string().describe("要取的 recipe canonical_id,如 gsheets_append"),
author: z.string().optional().describe("指定作者版本(不指定取市場最佳)"),
},
async ({ api_key, canonical_id, author }) => {
try {
// 1. 公庫取全文
const pubRes = await cypherFetch(env, `/public-recipes/${encodeURIComponent(canonical_id)}`, {
apiKey: api_key,
query: author ? { author } : undefined,
});
if (!pubRes.ok) return errorResponse("pull_failed", `公庫取 recipe 失敗`, [], await pubRes.text());
const pub = await pubRes.json() as
| { found: true; recipe: Record<string, unknown> & { uuid?: string } }
| { found: false; canonical_id: string; hint: string };
if (!pub.found) {
return successResponse(pub, [
"公庫沒有此 recipe。可自己做:arcrun_recipe_push(私庫)→ arcrun_recipe_submit_p(投稿成為作者)",
]);
}
// 2. 寫進私庫(帶 derived_from 溯源 + pull 級暴露同意)
const installRes = await cypherFetch(env, "/recipes", {
apiKey: api_key,
method: "POST",
body: {
...pub.recipe,
derived_from: pub.recipe.uuid,
exposure_consent: {
confirmed_by_human: true,
understood: `pull from public library: ${canonical_id}`,
confirmed_at: new Date().toISOString(),
},
},
});
if (!installRes.ok) return errorResponse("install_failed", `寫入私庫失敗`, [], await installRes.text());
const inst = await installRes.json();
return successResponse(inst, [`已拉進私庫,workflow 可用 component: ${canonical_id}`]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
}
},
);
}
/** arcrun_recipe_submit_p — 把私庫某 recipe 投稿到公庫(新增作者版本)。 */
export function registerRecipeSubmitP(server: McpServer, env: Env) {
server.tool(
"arcrun_recipe_submit_p",
"把私庫某 recipe 投稿到公庫(app-store 模型:新增一個作者版本,不覆蓋別人的)。投稿 = 把 recipe 暴露給全網,需帶 exposure_consent 明示同意。別人能搜到並 pull,市場數據決定它被不被選用。",
{
api_key: z.string().describe(apiKeyDesc),
canonical_id: z.string().describe("要投稿的私庫 recipe canonical_id"),
author: z.string().optional().describe("署名作者(預設用 recipe 既有 author"),
exposure_consent: z.boolean().describe(
"明示同意把此 recipe 暴露給公庫全網(投稿是暴露面,需人類同意)",
),
},
async ({ api_key, canonical_id, author, exposure_consent }) => {
try {
if (!exposure_consent) {
return errorResponse("consent_required", "投稿到公庫是暴露面,需 exposure_consent=true 明示同意", [
"確認要把 recipe 公開給全網後,帶 exposure_consent: true 再呼叫",
]);
}
// 1. 私庫取全文
const myRes = await cypherFetch(env, `/recipes/${encodeURIComponent(canonical_id)}`, { apiKey: api_key });
if (!myRes.ok) return errorResponse("not_found", `私庫找不到 recipe「${canonical_id}`, ["先 arcrun_recipe_push 或 arcrun_recipe_pull"], await myRes.text());
const my = await myRes.json() as { success: boolean; recipe?: Record<string, unknown> & { uuid?: string; author?: string; derived_from?: string } };
if (!my.success || !my.recipe) return errorResponse("not_found", `私庫無此 recipe`, []);
// 2. 投稿公庫(新增作者版本)
const consent = {
confirmed_by_human: true,
understood: `submit recipe to public library: ${canonical_id}`,
confirmed_at: new Date().toISOString(),
};
const subRes = await cypherFetch(env, "/recipes/submit", {
apiKey: api_key,
method: "POST",
body: {
...my.recipe,
author: author ?? my.recipe.author,
derived_from: my.recipe.derived_from ?? my.recipe.uuid,
submitter: author ?? api_key,
exposure_consent: consent,
},
});
if (!subRes.ok) return errorResponse("submit_failed", `投稿公庫失敗`, [], await subRes.text());
const data = await subRes.json();
return successResponse(data, ["已投稿公庫(新增作者版本)。市場數據累積後決定被不被選用"]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
}
},
);
}
/** arcrun_recipe_push — 上傳/更新私庫 recipe(就地更新自己的版本)。 */
export function registerRecipePush(server: McpServer, env: Env) {
server.tool(
"arcrun_recipe_push",
"上傳一份 recipe 到自己私庫(或就地更新自己既有版本)。recipe = 「http_request + 參數模板」的具名封裝,不需 deploy Worker。要投稿到公庫用 arcrun_recipe_submit_p。",
{
api_key: z.string().describe(apiKeyDesc),
recipe: z.object({
canonical_id: z.string(),
display_name: z.string().optional(),
description: z.string().optional(),
endpoint: z.string(),
method: z.string().optional(),
headers: z.record(z.string()).optional(),
body: z.record(z.unknown()).optional(),
auth_service: z.string().optional(),
author: z.string().optional(),
}).describe("recipe 定義(canonical_id + endpoint 必填)"),
},
async ({ api_key, recipe }) => {
try {
const res = await cypherFetch(env, "/recipes", { apiKey: api_key, method: "POST", body: recipe });
if (!res.ok) return errorResponse("push_failed", `上傳 recipe 失敗`, [], await res.text());
const data = await res.json();
return successResponse(data, [`workflow 用 component: ${recipe.canonical_id}`, "要公開給全網 → arcrun_recipe_submit_p"]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
}
},
);
}
/** arcrun_recipe_list — 列出自己私庫的 recipe。 */
export function registerRecipeList(server: McpServer, env: Env) {
server.tool(
"arcrun_recipe_list",
"列出自己私庫(本部署)的 recipe。要找公庫的用 arcrun_recipe_search。",
{
api_key: z.string().describe(apiKeyDesc),
},
async ({ api_key }) => {
try {
const res = await cypherFetch(env, "/recipes", { apiKey: api_key });
if (!res.ok) return errorResponse("list_failed", `列出 recipe 失敗`, [], await res.text());
const data = await res.json();
return successResponse(data);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
}
},
);
}
/** arcrun_recipe_delete — 刪除私庫某 recipe。 */
export function registerRecipeDelete(server: McpServer, env: Env) {
server.tool(
"arcrun_recipe_delete",
"刪除自己私庫某 recipecanonical_id / rec_hash / uuid)。不影響公庫別人的版本。",
{
api_key: z.string().describe(apiKeyDesc),
id: z.string().describe("canonical_id 或 rec_hash 或 uuid"),
},
async ({ api_key, id }) => {
try {
const res = await cypherFetch(env, `/recipes/${encodeURIComponent(id)}`, { apiKey: api_key, method: "DELETE" });
if (!res.ok) return errorResponse("delete_failed", `刪除 recipe 失敗`, [], await res.text());
const data = await res.json();
return successResponse(data);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
}
},
);
}
+3
View File
@@ -19,6 +19,7 @@ import { registerReportFeedback } from "./arcrun_report_feedback.js";
import { registerAllIntrospectionTools } from "./arcrun_introspection.js";
import { registerAllWorkflowCrudTools } from "./arcrun_workflow_crud.js";
import { registerAllSkillExampleTools } from "./arcrun_skills_examples.js";
import { registerAllRecipeTools } from "./arcrun_recipe.js";
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
registerSearchComponents(server, env, orgNamespace);
@@ -46,4 +47,6 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri
// LI SDD M3.2: skills + examples lookupKBDB-backed
// 走 sync-registry-to-kbdb.py 把 registry/{skills,examples} 同步進 KBDB
registerAllSkillExampleTools(server, env);
// kbdb-base §7.5.i: recipe 公庫/私庫工具(與 CLI 六能力對齊,rule 07 §5 MCP 不落後)
registerAllRecipeTools(server, env);
}