2026-08-11 23:07:26 +08:00
|
|
|
|
import { getRuntimeEnv } from "../../../lib/runtime-env";
|
2026-08-07 14:12:52 +08:00
|
|
|
|
import {
|
|
|
|
|
|
createMcpHandler,
|
|
|
|
|
|
McpServer,
|
|
|
|
|
|
type McpRequestContext,
|
|
|
|
|
|
} from "@modelcontextprotocol/server";
|
2026-08-07 11:59:45 +08:00
|
|
|
|
import { z } from "zod/v4";
|
|
|
|
|
|
import {
|
|
|
|
|
|
FeishuSourceError,
|
|
|
|
|
|
type FeishuBindings,
|
|
|
|
|
|
} from "../../../lib/feishu-client";
|
|
|
|
|
|
import {
|
|
|
|
|
|
buildClaimUrl,
|
|
|
|
|
|
createDistributionTask,
|
|
|
|
|
|
} from "../../../lib/task-service";
|
2026-08-07 14:12:52 +08:00
|
|
|
|
import { registerMcpOperationTools } from "../../../lib/mcp-tools";
|
|
|
|
|
|
import type { CollectionMcpBindings } from "../../../lib/mcp-collection-client";
|
2026-08-07 11:59:45 +08:00
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const env = getRuntimeEnv();
|
2026-08-07 11:59:45 +08:00
|
|
|
|
|
2026-08-07 14:12:52 +08:00
|
|
|
|
type McpBindings = FeishuBindings & CollectionMcpBindings & {
|
2026-08-07 11:59:45 +08:00
|
|
|
|
KOC_MCP_API_KEY?: string;
|
2026-08-11 23:07:26 +08:00
|
|
|
|
KOC_LOOP_MCP_API_KEY?: string;
|
2026-08-07 11:59:45 +08:00
|
|
|
|
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(),
|
2026-08-15 03:53:09 +08:00
|
|
|
|
platform: z.enum(["小红书", "抖音"]),
|
|
|
|
|
|
content_format: z.enum(["image_text", "video"]),
|
2026-08-07 11:59:45 +08:00
|
|
|
|
claim_url: z.string().url(),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function getBindings() {
|
|
|
|
|
|
return env as unknown as McpBindings;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 14:12:52 +08:00
|
|
|
|
function createServer(context: McpRequestContext) {
|
|
|
|
|
|
const bindings = getBindings();
|
|
|
|
|
|
const origin = context.requestInfo
|
|
|
|
|
|
? new URL(context.requestInfo.url).origin
|
|
|
|
|
|
: "";
|
2026-08-07 11:59:45 +08:00
|
|
|
|
const server = new McpServer(
|
2026-08-07 14:12:52 +08:00
|
|
|
|
{ name: "koc-loop", version: "2.0.0" },
|
2026-08-07 11:59:45 +08:00
|
|
|
|
{
|
|
|
|
|
|
instructions:
|
2026-08-07 14:12:52 +08:00
|
|
|
|
"用于创建和管理 KOC 内容分发任务、数据回收、公开数据采集与账号资源。创建任务前确认飞书链接、任务名和北京时间截止日期;写操作应先向用户说明影响。相同参数的任务创建和采集计划设置支持安全重试。",
|
2026-08-07 11:59:45 +08:00
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
server.registerTool(
|
|
|
|
|
|
"create_distribution_task",
|
|
|
|
|
|
{
|
|
|
|
|
|
title: "创建 KOC 分发任务",
|
|
|
|
|
|
description:
|
2026-08-15 03:53:09 +08:00
|
|
|
|
"读取飞书电子表格中的标题、正文及图片或视频,在 KOC LOOP 创建小红书/抖音分发任务,并返回可直接发给 KOC 的领取链接。飞书表格若有多个工作表,链接必须包含目标 sheet 参数。",
|
2026-08-07 11:59:45 +08:00
|
|
|
|
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("品牌或项目名称;未提供时系统记录为“未设置项目”"),
|
2026-08-15 03:53:09 +08:00
|
|
|
|
platform: z
|
|
|
|
|
|
.enum(["小红书", "抖音"])
|
|
|
|
|
|
.optional()
|
|
|
|
|
|
.describe("发布平台,默认小红书"),
|
|
|
|
|
|
content_format: z
|
|
|
|
|
|
.enum(["image_text", "video"])
|
|
|
|
|
|
.optional()
|
|
|
|
|
|
.describe("内容形式:image_text 图文,video 视频;默认图文"),
|
2026-08-07 11:59:45 +08:00
|
|
|
|
}),
|
|
|
|
|
|
outputSchema: toolOutputSchema,
|
|
|
|
|
|
annotations: {
|
|
|
|
|
|
readOnlyHint: false,
|
|
|
|
|
|
destructiveHint: false,
|
|
|
|
|
|
idempotentHint: true,
|
|
|
|
|
|
openWorldHint: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-08-15 03:53:09 +08:00
|
|
|
|
async ({ feishu_url, task_name, due_date, brand_project, platform, content_format }) => {
|
2026-08-07 11:59:45 +08:00
|
|
|
|
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,
|
2026-08-15 03:53:09 +08:00
|
|
|
|
platform: platform ?? "小红书",
|
|
|
|
|
|
contentFormat: content_format ?? "image_text",
|
2026-08-07 11:59:45 +08:00
|
|
|
|
},
|
|
|
|
|
|
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,
|
2026-08-15 03:53:09 +08:00
|
|
|
|
platform: result.platform,
|
|
|
|
|
|
content_format: result.contentFormat,
|
2026-08-07 11:59:45 +08:00
|
|
|
|
claim_url: buildClaimUrl(portalUrl, result.shareToken),
|
|
|
|
|
|
};
|
|
|
|
|
|
const actionText = result.created ? "已创建" : "已找到相同任务";
|
|
|
|
|
|
return {
|
|
|
|
|
|
content: [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: "text",
|
2026-08-15 03:53:09 +08:00
|
|
|
|
text: `${actionText}“${result.name}”,平台:${result.platform},内容形式:${result.contentFormat === "video" ? "视频" : "图文"},共 ${result.noteCount} 篇。领取链接:${output.claim_url}`,
|
2026-08-07 11:59:45 +08:00
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
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 }],
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-08-07 14:12:52 +08:00
|
|
|
|
registerMcpOperationTools(server, { bindings, origin });
|
|
|
|
|
|
|
2026-08-07 11:59:45 +08:00
|
|
|
|
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) {
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const bindings = getBindings();
|
|
|
|
|
|
const expected = String(
|
|
|
|
|
|
bindings.KOC_MCP_API_KEY ?? bindings.KOC_LOOP_MCP_API_KEY ?? "",
|
|
|
|
|
|
).trim();
|
2026-08-07 11:59:45 +08:00
|
|
|
|
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;
|