From 1a9a113229740f4340f8a9a6671c23e64d6fad95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B7=AB=E5=87=A4=E8=90=8D?= Date: Fri, 7 Aug 2026 11:59:45 +0800 Subject: [PATCH] Add MCP task creation endpoint --- .dev.vars.example | 1 + README.md | 49 ++++++++ app/api/action/route.ts | 82 ++---------- app/api/mcp/route.ts | 222 +++++++++++++++++++++++++++++++++ lib/task-service.ts | 222 +++++++++++++++++++++++++++++++++ package-lock.json | 30 ++++- package.json | 4 +- tests/mcp-task-server.test.mjs | 46 +++++++ 8 files changed, 579 insertions(+), 77 deletions(-) create mode 100644 app/api/mcp/route.ts create mode 100644 lib/task-service.ts create mode 100644 tests/mcp-task-server.test.mjs diff --git a/.dev.vars.example b/.dev.vars.example index 8baab46..5c2acf8 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -2,6 +2,7 @@ KOC_PORTAL_URL=http://localhost:3000 SUPER_ADMIN_USERNAME=admin SUPER_ADMIN_PASSWORD=qazxsw123admin 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. AI_TOOL_CENTER_MCP_URL=https://middle-aitool.gbotai.cn/mcp diff --git a/README.md b/README.md index 0f3499f..771fb32 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ npm run build - `SUPER_ADMIN_USERNAME`:唯一的超级管理员登录账号 - `SUPER_ADMIN_PASSWORD`:超级管理员初始密码,至少 8 位 - `ADMIN_INTERNAL_TOKEN`:自动采集等内部任务使用的服务密钥 +- `KOC_MCP_API_KEY`:Agent 调用 KOC LOOP MCP 使用的独立 Bearer 密钥 系统首次登录时创建唯一的超级管理员。后续管理员和普通用户均由“用户管理”页面创建,普通用户不能访问 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 +``` + +首期只开放 `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 - `npm run dev`: start local development diff --git a/app/api/action/route.ts b/app/api/action/route.ts index 8fec881..078ea21 100644 --- a/app/api/action/route.ts +++ b/app/api/action/route.ts @@ -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 ?? ""); diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts new file mode 100644 index 0000000..7d03642 --- /dev/null +++ b/app/api/mcp/route.ts @@ -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; diff --git a/lib/task-service.ts b/lib/task-service.ts new file mode 100644 index 0000000..247d56b --- /dev/null +++ b/lib/task-service.ts @@ -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(); +} + +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 { + 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(); +} diff --git a/package-lock.json b/package-lock.json index 265de1b..b3e5cad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,13 @@ "name": "site-creator-vinext-starter", "version": "0.1.0", "dependencies": { + "@modelcontextprotocol/server": "^2.0.0", "drizzle-orm": "0.45.2", "fflate": "0.7.4", "next": "16.2.6", "react": "19.2.6", - "react-dom": "19.2.6" + "react-dom": "19.2.6", + "zod": "^4.4.3" }, "devDependencies": { "@cloudflare/vite-plugin": "1.37.1", @@ -2123,6 +2125,31 @@ "@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": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -11146,7 +11173,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index b9564bf..2e3bb18 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,13 @@ "db:generate": "drizzle-kit generate" }, "dependencies": { + "@modelcontextprotocol/server": "^2.0.0", "drizzle-orm": "0.45.2", "fflate": "0.7.4", "next": "16.2.6", "react": "19.2.6", - "react-dom": "19.2.6" + "react-dom": "19.2.6", + "zod": "^4.4.3" }, "devDependencies": { "@cloudflare/vite-plugin": "1.37.1", diff --git a/tests/mcp-task-server.test.mjs b/tests/mcp-task-server.test.mjs new file mode 100644 index 0000000..86fe91e --- /dev/null +++ b/tests/mcp-task-server.test.mjs @@ -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"/); +});