Files
koc-loop/app/api/mcp/route.ts
2026-08-07 11:59:45 +08:00

223 lines
7.0 KiB
TypeScript
Raw 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 { env } from "cloudflare:workers";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { z } from "zod/v4";
import {
FeishuSourceError,
type FeishuBindings,
} from "../../../lib/feishu-client";
import {
buildClaimUrl,
createDistributionTask,
} from "../../../lib/task-service";
export const runtime = "edge";
type McpBindings = FeishuBindings & {
KOC_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(),
claim_url: z.string().url(),
});
function getBindings() {
return env as unknown as McpBindings;
}
function createServer() {
const server = new McpServer(
{ name: "koc-loop", version: "1.0.0" },
{
instructions:
"用于创建 KOC 内容分发任务。调用前先确认飞书表格链接、任务名称和截止日期;截止日期转换为北京时间 YYYY-MM-DD。相同参数的重试会返回原任务不会重复创建。",
},
);
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("品牌或项目名称;未提供时系统记录为“未设置项目”"),
}),
outputSchema: toolOutputSchema,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ feishu_url, task_name, due_date, brand_project }) => {
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,
},
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,
claim_url: buildClaimUrl(portalUrl, result.shareToken),
};
const actionText = result.created ? "已创建" : "已找到相同任务";
return {
content: [
{
type: "text",
text: `${actionText}${result.name}”,共 ${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 }],
};
}
},
);
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 expected = String(getBindings().KOC_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;