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:
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Recipe tools(kbdb-base §7.5.i)— MCP 薄殼補齊 recipe 能力。
|
||||
*
|
||||
* rule 07 §5:CLI + MCP 覆蓋同一組 API 能力,MCP 不可長期落後。
|
||||
* CLI 已有 recipe push/list/delete/search/pull/submit-p(cli/src/commands/recipe.ts);
|
||||
* 此檔把同六能力暴露為 MCP 工具,**薄殼**:只 cypherFetch + 格式化,無業務邏輯。
|
||||
*
|
||||
* 私庫操作(push/list/delete/pull-install)→ cypherFetch 打用戶 cypher(= 私庫)。
|
||||
* 公庫操作(search/pull-fetch/submit-p)→ 同樣經 cypher(MCP 連平台 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",
|
||||
"搜尋公庫 recipe(API 整合配方)。同 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",
|
||||
"刪除自己私庫某 recipe(canonical_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), []);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 lookup(KBDB-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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user