Add MCP task creation endpoint
This commit is contained in:
@@ -2,6 +2,7 @@ KOC_PORTAL_URL=http://localhost:3000
|
|||||||
SUPER_ADMIN_USERNAME=admin
|
SUPER_ADMIN_USERNAME=admin
|
||||||
SUPER_ADMIN_PASSWORD=qazxsw123admin
|
SUPER_ADMIN_PASSWORD=qazxsw123admin
|
||||||
ADMIN_INTERNAL_TOKEN=replace-with-a-random-secret
|
ADMIN_INTERNAL_TOKEN=replace-with-a-random-secret
|
||||||
|
KOC_MCP_API_KEY=replace-with-a-separate-long-random-secret
|
||||||
|
|
||||||
# Optional override. The production key must be stored as a runtime secret.
|
# Optional override. The production key must be stored as a runtime secret.
|
||||||
AI_TOOL_CENTER_MCP_URL=https://middle-aitool.gbotai.cn/mcp
|
AI_TOOL_CENTER_MCP_URL=https://middle-aitool.gbotai.cn/mcp
|
||||||
|
|||||||
49
README.md
49
README.md
@@ -27,6 +27,7 @@ npm run build
|
|||||||
- `SUPER_ADMIN_USERNAME`:唯一的超级管理员登录账号
|
- `SUPER_ADMIN_USERNAME`:唯一的超级管理员登录账号
|
||||||
- `SUPER_ADMIN_PASSWORD`:超级管理员初始密码,至少 8 位
|
- `SUPER_ADMIN_PASSWORD`:超级管理员初始密码,至少 8 位
|
||||||
- `ADMIN_INTERNAL_TOKEN`:自动采集等内部任务使用的服务密钥
|
- `ADMIN_INTERNAL_TOKEN`:自动采集等内部任务使用的服务密钥
|
||||||
|
- `KOC_MCP_API_KEY`:Agent 调用 KOC LOOP MCP 使用的独立 Bearer 密钥
|
||||||
|
|
||||||
系统首次登录时创建唯一的超级管理员。后续管理员和普通用户均由“用户管理”页面创建,普通用户不能访问 KOC 资源库。
|
系统首次登录时创建唯一的超级管理员。后续管理员和普通用户均由“用户管理”页面创建,普通用户不能访问 KOC 资源库。
|
||||||
|
|
||||||
@@ -49,6 +50,54 @@ This starter does not use `wrangler.jsonc`.
|
|||||||
|
|
||||||
后台不提供注册、找回密码和普通用户个人改密。密码重置统一由管理员在后台完成。
|
后台不提供注册、找回密码和普通用户个人改密。密码重置统一由管理员在后台完成。
|
||||||
|
|
||||||
|
## Agent MCP
|
||||||
|
|
||||||
|
生产地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://你的-KOC-LOOP-后台域名/api/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
MCP 使用独立的 `KOC_MCP_API_KEY` 鉴权,请通过请求头发送:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Bearer <KOC_MCP_API_KEY>
|
||||||
|
```
|
||||||
|
|
||||||
|
首期只开放 `create_distribution_task`,用于读取飞书表格并创建分发任务。参数如下:
|
||||||
|
|
||||||
|
- `feishu_url`:飞书 Wiki 或电子表格链接;多工作表时必须带目标 `sheet` 参数
|
||||||
|
- `task_name`:任务名称
|
||||||
|
- `due_date`:北京时间截止日期,格式 `YYYY-MM-DD`
|
||||||
|
- `brand_project`:可选,品牌或项目名称;未提供时记录为“未设置项目”
|
||||||
|
|
||||||
|
成功后返回任务 ID、笔记数量和 KOC 领取链接。完全相同的任务参数重复调用时,返回已经存在的任务,避免 Agent 重试产生重复任务。
|
||||||
|
|
||||||
|
在支持远程 MCP 的 Agent 中添加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"koc-loop": {
|
||||||
|
"url": "https://你的-KOC-LOOP-后台域名/api/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${KOC_LOOP_MCP_API_KEY}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
使用示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
用这个飞书表格创建发布任务:<飞书链接>。
|
||||||
|
任务名“8月骑手招募”,截止时间 2026-08-20。
|
||||||
|
创建后把 KOC 领取链接发给我。
|
||||||
|
```
|
||||||
|
|
||||||
|
密钥不要写入仓库、对话内容或 URL 查询参数,生产环境通过站点密钥管理配置。
|
||||||
|
|
||||||
## Useful Commands
|
## Useful Commands
|
||||||
|
|
||||||
- `npm run dev`: start local development
|
- `npm run dev`: start local development
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import {
|
|||||||
FeishuSourceError,
|
FeishuSourceError,
|
||||||
readFeishuSource,
|
readFeishuSource,
|
||||||
type FeishuBindings,
|
type FeishuBindings,
|
||||||
type FeishuSource,
|
|
||||||
} from "../../../lib/feishu-client";
|
} from "../../../lib/feishu-client";
|
||||||
|
import { createDistributionTask } from "../../../lib/task-service";
|
||||||
|
|
||||||
export const runtime = "edge";
|
export const runtime = "edge";
|
||||||
|
|
||||||
@@ -38,76 +38,6 @@ function numberValue(value: unknown, fallback = 0) {
|
|||||||
return Number.isFinite(parsed) ? parsed : fallback;
|
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) {
|
export async function POST(request: Request) {
|
||||||
if (!(await isAdminRequest(request))) return adminForbidden();
|
if (!(await isAdminRequest(request))) return adminForbidden();
|
||||||
try {
|
try {
|
||||||
@@ -140,11 +70,15 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const source = await readFeishuSource(
|
await createDistributionTask(
|
||||||
String(body.feishuUrl ?? "").trim(),
|
{
|
||||||
|
feishuUrl: String(body.feishuUrl ?? "").trim(),
|
||||||
|
name,
|
||||||
|
brand,
|
||||||
|
dueAt,
|
||||||
|
},
|
||||||
env as unknown as FeishuBindings,
|
env as unknown as FeishuBindings,
|
||||||
);
|
);
|
||||||
await createTaskFromSource(source, name, brand, dueAt);
|
|
||||||
} else if (body.action === "claim") {
|
} else if (body.action === "claim") {
|
||||||
const partnerId = String(body.partnerId ?? "");
|
const partnerId = String(body.partnerId ?? "");
|
||||||
const taskId = String(body.taskId ?? "");
|
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;
|
||||||
222
lib/task-service.ts
Normal file
222
lib/task-service.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { ensureSchema, getRawDb, uid } from "./mvp-db";
|
||||||
|
import {
|
||||||
|
readFeishuSource,
|
||||||
|
type FeishuBindings,
|
||||||
|
type FeishuSource,
|
||||||
|
} from "./feishu-client";
|
||||||
|
|
||||||
|
export type CreateDistributionTaskInput = {
|
||||||
|
feishuUrl: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
dueAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DistributionTaskCreation = {
|
||||||
|
created: boolean;
|
||||||
|
taskId: string;
|
||||||
|
shareToken: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
dueAt: string;
|
||||||
|
noteCount: number;
|
||||||
|
sheetId: string;
|
||||||
|
sheetName: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskRow = {
|
||||||
|
id: string;
|
||||||
|
share_token: string | null;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
due_at: string;
|
||||||
|
quantity: number;
|
||||||
|
source_url: string;
|
||||||
|
source_sheet_id: string;
|
||||||
|
source_sheet_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizedValue(value: string) {
|
||||||
|
return String(value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedDueDate(value: string) {
|
||||||
|
const dueAt = normalizedValue(value);
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(dueAt)) {
|
||||||
|
throw new Error("截止日期必须使用 YYYY-MM-DD 格式");
|
||||||
|
}
|
||||||
|
const [year, month, day] = dueAt.split("-").map(Number);
|
||||||
|
const parsed = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
if (
|
||||||
|
Number.isNaN(parsed.getTime()) ||
|
||||||
|
parsed.getUTCFullYear() !== year ||
|
||||||
|
parsed.getUTCMonth() + 1 !== month ||
|
||||||
|
parsed.getUTCDate() !== day
|
||||||
|
) {
|
||||||
|
throw new Error("截止日期无效");
|
||||||
|
}
|
||||||
|
return dueAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedFeishuUrl(value: string) {
|
||||||
|
const input = normalizedValue(value);
|
||||||
|
try {
|
||||||
|
const url = new URL(input);
|
||||||
|
url.searchParams.delete("from");
|
||||||
|
url.searchParams.sort();
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findExistingTask(
|
||||||
|
sourceUrl: string,
|
||||||
|
input: CreateDistributionTaskInput,
|
||||||
|
) {
|
||||||
|
const db = getRawDb();
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, share_token, name, brand, due_at, quantity,
|
||||||
|
source_url, source_sheet_id, source_sheet_name
|
||||||
|
FROM tasks
|
||||||
|
WHERE name = ?
|
||||||
|
AND brand = ?
|
||||||
|
AND due_at = ?
|
||||||
|
AND source_url = ?
|
||||||
|
AND status IN ('active', 'importing')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
)
|
||||||
|
.bind(input.name, input.brand, input.dueAt, sourceUrl)
|
||||||
|
.first<TaskRow>();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertTaskFromSource(
|
||||||
|
source: FeishuSource,
|
||||||
|
input: CreateDistributionTaskInput,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
input.name,
|
||||||
|
input.brand,
|
||||||
|
source.rows.length,
|
||||||
|
input.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { taskId, shareToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDistributionTask(
|
||||||
|
rawInput: CreateDistributionTaskInput,
|
||||||
|
bindings: FeishuBindings,
|
||||||
|
options: { deduplicate?: boolean } = {},
|
||||||
|
): Promise<DistributionTaskCreation> {
|
||||||
|
await ensureSchema();
|
||||||
|
const input = {
|
||||||
|
feishuUrl: normalizedFeishuUrl(rawInput.feishuUrl),
|
||||||
|
name: normalizedValue(rawInput.name),
|
||||||
|
brand: normalizedValue(rawInput.brand),
|
||||||
|
dueAt: normalizedDueDate(rawInput.dueAt),
|
||||||
|
};
|
||||||
|
if (!input.feishuUrl || !input.name || !input.brand) {
|
||||||
|
throw new Error("请补全飞书链接、任务名称和品牌/项目");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.deduplicate) {
|
||||||
|
const existing = await findExistingTask(input.feishuUrl, input);
|
||||||
|
if (existing?.share_token) {
|
||||||
|
return {
|
||||||
|
created: false,
|
||||||
|
taskId: existing.id,
|
||||||
|
shareToken: existing.share_token,
|
||||||
|
name: existing.name,
|
||||||
|
brand: existing.brand,
|
||||||
|
dueAt: existing.due_at,
|
||||||
|
noteCount: Number(existing.quantity),
|
||||||
|
sheetId: existing.source_sheet_id,
|
||||||
|
sheetName: existing.source_sheet_name,
|
||||||
|
sourceUrl: existing.source_url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = await readFeishuSource(input.feishuUrl, bindings);
|
||||||
|
const inserted = await insertTaskFromSource(source, input);
|
||||||
|
return {
|
||||||
|
created: true,
|
||||||
|
taskId: inserted.taskId,
|
||||||
|
shareToken: inserted.shareToken,
|
||||||
|
name: input.name,
|
||||||
|
brand: input.brand,
|
||||||
|
dueAt: input.dueAt,
|
||||||
|
noteCount: source.rows.length,
|
||||||
|
sheetId: source.sheetId,
|
||||||
|
sheetName: source.sheetName,
|
||||||
|
sourceUrl: source.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildClaimUrl(portalOrigin: string, shareToken: string) {
|
||||||
|
const origin = normalizedValue(portalOrigin).replace(/\/$/, "");
|
||||||
|
if (!origin) throw new Error("KOC 领取站点地址尚未配置");
|
||||||
|
const url = new URL(origin);
|
||||||
|
url.searchParams.set("task", shareToken);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -8,11 +8,13 @@
|
|||||||
"name": "site-creator-vinext-starter",
|
"name": "site-creator-vinext-starter",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/server": "^2.0.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"fflate": "0.7.4",
|
"fflate": "0.7.4",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-dom": "19.2.6"
|
"react-dom": "19.2.6",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.37.1",
|
"@cloudflare/vite-plugin": "1.37.1",
|
||||||
@@ -2123,6 +2125,31 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/core": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/server": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/core": "2.0.0",
|
||||||
|
"zod": "^4.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
@@ -11146,7 +11173,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@@ -14,11 +14,13 @@
|
|||||||
"db:generate": "drizzle-kit generate"
|
"db:generate": "drizzle-kit generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/server": "^2.0.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"fflate": "0.7.4",
|
"fflate": "0.7.4",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-dom": "19.2.6"
|
"react-dom": "19.2.6",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.37.1",
|
"@cloudflare/vite-plugin": "1.37.1",
|
||||||
|
|||||||
46
tests/mcp-task-server.test.mjs
Normal file
46
tests/mcp-task-server.test.mjs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
test("exposes one authenticated idempotent MCP tool for creating distribution tasks", async () => {
|
||||||
|
const [route, taskService, actionRoute, readme, envExample, packageJson] =
|
||||||
|
await Promise.all([
|
||||||
|
readFile(new URL("../app/api/mcp/route.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../lib/task-service.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../README.md", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../.dev.vars.example", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(route, /createMcpHandler/);
|
||||||
|
assert.match(route, /create_distribution_task/);
|
||||||
|
assert.match(route, /KOC_MCP_API_KEY/);
|
||||||
|
assert.match(route, /Authorization/);
|
||||||
|
assert.match(route, /Bearer/);
|
||||||
|
assert.match(route, /idempotentHint: true/);
|
||||||
|
assert.match(route, /brand_project\?\.trim\(\) \|\| "未设置项目"/);
|
||||||
|
assert.match(route, /buildClaimUrl/);
|
||||||
|
assert.match(route, /structuredContent: output/);
|
||||||
|
assert.match(route, /legacy: "stateless"/);
|
||||||
|
assert.match(route, /responseMode: "json"/);
|
||||||
|
assert.doesNotMatch(route, /ADMIN_INTERNAL_TOKEN/);
|
||||||
|
|
||||||
|
assert.match(taskService, /readFeishuSource/);
|
||||||
|
assert.match(taskService, /options\.deduplicate/);
|
||||||
|
assert.ok(
|
||||||
|
taskService.indexOf("findExistingTask(input.feishuUrl, input)") <
|
||||||
|
taskService.indexOf("readFeishuSource(input.feishuUrl, bindings)"),
|
||||||
|
);
|
||||||
|
assert.match(taskService, /searchParams\.delete\("from"\)/);
|
||||||
|
assert.match(taskService, /status IN \('active', 'importing'\)/);
|
||||||
|
assert.match(taskService, /shareToken/);
|
||||||
|
assert.match(actionRoute, /createDistributionTask/);
|
||||||
|
assert.doesNotMatch(actionRoute, /function createTaskFromSource/);
|
||||||
|
|
||||||
|
assert.match(readme, /create_distribution_task/);
|
||||||
|
assert.match(readme, /\/api\/mcp/);
|
||||||
|
assert.match(envExample, /KOC_MCP_API_KEY/);
|
||||||
|
assert.match(packageJson, /@modelcontextprotocol\/server/);
|
||||||
|
assert.match(packageJson, /"zod"/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user