Files
koc-loop/app/api/mcp/route.ts
2026-08-15 03:53:09 +08:00

253 lines
8.3 KiB
TypeScript
Raw Permalink 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.
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;