From cae426fbc27c358bbec2035adee288815fa4b64c Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Tue, 8 Sep 2026 15:21:13 +0800 Subject: [PATCH 1/2] =?UTF-8?q?v4.0.18=E7=89=88=E6=9C=AC=E5=8F=B7=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/package.json | 2 +- apps/h5-partner/package.json | 2 +- apps/h5-shop/package.json | 2 +- apps/mini-user/package.json | 2 +- packages/shared-ui/package.json | 2 +- server/dukang-api/package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index bfb110a..a911b70 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -1,6 +1,6 @@ { "name": "@dukang/admin-web", - "version": "0.1.0", + "version": "4.0.18", "private": true, "type": "module", "scripts": { diff --git a/apps/h5-partner/package.json b/apps/h5-partner/package.json index af1565f..0419e39 100644 --- a/apps/h5-partner/package.json +++ b/apps/h5-partner/package.json @@ -1,6 +1,6 @@ { "name": "@dukang/h5-partner", - "version": "0.1.0", + "version": "4.0.18", "private": true, "type": "module", "scripts": { diff --git a/apps/h5-shop/package.json b/apps/h5-shop/package.json index 31607d9..8c57d8a 100644 --- a/apps/h5-shop/package.json +++ b/apps/h5-shop/package.json @@ -1,6 +1,6 @@ { "name": "@dukang/h5-shop", - "version": "0.1.0", + "version": "4.0.18", "private": true, "type": "module", "scripts": { diff --git a/apps/mini-user/package.json b/apps/mini-user/package.json index 2c99b68..1876457 100644 --- a/apps/mini-user/package.json +++ b/apps/mini-user/package.json @@ -1,6 +1,6 @@ { "name": "@dukang/mini-user", - "version": "4.0.10", + "version": "4.0.18", "private": true, "description": "杜康好客 · C 端用户微信小程序(Taro)", "scripts": { diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index d7ebd0f..ef5e846 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,6 +1,6 @@ { "name": "@dukang/shared-ui", - "version": "0.1.0", + "version": "4.0.18", "private": true, "type": "module", "exports": { diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index d9def19..c06ecc4 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -1,6 +1,6 @@ { "name": "@dukang/api", - "version": "0.1.0", + "version": "4.0.18", "private": true, "scripts": { "predev": "pnpm --dir ../../packages/domain build", From 976bde37cc75a227c07d1962c8d01be74449dab7 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Tue, 8 Sep 2026 16:57:42 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(wecom):=20=E4=BC=81=E5=BE=AE=20API=20?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=A2=9E=E5=8A=A0=20MCP=20Streamable=20HTTP?= =?UTF-8?q?=20=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与 REST/OpenAPI 并存,按实例权限自动 tools/list,免手填工具。 Co-authored-by: Cursor --- .../src/pages/WecomApiPluginsPage.tsx | 17 +- docs/企微API插件-配置手册.md | 111 +++-- docs/杜康好客-v3-现状对照.md | 2 + docs/杜康好客-v3.5.17-开发文档.md | 2 +- docs/杜康好客-v3.5.18-开发文档.md | 50 ++ docs/杜康好客-v3编码手册.md | 2 +- docs/杜康好客-知识库.md | 1 + .../shared-types/src/wecom-plugin.test.ts | 28 ++ packages/shared-types/src/wecom-plugin.ts | 92 ++++ pnpm-lock.yaml | 428 ++++++++++++++++++ server/dukang-api/package.json | 8 +- .../interceptors/response.interceptor.test.ts | 11 + .../interceptors/response.interceptor.ts | 6 +- .../wecom/wecom-plugin-mcp.controller.ts | 27 ++ .../wecom/wecom-plugin-mcp.factory.test.ts | 63 +++ .../wecom/wecom-plugin-mcp.factory.ts | 71 +++ .../wecom/wecom-plugin-mcp.tools.test.ts | 46 ++ .../wecom/wecom-plugin-mcp.tools.ts | 204 +++++++++ .../wecom/wecom-plugin-query.service.ts | 10 +- .../wecom/wecom-plugin.controller.ts | 39 +- .../wecom/wecom-plugin.util.test.ts | 26 ++ .../integrations/wecom/wecom-plugin.util.ts | 26 ++ .../src/integrations/wecom/wecom.module.ts | 5 +- 23 files changed, 1212 insertions(+), 63 deletions(-) create mode 100644 docs/杜康好客-v3.5.18-开发文档.md create mode 100644 server/dukang-api/src/common/interceptors/response.interceptor.test.ts create mode 100644 server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.controller.ts create mode 100644 server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.factory.test.ts create mode 100644 server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.factory.ts create mode 100644 server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.tools.test.ts create mode 100644 server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.tools.ts create mode 100644 server/dukang-api/src/integrations/wecom/wecom-plugin.util.test.ts create mode 100644 server/dukang-api/src/integrations/wecom/wecom-plugin.util.ts diff --git a/apps/admin-web/src/pages/WecomApiPluginsPage.tsx b/apps/admin-web/src/pages/WecomApiPluginsPage.tsx index 867619b..18eb130 100644 --- a/apps/admin-web/src/pages/WecomApiPluginsPage.tsx +++ b/apps/admin-web/src/pages/WecomApiPluginsPage.tsx @@ -19,8 +19,10 @@ import { import type { ColumnsType } from 'antd/es/table'; import { WECOM_PLUGIN_API_KEY_HEADER, + WECOM_PLUGIN_MCP_TRANSPORT, WECOM_PLUGIN_PERMISSION_GROUPS, WECOM_PLUGIN_PERMISSION_LABELS, + resolveWecomPluginMcpUrl, resolveWecomPluginPublicUrl, type WecomApiPluginDto, type WecomApiPluginSecretDto, @@ -53,6 +55,7 @@ export default function WecomApiPluginsPage() { const [revealed, setRevealed] = useState(null); const pluginUrl = resolveWecomPluginPublicUrl(window.location.hostname); + const mcpUrl = resolveWecomPluginMcpUrl(window.location.hostname); const permissionsWatch = Form.useWatch('permissions', form) as WecomPluginPermission[] | undefined; const { data, loading, page, pageSize, setPage, setPageSize, reload } = @@ -224,7 +227,7 @@ export default function WecomApiPluginsPage() { {settingsModal} 新建插件 @@ -236,20 +239,24 @@ export default function WecomApiPluginsPage() { type="info" showIcon style={{ marginBottom: 16 }} - message="填企微「添加 API 插件」第 1 步" + message="填企微「添加插件」" description={
- 插件 URL: + MCP 插件 URL(推荐,{WECOM_PLUGIN_MCP_TRANSPORT}): + {mcpUrl} +
+
+ API 插件 URL(兼容 OpenAPI): {pluginUrl}
- OpenAPI(带该实例 Key): + OpenAPI(API 插件第 2 步,带该实例 Key): {`${pluginUrl}/openapi.json`}
授权方式 Service token / API key,Header 名 {WECOM_PLUGIN_API_KEY_HEADER} - ;第 2 步只添加本实例已勾选的工具(GET + Query)。 + 。MCP 会按本实例权限自动拉取工具;API 插件第 2 步只添加已勾选的 GET 工具。
} diff --git a/docs/企微API插件-配置手册.md b/docs/企微API插件-配置手册.md index abd2ce0..eb3b5c9 100644 --- a/docs/企微API插件-配置手册.md +++ b/docs/企微API插件-配置手册.md @@ -1,28 +1,75 @@ -# 企微 API 插件 · 配置手册 +# 企微 API / MCP 插件 · 配置手册 -> 面向运营/技术:在企微后台「智能机器人 → 添加 API 插件」逐步填表。 -> 后端前缀:`/api/v1/wecom/plugin` · 鉴权 Header `X-Api-Key` · 与 HQ 长连接 Bot **独立**。 -> 密钥与权限在 HQ **企微机器人 → API 插件** 维护(v3.5.17+)。 +> 面向运营/技术:在企微后台「智能机器人 → 添加插件」填表。 +> 后端前缀:`/api/v1/wecom/plugin` · MCP:`/api/v1/wecom/plugin/mcp` · 鉴权 Header `X-Api-Key` · 与 HQ 长连接 Bot **独立**。 +> 密钥与权限在 HQ **企微机器人 → API 插件** 维护(v3.5.17+;MCP 端点 v3.5.18)。 --- -## 1. 推荐流程(优先 OpenAPI 导入) +## 1. 推荐流程(MCP 插件) -**不要 17 个工具全手填。** 推荐: +企微 5.0.6+ 支持 **MCP 插件**:自动 `tools/list` 拉工具,不必手填、也不必导入 OpenAPI。REST API 插件仍保留,已配好的机器人不用改。 1. HQ → **企微机器人 → API 插件** → 新建实例,勾选工具权限,复制 **Key**(只显示一次)。 -2. 企微 **第 1 步**:填插件 URL + Header Key(见 §2)。 +2. 企微工作台 → 智能机器人 → **插件** → **添加 MCP 插件**: + - **插件 URL**:见下表(必须是企业域名,localhost 无法注入成员身份) + - **传输协议**:Streamable HTTP + - **授权方式**:Service token / API key + - **位置**:Header + - **Parameter name**:`X-Api-Key` + - **Service token**:该实例 Key +3. 企微会自动拉取已授权工具;把插件挂到「运营查询」类智能机器人即可。 +4. 白名单会话试问:「查一下今日经营指标」「订单 DK…」。HQ **日志 → 智能机器人日志** 应出现 `botKey=plugin:{id}`。 + +| 企微表单字段 | 测试环境 | 生产环境 | +|-------------|---------|---------| +| **MCP 插件 URL** | `https://api-test.dukanghaoke.com/api/v1/wecom/plugin/mcp` | `https://api.dukanghaoke.com/api/v1/wecom/plugin/mcp` | +| **传输协议** | Streamable HTTP | 同左 | +| **Header** | `X-Api-Key` | 同左 | + +**自检(JSON-RPC,无 `{code,message,data}` 信封):** + +```bash +# 无 Key → 401 +curl -sS -o /dev/null -w '%{http_code}\n' \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \ + "https://api-test.dukanghaoke.com/api/v1/wecom/plugin/mcp" + +# 带 Key:initialize +curl -sS -H "X-Api-Key: <你的Key>" -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \ + "https://api-test.dukanghaoke.com/api/v1/wecom/plugin/mcp" + +# 带 Key:tools/list(仅含本实例已勾选权限) +curl -sS -H "X-Api-Key: <你的Key>" -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ + "https://api-test.dukanghaoke.com/api/v1/wecom/plugin/mcp" +``` + +若「添加插件」拉不到工具,再试传输协议 **SSE**(需运维给 `/api/v1/wecom/plugin/mcp` 关 `proxy_buffering`);第一期默认 Streamable HTTP 无状态即可。 + +--- + +## 2. 兼容流程(API 插件 + OpenAPI 导入) + +**不要 17 个工具全手填。** API 插件推荐: + +1. HQ 建实例并复制 Key(同上)。 +2. 企微 **第 1 步**:填 API 插件 URL + Header Key(见 §3)。 3. 企微 **第 2 步**:**OpenAPI 导入** - URL:`{Base URL}/openapi.json` - **同样带 Header `X-Api-Key`**(与第 1 步相同 Key) - 服务端按该 Key 的权限**只返回已授权路径**,与 HQ 勾选一致。 -4. 导入后逐条核对 §3「全局规则」;类型不对时按 §5 手工修正。 +4. 导入后逐条核对 §4「全局规则」;类型不对时按 §6 手工修正。 手填仅适用于:导入失败,或只加 2~3 个高频工具。 --- -## 2. 第 1 步:添加 API 插件 +## 3. 第 1 步:添加 API 插件 | 企微表单字段 | 测试环境 | 生产环境 | |-------------|---------|---------| @@ -43,7 +90,7 @@ HQ 页也会展示 Base URL 与 Header 名(不含密钥)。 --- -## 3. 全局规则(调试不过先看这里) +## 4. 全局规则(调试不过先看这里) | 项 | 正确 | 错误 | |----|------|------| @@ -69,20 +116,20 @@ HQ 页也会展示 Base URL 与 Header 名(不含密钥)。 --- -## 4. HQ 权限与工具对照 +## 5. HQ 权限与工具对照 -只添加 HQ **已勾选**权限对应的工具;未授权返回 **403**。 +只添加 HQ **已勾选**权限对应的工具;未授权 REST 返回 **403**,MCP 则不出现在 `tools/list`。 -| HQ 权限 | 路径 | 建议工具名 | -|---------|------|-----------| -| `metrics.read` | `/metrics` | 查询经营指标 | -| `order.read` | `/orders` | 查询订单 | -| `user.read` | `/users` | 查询用户 | -| `store.read` | `/stores` | 查询门店 | -| `redeem.read` | `/redeems` | 查询核销 | -| `promo.read` | `/promo-codes`、`/promo-codes/{code}/stats` | 查询推广码、推广码统计 | -| `store.audit.read` | `/store-audits`、`/store-info-audits`、`/store-info-audits/{id}`、`/store-package-audits`、`/store-package-audits/{id}` | 门店/信息/套餐审核 | -| `partner.read` | `/partners`、`/partners/{partnerId}/users|stores|orders` | 合伙人及关联数据 | +| HQ 权限 | REST 路径 | MCP 工具名 | +|---------|-----------|-----------| +| `metrics.read` | `/metrics` | `query_metrics` | +| `order.read` | `/orders` | `query_orders` | +| `user.read` | `/users` | `query_users` | +| `store.read` | `/stores` | `query_stores` | +| `redeem.read` | `/redeems` | `query_redeems` | +| `promo.read` | `/promo-codes`、`/promo-codes/{code}/stats` | `query_promo_codes`、`query_promo_code_stats` | +| `store.audit.read` | `/store-audits`、`/store-info-audits`、`/store-info-audits/{id}`、`/store-package-audits`、`/store-package-audits/{id}` | `query_store_audits` 等 5 个 | +| `partner.read` | `/partners`、`/partners/{partnerId}/users|stores|orders` | `query_partners` 等 4 个 | **实例建议:** @@ -94,7 +141,7 @@ HQ 页也会展示 Base URL 与 Header 名(不含密钥)。 --- -## 5. 第 2 步:工具参数明细 +## 6. 第 2 步:工具参数明细 以下均为 **GET**。Base URL 与 §2 相同,路径为相对路径。 @@ -332,7 +379,7 @@ HQ 页也会展示 Base URL 与 Header 名(不含密钥)。 --- -## 6. OpenAPI 导入 +## 7. OpenAPI 导入 | 项 | 值 | |----|-----| @@ -350,7 +397,16 @@ curl -H "X-Api-Key: " \ --- -## 7. 调试检查清单 +## 8. 调试检查清单 + +### MCP 插件 + +- [ ] 无 Key / 错 Key → 401 +- [ ] `initialize` + `tools/list` 仅含 HQ 已勾选权限对应工具 +- [ ] 企微后台能自动拉到工具(Streamable HTTP) +- [ ] 白名单会话能问到真实数据;HQ 日志 `botKey=plugin:{id}` + +### API 插件(兼容) - [ ] 第 1 步 Key 与 HQ 实例一致,实例 **已启用** - [ ] curl 带 Key 返回 `code: 0` @@ -363,7 +419,7 @@ curl -H "X-Api-Key: " \ --- -## 8. 多实例与分工 +## 9. 多实例与分工 | 企微插件 | HQ 实例 | 说明 | |----------|---------|------| @@ -373,8 +429,9 @@ curl -H "X-Api-Key: " \ --- -## 9. 相关文档 +## 10. 相关文档 +- [杜康好客-v3.5.18-开发文档](./杜康好客-v3.5.18-开发文档.md) — MCP Streamable HTTP 端点 - [杜康好客-v3.5.17-开发文档](./杜康好客-v3.5.17-开发文档.md) — 表结构、Admin API、权限目录 - [杜康好客-v3.5.16-开发文档](./杜康好客-v3.5.16-开发文档.md) — 插件初版与 curl 示例 - [杜康好客-v3编码手册](./杜康好客-v3编码手册.md) — 编码验收一句 diff --git a/docs/杜康好客-v3-现状对照.md b/docs/杜康好客-v3-现状对照.md index 63c2022..7dee69d 100644 --- a/docs/杜康好客-v3-现状对照.md +++ b/docs/杜康好客-v3-现状对照.md @@ -70,9 +70,11 @@ | 3.5.14 | [`提交订单/支付成功日志端回填 USER_MINI`](./杜康好客-v3.5.14-开发文档.md) | 🔶 开发完成 | | 3.5.16 | [`企微智能机器人 API 插件`](./杜康好客-v3.5.16-开发文档.md) | 🔶 开发完成 | | 3.5.17 | [`企微 API 插件迁入 HQ 后台`](./杜康好客-v3.5.17-开发文档.md) | 🔶 开发完成 | +| 3.5.18 | [`企微 API 插件 MCP 端点`](./杜康好客-v3.5.18-开发文档.md) | 🔶 开发完成 | | 日期 | 说明 | |------|------| +| 2026-09-08 | v3.5.18:企微插件增加 Streamable HTTP MCP 端点 `/api/v1/wecom/plugin/mcp`,与 REST/OpenAPI 并存 | | 2026-09-08 | HQ「门店账户」主账号详情可 CRUD 店员子账号(`/admin/store-accounts/:id/staff`) | | 2026-09-06 | v3.5.17:企微 API 插件迁入 HQ「企微机器人 → API 插件」;多实例 Key + 工具权限;不再依赖 `WECOM_PLUGIN_*` env | | 2026-09-03 | v3.5.16:企微 API 插件只读数据面 `GET /api/v1/wecom/plugin/*`(X-Api-Key);与长连接 Bot 独立 | diff --git a/docs/杜康好客-v3.5.17-开发文档.md b/docs/杜康好客-v3.5.17-开发文档.md index 95917b5..0dcf739 100644 --- a/docs/杜康好客-v3.5.17-开发文档.md +++ b/docs/杜康好客-v3.5.17-开发文档.md @@ -75,7 +75,7 @@ ## 6. 企微侧怎么配 -详见 **[企微 API 插件 · 配置手册](./企微API插件-配置手册.md)**(第 1 步插件、第 2 步工具/OpenAPI 导入、全接口参数表)。 +详见 **[企微 API 插件 · 配置手册](./企微API插件-配置手册.md)**。v3.5.18 起优先配 MCP 插件;本节 API + OpenAPI 仍可用。 1. 插件 URL = HQ 页展示的 Base URL(全实例相同) 2. Header `X-Api-Key` = 该实例密钥 diff --git a/docs/杜康好客-v3.5.18-开发文档.md b/docs/杜康好客-v3.5.18-开发文档.md new file mode 100644 index 0000000..f550e8c --- /dev/null +++ b/docs/杜康好客-v3.5.18-开发文档.md @@ -0,0 +1,50 @@ +# 杜康好客 · v3.5.18 开发文档 + +> **2026-09-08** · integrations/wecom · admin-web · shared-types +> **主题**:企微智能机器人 **MCP 插件**(Streamable HTTP),与 REST API 插件并存 + +--- + +## 1. 版本目标 + +企微后台「添加 MCP 插件」可对接本系统:托管大模型按 MCP `tools/list` / `tools/call` 拉数。不改长连接 Bot、不删 REST/OpenAPI。 + +**不做**:写操作、明文手机/地址、把 `/admin/*` JWT 暴露给企微、第一期 SSE 传输。 + +--- + +## 2. 端点 + +| 项 | 值 | +|----|-----| +| MCP URL | `/api/v1/wecom/plugin/mcp` | +| 传输 | Streamable HTTP,无状态(每请求新建 `McpServer`) | +| 鉴权 | Header `X-Api-Key`(与 REST 同一套 HQ 实例) | +| 响应 | JSON-RPC;**无** `{code,message,data}` 信封 | +| 工具结果 | `JSON.stringify(查询结果)` | +| 权限 | `tools/list` 只注册已勾选权限;QueryService 仍 `requirePerm` | + +生产:`https://api.dukanghaoke.com/api/v1/wecom/plugin/mcp` +测试:`https://api-test.dukanghaoke.com/api/v1/wecom/plugin/mcp` + +--- + +## 3. 变更面 + +| 层 | 路径 | +|----|------| +| shared-types | `WECOM_PLUGIN_MCP_*`、`allowedWecomPluginMcpTools` | +| API | `wecom-plugin-mcp.*`;`WecomPluginMcpFactory`;Interceptor skip MCP | +| HQ | `WecomApiPluginsPage` 展示 MCP URL | +| 文档 | [`企微API插件-配置手册.md`](./企微API插件-配置手册.md) | + +--- + +## 4. 验收 + +- [ ] 无 Key / 错 Key → MCP 入口 401 +- [ ] 带 Key `tools/list` 仅含该实例已勾选权限 +- [ ] `tools/call` 查询口径与 REST 一致,结果无 REST 信封 +- [ ] HQ 日志 `botKey=plugin:{id}` +- [ ] 现有 API 插件机器人行为不变 +- [ ] 长连接 Bot 不变 diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md index 9e0ea8b..2ed2149 100644 --- a/docs/杜康好客-v3编码手册.md +++ b/docs/杜康好客-v3编码手册.md @@ -48,7 +48,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd **HQ 企微报告(v3.5.15)**:企微机器人下「报告」与「消息推送」分开。日报/周报/月报各配 Webhook 与发送时刻;走群机器人 markdown。账期截在发送日北京 0 点(前一天 24 点),不含发送当天:日报=昨日存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单笔数与金额均排除待支付 / 已取消 / 退款中;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。 -**企微 API 插件(v3.5.17)**:实例在 HQ「企微机器人 → API 插件」维护;共用 `GET /api/v1/wecom/plugin/*`,Header `X-Api-Key` 区分实例并按权限放行。经营指标含新增门店(`newStores`);门店搜索含经营数据;支持门店/信息/套餐审核对照只读查询;支持按合伙人查关联用户/门店/订单(`from`/`to` 时间筛选)。 +**企微 API 插件(v3.5.17 / MCP v3.5.18)**:实例在 HQ「企微机器人 → API 插件」维护;共用 Header `X-Api-Key`。REST `GET /api/v1/wecom/plugin/*` 与 MCP `POST /api/v1/wecom/plugin/mcp`(Streamable HTTP 无状态)并存;MCP 按权限自动 `tools/list`。经营指标含新增门店(`newStores`);门店搜索含经营数据;支持门店/信息/套餐审核对照只读查询;支持按合伙人查关联用户/门店/订单(`from`/`to` 时间筛选)。 **HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。 diff --git a/docs/杜康好客-知识库.md b/docs/杜康好客-知识库.md index f3518d8..dff7034 100644 --- a/docs/杜康好客-知识库.md +++ b/docs/杜康好客-知识库.md @@ -151,6 +151,7 @@ HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑 |------|------| | 智能机器人 | `/wecom/bots` 长连接指令 | | API 插件 | HQ `/wecom/plugins` 多实例;`/api/v1/wecom/plugin` Header `X-Api-Key` 只读查询(与长连接独立) | +| MCP 插件 | 同上 Key;`POST /api/v1/wecom/plugin/mcp` Streamable HTTP;`tools/list` 按权限过滤 | | 消息推送 | `/wecom/pushes` Webhook+eventKey | | 日志 | `/logs/wecom-bots` | | C 端微信客服 | 系统设置 `CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`;小程序须已关联该企业微信客服 | diff --git a/packages/shared-types/src/wecom-plugin.test.ts b/packages/shared-types/src/wecom-plugin.test.ts index 494d12c..a3556aa 100644 --- a/packages/shared-types/src/wecom-plugin.test.ts +++ b/packages/shared-types/src/wecom-plugin.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest'; import { WECOM_PLUGIN_API_KEY_HEADER, WECOM_PLUGIN_BASE_PATH, + WECOM_PLUGIN_MCP_PATH, WECOM_PLUGIN_PATH_PERMISSION, + allowedWecomPluginMcpTools, allowedWecomPluginOpenApiPaths, maskWecomPluginApiKey, parseWecomPluginPermissions, + resolveWecomPluginMcpUrl, resolveWecomPluginPublicUrl, wecomPluginHasPermission, } from './wecom-plugin'; @@ -35,6 +38,15 @@ describe('resolveWecomPluginPublicUrl', () => { it('uses X-Api-Key as the plugin auth header', () => { expect(WECOM_PLUGIN_API_KEY_HEADER).toBe('X-Api-Key'); }); + + it('maps hosts to the Streamable HTTP MCP endpoint', () => { + expect(resolveWecomPluginMcpUrl('admin.dukanghaoke.com')).toBe( + `https://api.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}${WECOM_PLUGIN_MCP_PATH}`, + ); + expect(resolveWecomPluginMcpUrl('admin-test.dukanghaoke.com')).toBe( + `https://api-test.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}${WECOM_PLUGIN_MCP_PATH}`, + ); + }); }); describe('parseWecomPluginPermissions', () => { @@ -76,4 +88,20 @@ describe('maskWecomPluginApiKey / path permission map', () => { expect(wecomPluginHasPermission(['order.read'], 'order.read')).toBe(true); expect(wecomPluginHasPermission(['order.read'], 'metrics.read')).toBe(false); }); + + it('filters MCP tools by instance permissions', () => { + expect(allowedWecomPluginMcpTools(['order.read', 'user.read'])).toEqual([ + 'query_orders', + 'query_users', + ]); + expect(allowedWecomPluginMcpTools(['store.audit.read'])).toEqual([ + 'query_store_audits', + 'query_store_info_audits', + 'query_store_info_audit_detail', + 'query_store_package_audits', + 'query_store_package_audit_detail', + ]); + expect(allowedWecomPluginMcpTools(['partner.read'])).toContain('query_partners'); + expect(allowedWecomPluginMcpTools(['metrics.read'])).not.toContain('query_orders'); + }); }); diff --git a/packages/shared-types/src/wecom-plugin.ts b/packages/shared-types/src/wecom-plugin.ts index f79af8c..7f32357 100644 --- a/packages/shared-types/src/wecom-plugin.ts +++ b/packages/shared-types/src/wecom-plugin.ts @@ -4,6 +4,11 @@ export const WECOM_PLUGIN_API_KEY_HEADER = 'X-Api-Key'; export const WECOM_PLUGIN_BASE_PATH = '/api/v1/wecom/plugin'; +/** 企微 MCP 插件相对 Base 的路径(Streamable HTTP 无状态) */ +export const WECOM_PLUGIN_MCP_PATH = '/mcp'; + +export const WECOM_PLUGIN_MCP_TRANSPORT = 'Streamable HTTP'; + export const WECOM_PLUGIN_PAGE_SIZE_DEFAULT = 5; export const WECOM_PLUGIN_PAGE_SIZE_MAX = 10; @@ -81,6 +86,81 @@ export const WECOM_PLUGIN_PATH_PERMISSION: Record '/partners/{partnerId}/orders': 'partner.read', }; +export const WECOM_PLUGIN_MCP_TOOL_NAMES = [ + 'query_orders', + 'query_users', + 'query_stores', + 'query_redeems', + 'query_promo_codes', + 'query_promo_code_stats', + 'query_metrics', + 'query_store_audits', + 'query_store_info_audits', + 'query_store_info_audit_detail', + 'query_store_package_audits', + 'query_store_package_audit_detail', + 'query_partners', + 'query_partner_users', + 'query_partner_stores', + 'query_partner_orders', +] as const; + +export type WecomPluginMcpToolName = (typeof WECOM_PLUGIN_MCP_TOOL_NAMES)[number]; + +export const WECOM_PLUGIN_MCP_TOOL_META: Record< + WecomPluginMcpToolName, + { permission: WecomPluginPermission; description: string } +> = { + query_orders: { permission: 'order.read', description: '按订单号模糊查询' }, + query_users: { permission: 'user.read', description: '按用户号或 11 位手机号查询;手机号脱敏' }, + query_stores: { + permission: 'store.read', + description: '按门店名称模糊查询,返回经营数据:评分、核销笔数、累计核销好客权益、合伙人、审核状态', + }, + query_redeems: { permission: 'redeem.read', description: '按核销单号或门店名查询' }, + query_promo_codes: { permission: 'promo.read', description: '按推广码 code 或名称查询' }, + query_promo_code_stats: { permission: 'promo.read', description: '按推广码 code 查询扫码/成交统计' }, + query_metrics: { + permission: 'metrics.read', + description: '经营指标。kind=today 为今日截至当前;daily/weekly/monthly 与企微经营报告同一口径', + }, + query_store_audits: { permission: 'store.audit.read', description: '门店入驻审核列表,默认 status=PENDING' }, + query_store_info_audits: { permission: 'store.audit.read', description: '门店信息变更审核列表' }, + query_store_info_audit_detail: { + permission: 'store.audit.read', + description: '门店信息变更审核对比,返回 live vs proposed', + }, + query_store_package_audits: { permission: 'store.audit.read', description: '门店套餐审核列表' }, + query_store_package_audit_detail: { + permission: 'store.audit.read', + description: '门店套餐审核对比,返回 proposedPackages 与 livePackages', + }, + query_partners: { permission: 'partner.read', description: '按姓名、公司名、手机号或 ID 搜索合伙人主账号' }, + query_partner_users: { permission: 'partner.read', description: '查询合伙人关联用户,可选 from/to' }, + query_partner_stores: { permission: 'partner.read', description: '查询合伙人名下门店及经营数据,可选 from/to' }, + query_partner_orders: { + permission: 'partner.read', + description: '查询佣金归属该合伙人的订单,可选 from/to', + }, +}; + +export const WECOM_PLUGIN_MCP_TOOLS: Record = { + 'order.read': ['query_orders'], + 'user.read': ['query_users'], + 'store.read': ['query_stores'], + 'redeem.read': ['query_redeems'], + 'promo.read': ['query_promo_codes', 'query_promo_code_stats'], + 'metrics.read': ['query_metrics'], + 'store.audit.read': [ + 'query_store_audits', + 'query_store_info_audits', + 'query_store_info_audit_detail', + 'query_store_package_audits', + 'query_store_package_audit_detail', + ], + 'partner.read': ['query_partners', 'query_partner_users', 'query_partner_stores', 'query_partner_orders'], +}; + export const WECOM_PLUGIN_TOOL_PATHS: Record = { 'order.read': ['/orders'], 'user.read': ['/users'], @@ -145,6 +225,14 @@ export function allowedWecomPluginOpenApiPaths( .map(([path]) => path); } +/** 当前实例可暴露给企微 MCP 插件的 tools/list */ +export function allowedWecomPluginMcpTools( + permissions: WecomPluginPermission[], +): WecomPluginMcpToolName[] { + const allowed = new Set(permissions); + return WECOM_PLUGIN_MCP_TOOL_NAMES.filter((name) => allowed.has(WECOM_PLUGIN_MCP_TOOL_META[name].permission)); +} + export function maskWecomPluginApiKey(apiKey?: string | null): string { const v = String(apiKey ?? '').trim(); if (!v) return '未配置'; @@ -205,3 +293,7 @@ export function resolveWecomPluginPublicUrl(hostname: string): string { } return `http://localhost:3010${WECOM_PLUGIN_BASE_PATH}`; } + +export function resolveWecomPluginMcpUrl(hostname: string): string { + return `${resolveWecomPluginPublicUrl(hostname)}${WECOM_PLUGIN_MCP_PATH}`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 845d68a..852d6d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -322,6 +322,9 @@ importers: '@dukang/shared-types': specifier: workspace:* version: link:../../packages/shared-types + '@modelcontextprotocol/sdk': + specifier: ^1.30.0 + version: 1.30.0(zod@4.5.4) '@nestjs/bullmq': specifier: ^10.2.0 version: 10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(bullmq@5.79.2) @@ -400,6 +403,9 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 + zod: + specifier: ^4.5.4 + version: 4.5.4 devDependencies: '@nestjs/cli': specifier: ^10.4.0 @@ -437,6 +443,9 @@ importers: typescript: specifier: ^5.4.5 version: 5.9.3 + vitest: + specifier: ^1.6.1 + version: 1.6.1(@types/node@20.19.43)(sass@1.101.0)(terser@5.48.0) packages: @@ -1413,6 +1422,12 @@ packages: '@hapi/topo@5.1.0': resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1648,6 +1663,16 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} cpu: [arm64] @@ -3026,6 +3051,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -3064,6 +3093,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -3365,6 +3402,10 @@ packages: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + bowser@1.9.4: resolution: {integrity: sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==} @@ -3682,16 +3723,28 @@ packages: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -4165,6 +4218,14 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + exceljs@4.4.0: resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} engines: {node: '>=8.3.0'} @@ -4173,10 +4234,20 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + express-rate-limit@8.7.0: + resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + ext-list@2.2.2: resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} engines: {node: '>=0.10.0'} @@ -4306,6 +4377,10 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -4380,6 +4455,10 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + from2@2.3.0: resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} @@ -4602,6 +4681,10 @@ packages: hls.js@1.6.16: resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + hono@4.13.7: + resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + engines: {node: '>=16.9.0'} + hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -4656,6 +4739,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ics@3.12.0: resolution: {integrity: sha512-BBPiZ/TbUyZrxui7SHKGOa/n2HUxaW9ucn7cw47NW9OSKe5tItYxIdN7Uczti5nKURmfwVvYi2aLigCJGH1+cA==} @@ -4731,6 +4818,10 @@ packages: resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} engines: {node: '>=12.22.0'} + ip-address@10.7.0: + resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} + engines: {node: '>= 12'} + ip2region@2.3.0: resolution: {integrity: sha512-zV5Xsadzrx9Ej6heoyhbXMsfGWWQ3C6bAIYStrHhw9kzLpGpVNlnAyRBxxPgxA1GNqr1Ti7oUxcWsMWNN3jZBg==} peerDependencies: @@ -4811,6 +4902,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-retry-allowed@1.2.0: resolution: {integrity: sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==} engines: {node: '>=0.10.0'} @@ -4876,6 +4970,9 @@ packages: joi@17.13.4: resolution: {integrity: sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + js-base64@2.6.4: resolution: {integrity: sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==} @@ -4912,6 +5009,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -5186,6 +5286,10 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + memfs@3.5.3: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} @@ -5197,6 +5301,10 @@ packages: merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -5220,10 +5328,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -5342,6 +5458,10 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -5583,6 +5703,9 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -5643,6 +5766,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -5822,6 +5949,10 @@ packages: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + query-string@5.1.1: resolution: {integrity: sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==} engines: {node: '>=0.10.0'} @@ -5847,6 +5978,10 @@ packages: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc-cascader@3.34.0: resolution: {integrity: sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==} peerDependencies: @@ -6262,6 +6397,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-async@2.4.1: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} engines: {node: '>=0.12.0'} @@ -6352,6 +6491,10 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + sentence-case@3.0.4: resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} @@ -6372,6 +6515,10 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -6854,6 +7001,10 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -7270,6 +7421,14 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zrender@6.1.0: resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} @@ -8528,6 +8687,10 @@ snapshots: dependencies: '@hapi/hoek': 9.3.0 + '@hono/node-server@2.1.1(hono@4.13.7)': + dependencies: + hono: 4.13.7 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -8710,6 +8873,28 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@modelcontextprotocol/sdk@1.30.0(zod@4.5.4)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.7) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.7.0(express@5.2.1) + hono: 4.13.7 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.5.4 + zod-to-json-schema: 3.25.2(zod@4.5.4) + transitivePeerDependencies: + - supports-color + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true @@ -10213,6 +10398,11 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -10245,6 +10435,10 @@ snapshots: optionalDependencies: ajv: 8.20.0 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv-keywords@3.5.2(ajv@6.15.0): dependencies: ajv: 6.15.0 @@ -10674,6 +10868,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bowser@1.9.4: {} brace-expansion@1.1.15: @@ -11060,12 +11268,18 @@ snapshots: dependencies: safe-buffer: 5.2.1 + content-disposition@1.1.0: {} + content-type@1.0.5: {} + content-type@2.1.0: {} + convert-source-map@2.0.0: {} cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} + cookie@0.7.2: {} copy-to-clipboard@3.3.3: @@ -11594,6 +11808,12 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + exceljs@4.4.0: dependencies: archiver: 5.3.2 @@ -11618,6 +11838,14 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + express-rate-limit@8.7.0(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.7.0 + transitivePeerDependencies: + - supports-color + express@4.22.1: dependencies: accepts: 1.3.8 @@ -11654,6 +11882,39 @@ snapshots: transitivePeerDependencies: - supports-color + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + ext-list@2.2.2: dependencies: mime-db: 1.52.0 @@ -11783,6 +12044,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -11879,6 +12151,8 @@ snapshots: fresh@0.5.2: {} + fresh@2.0.0: {} + from2@2.3.0: dependencies: inherits: 2.0.4 @@ -12138,6 +12412,8 @@ snapshots: hls.js@1.6.16: {} + hono@4.13.7: {} + hookified@1.15.1: {} hookified@2.2.0: {} @@ -12202,6 +12478,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ics@3.12.0: dependencies: nanoid: 3.3.15 @@ -12317,6 +12597,8 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.7.0: {} + ip2region@2.3.0(@types/node@20.19.43): dependencies: '@types/node': 20.19.43 @@ -12369,6 +12651,8 @@ snapshots: is-plain-object@5.0.0: {} + is-promise@4.0.0: {} + is-retry-allowed@1.2.0: {} is-stream@1.1.0: {} @@ -12432,6 +12716,8 @@ snapshots: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 + jose@6.2.12: {} + js-base64@2.6.4: {} js-md5@0.8.3: {} @@ -12456,6 +12742,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json2mq@0.2.0: @@ -12710,6 +12998,8 @@ snapshots: media-typer@0.3.0: {} + media-typer@1.1.1: {} + memfs@3.5.3: dependencies: fs-monkey: 1.1.0 @@ -12718,6 +13008,8 @@ snapshots: merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -12733,10 +13025,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} mime@2.6.0: {} @@ -12842,6 +13140,10 @@ snapshots: negotiator@0.6.3: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + neo-async@2.6.2: {} no-case@2.3.2: @@ -13071,6 +13373,8 @@ snapshots: path-to-regexp@6.3.0: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} pathe@1.1.2: {} @@ -13117,6 +13421,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -13276,6 +13582,11 @@ snapshots: dependencies: side-channel: 1.1.1 + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + query-string@5.1.1: dependencies: decode-uri-component: 0.2.2 @@ -13308,6 +13619,13 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + rc-cascader@3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.29.7 @@ -13838,6 +14156,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-async@2.4.1: {} run-async@3.0.0: {} @@ -13954,6 +14282,22 @@ snapshots: transitivePeerDependencies: - supports-color + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + sentence-case@3.0.4: dependencies: no-case: 3.0.4 @@ -13979,6 +14323,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-blocking@2.0.0: {} set-function-length@1.2.2: @@ -14507,6 +14860,12 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -14667,6 +15026,24 @@ snapshots: vary@1.1.2: {} + vite-node@1.6.1(@types/node@20.19.43)(sass@1.101.0)(terser@5.48.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@20.19.43)(sass@1.101.0)(terser@5.48.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0): dependencies: cac: 6.7.14 @@ -14693,6 +15070,17 @@ snapshots: picocolors: 1.1.1 vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0) + vite@5.4.21(@types/node@20.19.43)(sass@1.101.0)(terser@5.48.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.62.2 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + sass: 1.101.0 + terser: 5.48.0 + vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0): dependencies: esbuild: 0.21.5 @@ -14704,6 +15092,40 @@ snapshots: sass: 1.101.0 terser: 5.48.0 + vitest@1.6.1(@types/node@20.19.43)(sass@1.101.0)(terser@5.48.0): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.5 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@20.19.43)(sass@1.101.0)(terser@5.48.0) + vite-node: 1.6.1(@types/node@20.19.43)(sass@1.101.0)(terser@5.48.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0): dependencies: '@vitest/expect': 1.6.1 @@ -14992,6 +15414,12 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 + zod-to-json-schema@3.25.2(zod@4.5.4): + dependencies: + zod: 4.5.4 + + zod@4.5.4: {} + zrender@6.1.0: dependencies: tslib: 2.3.0 diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index c06ecc4..6d3fe1c 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -9,6 +9,7 @@ "dev": "nest start --watch", "start": "node dist/main", "lint": "eslint src", + "test": "vitest run src", "prisma:generate": "prisma generate", "prisma:push": "prisma db push", "prisma:migrate": "prisma migrate dev", @@ -33,6 +34,7 @@ "@alicloud/openapi-client": "^0.4.15", "@dukang/domain": "workspace:*", "@dukang/shared-types": "workspace:*", + "@modelcontextprotocol/sdk": "^1.30.0", "@nestjs/bullmq": "^10.2.0", "@nestjs/common": "^10.4.0", "@nestjs/config": "^3.2.0", @@ -58,7 +60,8 @@ "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "sharp": "^0.34.5" + "sharp": "^0.34.5", + "zod": "^4.5.4" }, "devDependencies": { "@nestjs/cli": "^10.4.0", @@ -72,6 +75,7 @@ "@types/qrcode": "^1.5.6", "prisma": "^5.18.0", "ts-node": "^10.9.2", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "vitest": "^1.6.1" } } diff --git a/server/dukang-api/src/common/interceptors/response.interceptor.test.ts b/server/dukang-api/src/common/interceptors/response.interceptor.test.ts new file mode 100644 index 0000000..a39def9 --- /dev/null +++ b/server/dukang-api/src/common/interceptors/response.interceptor.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; +import { skipWecomPluginResponseWrap } from './response.interceptor'; + +describe('skipWecomPluginResponseWrap', () => { + it('skips OpenAPI and MCP JSON-RPC paths', () => { + expect(skipWecomPluginResponseWrap('/api/v1/wecom/plugin/openapi.json')).toBe(true); + expect(skipWecomPluginResponseWrap('/api/v1/wecom/plugin/mcp')).toBe(true); + expect(skipWecomPluginResponseWrap('/api/v1/wecom/plugin/mcp?x=1')).toBe(true); + expect(skipWecomPluginResponseWrap('/api/v1/wecom/plugin/metrics')).toBe(false); + }); +}); diff --git a/server/dukang-api/src/common/interceptors/response.interceptor.ts b/server/dukang-api/src/common/interceptors/response.interceptor.ts index 2d644b0..2ee4a69 100644 --- a/server/dukang-api/src/common/interceptors/response.interceptor.ts +++ b/server/dukang-api/src/common/interceptors/response.interceptor.ts @@ -6,9 +6,9 @@ import { } from '@nestjs/common'; import { Observable, map } from 'rxjs'; -function skipResponseWrap(url?: string): boolean { +export function skipWecomPluginResponseWrap(url?: string): boolean { const path = (url || '').split('?')[0]; - return path.endsWith('/wecom/plugin/openapi.json'); + return path.endsWith('/wecom/plugin/openapi.json') || path.includes('/wecom/plugin/mcp'); } @Injectable() @@ -16,7 +16,7 @@ export class ResponseInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const req = context.switchToHttp().getRequest<{ originalUrl?: string; url?: string }>(); const res = context.switchToHttp().getResponse<{ headersSent?: boolean }>(); - if (skipResponseWrap(req.originalUrl || req.url)) { + if (skipWecomPluginResponseWrap(req.originalUrl || req.url)) { return next.handle(); } return next.handle().pipe( diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.controller.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.controller.ts new file mode 100644 index 0000000..e0d0170 --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Delete, Get, Post, Req, Res, UseGuards } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { CurrentWecomPlugin } from './wecom-plugin.decorators'; +import { WecomPluginGuard } from './wecom-plugin.guard'; +import { WecomPluginMcpFactory } from './wecom-plugin-mcp.factory'; +import type { WecomPluginRuntime } from './wecom-plugin.types'; + +@Controller('wecom/plugin/mcp') +@UseGuards(WecomPluginGuard) +export class WecomPluginMcpController { + constructor(private readonly mcp: WecomPluginMcpFactory) {} + + @Post() + post(@CurrentWecomPlugin() plugin: WecomPluginRuntime, @Req() req: Request, @Res() res: Response) { + return this.mcp.handle(req, res, plugin); + } + + @Get() + get(@CurrentWecomPlugin() plugin: WecomPluginRuntime, @Req() req: Request, @Res() res: Response) { + return this.mcp.handle(req, res, plugin); + } + + @Delete() + delete(@CurrentWecomPlugin() plugin: WecomPluginRuntime, @Req() req: Request, @Res() res: Response) { + return this.mcp.handle(req, res, plugin); + } +} diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.factory.test.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.factory.test.ts new file mode 100644 index 0000000..bcf2146 --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.factory.test.ts @@ -0,0 +1,63 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { describe, expect, it, vi } from 'vitest'; +import { createWecomPluginMcpServer, WecomPluginMcpFactory } from './wecom-plugin-mcp.factory'; +import type { WecomPluginQueryService } from './wecom-plugin-query.service'; +import type { WecomPluginRuntime } from './wecom-plugin.types'; + +const plugin: WecomPluginRuntime = { + id: '9', + name: 'ops-readonly', + permissions: ['order.read', 'metrics.read'], +}; + +async function listen(server: http.Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + return `http://127.0.0.1:${port}/mcp`; +} + +describe('WecomPluginMcpFactory Streamable HTTP', () => { + it('lists only permitted tools and calls query_orders', async () => { + const query = { + queryOrders: vi.fn().mockResolvedValue({ total: 1, items: [{ orderNo: 'DK1' }] }), + } as unknown as WecomPluginQueryService; + const factory = new WecomPluginMcpFactory(query); + const httpServer = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + (req as http.IncomingMessage & { body?: unknown }).body = raw ? JSON.parse(raw) : undefined; + void factory.handle(req, res, plugin); + }); + }); + + const url = await listen(httpServer); + const client = new Client({ name: 'wecom-plugin-test', version: '1.0.0' }); + const transport = new StreamableHTTPClientTransport(new URL(url)); + try { + await client.connect(transport); + const listed = await client.listTools(); + expect(listed.tools.map((t) => t.name).sort()).toEqual(['query_metrics', 'query_orders']); + const called = await client.callTool({ name: 'query_orders', arguments: { q: 'DK' } }); + expect(called.isError).toBeFalsy(); + const text = called.content.find((c) => c.type === 'text'); + expect(text && 'text' in text ? JSON.parse(text.text) : null).toEqual({ + total: 1, + items: [{ orderNo: 'DK1' }], + }); + } finally { + await client.close().catch(() => undefined); + await new Promise((resolve, reject) => httpServer.close((err) => (err ? reject(err) : resolve()))); + } + }); + + it('createWecomPluginMcpServer does not register unauthorized tools', () => { + const query = {} as WecomPluginQueryService; + const server = createWecomPluginMcpServer(query, plugin, 'alice'); + expect(server).toBeTruthy(); + }); +}); diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.factory.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.factory.ts new file mode 100644 index 0000000..6387c14 --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.factory.ts @@ -0,0 +1,71 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { allowedWecomPluginMcpTools } from '@dukang/shared-types'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { WECOM_PLUGIN_MCP_TOOL_DEFS } from './wecom-plugin-mcp.tools'; +import type { WecomPluginQueryService } from './wecom-plugin-query.service'; +import type { WecomPluginRuntime } from './wecom-plugin.types'; +import { wecomPluginCaller } from './wecom-plugin.util'; + +export function createWecomPluginMcpServer( + query: WecomPluginQueryService, + plugin: WecomPluginRuntime, + wecomUserId: string, +): McpServer { + const server = new McpServer({ + name: plugin.name || 'dukang-wecom-plugin', + version: '1.0.0', + }); + const allowed = new Set(allowedWecomPluginMcpTools(plugin.permissions)); + const ctx = { query, plugin, wecomUserId }; + for (const def of WECOM_PLUGIN_MCP_TOOL_DEFS) { + if (!allowed.has(def.name)) continue; + server.registerTool( + def.name, + { title: def.name, description: def.description, inputSchema: def.inputSchema }, + async (args) => def.invoke(ctx, (args ?? {}) as Record), + ); + } + return server; +} + +@Injectable() +export class WecomPluginMcpFactory { + private readonly logger = new Logger(WecomPluginMcpFactory.name); + + constructor(private readonly query: WecomPluginQueryService) {} + + async handle( + req: IncomingMessage & { body?: unknown; headers: IncomingMessage['headers'] }, + res: ServerResponse, + plugin: WecomPluginRuntime, + ): Promise { + const server = createWecomPluginMcpServer(this.query, plugin, wecomPluginCaller(req)); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + res.on('close', () => { + void transport.close(); + void server.close(); + }); + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + this.logger.error(`wecom plugin MCP failed: ${message}`); + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32603, message }, + id: null, + }), + ); + } + } + } +} diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.tools.test.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.tools.test.ts new file mode 100644 index 0000000..3f40e9a --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.tools.test.ts @@ -0,0 +1,46 @@ +import { BadRequestException } from '@nestjs/common'; +import { WECOM_PLUGIN_MCP_TOOL_NAMES } from '@dukang/shared-types'; +import { describe, expect, it, vi } from 'vitest'; +import { WECOM_PLUGIN_MCP_TOOL_DEFS } from './wecom-plugin-mcp.tools'; +import type { WecomPluginQueryService } from './wecom-plugin-query.service'; +import type { WecomPluginRuntime } from './wecom-plugin.types'; + +const plugin: WecomPluginRuntime = { + id: '1', + name: 'ops', + permissions: ['order.read', 'metrics.read'], +}; + +function textOf(result: { content: Array<{ type: string; text?: string }> }): unknown { + const part = result.content[0]; + expect(part?.type).toBe('text'); + return JSON.parse(String(part?.text)); +} + +describe('WECOM_PLUGIN_MCP_TOOL_DEFS', () => { + it('covers the shared-types catalog 1:1', () => { + expect(WECOM_PLUGIN_MCP_TOOL_DEFS.map((d) => d.name).sort()).toEqual([...WECOM_PLUGIN_MCP_TOOL_NAMES].sort()); + }); + + it('returns raw JSON without REST envelope', async () => { + const query = { + queryOrders: vi.fn().mockResolvedValue({ total: 1, items: [{ orderNo: 'DK1' }] }), + } as unknown as WecomPluginQueryService; + const def = WECOM_PLUGIN_MCP_TOOL_DEFS.find((d) => d.name === 'query_orders'); + expect(def).toBeTruthy(); + const result = await def!.invoke({ query, plugin, wecomUserId: 'alice' }, { q: 'DK' }); + expect(result.isError).toBeFalsy(); + expect(textOf(result)).toEqual({ total: 1, items: [{ orderNo: 'DK1' }] }); + expect(query.queryOrders).toHaveBeenCalledWith(plugin, 'alice', 'DK', undefined, undefined); + }); + + it('maps HttpException to isError text', async () => { + const query = { + queryOrders: vi.fn().mockRejectedValue(new BadRequestException('请提供查询关键词 q')), + } as unknown as WecomPluginQueryService; + const def = WECOM_PLUGIN_MCP_TOOL_DEFS.find((d) => d.name === 'query_orders'); + const result = await def!.invoke({ query, plugin, wecomUserId: 'alice' }, {}); + expect(result.isError).toBe(true); + expect(result.content[0]).toMatchObject({ type: 'text', text: '请提供查询关键词 q' }); + }); +}); diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.tools.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.tools.ts new file mode 100644 index 0000000..49250ec --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin-mcp.tools.ts @@ -0,0 +1,204 @@ +import { WECOM_PLUGIN_MCP_TOOL_META, type WecomPluginMcpToolName } from '@dukang/shared-types'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { wecomPluginMcpErrorText, wecomPluginQueryArg } from './wecom-plugin.util'; +import type { WecomPluginQueryService } from './wecom-plugin-query.service'; +import type { WecomPluginRuntime } from './wecom-plugin.types'; + +const q = (description: string) => z.string().describe(description); +const page = z.number().int().min(1).optional().describe('页码,默认 1'); +const pageSize = z.number().int().min(1).max(10).optional().describe('每页条数,默认 5,最大 10'); +const from = z.string().optional().describe('起始日期 YYYY-MM-DD 或 ISO'); +const to = z.string().optional().describe('结束日期 YYYY-MM-DD 或 ISO(含当天)'); +const auditStatus = z + .enum(['PENDING', 'APPROVED', 'REJECTED']) + .optional() + .describe('审核状态,默认 PENDING'); + +export type WecomPluginMcpToolContext = { + query: WecomPluginQueryService; + plugin: WecomPluginRuntime; + wecomUserId: string; +}; + +async function jsonResult(run: () => Promise): Promise { + try { + const data = await run(); + return { content: [{ type: 'text', text: JSON.stringify(data) }] }; + } catch (e) { + return { content: [{ type: 'text', text: wecomPluginMcpErrorText(e) }], isError: true }; + } +} + +type ToolDef = { + name: WecomPluginMcpToolName; + description: string; + inputSchema: Record; + invoke: (ctx: WecomPluginMcpToolContext, args: Record) => Promise; +}; + +function str(args: Record, key: string): string | undefined { + return wecomPluginQueryArg(args[key] as string | number | undefined); +} + +export const WECOM_PLUGIN_MCP_TOOL_DEFS: ToolDef[] = [ + { + name: 'query_orders', + description: WECOM_PLUGIN_MCP_TOOL_META.query_orders.description, + inputSchema: { q: q('订单号,如 DK20260903xxxx'), page, pageSize }, + invoke: (ctx, args) => + jsonResult(() => ctx.query.queryOrders(ctx.plugin, ctx.wecomUserId, str(args, 'q'), str(args, 'page'), str(args, 'pageSize'))), + }, + { + name: 'query_users', + description: WECOM_PLUGIN_MCP_TOOL_META.query_users.description, + inputSchema: { q: q('用户号或 11 位手机号'), page, pageSize }, + invoke: (ctx, args) => + jsonResult(() => ctx.query.queryUsers(ctx.plugin, ctx.wecomUserId, str(args, 'q'), str(args, 'page'), str(args, 'pageSize'))), + }, + { + name: 'query_stores', + description: WECOM_PLUGIN_MCP_TOOL_META.query_stores.description, + inputSchema: { q: q('门店名称关键词'), page, pageSize }, + invoke: (ctx, args) => + jsonResult(() => ctx.query.queryStores(ctx.plugin, ctx.wecomUserId, str(args, 'q'), str(args, 'page'), str(args, 'pageSize'))), + }, + { + name: 'query_redeems', + description: WECOM_PLUGIN_MCP_TOOL_META.query_redeems.description, + inputSchema: { q: q('核销单号或门店名'), page, pageSize }, + invoke: (ctx, args) => + jsonResult(() => ctx.query.queryRedeems(ctx.plugin, ctx.wecomUserId, str(args, 'q'), str(args, 'page'), str(args, 'pageSize'))), + }, + { + name: 'query_promo_codes', + description: WECOM_PLUGIN_MCP_TOOL_META.query_promo_codes.description, + inputSchema: { q: q('推广码或名称'), page, pageSize }, + invoke: (ctx, args) => + jsonResult(() => + ctx.query.queryPromoCodes(ctx.plugin, ctx.wecomUserId, str(args, 'q'), str(args, 'page'), str(args, 'pageSize')), + ), + }, + { + name: 'query_promo_code_stats', + description: WECOM_PLUGIN_MCP_TOOL_META.query_promo_code_stats.description, + inputSchema: { code: q('推广码 code') }, + invoke: (ctx, args) => jsonResult(() => ctx.query.queryPromoCodeStats(ctx.plugin, ctx.wecomUserId, str(args, 'code'))), + }, + { + name: 'query_metrics', + description: WECOM_PLUGIN_MCP_TOOL_META.query_metrics.description, + inputSchema: { + kind: z.enum(['today', 'daily', 'weekly', 'monthly']).optional().describe('默认 today'), + }, + invoke: (ctx, args) => jsonResult(() => ctx.query.queryMetrics(ctx.plugin, ctx.wecomUserId, str(args, 'kind'))), + }, + { + name: 'query_store_audits', + description: WECOM_PLUGIN_MCP_TOOL_META.query_store_audits.description, + inputSchema: { q: z.string().optional().describe('门店名称'), status: auditStatus, page, pageSize }, + invoke: (ctx, args) => + jsonResult(() => + ctx.query.queryStoreAudits( + ctx.plugin, + ctx.wecomUserId, + str(args, 'q'), + str(args, 'status'), + str(args, 'page'), + str(args, 'pageSize'), + ), + ), + }, + { + name: 'query_store_info_audits', + description: WECOM_PLUGIN_MCP_TOOL_META.query_store_info_audits.description, + inputSchema: { status: auditStatus, page, pageSize }, + invoke: (ctx, args) => + jsonResult(() => + ctx.query.queryStoreInfoAudits(ctx.plugin, ctx.wecomUserId, str(args, 'status'), str(args, 'page'), str(args, 'pageSize')), + ), + }, + { + name: 'query_store_info_audit_detail', + description: WECOM_PLUGIN_MCP_TOOL_META.query_store_info_audit_detail.description, + inputSchema: { id: q('信息变更审核 ID') }, + invoke: (ctx, args) => + jsonResult(() => ctx.query.queryStoreInfoAuditDetail(ctx.plugin, ctx.wecomUserId, str(args, 'id') ?? '')), + }, + { + name: 'query_store_package_audits', + description: WECOM_PLUGIN_MCP_TOOL_META.query_store_package_audits.description, + inputSchema: { status: auditStatus, page, pageSize }, + invoke: (ctx, args) => + jsonResult(() => + ctx.query.queryStorePackageAudits(ctx.plugin, ctx.wecomUserId, str(args, 'status'), str(args, 'page'), str(args, 'pageSize')), + ), + }, + { + name: 'query_store_package_audit_detail', + description: WECOM_PLUGIN_MCP_TOOL_META.query_store_package_audit_detail.description, + inputSchema: { id: q('套餐审核 ID') }, + invoke: (ctx, args) => + jsonResult(() => ctx.query.queryStorePackageAuditDetail(ctx.plugin, ctx.wecomUserId, str(args, 'id') ?? '')), + }, + { + name: 'query_partners', + description: WECOM_PLUGIN_MCP_TOOL_META.query_partners.description, + inputSchema: { q: q('合伙人关键词'), page, pageSize }, + invoke: (ctx, args) => + jsonResult(() => + ctx.query.queryPartners(ctx.plugin, ctx.wecomUserId, str(args, 'q'), str(args, 'page'), str(args, 'pageSize')), + ), + }, + { + name: 'query_partner_users', + description: WECOM_PLUGIN_MCP_TOOL_META.query_partner_users.description, + inputSchema: { partnerId: q('合伙人 ID'), from, to, page, pageSize }, + invoke: (ctx, args) => + jsonResult(() => + ctx.query.queryPartnerUsers( + ctx.plugin, + ctx.wecomUserId, + str(args, 'partnerId') ?? '', + str(args, 'from'), + str(args, 'to'), + str(args, 'page'), + str(args, 'pageSize'), + ), + ), + }, + { + name: 'query_partner_stores', + description: WECOM_PLUGIN_MCP_TOOL_META.query_partner_stores.description, + inputSchema: { partnerId: q('合伙人 ID'), from, to, page, pageSize }, + invoke: (ctx, args) => + jsonResult(() => + ctx.query.queryPartnerStores( + ctx.plugin, + ctx.wecomUserId, + str(args, 'partnerId') ?? '', + str(args, 'from'), + str(args, 'to'), + str(args, 'page'), + str(args, 'pageSize'), + ), + ), + }, + { + name: 'query_partner_orders', + description: WECOM_PLUGIN_MCP_TOOL_META.query_partner_orders.description, + inputSchema: { partnerId: q('合伙人 ID'), from, to, page, pageSize }, + invoke: (ctx, args) => + jsonResult(() => + ctx.query.queryPartnerOrders( + ctx.plugin, + ctx.wecomUserId, + str(args, 'partnerId') ?? '', + str(args, 'from'), + str(args, 'to'), + str(args, 'page'), + str(args, 'pageSize'), + ), + ), + }, +]; diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin-query.service.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin-query.service.ts index 24d1436..64488dc 100644 --- a/server/dukang-api/src/integrations/wecom/wecom-plugin-query.service.ts +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin-query.service.ts @@ -13,7 +13,10 @@ import { type WecomReportStats, } from '@dukang/domain'; import { + WECOM_PLUGIN_MCP_PATH, + WECOM_PLUGIN_MCP_TRANSPORT, WECOM_PLUGIN_TOOL_PATHS, + allowedWecomPluginMcpTools, wecomPluginHasPermission, STORE_INFO_CHANGEABLE_FIELD_LABELS, type StoreInfoChangeableField, @@ -49,10 +52,15 @@ export class WecomPluginQueryService { info(plugin: WecomPluginRuntime) { return { name: plugin.name, - description: '企微智能机器人只读 API 插件。查询订单、用户、门店经营、核销、推广码、经营指标、审核对照与合伙人关联数据。', + description: '企微智能机器人只读插件。查询订单、用户、门店经营、核销、推广码、经营指标、审核对照与合伙人关联数据。', auth: { header: 'X-Api-Key' }, permissions: plugin.permissions, tools: plugin.permissions.flatMap((p) => WECOM_PLUGIN_TOOL_PATHS[p]), + mcp: { + path: WECOM_PLUGIN_MCP_PATH, + transport: WECOM_PLUGIN_MCP_TRANSPORT, + tools: allowedWecomPluginMcpTools(plugin.permissions), + }, }; } diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin.controller.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin.controller.ts index 21a4b5d..7356df8 100644 --- a/server/dukang-api/src/integrations/wecom/wecom-plugin.controller.ts +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin.controller.ts @@ -4,6 +4,7 @@ import { WecomPluginGuard } from './wecom-plugin.guard'; import { WecomPluginQueryService } from './wecom-plugin-query.service'; import { CurrentWecomPlugin } from './wecom-plugin.decorators'; import type { WecomPluginRuntime } from './wecom-plugin.types'; +import { wecomPluginCaller } from './wecom-plugin.util'; @Controller('wecom/plugin') @UseGuards(WecomPluginGuard) @@ -28,7 +29,7 @@ export class WecomPluginController { @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.query.queryOrders(plugin, pluginCaller(req), q, page, pageSize); + return this.query.queryOrders(plugin, wecomPluginCaller(req), q, page, pageSize); } @Get('users') @@ -39,7 +40,7 @@ export class WecomPluginController { @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.query.queryUsers(plugin, pluginCaller(req), q, page, pageSize); + return this.query.queryUsers(plugin, wecomPluginCaller(req), q, page, pageSize); } @Get('stores') @@ -50,7 +51,7 @@ export class WecomPluginController { @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.query.queryStores(plugin, pluginCaller(req), q, page, pageSize); + return this.query.queryStores(plugin, wecomPluginCaller(req), q, page, pageSize); } @Get('store-audits') @@ -62,7 +63,7 @@ export class WecomPluginController { @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.query.queryStoreAudits(plugin, pluginCaller(req), q, status, page, pageSize); + return this.query.queryStoreAudits(plugin, wecomPluginCaller(req), q, status, page, pageSize); } @Get('store-info-audits') @@ -73,7 +74,7 @@ export class WecomPluginController { @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.query.queryStoreInfoAudits(plugin, pluginCaller(req), status, page, pageSize); + return this.query.queryStoreInfoAudits(plugin, wecomPluginCaller(req), status, page, pageSize); } @Get('store-info-audits/:id') @@ -82,7 +83,7 @@ export class WecomPluginController { @Req() req: Request, @Param('id') id: string, ) { - return this.query.queryStoreInfoAuditDetail(plugin, pluginCaller(req), id); + return this.query.queryStoreInfoAuditDetail(plugin, wecomPluginCaller(req), id); } @Get('store-package-audits') @@ -93,7 +94,7 @@ export class WecomPluginController { @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.query.queryStorePackageAudits(plugin, pluginCaller(req), status, page, pageSize); + return this.query.queryStorePackageAudits(plugin, wecomPluginCaller(req), status, page, pageSize); } @Get('store-package-audits/:id') @@ -102,7 +103,7 @@ export class WecomPluginController { @Req() req: Request, @Param('id') id: string, ) { - return this.query.queryStorePackageAuditDetail(plugin, pluginCaller(req), id); + return this.query.queryStorePackageAuditDetail(plugin, wecomPluginCaller(req), id); } @Get('redeems') @@ -113,7 +114,7 @@ export class WecomPluginController { @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.query.queryRedeems(plugin, pluginCaller(req), q, page, pageSize); + return this.query.queryRedeems(plugin, wecomPluginCaller(req), q, page, pageSize); } @Get('promo-codes') @@ -124,7 +125,7 @@ export class WecomPluginController { @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.query.queryPromoCodes(plugin, pluginCaller(req), q, page, pageSize); + return this.query.queryPromoCodes(plugin, wecomPluginCaller(req), q, page, pageSize); } @Get('promo-codes/:code/stats') @@ -133,7 +134,7 @@ export class WecomPluginController { @Req() req: Request, @Param('code') code: string, ) { - return this.query.queryPromoCodeStats(plugin, pluginCaller(req), code); + return this.query.queryPromoCodeStats(plugin, wecomPluginCaller(req), code); } @Get('partners') @@ -144,7 +145,7 @@ export class WecomPluginController { @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.query.queryPartners(plugin, pluginCaller(req), q, page, pageSize); + return this.query.queryPartners(plugin, wecomPluginCaller(req), q, page, pageSize); } @Get('partners/:partnerId/users') @@ -159,7 +160,7 @@ export class WecomPluginController { ) { return this.query.queryPartnerUsers( plugin, - pluginCaller(req), + wecomPluginCaller(req), partnerId, from, to, @@ -180,7 +181,7 @@ export class WecomPluginController { ) { return this.query.queryPartnerStores( plugin, - pluginCaller(req), + wecomPluginCaller(req), partnerId, from, to, @@ -201,7 +202,7 @@ export class WecomPluginController { ) { return this.query.queryPartnerOrders( plugin, - pluginCaller(req), + wecomPluginCaller(req), partnerId, from, to, @@ -216,12 +217,6 @@ export class WecomPluginController { @Req() req: Request, @Query('kind') kind?: string, ) { - return this.query.queryMetrics(plugin, pluginCaller(req), kind); + return this.query.queryMetrics(plugin, wecomPluginCaller(req), kind); } } - -function pluginCaller(req: Request): string { - const raw = req.headers['x-wecom-userid'] ?? req.headers['userid']; - const v = Array.isArray(raw) ? raw[0] : raw; - return String(v || 'plugin').trim() || 'plugin'; -} diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin.util.test.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin.util.test.ts new file mode 100644 index 0000000..783544c --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin.util.test.ts @@ -0,0 +1,26 @@ +import { BadRequestException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { wecomPluginCaller, wecomPluginMcpErrorText, wecomPluginQueryArg } from './wecom-plugin.util'; + +describe('wecomPluginCaller', () => { + it('prefers X-WeCom-User-Id', () => { + expect(wecomPluginCaller({ headers: { 'x-wecom-userid': 'alice', userid: 'bob' } })).toBe('alice'); + }); + + it('falls back to plugin', () => { + expect(wecomPluginCaller({ headers: {} })).toBe('plugin'); + }); +}); + +describe('wecomPluginMcpErrorText', () => { + it('unwraps Nest HttpException message', () => { + expect(wecomPluginMcpErrorText(new BadRequestException('请提供查询关键词 q'))).toBe('请提供查询关键词 q'); + }); +}); + +describe('wecomPluginQueryArg', () => { + it('stringifies numbers and drops empty', () => { + expect(wecomPluginQueryArg(2)).toBe('2'); + expect(wecomPluginQueryArg(' ')).toBeUndefined(); + }); +}); diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin.util.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin.util.ts new file mode 100644 index 0000000..2c53ce9 --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin.util.ts @@ -0,0 +1,26 @@ +import { HttpException } from '@nestjs/common'; + +export function wecomPluginCaller(req: { headers: Record }): string { + const raw = req.headers['x-wecom-userid'] ?? req.headers['userid']; + const v = Array.isArray(raw) ? raw[0] : raw; + return String(v || 'plugin').trim() || 'plugin'; +} + +export function wecomPluginMcpErrorText(e: unknown): string { + if (e instanceof HttpException) { + const res = e.getResponse(); + if (typeof res === 'string') return res; + if (res && typeof res === 'object' && 'message' in res) { + const message = (res as { message?: string | string[] }).message; + return Array.isArray(message) ? message.join(', ') : String(message ?? e.message); + } + return e.message; + } + return e instanceof Error ? e.message : String(e); +} + +export function wecomPluginQueryArg(v?: string | number): string | undefined { + if (v == null) return undefined; + const s = String(v).trim(); + return s ? s : undefined; +} diff --git a/server/dukang-api/src/integrations/wecom/wecom.module.ts b/server/dukang-api/src/integrations/wecom/wecom.module.ts index 74ae012..3bf2686 100644 --- a/server/dukang-api/src/integrations/wecom/wecom.module.ts +++ b/server/dukang-api/src/integrations/wecom/wecom.module.ts @@ -14,6 +14,8 @@ import { WecomBotSessionService } from './wecom-bot-session.service'; import { WecomPluginAuthService } from './wecom-plugin-auth.service'; import { WecomPluginController } from './wecom-plugin.controller'; import { WecomPluginGuard } from './wecom-plugin.guard'; +import { WecomPluginMcpController } from './wecom-plugin-mcp.controller'; +import { WecomPluginMcpFactory } from './wecom-plugin-mcp.factory'; import { WecomPluginQueryService } from './wecom-plugin-query.service'; /** 企微多机器人:依赖 Common(工单)+ Settlement/DevPlan + Promo + Integrations(短信)+ Llm */ @@ -26,7 +28,7 @@ import { WecomPluginQueryService } from './wecom-plugin-query.service'; PromoModule, LlmModule, ], - controllers: [WecomPluginController], + controllers: [WecomPluginController, WecomPluginMcpController], providers: [ WecomBotSessionService, WecomBotAuditService, @@ -37,6 +39,7 @@ import { WecomPluginQueryService } from './wecom-plugin-query.service'; WecomPluginAuthService, WecomPluginGuard, WecomPluginQueryService, + WecomPluginMcpFactory, ], exports: [WecomAibotService, WecomBotAuditService], })