import { getRuntimeEnv } from "../../../lib/runtime-env"; import { createMcpHandler, McpServer, type McpRequestContext, } from "@modelcontextprotocol/server"; import { z } from "zod/v4"; import { FeishuSourceError, type FeishuBindings, } from "../../../lib/feishu-client"; import { buildClaimUrl, createDistributionTask, } from "../../../lib/task-service"; import { registerMcpOperationTools } from "../../../lib/mcp-tools"; import type { CollectionMcpBindings } from "../../../lib/mcp-collection-client"; const env = getRuntimeEnv(); type McpBindings = FeishuBindings & CollectionMcpBindings & { KOC_MCP_API_KEY?: string; KOC_LOOP_MCP_API_KEY?: string; KOC_PORTAL_URL?: string; }; const toolOutputSchema = z.object({ created: z.boolean(), task_id: z.string(), task_name: z.string(), brand_project: z.string(), due_date: z.string(), sheet_name: z.string(), note_count: z.number().int().nonnegative(), platform: z.enum(["小红书", "抖音"]), content_format: z.enum(["image_text", "video"]), claim_url: z.string().url(), }); function getBindings() { return env as unknown as McpBindings; } function createServer(context: McpRequestContext) { const bindings = getBindings(); const origin = context.requestInfo ? new URL(context.requestInfo.url).origin : ""; const server = new McpServer( { name: "koc-loop", version: "2.0.0" }, { instructions: "用于创建和管理 KOC 内容分发任务、数据回收、公开数据采集与账号资源。创建任务前确认飞书链接、任务名和北京时间截止日期;写操作应先向用户说明影响。相同参数的任务创建和采集计划设置支持安全重试。", }, ); server.registerTool( "create_distribution_task", { title: "创建 KOC 分发任务", description: "读取飞书电子表格中的标题、正文及图片或视频,在 KOC LOOP 创建小红书/抖音分发任务,并返回可直接发给 KOC 的领取链接。飞书表格若有多个工作表,链接必须包含目标 sheet 参数。", inputSchema: z.object({ feishu_url: z .string() .url() .describe("飞书 Wiki 或电子表格链接,建议包含目标 sheet 参数"), task_name: z.string().min(1).max(100).describe("分发任务名称"), due_date: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .describe("北京时间截止日期,格式为 YYYY-MM-DD"), brand_project: z .string() .min(1) .max(100) .optional() .describe("品牌或项目名称;未提供时系统记录为“未设置项目”"), platform: z .enum(["小红书", "抖音"]) .optional() .describe("发布平台,默认小红书"), content_format: z .enum(["image_text", "video"]) .optional() .describe("内容形式:image_text 图文,video 视频;默认图文"), }), outputSchema: toolOutputSchema, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, async ({ feishu_url, task_name, due_date, brand_project, platform, content_format }) => { try { const bindings = getBindings(); const portalUrl = String(bindings.KOC_PORTAL_URL ?? "").trim(); if (!portalUrl) { throw new Error("KOC 领取站点地址尚未配置"); } const result = await createDistributionTask( { feishuUrl: feishu_url, name: task_name, brand: brand_project?.trim() || "未设置项目", dueAt: due_date, platform: platform ?? "小红书", contentFormat: content_format ?? "image_text", }, bindings, { deduplicate: true }, ); const output = { created: result.created, task_id: result.taskId, task_name: result.name, brand_project: result.brand, due_date: result.dueAt, sheet_name: result.sheetName, note_count: result.noteCount, platform: result.platform, content_format: result.contentFormat, claim_url: buildClaimUrl(portalUrl, result.shareToken), }; const actionText = result.created ? "已创建" : "已找到相同任务"; return { content: [ { type: "text", text: `${actionText}“${result.name}”,平台:${result.platform},内容形式:${result.contentFormat === "video" ? "视频" : "图文"},共 ${result.noteCount} 篇。领取链接:${output.claim_url}`, }, ], structuredContent: output, }; } catch (error) { const message = error instanceof FeishuSourceError ? error.message : error instanceof Error && [ "KOC 领取站点地址尚未配置", "截止日期必须使用 YYYY-MM-DD 格式", "截止日期无效", "请补全飞书链接、任务名称和品牌/项目", ].includes(error.message) ? error.message : "创建任务失败,请稍后重试或联系系统管理员"; return { isError: true, content: [{ type: "text", text: message }], }; } }, ); registerMcpOperationTools(server, { bindings, origin }); return server; } const mcpHandler = createMcpHandler(createServer, { legacy: "stateless", responseMode: "json", }); async function secretDigest(value: string) { return new Uint8Array( await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)), ); } async function secretsMatch(received: string, expected: string) { const [left, right] = await Promise.all([ secretDigest(received), secretDigest(expected), ]); let difference = left.length ^ right.length; for (let index = 0; index < Math.max(left.length, right.length); index += 1) { difference |= (left[index] ?? 0) ^ (right[index] ?? 0); } return difference === 0; } function responseHeaders(response: Response, request: Request) { const headers = new Headers(response.headers); const origin = request.headers.get("Origin"); if (origin) headers.set("Access-Control-Allow-Origin", origin); headers.set("Vary", "Origin"); headers.set( "Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate", ); return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); } function originRejected(request: Request) { const origin = request.headers.get("Origin"); return Boolean(origin && origin !== new URL(request.url).origin); } async function authorize(request: Request) { const bindings = getBindings(); const expected = String( bindings.KOC_MCP_API_KEY ?? bindings.KOC_LOOP_MCP_API_KEY ?? "", ).trim(); const authorization = request.headers.get("Authorization") ?? ""; const match = authorization.match(/^Bearer\s+(.+)$/i); if (!expected || !match || !(await secretsMatch(match[1].trim(), expected))) { return new Response("Unauthorized", { status: 401, headers: { "WWW-Authenticate": 'Bearer realm="KOC LOOP MCP"' }, }); } return null; } async function handle(request: Request) { if (originRejected(request)) { return new Response("Forbidden origin", { status: 403 }); } const unauthorized = await authorize(request); if (unauthorized) return responseHeaders(unauthorized, request); return responseHeaders(await mcpHandler.fetch(request), request); } export async function OPTIONS(request: Request) { if (originRejected(request)) { return new Response("Forbidden origin", { status: 403 }); } return new Response(null, { status: 204, headers: { "Access-Control-Allow-Origin": request.headers.get("Origin") ?? new URL(request.url).origin, "Access-Control-Allow-Methods": "POST, GET, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type, MCP-Protocol-Version, Mcp-Session-Id, Last-Event-ID, Mcp-Name, Mcp-Method", "Access-Control-Max-Age": "86400", Vary: "Origin", }, }); } export const POST = handle; export const GET = handle; export const DELETE = handle;