Expand KOC LOOP MCP operations

This commit is contained in:
巫凤萍
2026-08-07 14:12:52 +08:00
parent 1a9a113229
commit 5670a7939b
15 changed files with 2426 additions and 24 deletions

View File

@@ -64,7 +64,7 @@ MCP 使用独立的 `KOC_MCP_API_KEY` 鉴权,请通过请求头发送:
Authorization: Bearer <KOC_MCP_API_KEY>
```
首期只开放 `create_distribution_task`用于读取飞书表格并创建分发任务。参数如下:
创建分发任务使用 `create_distribution_task`,参数如下:
- `feishu_url`:飞书 Wiki 或电子表格链接;多工作表时必须带目标 `sheet` 参数
- `task_name`:任务名称
@@ -73,6 +73,15 @@ Authorization: Bearer <KOC_MCP_API_KEY>
成功后返回任务 ID、笔记数量和 KOC 领取链接。完全相同的任务参数重复调用时,返回已经存在的任务,避免 Agent 重试产生重复任务。
同时开放以下运营工具:
- 任务:`task_list``task_get`
- 数据回收:`recovery_list``recovery_export`
- 数据采集:`collection_plan_set``collection_run_due``collection_collect_now``collection_retry_failed`
- KOC 资源:`resource_search``resource_get``resource_backfill_profile``resource_export`
`recovery_export``resource_export` 返回 15 分钟有效的安全下载链接。链接不包含 MCP 密钥;任务数据导出会继续把笔记原图、发布截图和创作者截图直接嵌入 Excel。
在支持远程 MCP 的 Agent 中添加:
```json

View File

@@ -1,5 +1,9 @@
import { env } from "cloudflare:workers";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import {
createMcpHandler,
McpServer,
type McpRequestContext,
} from "@modelcontextprotocol/server";
import { z } from "zod/v4";
import {
FeishuSourceError,
@@ -9,10 +13,12 @@ import {
buildClaimUrl,
createDistributionTask,
} from "../../../lib/task-service";
import { registerMcpOperationTools } from "../../../lib/mcp-tools";
import type { CollectionMcpBindings } from "../../../lib/mcp-collection-client";
export const runtime = "edge";
type McpBindings = FeishuBindings & {
type McpBindings = FeishuBindings & CollectionMcpBindings & {
KOC_MCP_API_KEY?: string;
KOC_PORTAL_URL?: string;
};
@@ -32,12 +38,16 @@ function getBindings() {
return env as unknown as McpBindings;
}
function createServer() {
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: "1.0.0" },
{ name: "koc-loop", version: "2.0.0" },
{
instructions:
"用于创建 KOC 内容分发任务。调用前先确认飞书表格链接、任务名称和截止日期;截止日期转换为北京时间 YYYY-MM-DD。相同参数的重试会返回原任务不会重复创建。",
"用于创建和管理 KOC 内容分发任务、数据回收、公开数据采集与账号资源。创建任务前确认飞书链接、任务名和北京时间截止日期;写操作应先向用户说明影响。相同参数的任务创建和采集计划设置支持安全重试。",
},
);
@@ -130,6 +140,8 @@ function createServer() {
},
);
registerMcpOperationTools(server, { bindings, origin });
return server;
}

View File

@@ -15,6 +15,7 @@ import {
type RecoveryWorkbookImage,
type RecoveryWorkbookRow,
} from "../../../lib/recovery-workbook";
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
export const runtime = "edge";
@@ -218,10 +219,17 @@ function exactArrayBuffer(bytes: Uint8Array) {
}
export async function GET(request: Request) {
if (!(await isAdminRequest(request))) return adminForbidden();
try {
const url = new URL(request.url);
const token = url.searchParams.get("token")?.trim() || "";
const tokenPayload = token
? await consumeMcpExportToken(token, "recovery")
: null;
if (!tokenPayload && !(await isAdminRequest(request))) return adminForbidden();
await ensureSchema();
const taskId = new URL(request.url).searchParams.get("task")?.trim() || "";
const taskId = tokenPayload
? String(tokenPayload.taskId ?? "").trim()
: url.searchParams.get("task")?.trim() || "";
if (!taskId) {
return Response.json({ error: "缺少导出任务" }, { status: 400 });
}

View File

@@ -5,6 +5,7 @@ import {
buildRecoveryWorkbook,
type RecoveryWorkbookRow,
} from "../../../lib/recovery-workbook";
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
export const runtime = "edge";
@@ -47,21 +48,20 @@ function exactArrayBuffer(bytes: Uint8Array) {
return copy.buffer;
}
export async function POST(request: Request) {
if (!(await isManagerRequest(request))) return managerForbidden();
try {
const body = (await request.json()) as { accountIds?: unknown };
if (!Array.isArray(body.accountIds)) {
return Response.json({ error: "缺少需要导出的账号" }, { status: 400 });
}
const accountIds = [
function normalizeAccountIds(value: unknown) {
if (!Array.isArray(value)) return [];
return [
...new Set(
body.accountIds
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
value
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean),
),
].slice(0, 5000);
}
async function exportAccounts(accountIds: string[]) {
try {
if (accountIds.length === 0) {
return Response.json({ error: "当前筛选结果为空" }, { status: 400 });
}
@@ -187,3 +187,28 @@ export async function POST(request: Request) {
);
}
}
export async function POST(request: Request) {
if (!(await isManagerRequest(request))) return managerForbidden();
try {
const body = (await request.json()) as { accountIds?: unknown };
if (!Array.isArray(body.accountIds)) {
return Response.json({ error: "缺少需要导出的账号" }, { status: 400 });
}
return exportAccounts(normalizeAccountIds(body.accountIds));
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "导出失败" },
{ status: 500 },
);
}
}
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get("token")?.trim() || "";
const payload = token
? await consumeMcpExportToken(token, "resources")
: null;
if (!payload) return managerForbidden();
return exportAccounts(normalizeAccountIds(payload.accountIds));
}

View File

@@ -220,3 +220,15 @@ export const authSessions = sqliteTable(
index("auth_sessions_expires_at_idx").on(table.expiresAt),
],
);
export const mcpExportTokens = sqliteTable(
"mcp_export_tokens",
{
tokenHash: text("token_hash").primaryKey(),
kind: text("kind").notNull(),
payload: text("payload").notNull(),
expiresAt: text("expires_at").notNull(),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("mcp_export_tokens_expires_at_idx").on(table.expiresAt)],
);

View File

