/** * # MCP 服务器 DusunIoT CMS(stdio) * 让 ZCode 等 Agent 直接管理站点内容:页面/文章 CRUD(支持 Elementor JSON * 自定义单页面)、修订恢复、媒体上传、产品/订单/询盘查询与状态流转、看板统计。 * * 协议:JSON-RPC 2.0 over stdio(MCP 2024-11-05),无 SDK 依赖,Bun 直跑: * bun mcp/server.ts * 认证:本地直连 PostgreSQL(与站点同库),仅限受信任的本机环境使用; * 写操作全部落 audit_logs(actorId 为空表示 MCP 调用)。 */ import { SQL } from "bun"; import { parseImageSize } from "../src/server/image-size"; import pkg from "../package.json"; /** # 成员变量 数据库连接(与站点同库;单连接足够 MCP 串行调用) **/ const sql = new SQL(process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot", { max: 2 }); /** # 成员变量 MCP 工具定义(name/description/inputSchema) **/ const TOOLS = [ { name: "dashboard_stats", description: "站点运营看板统计:内容/产品/订单/询盘/媒体计数、新询盘数、待处理订单数、营收(按币种)、近 7 天询盘趋势", inputSchema: { type: "object", properties: {}, additionalProperties: false }, }, { name: "list_contents", description: "内容列表(页面 page / 文章 post / 落地页 landing),支持状态与关键字过滤、分页;返回 id/slug/标题/状态/更新时间", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["page", "post", "landing", "template"], description: "内容类型过滤" }, status: { type: "string", enum: ["draft", "review", "published"], description: "状态过滤" }, q: { type: "string", description: "标题/别名关键字" }, page: { type: "number", description: "页码,默认 1" }, pageSize: { type: "number", description: "每页条数,默认 20,上限 100" }, }, additionalProperties: false, }, }, { name: "get_content", description: "读取单条内容全文:正文 HTML、Elementor JSON(meta.elementor)、SEO 字段、修订数", inputSchema: { type: "object", properties: { id: { type: "number" } }, required: ["id"], additionalProperties: false }, }, { name: "create_content", description: "新建内容(自定义单页面推荐 type=page + elementorJson,前台 /slug/ 通栏渲染;纯 HTML 页用 html 字段)", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["page", "post", "landing"], description: "内容类型" }, title: { type: "string", description: "标题(必填)" }, slug: { type: "string", description: "URL 别名,留空按标题生成" }, html: { type: "string", description: "正文 HTML" }, elementorJson: { type: "string", description: "Elementor 文档 JSON 字符串(存 meta.elementor,优先于 HTML 渲染)" }, excerpt: { type: "string" }, status: { type: "string", enum: ["draft", "review", "published"] }, seoTitle: { type: "string" }, seoDescription: { type: "string" }, }, required: ["type", "title"], additionalProperties: false, }, }, { name: "update_content", description: "更新内容(部分字段即可;elementorJson 传 null 表示清除 Elementor 数据)。更新前自动写修订快照", inputSchema: { type: "object", properties: { id: { type: "number" }, title: { type: "string" }, slug: { type: "string" }, html: { type: "string" }, elementorJson: { type: ["string", "null"], description: "Elementor JSON 字符串或 null(清除)" }, excerpt: { type: "string" }, status: { type: "string", enum: ["draft", "review", "published"] }, seoTitle: { type: ["string", "null"] }, seoDescription: { type: ["string", "null"] }, }, required: ["id"], additionalProperties: false, }, }, { name: "delete_content", description: "删除内容(物理删除)", inputSchema: { type: "object", properties: { id: { type: "number" } }, required: ["id"], additionalProperties: false }, }, { name: "list_revisions", description: "内容修订快照列表(每次保存自动留档)", inputSchema: { type: "object", properties: { contentId: { type: "number" } }, required: ["contentId"], additionalProperties: false }, }, { name: "restore_revision", description: "恢复内容到指定修订(当前版本先再留档)", inputSchema: { type: "object", properties: { id: { type: "number", description: "修订 ID" } }, required: ["id"], additionalProperties: false }, }, { name: "list_media", description: "媒体库列表(路径/URL/宽高/大小),支持文件名关键字与分页", inputSchema: { type: "object", properties: { q: { type: "string" }, page: { type: "number" }, pageSize: { type: "number" } }, additionalProperties: false, }, }, { name: "upload_media", description: "上传媒体文件(base64),写入 uploads/YYYY/MM/ 并入库,自动解析图片宽高", inputSchema: { type: "object", properties: { filename: { type: "string" }, mime: { type: "string" }, dataBase64: { type: "string", description: "文件内容 base64(≤20MB)" }, }, required: ["filename", "dataBase64"], additionalProperties: false, }, }, { name: "list_products", description: "产品列表(SKU/标题/价格/库存/状态),支持关键字与分页", inputSchema: { type: "object", properties: { q: { type: "string" }, page: { type: "number" }, pageSize: { type: "number" } }, additionalProperties: false, }, }, { name: "list_orders", description: "订单列表(单号/状态/金额/客户名),支持状态过滤与分页", inputSchema: { type: "object", properties: { status: { type: "string" }, page: { type: "number" }, pageSize: { type: "number" } }, additionalProperties: false, }, }, { name: "update_order_status", description: "更新订单状态(pending/on-hold/processing/completed/cancelled/refunded)", inputSchema: { type: "object", properties: { id: { type: "number" }, status: { type: "string" } }, required: ["id", "status"], additionalProperties: false, }, }, { name: "list_inquiries", description: "询盘列表(姓名/邮箱/公司/国家/感兴趣产品/状态),支持状态过滤与分页;CRM 跟进用", inputSchema: { type: "object", properties: { status: { type: "string", enum: ["new", "read", "replied", "spam", "archived"] }, q: { type: "string" }, page: { type: "number" }, pageSize: { type: "number" } }, additionalProperties: false, }, }, { name: "update_inquiry_status", description: "更新询盘状态(跟进流转:new→read→replied,或标记 spam/archived)", inputSchema: { type: "object", properties: { id: { type: "number" }, status: { type: "string", enum: ["new", "read", "replied", "spam", "archived"] } }, required: ["id", "status"], additionalProperties: false, }, }, { name: "get_setting", description: "读取系统设置键(site/woocommerce/mail/seo 等)", inputSchema: { type: "object", properties: { key: { type: "string" } }, required: ["key"], additionalProperties: false }, }, { name: "set_setting", description: "写入系统设置键(整键覆写,value 须为 JSON 对象)", inputSchema: { type: "object", properties: { key: { type: "string" }, value: { type: "object" } }, required: ["key", "value"], additionalProperties: false, }, }, ]; /** # 函数 内部工具:把标题转成 URL 别名(slug),与后台 admin.ts 同规则 */ function slugify(title: string): string { return ( title .toLowerCase() .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 200) || `item-${Date.now()}` ); } /** # 函数 内部工具:写审计日志(MCP 调用 actorId 为空) */ async function audit(action: string, entity: string, entityId: string | number, meta?: unknown) { await sql`insert into audit_logs (actor_id, action, entity, entity_id, meta) values (null, ${action}, ${entity}, ${String(entityId)}, ${sql.json(meta ?? {})}::jsonb)`; } /** # 函数 内部工具:把 SQL 片段用 " and " 连接(Bun SQL 无 sql.join/sql.raw,嵌套模板实现) */ function andJoin(conds: SQL[]): SQL { return conds.reduce((acc, c) => (acc ? (sql`${acc} and ${c}` as SQL) : c)); } /** # 函数 内部工具:把 SQL 片段用 ", " 连接(UPDATE SET 列表用) */ function commaJoin(chunks: SQL[]): SQL { return chunks.reduce((acc, c) => (acc ? (sql`${acc}, ${c}` as SQL) : c)); } /** # 函数 内部工具:slug 冲突时追加 -2/-3 …(contents_slug_type_idx 唯一约束) */ async function uniqueSlug(base: string, type: string): Promise { let slug = base; for (let n = 1; n < 100; n++) { const hit = await sql`select 1 from contents where slug = ${slug} and type = ${type} limit 1`; if (hit.length === 0) return slug; slug = `${base}-${n + 1}`; } throw new Error("slug 生成失败:冲突过多"); } /** # 函数 工具调用实现(tools/call 分发) * 参数 name:工具名;args:入参对象 * 返回值 任意可 JSON 序列化结果(由调用方包成 MCP text content) */ async function callTool(name: string, args: Record): Promise { const a = args ?? {}; switch (name) { case "dashboard_stats": { const [counts] = await sql` select (select count(*) from contents) as contents, (select count(*) from products) as products, (select count(*) from orders) as orders, (select count(*) from inquiries) as inquiries, (select count(*) from media) as media, (select count(*) from orders where status in ('pending','on-hold')) as pending_orders, (select count(*) from inquiries where status = 'new') as new_inquiries`; const revenue = await sql`select currency, coalesce(sum(total),0) as total from orders where status in ('completed','processing') group by currency`; const trend = await sql` select to_char(date_trunc('day', created_at), 'YYYY-MM-DD') as day, count(*) as n from inquiries where created_at > now() - interval '7 days' group by 1 order by 1`; return { counts, revenue, trend }; } case "list_contents": { const page = Math.max(1, Number(a.page) || 1); const pageSize = Math.min(100, Number(a.pageSize) || 20); const conds: SQL[] = []; if (a.type) conds.push(sql`type = ${a.type}`); if (a.status) conds.push(sql`status = ${a.status}`); if (a.q) conds.push(sql`(title ilike ${"%" + a.q + "%"} or slug ilike ${"%" + a.q + "%"})`); const where = conds.length ? sql` where ${andJoin(conds)}` : sql``; const items = await sql`select id, type, slug, title, status, template, views, published_at, updated_at from contents${where} order by updated_at desc limit ${pageSize} offset ${(page - 1) * pageSize}`; const [{ total }] = await sql`select count(*) as total from contents${where}`; return { items, total: Number(total), page, pageSize }; } case "get_content": { const [row] = await sql`select * from contents where id = ${Number(a.id)}`; if (!row) throw new Error(`内容 #${a.id} 不存在`); const revs = await sql`select count(*) as n from content_revisions where content_id = ${Number(a.id)}`; return { ...row, revisionCount: Number(revs[0].n) }; } case "create_content": { const type = String(a.type || "page"); const title = String(a.title || "").trim(); if (!title) throw new Error("title 必填"); const slug = await uniqueSlug(String(a.slug || "").trim() || slugify(title), type); // elementorJson 解析进 meta.elementor(非法 JSON 直接报错,防脏数据入渲染器) const meta: Record = {}; if (typeof a.elementorJson === "string" && a.elementorJson.trim()) { try { meta.elementor = JSON.parse(a.elementorJson); } catch { throw new Error("elementorJson 不是合法 JSON"); } } const status = String(a.status || "draft"); const [row] = await sql` insert into contents (type, slug, title, excerpt, html, status, template, seo_title, seo_description, meta, published_at, author_id) values (${type}, ${slug}, ${title}, ${String(a.excerpt || "")}, ${String(a.html || "")}, ${status}, 'default', ${a.seoTitle ? String(a.seoTitle) : null}, ${a.seoDescription ? String(a.seoDescription) : null}, ${sql.json(meta)}::jsonb, ${status === "published" ? new Date() : null}, null) returning id`; await audit("content.create", "content", row.id, { title, type, via: "mcp" }); return { ok: true, id: row.id, slug, frontUrl: status === "published" ? `/${slug}/` : null }; } case "update_content": { const id = Number(a.id); const [cur] = await sql`select id, title, html, meta, status, published_at from contents where id = ${id}`; if (!cur) throw new Error(`内容 #${id} 不存在`); // 修订快照(与后台保存一致) await sql`insert into content_revisions (content_id, title, html, author_id) values (${id}, ${cur.title}, ${cur.html}, null)`; // 逐字段构建 SET(仅更新显式传入的字段) const sets: SQL[] = [sql`updated_at = ${new Date()}`]; if (a.title !== undefined) sets.push(sql`title = ${String(a.title)}`); if (a.slug !== undefined) sets.push(sql`slug = ${await uniqueSlug(String(a.slug), String(cur.type ?? "page"))}`); if (a.html !== undefined) sets.push(sql`html = ${String(a.html)}`); if (a.excerpt !== undefined) sets.push(sql`excerpt = ${String(a.excerpt)}`); if (a.status !== undefined) { const st = String(a.status); sets.push(sql`status = ${st}`); if (st === "published" && !cur.published_at) sets.push(sql`published_at = ${new Date()}`); } if (a.seoTitle !== undefined) sets.push(sql`seo_title = ${a.seoTitle === null ? null : String(a.seoTitle)}`); if (a.seoDescription !== undefined) sets.push(sql`seo_description = ${a.seoDescription === null ? null : String(a.seoDescription)}`); if (a.elementorJson !== undefined) { const base = (cur.meta ?? {}) as Record; if (a.elementorJson === null) delete base.elementor; else { try { base.elementor = JSON.parse(String(a.elementorJson)); } catch { throw new Error("elementorJson 不是合法 JSON"); } } sets.push(sql`meta = ${sql.json(base)}::jsonb`); } await sql`update contents set ${commaJoin(sets)} where id = ${id}`; await audit("content.update", "content", id, { fields: Object.keys(a).filter((k) => k !== "id"), via: "mcp" }); return { ok: true, id }; } case "delete_content": { const id = Number(a.id); await sql`delete from content_terms where content_id = ${id}`; await sql`delete from content_revisions where content_id = ${id}`; await sql`delete from contents where id = ${id}`; await audit("content.delete", "content", id, { via: "mcp" }); return { ok: true }; } case "list_revisions": return await sql`select id, content_id, title, author_id, created_at from content_revisions where content_id = ${Number(a.contentId)} order by id desc limit 50`; case "restore_revision": { const [rev] = await sql`select * from content_revisions where id = ${Number(a.id)}`; if (!rev) throw new Error(`修订 #${a.id} 不存在`); const [cur] = await sql`select title, html from contents where id = ${rev.content_id}`; if (cur) await sql`insert into content_revisions (content_id, title, html, author_id) values (${rev.content_id}, ${cur.title}, ${cur.html}, null)`; await sql`update contents set title = ${rev.title}, html = ${rev.html}, updated_at = ${new Date()} where id = ${rev.content_id}`; await audit("content.revision.restore", "content", rev.content_id, { revisionId: rev.id, via: "mcp" }); return { ok: true, contentId: rev.content_id }; } case "list_media": { const page = Math.max(1, Number(a.page) || 1); const pageSize = Math.min(100, Number(a.pageSize) || 20); const where = a.q ? sql` where path ilike ${"%" + a.q + "%"}` : sql``; const items = await sql`select id, path, url, mime_type, width, height, size, alt_text, created_at from media${where} order by id desc limit ${pageSize} offset ${(page - 1) * pageSize}`; const [{ total }] = await sql`select count(*) as total from media${where}`; return { items, total: Number(total), page, pageSize }; } case "upload_media": { const buf = Buffer.from(String(a.dataBase64 || ""), "base64"); if (!buf.length) throw new Error("空文件"); if (buf.length > 20 * 1024 * 1024) throw new Error("文件超过 20MB 上限"); const now = new Date(); const dir = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, "0")}`; const clean = String(a.filename || `file-${Date.now()}`).replace(/[\\/]+/g, "-").replace(/[^a-zA-Z0-9._\-\u4e00-\u9fa5]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 160) || `file-${Date.now()}`; const dot = clean.lastIndexOf("."); const stem = dot === -1 ? clean : clean.slice(0, dot); const ext = dot === -1 ? "" : clean.slice(dot).toLowerCase(); const fs = await import("node:fs/promises"); const path = await import("node:path"); const root = process.env.DUSUN_ROOT || path.resolve(import.meta.dir, ".."); const absDir = path.join(root, "public", "uploads", dir); await fs.mkdir(absDir, { recursive: true }); let rel = `${dir}/${stem}${ext}`; for (let n = 1; n < 1000; n++) { try { await fs.access(path.join(root, "public", "uploads", rel)); rel = `${dir}/${stem}-${n}${ext}`; } catch { break; } } await fs.writeFile(path.join(root, "public", "uploads", rel), buf); const dims = parseImageSize(new Uint8Array(buf.slice(0, Math.min(buf.length, 1024 * 1024)))); const [row] = await sql` insert into media (path, url, mime_type, width, height, size) values (${rel}, ${"/uploads/" + rel}, ${String(a.mime || "application/octet-stream")}, ${dims?.[0] ?? null}, ${dims?.[1] ?? null}, ${buf.length}) returning id`; await audit("media.upload", "media", row.id, { path: rel, via: "mcp" }); return { ok: true, id: row.id, url: `/uploads/${rel}`, width: dims?.[0] ?? null, height: dims?.[1] ?? null }; } case "list_products": { const page = Math.max(1, Number(a.page) || 1); const pageSize = Math.min(100, Number(a.pageSize) || 20); const where = a.q ? sql` where (title ilike ${"%" + a.q + "%"} or sku ilike ${"%" + a.q + "%"} or slug ilike ${"%" + a.q + "%"})` : sql``; const items = await sql`select id, sku, slug, title, price, regular_price, stock, stock_status, status, featured, updated_at from products${where} order by updated_at desc limit ${pageSize} offset ${(page - 1) * pageSize}`; const [{ total }] = await sql`select count(*) as total from products${where}`; return { items, total: Number(total), page, pageSize }; } case "list_orders": { const page = Math.max(1, Number(a.page) || 1); const pageSize = Math.min(100, Number(a.pageSize) || 20); const where = a.status ? sql` where status = ${a.status}` : sql``; const items = await sql`select id, order_number, status, currency, total, payment_status, billing, created_at from orders${where} order by created_at desc limit ${pageSize} offset ${(page - 1) * pageSize}`; const [{ total }] = await sql`select count(*) as total from orders${where}`; return { items, total: Number(total), page, pageSize }; } case "update_order_status": { const id = Number(a.id); const status = String(a.status); if (!["pending", "on-hold", "processing", "completed", "cancelled", "refunded"].includes(status)) throw new Error("非法订单状态"); await sql`update orders set status = ${status}, updated_at = ${new Date()} where id = ${id}`; await audit("order.status", "order", id, { status, via: "mcp" }); return { ok: true }; } case "list_inquiries": { const page = Math.max(1, Number(a.page) || 1); const pageSize = Math.min(100, Number(a.pageSize) || 20); const conds: SQL[] = []; if (a.status) conds.push(sql`status = ${a.status}`); if (a.q) conds.push(sql`(name ilike ${"%" + a.q + "%"} or email ilike ${"%" + a.q + "%"} or company ilike ${"%" + a.q + "%"})`); const where = conds.length ? sql` where ${andJoin(conds)}` : sql``; const items = await sql`select id, name, email, company, country, product_interest, status, created_at from inquiries${where} order by created_at desc limit ${pageSize} offset ${(page - 1) * pageSize}`; const [{ total }] = await sql`select count(*) as total from inquiries${where}`; return { items, total: Number(total), page, pageSize }; } case "update_inquiry_status": { const id = Number(a.id); const status = String(a.status); if (!["new", "read", "replied", "spam", "archived"].includes(status)) throw new Error("非法询盘状态"); await sql`update inquiries set status = ${status} where id = ${id}`; await audit("inquiry.status", "inquiry", id, { status, via: "mcp" }); return { ok: true }; } case "get_setting": { const [row] = await sql`select key, value, updated_at from settings where key = ${String(a.key)}`; return row ?? null; } case "set_setting": { const key = String(a.key); if (!/^[a-z0-9_-]{1,128}$/i.test(key)) throw new Error("非法设置键名"); await sql`insert into settings (key, value, updated_at) values (${key}, ${sql.json(a.value)}::jsonb, ${new Date()}) on conflict (key) do update set value = ${sql.json(a.value)}::jsonb, updated_at = ${new Date()}`; await audit("settings.update", "settings", key, { via: "mcp" }); return { ok: true }; } default: throw new Error(`未知工具:${name}`); } } /* ============================= MCP stdio 协议层 ============================= */ /** # 函数 写一帧 JSON-RPC 响应到 stdout */ function send(msg: Record) { process.stdout.write(JSON.stringify(msg) + "\n"); } /** # 类型 已初始化标记(initialize 之前拒绝 tools/* 调用) **/ let initialized = false; // ---- 访问令牌校验(v0.16.0)---- // 设置环境变量 DUSUN_MCP_TOKEN 后启动即进入令牌模式:值必须命中某后台角色用户的 // users.mcp_token(个人设置页生成),且该角色在 权限管理 里允许 MCP;否则进程退出。 // 未设置环境变量保持旧行为(本地直连受信任环境,不校验),向后兼容。 if (process.env.DUSUN_MCP_TOKEN) { const { findMcpTokenUser } = await import("../src/server/permissions"); const hit = await findMcpTokenUser(process.env.DUSUN_MCP_TOKEN).catch(() => null); if (!hit) { console.error("dusuniot-cms: DUSUN_MCP_TOKEN 无效或该角色未获 MCP 权限,拒绝启动"); process.exit(1); } console.error(`dusuniot-cms: token auth ok (${hit})`); } /** # 函数 逐行解析 stdin 请求并分发(initialize / tools/list / tools/call / ping) */ for await (const chunk of Bun.stdin.stream()) { const text = new TextDecoder().decode(chunk); for (const line of text.split("\n")) { if (!line.trim()) continue; let req: { id?: unknown; method?: string; params?: Record }; try { req = JSON.parse(line); } catch { continue; } const id = req.id; try { switch (req.method) { case "initialize": send({ jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "dusuniot-cms", version: pkg.version }, }, }); break; case "notifications/initialized": initialized = true; break; case "ping": send({ jsonrpc: "2.0", id, result: {} }); break; case "tools/list": send({ jsonrpc: "2.0", id, result: { tools: TOOLS } }); break; case "tools/call": { if (!initialized) throw new Error("server not initialized"); const name = String((req.params as { name?: string }).name); const args = ((req.params as { arguments?: Record }).arguments ?? {}) as Record; try { const result = await callTool(name, args); send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] } }); } catch (e) { // 工具级错误按 isError 返回(协议要求不中断会话) send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: String(e instanceof Error ? e.message : e) }], isError: true }, }); } break; } default: if (id !== undefined) send({ jsonrpc: "2.0", id, error: { code: -32601, message: `method not found: ${req.method}` } }); } } catch (e) { if (id !== undefined) send({ jsonrpc: "2.0", id, error: { code: -32603, message: String(e instanceof Error ? e.message : e) } }); } } }