Add MCP task creation endpoint
This commit is contained in:
@@ -23,8 +23,8 @@ import {
|
||||
FeishuSourceError,
|
||||
readFeishuSource,
|
||||
type FeishuBindings,
|
||||
type FeishuSource,
|
||||
} from "../../../lib/feishu-client";
|
||||
import { createDistributionTask } from "../../../lib/task-service";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
@@ -38,76 +38,6 @@ function numberValue(value: unknown, fallback = 0) {
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
async function createTaskFromSource(
|
||||
source: FeishuSource,
|
||||
name: string,
|
||||
brand: string,
|
||||
dueAt: string,
|
||||
) {
|
||||
const db = getRawDb();
|
||||
const taskId = uid("task");
|
||||
const shareToken = crypto.randomUUID().replaceAll("-", "");
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO tasks
|
||||
(id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||
source_url, source_sheet_id, source_sheet_name, source_synced_at,
|
||||
share_token)
|
||||
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
taskId,
|
||||
name,
|
||||
brand,
|
||||
source.rows.length,
|
||||
dueAt,
|
||||
source.url,
|
||||
source.sheetId,
|
||||
source.sheetName,
|
||||
source.syncedAt,
|
||||
shareToken,
|
||||
)
|
||||
.run();
|
||||
|
||||
try {
|
||||
const contentStatements = source.rows.map((row) => {
|
||||
const contentId = uid("content");
|
||||
const imageAssets = row.images.map((image) => ({
|
||||
...image,
|
||||
key: `content-assets/${taskId}/${contentId}/${image.index}`,
|
||||
}));
|
||||
return db
|
||||
.prepare(
|
||||
`INSERT INTO contents
|
||||
(id, task_id, title, body, image_assets, status, source, source_row)
|
||||
VALUES (?, ?, ?, ?, ?, 'available', ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
contentId,
|
||||
taskId,
|
||||
row.title,
|
||||
row.body,
|
||||
JSON.stringify(imageAssets),
|
||||
`飞书 · ${source.sheetName}`,
|
||||
row.sourceRow,
|
||||
);
|
||||
});
|
||||
for (let index = 0; index < contentStatements.length; index += 100) {
|
||||
await db.batch(contentStatements.slice(index, index + 100));
|
||||
}
|
||||
await db
|
||||
.prepare("UPDATE tasks SET status = 'active' WHERE id = ?")
|
||||
.bind(taskId)
|
||||
.run();
|
||||
} catch (error) {
|
||||
await db.batch([
|
||||
db.prepare("DELETE FROM contents WHERE task_id = ?").bind(taskId),
|
||||
db.prepare("DELETE FROM tasks WHERE id = ?").bind(taskId),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!(await isAdminRequest(request))) return adminForbidden();
|
||||
try {
|
||||
@@ -140,11 +70,15 @@ export async function POST(request: Request) {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const source = await readFeishuSource(
|
||||
String(body.feishuUrl ?? "").trim(),
|
||||
await createDistributionTask(
|
||||
{
|
||||
feishuUrl: String(body.feishuUrl ?? "").trim(),
|
||||
name,
|
||||
brand,
|
||||
dueAt,
|
||||
},
|
||||
env as unknown as FeishuBindings,
|
||||
);
|
||||
await createTaskFromSource(source, name, brand, dueAt);
|
||||
} else if (body.action === "claim") {
|
||||
const partnerId = String(body.partnerId ?? "");
|
||||
const taskId = String(body.taskId ?? "");
|
||||
|
||||
222
app/api/mcp/route.ts
Normal file
222
app/api/mcp/route.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user