@@ -0,0 +1,332 @@
# KOC LOOP 部署指南
KOC LOOP 由两个独立站点组成:
| 站点 | 代码目录 | 作用 | 生产地址 |
| --- | --- | --- | --- |
| 运营后台 | 仓库根目录 | 任务导入、内容分发、KOC 资源、数据回收和自动采集 | [KOC LOOP 后台](https://koc-loop-mvp-wufp.pyeongwu.chatgpt.site) |
| KOC 领取站点 | `koc-portal/` | 外部 KOC 领取笔记、查看内容、回填发布及第 7 天数据 | [KOC 领取站点](https://koc-task-portal-wufp.pyeongwu.chatgpt.site) |
两个站点都部署在 Sites。运营后台绑定 Cloudflare D1 数据库和 R2 文件存储KOC 领取站点不保存业务数据,通过公开的合作方接口访问后台。
## 1. 准备项目
运行环境:
- Node.js `>= 22.13.0`
- npm
- Git
- 可访问 Sites 项目和生产环境变量的账号
下载代码并安装两个站点的依赖:
```bash
git clone ssh://git@gta.gbotai.cn:42001/wufengping/koc-loop.git
cd koc-loop
npm ci
cd koc-portal
npm ci
```
## 2. 配置运营后台
### 2.1 资源绑定
运营后台的 `.openai/hosting.json` 必须保留以下逻辑绑定:
```json
{
"project_id": "以仓库现有配置为准",
"d1": "DB",
"r2": "UPLOADS"
}
```
- `DB`:保存任务、笔记、领取、发布、账号资源和采集记录。
- `UPLOADS`:保存飞书配图、发布截图和创作者中心截图。
- 已有 `project_id` 时必须复用,不能重新创建站点,否则会产生新的数据库、存储和生产地址。
KOC 领取站点使用 `koc-portal/.openai/hosting.json` 中的既有 `project_id`,不绑定 D1 和 R2。
### 2.2 生产环境变量
在运营后台 Sites 项目的运行时环境变量中配置以下内容,不能保留示例占位值:
| 变量 | 类型 | 用途 |
| --- | --- | --- |
| `SUPER_ADMIN_USERNAME` | 密钥 | 首次初始化时创建唯一超级管理员的登录账号 |
| `SUPER_ADMIN_PASSWORD` | 密钥 | 首次初始化时创建唯一超级管理员的初始密码 |
| `ADMIN_INTERNAL_TOKEN` | 密钥 | 定时采集补跑、内部管理接口调用鉴权 |
| `KOC_MCP_API_KEY` | 密钥 | Agent 调用 `/api/mcp` 的独立 Bearer 密钥 |
| `KOC_PORTAL_URL` | 普通变量 | KOC 领取站点生产 Origin用于生成领取链接和 CORS 校验 |
| `AI_TOOL_CENTER_MCP_URL` | 普通变量 | 正式数据采集 MCP 地址 |
| `AI_TOOL_CENTER_MCP_KEY` | 密钥 | 正式数据采集 MCP 密钥 |
| `FEISHU_APP_ID` | 密钥 | 飞书自建应用 App ID |
| `FEISHU_APP_SECRET` | 密钥 | 飞书自建应用 App Secret |
注意:
- 密钥只能保存在本地 `.dev.vars` 或 Sites 运行时环境变量中,禁止写入 Git、部署文档、命令历史或日志。
- `KOC_MCP_API_KEY` 必须和 `ADMIN_INTERNAL_TOKEN``AI_TOOL_CENTER_MCP_KEY` 使用三份不同的随机值,不能复用。
- `SUPER_ADMIN_USERNAME``SUPER_ADMIN_PASSWORD` 只在系统尚无超级管理员时用于初始化。账号创建后,后续账号和密码调整统一在后台“用户管理”中完成。
- `KOC_PORTAL_URL` 应填写领取站点的完整 Origin例如 `https://站点域名`,不要附加任务路径或查询参数。
- 领取站点当前不需要单独配置生产环境变量。它在 `koc-portal/app/page.tsx` 中指向运营后台生产地址;后台地址变化时必须同步修改并重新部署领取站点。
### 2.3 飞书应用权限
飞书自建应用至少需要:
- 读取电子表格;
- 读取知识库节点;
- 下载云文档素材。
同时需要将飞书应用添加到目标知识库或目标电子表格的文档应用中,否则即使 App ID 和 App Secret 正确也无法导入内容。
### 2.4 Agent MCP
生产 MCP 地址固定为:
```text
https://运营后台域名/api/mcp
```
请求使用独立 Bearer 鉴权:
```text
Authorization: Bearer <KOC_MCP_API_KEY>
```
当前应发现 13 个工具:
- 创建任务:`create_distribution_task`
- 任务查询:`task_list``task_get`
- 数据回收:`recovery_list``recovery_export`
- 数据采集:`collection_plan_set``collection_run_due``collection_collect_now``collection_retry_failed`
- KOC 资源:`resource_search``resource_get``resource_backfill_profile``resource_export`
`recovery_export``resource_export` 返回一次性、15 分钟有效的下载链接。链接只承载随机导出令牌,不包含 MCP 密钥;任务导出的原图和截图仍直接嵌入 Excel。
## 3. 本地开发
复制后台环境变量示例:
```bash
cd koc-loop
cp .dev.vars.example .dev.vars
```
`.dev.vars` 中填写本地测试配置。不要提交该文件。
先启动运营后台,使用 `3001` 端口:
```bash
cd koc-loop
npm run dev -- --port 3001
```
再启动 KOC 领取站点,使用 `3000` 端口:
```bash
cd koc-loop/koc-portal
npm run dev -- --port 3000
```
本地领取站点会自动请求 `http://localhost:3001` 的后台接口。
## 4. 发布前验证
运营后台:
```bash
cd koc-loop
npm test
npm run lint
```
KOC 领取站点:
```bash
cd koc-loop/koc-portal
npm test
npm run lint
```
全部命令成功后再提交代码:
```bash
git status
git add <本次修改的文件>
git commit -m "说明本次变更"
git push origin main
```
不要提交 `.dev.vars`、生产密钥、临时压缩包、构建缓存或本地数据库文件。
## 5. 首次部署
KOC LOOP 使用 Sites 版本发布流程,不需要安装 systemd 服务,也不需要自行创建 Cloudflare Worker、D1 或 R2。
### 5.1 部署运营后台
1. 以仓库根目录作为构建目录。
2. 执行 `npm run build`
3. 确认生成:
- `dist/server/index.js`
- `dist/.openai/hosting.json`
- `dist/.openai/drizzle/`
4. 将已经推送到 Git 的同一提交保存为 Sites 版本。
5. 公开部署该版本,并等待部署状态变为成功。
6. 在 Sites 中补齐第 2 节列出的生产环境变量。
7. 将站点访问方式设置为公开后台仍会通过账号密码和角色权限做业务访问控制MCP 则使用独立 Bearer 密钥。
### 5.2 部署 KOC 领取站点
1.`koc-portal/` 作为构建目录。
2. 确认 `PRODUCTION_ADMIN_ORIGIN` 指向已部署的运营后台地址。
3. 执行 `npm run build`
4. 复用 `koc-portal/.openai/hosting.json` 中的既有 Sites 项目。
5. 保存并公开部署新版本。
6. 将领取站点生产地址填入运营后台的 `KOC_PORTAL_URL`
7. 如果 `KOC_PORTAL_URL` 是首次设置或发生变化,重新部署一次运营后台,使新环境变量进入生产版本。
## 6. 更新代码
常规更新流程:
```bash
cd koc-loop
git pull --ff-only
npm ci
npm test
npm run lint
```
根据修改范围决定部署对象:
- 只修改根目录的后台、接口、数据库或采集逻辑:部署运营后台。
- 只修改 `koc-portal/`:部署 KOC 领取站点。
- 同时修改接口和领取页面:先部署运营后台,再部署 KOC 领取站点。
- 修改共享链路、站点地址或 CORS两个站点都要部署并完成联调。
每次部署都必须复用对应 `.openai/hosting.json` 中的 `project_id`,保存新版本后再发布,不能直接用未保存的本地构建覆盖生产环境。
## 7. 数据库变更
修改 `db/schema.ts` 后生成迁移:
```bash
cd koc-loop
npm run db:generate
```
提交前检查:
- `drizzle/` 中只新增预期迁移;
- 不允许手工修改已经在线执行过的旧迁移;
- `npm test` 通过;
- 新字段兼容已有数据和空值。
本版本新增 `mcp_export_tokens` 表,用于保存导出令牌哈希、导出类型、筛选范围和过期时间。数据库只保存令牌哈希,不保存可直接使用的明文令牌;过期记录会在后续签发时清理。
后台构建会把 `drizzle/` 自动复制到 `dist/.openai/drizzle/`Sites 发布时随版本处理数据库迁移。重新部署不会清空 D1 或 R2 数据。
## 8. 每日自动采集
运营后台 Worker 配置了 Cloudflare Cron
```text
0 2 * * *
```
Cloudflare Cron 使用 UTC`02:00 UTC` 对应北京时间每天 `10:00`。定时任务会:
1. 确认数据库结构;
2. 执行当天已创建的笔记数据采集任务;
3. 回写点赞、收藏、评论、总互动和采集状态;
4. 尝试补全账号主页、小红书号、IP 地址和粉丝数。
手动补跑时先将密钥读入当前终端,不要直接写进命令:
```bash
export KOC_ADMIN_URL="https://koc-loop-mvp-wufp.pyeongwu.chatgpt.site"
read -s ADMIN_INTERNAL_TOKEN
export ADMIN_INTERNAL_TOKEN
curl -X POST "$KOC_ADMIN_URL/api/action" \
-H "Content-Type: application/json" \
-H "x-koc-admin-token: $ADMIN_INTERNAL_TOKEN" \
-d '{"action":"run_due_collections"}'
unset ADMIN_INTERNAL_TOKEN
```
接口成功但当天没有符合条件的发布记录时,返回空结果属于正常成功,不应反复重试。
## 9. 部署后验证
### 9.1 运营后台
1. 打开运营后台生产地址。
2. 使用超级管理员账号登录。
3. 确认工作台、内容任务、内容分发、KOC 资源、数据回收和用户管理页面可以打开。
4. 创建一个普通用户账号并登录,确认普通用户无法看到 KOC 资源库;管理员和超级管理员可以正常访问全部业务模块。
5. 使用一个已授权的飞书表格链接执行“读取表格”,确认标题、正文和图片可读取。
6. 检查已有任务和历史截图仍然存在,确认 D1、R2 没有被替换。
### 9.2 KOC 领取站点
1. 从后台复制一个有效任务领取链接。
2. 在未登录后台的浏览器中打开链接。
3. 确认可以领取笔记、查看标题/正文/图片,并回填发布链接与截图。
4. 确认后台能看到对应的领取、发布和数据回收记录。
### 9.3 自动采集
1. 为测试任务设置一个采集日期。
2. 确认对应采集任务已创建。
3. 到点后检查点赞、收藏、评论、总互动、更新时间和采集状态。
4. 异常数据使用后台“一键补采异常数据”或受鉴权的内部接口补跑。
### 9.4 MCP
1. 不带 `Authorization` 请求 `/api/mcp`,确认返回 `401`
2. 使用生产 `KOC_MCP_API_KEY` 执行 `tools/list`,确认发现 13 个工具。
3. 调用 `task_list``resource_search``recovery_list`,确认只读查询正常。
4. 使用测试任务调用 `task_get`,核对笔记、领取、发布回填和采集记录。
5. 调用两种导出工具,确认下载链接在有效期内可打开、过期或二次使用后失效,且 Excel 中图片为直接嵌入。
6. 采集和账号补全工具会访问外部正式采集 MCP只在明确选择测试记录后执行。
## 10. 回滚
优先在 Sites 中选择上一个正常版本重新部署。代码也需要回退时使用:
```bash
git revert <需要撤销的提交>
git push origin main
```
然后按第 6 节重新部署对应站点。不要使用 `git reset --hard` 覆盖共享分支,也不要删除 D1 或 R2 来处理普通代码故障。
## 11. 常见问题
| 现象 | 排查项 |
| --- | --- |
| 超级管理员无法首次登录 | 检查 `SUPER_ADMIN_USERNAME``SUPER_ADMIN_PASSWORD` 是否已配置;若系统已有超级管理员,应在后台重置账号密码 |
| MCP 返回 401 | 检查 Agent 请求头是否使用独立的 `KOC_MCP_API_KEY`,不要误用后台或数据采集密钥 |
| MCP 导出链接失效 | 重新调用导出工具生成新链接;链接为一次性且仅保留 15 分钟 |
| KOC 领取页无法访问后台接口 | 检查 `KOC_PORTAL_URL`、领取站点 Origin、后台地址和 CORS |
| 飞书表格读取失败 | 检查飞书 App ID/Secret、应用权限、文档应用授权和表格链接 |
| 自动采集失败 | 检查正式 MCP URL/Key、笔记发布链接、采集计划和 Worker 日志 |
| 图片上传或查看失败 | 检查运营后台 `UPLOADS` R2 绑定 |
| 构建提示 `vinext: command not found` | 在对应站点目录执行 `npm ci` 后重新构建 |
| 定时任务未执行 | 检查生产版本是否包含 Cron、采集日期是否已保存、笔记是否已回填发布链接 |
## 12. 参考资料
- [MCP 部署指南](https://gta.gbotai.cn/ai-team/mcp-project/src/branch/main/docs/MCP%20%E9%83%A8%E7%BD%B2%E6%8C%87%E5%8D%97.md)
- 仓库根目录 `README.md`
- `koc-portal/README.md`
- `.openai/hosting.json`
- `koc-portal/.openai/hosting.json`

View File

@@ -0,0 +1,9 @@
CREATE TABLE `mcp_export_tokens` (
`token_hash` text PRIMARY KEY NOT NULL,
`kind` text NOT NULL,
`payload` text NOT NULL,
`expires_at` text NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE INDEX `mcp_export_tokens_expires_at_idx` ON `mcp_export_tokens` (`expires_at`);

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,13 @@
"when": 1786069402457,
"tag": "0007_fantastic_sentinels",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1786082929799,
"tag": "0008_worried_ultimatum",
"breakpoints": true
}
]
}

75
lib/mcp-export-token.ts Normal file
View File

@@ -0,0 +1,75 @@
import { ensureSchema, getRawDb } from "./mvp-db";
export type McpExportKind = "recovery" | "resources";
type ExportTokenRow = {
kind: McpExportKind;
payload: string;
expires_at: string;
};
function sqliteTimestamp(date: Date) {
return date.toISOString().replace("T", " ").slice(0, 19);
}
async function digest(value: string) {
const bytes = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(value),
);
return [...new Uint8Array(bytes)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
export async function issueMcpExportToken(
kind: McpExportKind,
payload: Record<string, unknown>,
ttlMinutes = 15,
) {
await ensureSchema();
const db = getRawDb();
const token = `${crypto.randomUUID().replaceAll("-", "")}${crypto
.randomUUID()
.replaceAll("-", "")}`;
const expiresAt = new Date(
Date.now() + Math.max(1, Math.min(60, ttlMinutes)) * 60_000,
);
await db.prepare("DELETE FROM mcp_export_tokens WHERE expires_at <= CURRENT_TIMESTAMP").run();
await db
.prepare(
`INSERT INTO mcp_export_tokens (token_hash, kind, payload, expires_at)
VALUES (?, ?, ?, ?)`,
)
.bind(await digest(token), kind, JSON.stringify(payload), sqliteTimestamp(expiresAt))
.run();
return { token, expiresAt: expiresAt.toISOString() };
}
export async function consumeMcpExportToken(
token: string,
expectedKind: McpExportKind,
) {
if (!token || token.length < 32) return null;
await ensureSchema();
const db = getRawDb();
const tokenHash = await digest(token);
const row = await db
.prepare(
`SELECT kind, payload, expires_at
FROM mcp_export_tokens
WHERE token_hash = ? AND expires_at > CURRENT_TIMESTAMP`,
)
.bind(tokenHash)
.first<ExportTokenRow>();
if (!row || row.kind !== expectedKind) return null;
await db
.prepare("DELETE FROM mcp_export_tokens WHERE token_hash = ?")
.bind(tokenHash)
.run();
try {
return JSON.parse(row.payload) as Record<string, unknown>;
} catch {
return null;
}
}

464
lib/mcp-operations.ts Normal file
View File

@@ -0,0 +1,464 @@
import { enrichDistributionAccount } from "./account-enrichment-service";
import {
collectDistributionMetrics,
createCollectionRunTasks,
retryFailedCollections,
runDueScheduledCollections,
shanghaiDateFromTimestamp,
} from "./collection-service";
import { issueMcpExportToken } from "./mcp-export-token";
import {
resolveCollectionMcpConfig,
type CollectionMcpBindings,
} from "./mcp-collection-client";
import { ensureSchema, getRawDb } from "./mvp-db";
import { extractXhsPublishUrl } from "./publish-url";
import { buildClaimUrl } from "./task-service";
export type McpOperationBindings = CollectionMcpBindings & {
KOC_PORTAL_URL?: string;
};
type Pagination = { limit?: number; offset?: number };
type ResourceFilters = Pagination & {
query?: string;
ipLocation?: string;
cooperationSource?: string;
platform?: string;
};
function page(input: Pagination) {
return {
limit: Math.max(1, Math.min(200, Math.floor(input.limit ?? 50))),
offset: Math.max(0, Math.floor(input.offset ?? 0)),
};
}
function like(value: string) {
return `%${value.replace(/[\\%_]/g, "\\$&")}%`;
}
function parseJsonArray(value: unknown) {
try {
const parsed = JSON.parse(String(value ?? "[]"));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
async function taskExists(taskId: string) {
const task = await getRawDb()
.prepare("SELECT id FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string }>();
if (!task) throw new Error("任务不存在");
}
export async function taskList(
input: Pagination & { query?: string; status?: string },
portalUrl: string,
) {
await ensureSchema();
const { limit, offset } = page(input);
const conditions: string[] = [];
const bindings: unknown[] = [];
if (input.query?.trim()) {
conditions.push("(t.name LIKE ? ESCAPE '\\' OR t.brand LIKE ? ESCAPE '\\')");
const pattern = like(input.query.trim());
bindings.push(pattern, pattern);
}
if (input.status?.trim() && input.status !== "all") {
conditions.push("t.status = ?");
bindings.push(input.status.trim());
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const db = getRawDb();
const [rows, count] = await Promise.all([
db
.prepare(
`SELECT t.*,
(SELECT COUNT(*) FROM contents c WHERE c.task_id = t.id) AS note_count,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id) AS claimed_count,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND COALESCE(d.publish_url, '') != '') AS published_count,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.exposure IS NOT NULL AND d.views IS NOT NULL) AS day7_count
FROM tasks t ${where}
ORDER BY t.created_at DESC LIMIT ? OFFSET ?`,
)
.bind(...bindings, limit, offset)
.all<Record<string, unknown>>(),
db
.prepare(`SELECT COUNT(*) AS total FROM tasks t ${where}`)
.bind(...bindings)
.first<{ total: number }>(),
]);
return {
total: count?.total ?? 0,
limit,
offset,
tasks: rows.results.map((row) => ({
...row,
collection_days: parseJsonArray(row.collection_days),
claim_url:
portalUrl && row.share_token
? buildClaimUrl(portalUrl, String(row.share_token))
: null,
})),
};
}
export async function taskGet(taskId: string, portalUrl: string) {
await ensureSchema();
const db = getRawDb();
const task = await db
.prepare("SELECT * FROM tasks WHERE id = ?")
.bind(taskId)
.first<Record<string, unknown>>();
if (!task) throw new Error("任务不存在");
const [notes, claims, runs] = await Promise.all([
db
.prepare(
`SELECT c.id AS content_id, c.source_row, c.title, c.body, c.image_assets, c.status AS content_status,
d.id AS distribution_id, d.status AS distribution_status, d.publish_url, d.publish_time,
d.exposure, d.views, d.latest_likes, d.latest_comments, d.latest_collects,
d.collection_status, d.collection_status_description, d.collection_updated_at,
a.id AS account_id, a.nickname AS account_nickname, a.profile_url, a.followers,
p.name AS cooperation_source, cl.claimant_name
FROM contents c
LEFT JOIN distributions d ON d.content_id = c.id
LEFT JOIN accounts a ON a.id = d.account_id
LEFT JOIN partners p ON p.id = d.partner_id
LEFT JOIN claims cl ON cl.id = d.claim_id
WHERE c.task_id = ?
ORDER BY COALESCE(c.source_row, 999999), c.created_at, d.claimed_at`,
)
.bind(taskId)
.all<Record<string, unknown>>(),
db
.prepare(
`SELECT cl.*, p.name AS partner_name,
(SELECT COUNT(*) FROM distributions d WHERE d.claim_id = cl.id AND COALESCE(d.publish_url, '') != '') AS published_count
FROM claims cl JOIN partners p ON p.id = cl.partner_id
WHERE cl.task_id = ? ORDER BY cl.created_at DESC`,
)
.bind(taskId)
.all<Record<string, unknown>>(),
db
.prepare(
`SELECT * FROM collection_runs WHERE task_id = ?
ORDER BY scheduled_date DESC, created_at DESC LIMIT 500`,
)
.bind(taskId)
.all<Record<string, unknown>>(),
]);
return {
task: {
...task,
collection_days: parseJsonArray(task.collection_days),
claim_url:
portalUrl && task.share_token
? buildClaimUrl(portalUrl, String(task.share_token))
: null,
},
notes: notes.results.map((row) => ({
...row,
image_assets: parseJsonArray(row.image_assets),
total_interactions:
row.latest_likes == null
? null
: Number(row.latest_likes) +
Number(row.latest_comments ?? 0) +
Number(row.latest_collects ?? 0),
})),
claims: claims.results,
collection_runs: runs.results,
};
}
export async function recoveryList(
input: Pagination & {
taskId?: string;
stage?: "all" | "published" | "unfilled" | "waiting_day7" | "day7_due";
},
) {
await ensureSchema();
const { limit, offset } = page(input);
const db = getRawDb();
const conditions: string[] = [];
const bindings: unknown[] = [];
if (input.taskId?.trim()) {
conditions.push("d.task_id = ?");
bindings.push(input.taskId.trim());
}
const stage = input.stage ?? "all";
if (stage === "published") conditions.push("COALESCE(d.publish_url, '') != ''");
if (stage === "unfilled") conditions.push("COALESCE(d.publish_url, '') = ''");
if (stage === "waiting_day7") {
conditions.push("COALESCE(d.publish_url, '') != '' AND (d.exposure IS NULL OR d.views IS NULL)");
}
if (stage === "day7_due") {
conditions.push(
"COALESCE(d.publish_url, '') != '' AND (d.exposure IS NULL OR d.views IS NULL) AND datetime(d.publish_time, '+7 days') <= CURRENT_TIMESTAMP",
);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const base = `FROM distributions d
JOIN tasks t ON t.id = d.task_id
JOIN contents c ON c.id = d.content_id
LEFT JOIN accounts a ON a.id = d.account_id
LEFT JOIN partners p ON p.id = d.partner_id
LEFT JOIN claims cl ON cl.id = d.claim_id ${where}`;
const [rows, count] = await Promise.all([
db
.prepare(
`SELECT d.*, t.name AS task_name, c.source_row, c.title,
a.nickname AS account_nickname, a.profile_url, p.name AS cooperation_source,
cl.claimant_name ${base}
ORDER BY COALESCE(d.publish_time, d.claimed_at) DESC LIMIT ? OFFSET ?`,
)
.bind(...bindings, limit, offset)
.all<Record<string, unknown>>(),
db
.prepare(`SELECT COUNT(*) AS total ${base}`)
.bind(...bindings)
.first<{ total: number }>(),
]);
return {
total: count?.total ?? 0,
limit,
offset,
items: rows.results.map((row) => ({
...row,
total_interactions:
row.latest_likes == null
? null
: Number(row.latest_likes) + Number(row.latest_comments ?? 0) + Number(row.latest_collects ?? 0),
})),
};
}
export async function recoveryExport(taskId: string, origin: string) {
await ensureSchema();
await taskExists(taskId);
const issued = await issueMcpExportToken("recovery", { taskId });
return {
task_id: taskId,
download_url: `${origin}/api/recovery-export?token=${encodeURIComponent(issued.token)}`,
expires_at: issued.expiresAt,
};
}
export async function setCollectionPlan(
taskId: string,
startDate: string,
days: number[],
bindings: McpOperationBindings,
) {
await ensureSchema();
await taskExists(taskId);
const normalizedDays = [...new Set(days)]
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7)
.sort((a, b) => a - b);
if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate) || Number.isNaN(Date.parse(`${startDate}T00:00:00+08:00`))) {
throw new Error("开始采集日期无效");
}
if (!normalizedDays.length) throw new Error("请至少选择一个采集日");
const db = getRawDb();
await db.batch([
db.prepare(
`UPDATE tasks SET collection_start_date = ?, collection_days = ?,
collection_schedule_updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
).bind(startDate, JSON.stringify(normalizedDays), taskId),
db.prepare(
`UPDATE distributions SET
collection_status = CASE WHEN latest_likes IS NULL THEN 'scheduled' ELSE collection_status END,
collection_status_description = CASE WHEN latest_likes IS NULL THEN ? ELSE collection_status_description END,
updated_at = CURRENT_TIMESTAMP
WHERE task_id = ? AND COALESCE(publish_url, '') != ''`,
).bind(`已安排${normalizedDays.length}个采集日每日10:00执行`, taskId),
]);
const created = await createCollectionRunTasks(db, taskId, startDate, normalizedDays);
const catchup = await runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(bindings),
"catchup",
taskId,
);
return { task_id: taskId, start_date: startDate, days: normalizedDays, created, catchup };
}
export async function runDueCollections(taskId: string | undefined, bindings: McpOperationBindings) {
await ensureSchema();
if (taskId) await taskExists(taskId);
return runDueScheduledCollections(
getRawDb(),
Date.now(),
resolveCollectionMcpConfig(bindings),
"catchup",
taskId,
);
}
export async function collectNow(
distributionId: string,
scheduleDay: number | undefined,
bindings: McpOperationBindings,
) {
await ensureSchema();
const day = scheduleDay ?? null;
const metrics = await collectDistributionMetrics(
getRawDb(),
distributionId,
shanghaiDateFromTimestamp(Date.now()),
day,
"manual",
resolveCollectionMcpConfig(bindings),
);
return { distribution_id: distributionId, schedule_day: day, ...metrics };
}
export async function retryFailed(taskId: string | undefined, bindings: McpOperationBindings) {
await ensureSchema();
const db = getRawDb();
const ids = taskId
? [taskId]
: (
await db
.prepare(
`SELECT DISTINCT task_id FROM distributions
WHERE collection_status = 'failed' ORDER BY updated_at DESC LIMIT 20`,
)
.all<{ task_id: string }>()
).results.map((row) => row.task_id);
if (taskId) await taskExists(taskId);
const results = [];
for (const id of ids) {
results.push({ task_id: id, ...(await retryFailedCollections(db, id, resolveCollectionMcpConfig(bindings))) });
}
return {
task_count: results.length,
attempted: results.reduce((sum, item) => sum + item.attempted, 0),
succeeded: results.reduce((sum, item) => sum + item.succeeded, 0),
failed: results.reduce((sum, item) => sum + item.failed, 0),
tasks: results,
};
}
function resourceWhere(input: ResourceFilters) {
const conditions: string[] = [];
const bindings: unknown[] = [];
if (input.query?.trim()) {
conditions.push("(a.nickname LIKE ? ESCAPE '\\' OR a.public_account_id LIKE ? ESCAPE '\\')");
const pattern = like(input.query.trim());
bindings.push(pattern, pattern);
}
if (input.ipLocation?.trim()) {
conditions.push("a.ip_location LIKE ? ESCAPE '\\'");
bindings.push(like(input.ipLocation.trim()));
}
if (input.cooperationSource?.trim()) {
conditions.push(
`EXISTS (SELECT 1 FROM distributions dx JOIN partners px ON px.id = dx.partner_id
WHERE dx.account_id = a.id AND px.name LIKE ? ESCAPE '\\')`,
);
bindings.push(like(input.cooperationSource.trim()));
}
if (input.platform?.trim() && input.platform !== "all") {
conditions.push("a.platform = ?");
bindings.push(input.platform.trim());
}
return { where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "", bindings };
}
export async function resourceSearch(input: ResourceFilters) {
await ensureSchema();
const { limit, offset } = page(input);
const { where, bindings } = resourceWhere(input);
const db = getRawDb();
const select = `SELECT a.*,
(SELECT COUNT(*) FROM distributions d WHERE d.account_id = a.id) AS cooperation_count,
(SELECT GROUP_CONCAT(DISTINCT p.name) FROM distributions d JOIN partners p ON p.id = d.partner_id WHERE d.account_id = a.id) AS cooperation_sources`;
const [rows, count] = await Promise.all([
db.prepare(`${select} FROM accounts a ${where} ORDER BY a.last_seen_at DESC LIMIT ? OFFSET ?`)
.bind(...bindings, limit, offset).all<Record<string, unknown>>(),
db.prepare(`SELECT COUNT(*) AS total FROM accounts a ${where}`).bind(...bindings).first<{ total: number }>(),
]);
return {
total: count?.total ?? 0,
limit,
offset,
accounts: rows.results.map((row) => ({
...row,
cooperation_sources: String(row.cooperation_sources ?? "").split(",").filter(Boolean),
})),
};
}
export async function resourceGet(accountId: string) {
await ensureSchema();
const db = getRawDb();
const account = await db.prepare("SELECT * FROM accounts WHERE id = ?").bind(accountId).first<Record<string, unknown>>();
if (!account) throw new Error("账号不存在");
const history = await db.prepare(
`SELECT d.id AS distribution_id, d.task_id, t.name AS task_name, c.title,
d.publish_url, d.publish_time, d.latest_likes, d.latest_comments, d.latest_collects,
d.exposure, d.views, p.name AS cooperation_source, cl.claimant_name
FROM distributions d JOIN tasks t ON t.id = d.task_id
JOIN contents c ON c.id = d.content_id JOIN partners p ON p.id = d.partner_id
LEFT JOIN claims cl ON cl.id = d.claim_id
WHERE d.account_id = ? ORDER BY COALESCE(d.publish_time, d.claimed_at) DESC`,
).bind(accountId).all<Record<string, unknown>>();
return { account, cooperation_history: history.results };
}
export async function backfillResourceProfile(
input: { distributionId?: string; publishUrl?: string },
bindings: McpOperationBindings,
) {
await ensureSchema();
const db = getRawDb();
const publishUrl = input.publishUrl ? extractXhsPublishUrl(input.publishUrl) : "";
const row = input.distributionId
? await db.prepare(
`SELECT d.id, d.publish_url, COALESCE(a.nickname, '') AS nickname
FROM distributions d LEFT JOIN accounts a ON a.id = d.account_id WHERE d.id = ?`,
).bind(input.distributionId).first<{ id: string; publish_url: string | null; nickname: string }>()
: publishUrl
? await db.prepare(
`SELECT d.id, d.publish_url, COALESCE(a.nickname, '') AS nickname
FROM distributions d LEFT JOIN accounts a ON a.id = d.account_id
WHERE d.publish_url = ? ORDER BY d.updated_at DESC LIMIT 1`,
).bind(publishUrl).first<{ id: string; publish_url: string | null; nickname: string }>()
: null;
if (!row?.publish_url) throw new Error("没有找到可补全的发布记录");
const result = await enrichDistributionAccount(
db,
row.id,
row.publish_url,
row.nickname,
resolveCollectionMcpConfig(bindings),
);
const account = result.updated && result.accountId
? await db.prepare("SELECT * FROM accounts WHERE id = ?").bind(result.accountId).first<Record<string, unknown>>()
: null;
return { distribution_id: row.id, ...result, account };
}
export async function resourceExport(input: ResourceFilters, origin: string) {
const result = await resourceSearch({ ...input, limit: 200, offset: 0 });
const allIds: string[] = result.accounts.map((account) => String(account.id));
if (result.total > result.accounts.length) {
const { where, bindings } = resourceWhere(input);
const rows = await getRawDb().prepare(`SELECT a.id FROM accounts a ${where} ORDER BY a.last_seen_at DESC LIMIT 5000`)
.bind(...bindings).all<{ id: string }>();
allIds.splice(0, allIds.length, ...rows.results.map((row) => row.id));
}
if (!allIds.length) throw new Error("当前筛选结果为空");
const issued = await issueMcpExportToken("resources", { accountIds: allIds });
return {
account_count: allIds.length,
download_url: `${origin}/api/resources-export?token=${encodeURIComponent(issued.token)}`,
expires_at: issued.expiresAt,
};
}

247
lib/mcp-tools.ts Normal file
View File

@@ -0,0 +1,247 @@
import { type McpServer } from "@modelcontextprotocol/server";
import { z } from "zod/v4";
import {
backfillResourceProfile,
collectNow,
recoveryExport,
recoveryList,
resourceExport,
resourceGet,
resourceSearch,
retryFailed,
runDueCollections,
setCollectionPlan,
taskGet,
taskList,
type McpOperationBindings,
} from "./mcp-operations";
type Options = {
bindings: McpOperationBindings;
origin: string;
};
function result(label: string, data: unknown) {
return {
content: [{ type: "text" as const, text: label }],
structuredContent: data as Record<string, unknown>,
};
}
function errorResult(error: unknown) {
const message = error instanceof Error ? error.message : "操作失败,请稍后重试";
return {
isError: true as const,
content: [{ type: "text" as const, text: message.slice(0, 240) }],
};
}
function withError<T extends unknown[]>(handler: (...args: T) => Promise<ReturnType<typeof result>>) {
return async (...args: T) => {
try {
return await handler(...args);
} catch (error) {
return errorResult(error);
}
};
}
const pagination = {
limit: z.number().int().min(1).max(200).optional().describe("返回数量默认50最大200"),
offset: z.number().int().min(0).optional().describe("分页偏移量默认0"),
};
const resourceFilters = {
query: z.string().max(100).optional().describe("账号名称或小红书号/抖音号,支持模糊搜索"),
ip_location: z.string().max(100).optional().describe("IP地区关键词支持模糊搜索"),
cooperation_source: z.string().max(100).optional().describe("历史合作来源关键词,支持模糊搜索"),
platform: z.string().max(30).optional().describe("平台,例如小红书;不传表示全部"),
};
export function registerMcpOperationTools(server: McpServer, options: Options) {
server.registerTool(
"task_list",
{
title: "查询任务及进度",
description: "查询 KOC 分发任务列表、领取数、发布数、第7天回收数及领取链接。",
inputSchema: z.object({
query: z.string().max(100).optional().describe("任务名或品牌/项目关键词"),
status: z.string().max(30).optional().describe("任务状态;不传或 all 表示全部"),
...pagination,
}),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
},
withError(async (input) => {
const data = await taskList(input, options.bindings.KOC_PORTAL_URL?.trim() || "");
return result(`共找到 ${data.total} 个任务。`, data);
}),
);
server.registerTool(
"task_get",
{
title: "查看任务完整情况",
description: "查看指定任务、笔记、领取记录、发布回填和采集执行记录。",
inputSchema: z.object({ task_id: z.string().min(1).describe("任务ID") }),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
},
withError(async ({ task_id }) => {
const data = await taskGet(task_id, options.bindings.KOC_PORTAL_URL?.trim() || "");
return result(`已读取任务“${String(data.task.name)}”的完整情况。`, data);
}),
);
server.registerTool(
"recovery_list",
{
title: "查询数据回收队列",
description: "查询已发布、未回填、待第7天数据或已到第7天仍未回填的笔记。",
inputSchema: z.object({
task_id: z.string().optional().describe("任务ID不传则跨任务查询"),
stage: z.enum(["all", "published", "unfilled", "waiting_day7", "day7_due"]).optional(),
...pagination,
}),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
},
withError(async ({ task_id, stage, limit, offset }) => {
const data = await recoveryList({ taskId: task_id, stage, limit, offset });
return result(`数据回收队列共 ${data.total} 条记录。`, data);
}),
);
server.registerTool(
"recovery_export",
{
title: "导出任务完整数据",
description: "生成任务完整 Excel包含笔记原图、发布截图、创作者截图和回收数据。",
inputSchema: z.object({ task_id: z.string().min(1).describe("任务ID") }),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false },
},
withError(async ({ task_id }) => {
const data = await recoveryExport(task_id, options.origin);
return result(`导出文件已生成,下载链接将在 ${data.expires_at} 失效。`, data);
}),
);
server.registerTool(
"collection_plan_set",
{
title: "设置自动采集计划",
description: "为任务设置开始日期及第1至第7天的自动采集日系统在北京时间10:00执行。",
inputSchema: z.object({
task_id: z.string().min(1),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("北京时间开始日期 YYYY-MM-DD"),
days: z.array(z.number().int().min(1).max(7)).min(1).max(7).describe("需要采集的相对天数,例如 [2,5,7]"),
}),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
},
withError(async ({ task_id, start_date, days }) => {
const data = await setCollectionPlan(task_id, start_date, days, options.bindings);
return result(`已为任务设置 ${data.days.length} 个自动采集日。`, data);
}),
);
server.registerTool(
"collection_run_due",
{
title: "执行到期采集",
description: "立即执行今天或此前已到期但尚未成功的自动采集任务。",
inputSchema: z.object({ task_id: z.string().optional().describe("任务ID不传则执行所有到期任务") }),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
},
withError(async ({ task_id }) => {
const data = await runDueCollections(task_id, options.bindings);
return result(`到期采集完成:成功 ${data.succeeded},失败 ${data.failed}`, data);
}),
);
server.registerTool(
"collection_collect_now",
{
title: "立即采集指定笔记",
description: "对指定分发记录立即采集点赞、收藏和评论数据。",
inputSchema: z.object({
distribution_id: z.string().min(1).describe("分发记录ID可从 task_get 或 recovery_list 获取"),
schedule_day: z.number().int().min(1).max(7).optional().describe("标记为第几天采集,可不传"),
}),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
},
withError(async ({ distribution_id, schedule_day }) => {
const data = await collectNow(distribution_id, schedule_day, options.bindings);
return result("指定笔记采集完成。", data);
}),
);
server.registerTool(
"collection_retry_failed",
{
title: "补采异常数据",
description: "重试指定任务或全部任务中采集状态异常的已发布笔记。",
inputSchema: z.object({ task_id: z.string().optional().describe("任务ID不传则补采最近异常任务") }),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
},
withError(async ({ task_id }) => {
const data = await retryFailed(task_id, options.bindings);
return result(`补采完成:成功 ${data.succeeded},失败 ${data.failed}`, data);
}),
);
server.registerTool(
"resource_search",
{
title: "搜索 KOC 账号资源",
description: "按账号名称/账号号、IP地区、合作来源或平台搜索 KOC 资源。",
inputSchema: z.object({ ...resourceFilters, ...pagination }),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
},
withError(async ({ query, ip_location, cooperation_source, platform, limit, offset }) => {
const data = await resourceSearch({ query, ipLocation: ip_location, cooperationSource: cooperation_source, platform, limit, offset });
return result(`共找到 ${data.total} 个账号。`, data);
}),
);
server.registerTool(
"resource_get",
{
title: "查看 KOC 账号详情",
description: "查看账号主页、账号号、粉丝数、IP地区以及全部合作记录。",
inputSchema: z.object({ account_id: z.string().min(1).describe("KOC LOOP 账号ID") }),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
},
withError(async ({ account_id }) => {
const data = await resourceGet(account_id);
return result(`已读取账号“${String(data.account.nickname)}”的详情。`, data);
}),
);
server.registerTool(
"resource_backfill_profile",
{
title: "补全公开账号信息",
description: "根据已回填的发布链接补全账号主页、昵称、小红书号、IP地区和粉丝数。",
inputSchema: z.object({
distribution_id: z.string().optional().describe("分发记录ID和发布链接二选一"),
publish_url: z.string().optional().describe("小红书发布链接或包含链接的分享文案"),
}).refine((value) => Boolean(value.distribution_id || value.publish_url), "请提供分发记录ID或发布链接"),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
},
withError(async ({ distribution_id, publish_url }) => {
const data = await backfillResourceProfile({ distributionId: distribution_id, publishUrl: publish_url }, options.bindings);
return result(data.updated ? "账号公开信息已补全。" : "账号信息未发生变化。", data);
}),
);
server.registerTool(
"resource_export",
{
title: "导出 KOC 资源",
description: "按账号、IP地区、合作来源或平台筛选并导出 KOC 资源 Excel。",
inputSchema: z.object(resourceFilters),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false },
},
withError(async ({ query, ip_location, cooperation_source, platform }) => {
const data = await resourceExport({ query, ipLocation: ip_location, cooperationSource: cooperation_source, platform }, options.origin);
return result(`已生成 ${data.account_count} 个账号的导出文件。`, data);
}),
);
}

View File

@@ -165,6 +165,13 @@ export async function ensureSchema(database?: D1Database) {
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS mcp_export_tokens (
token_hash TEXT PRIMARY KEY,
kind TEXT NOT NULL,
payload TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
];
for (const statement of statements) {
@@ -340,6 +347,11 @@ export async function ensureSchema(database?: D1Database) {
"CREATE INDEX IF NOT EXISTS auth_sessions_expires_at_idx ON auth_sessions(expires_at)",
)
.run();
await db
.prepare(
"CREATE INDEX IF NOT EXISTS mcp_export_tokens_expires_at_idx ON mcp_export_tokens(expires_at)",
)
.run();
await db
.prepare(
`UPDATE distributions

View File

@@ -2,10 +2,14 @@ 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] =
test("exposes authenticated KOC task, recovery, collection, and resource MCP tools", async () => {
const [route, tools, operations, tokenService, migration, taskService, actionRoute, readme, envExample, packageJson] =
await Promise.all([
readFile(new URL("../app/api/mcp/route.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/mcp-tools.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/mcp-operations.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/mcp-export-token.ts", import.meta.url), "utf8"),
readFile(new URL("../drizzle/0008_worried_ultimatum.sql", 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"),
@@ -15,6 +19,22 @@ test("exposes one authenticated idempotent MCP tool for creating distribution ta
assert.match(route, /createMcpHandler/);
assert.match(route, /create_distribution_task/);
for (const name of [
"task_list",
"task_get",
"recovery_list",
"recovery_export",
"collection_plan_set",
"collection_run_due",
"collection_collect_now",
"collection_retry_failed",
"resource_search",
"resource_get",
"resource_backfill_profile",
"resource_export",
]) {
assert.match(tools, new RegExp(`"${name}"`));
}
assert.match(route, /KOC_MCP_API_KEY/);
assert.match(route, /Authorization/);
assert.match(route, /Bearer/);
@@ -25,6 +45,16 @@ test("exposes one authenticated idempotent MCP tool for creating distribution ta
assert.match(route, /legacy: "stateless"/);
assert.match(route, /responseMode: "json"/);
assert.doesNotMatch(route, /ADMIN_INTERNAL_TOKEN/);
assert.match(route, /registerMcpOperationTools/);
assert.match(operations, /runDueScheduledCollections/);
assert.match(operations, /retryFailedCollections/);
assert.match(operations, /enrichDistributionAccount/);
assert.match(operations, /issueMcpExportToken/);
assert.match(tokenService, /SHA-256/);
assert.match(tokenService, /expires_at > CURRENT_TIMESTAMP/);
assert.doesNotMatch(tokenService, /KOC_MCP_API_KEY/);
assert.match(migration, /CREATE TABLE `mcp_export_tokens`/);
assert.match(migration, /mcp_export_tokens_expires_at_idx/);
assert.match(taskService, /readFeishuSource/);
assert.match(taskService, /options\.deduplicate/);
@@ -39,6 +69,8 @@ test("exposes one authenticated idempotent MCP tool for creating distribution ta
assert.doesNotMatch(actionRoute, /function createTaskFromSource/);
assert.match(readme, /create_distribution_task/);
assert.match(readme, /collection_plan_set/);
assert.match(readme, /resource_backfill_profile/);
assert.match(readme, /\/api\/mcp/);
assert.match(envExample, /KOC_MCP_API_KEY/);
assert.match(packageJson, /@modelcontextprotocol\/server/);

View File

@@ -246,6 +246,7 @@ test("exports complete task recovery data to Excel with embedded images", async
assert.match(exportRoute, /screenshot_key/);
assert.match(exportRoute, /image_assets/);
assert.match(exportRoute, /isAdminRequest/);
assert.match(exportRoute, /consumeMcpExportToken/);
assert.match(workbook, /xl\/drawings\/drawing1\.xml/);
assert.match(workbook, /xl\/media\/image/);
assert.match(workbook, /oneCellAnchor/);
@@ -327,6 +328,7 @@ test("filters and exports the current KOC resource result set", async () => {
assert.match(exportRoute, /历史合作来源/);
assert.match(exportRoute, /合作社资源 · 不可直联/);
assert.match(exportRoute, /isManagerRequest/);
assert.match(exportRoute, /consumeMcpExportToken/);
assert.match(workbook, /relationships\/hyperlink/);
});