@leadfund/lead-mcp 0.0.0-stage → 202610.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +187 -2
- package/package.json +38 -4
- package/src/cli.js +26 -0
- package/src/client.js +182 -0
- package/src/config.js +194 -0
- package/src/logger.js +64 -0
- package/src/server.js +190 -0
- package/test/config.test.mjs +98 -0
- package/test/smoke.mjs +348 -0
package/README.md
CHANGED
|
@@ -1,3 +1,188 @@
|
|
|
1
|
-
#
|
|
1
|
+
# lead-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
利得零售系统 MCP Server —— 客户账户信息**只读**查询。
|
|
4
|
+
|
|
5
|
+
以 stdio 传输向 MCP Client(Claude Code、Claude Desktop 等)暴露工具,
|
|
6
|
+
工具的真实实现在 dmmp-system(系统 id 275)的 `/ai/**` 接口上,本项目只做协议适配。
|
|
7
|
+
|
|
8
|
+
- 纯 JavaScript(ESM)+ JSDoc,**无构建步骤**,装完即用
|
|
9
|
+
- 只提供只读工具,不含任何写操作
|
|
10
|
+
- 日志一律走 stderr(stdio 传输下 stdout 是 JSON-RPC 通道)
|
|
11
|
+
|
|
12
|
+
## 工具
|
|
13
|
+
|
|
14
|
+
| 工具 | 说明 |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `dmmp_query_account` | 根据手机号或身份证号查询客户账户信息,网点号可选 |
|
|
17
|
+
|
|
18
|
+
**入参**
|
|
19
|
+
|
|
20
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
21
|
+
|---|---|---|---|
|
|
22
|
+
| `mobile` | string | 与 `idNo` / `memberId` 至少提供一项 | 完整手机号,11 位且以 1 开头 |
|
|
23
|
+
| `idNo` | string | 与 `mobile` / `memberId` 至少提供一项 | 完整身份证号,15 位或 18 位 |
|
|
24
|
+
| `ownerNet` | string | 否 | 网点号,传入后仅返回该网点下的客户 |
|
|
25
|
+
| `memberId` | string | 否 | 会员号,命中多个会员时确认后带上再查 |
|
|
26
|
+
|
|
27
|
+
**返回**:会员 id、姓名、手机号(脱敏)、证件类型、证件号码(脱敏)、证件有效期。
|
|
28
|
+
|
|
29
|
+
### 一号多会员
|
|
30
|
+
|
|
31
|
+
一个手机号/证件号可能对应多个会员(MES 的 `T_MES_BASE.REG_ACCOUNT` 不唯一)。
|
|
32
|
+
|
|
33
|
+
命中多个时后端只返回候选会员号,**不返回任何档案字段**,避免臆断。
|
|
34
|
+
本 Server 会转成一段明确的确认提示:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
该手机号/证件号在系统中匹配到多个会员,无法确定具体是哪一个。
|
|
38
|
+
请让用户确认需要查询哪一个会员,然后带上 memberId 重新查询,不要自行选择其中一个。
|
|
39
|
+
候选会员号:
|
|
40
|
+
- M20001
|
|
41
|
+
- M20002
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 网点号的语义
|
|
45
|
+
|
|
46
|
+
传入网点号后结果唯一 —— 手机号/证件号在该网点下要么查到唯一账户、要么查不到。
|
|
47
|
+
查不到时的提示会区分两种情况:
|
|
48
|
+
|
|
49
|
+
| 场景 | 提示 |
|
|
50
|
+
|---|---|
|
|
51
|
+
| 未传网点号 | 未查询到该客户信息。 |
|
|
52
|
+
| 传了网点号 | 未查询到该客户信息。该手机号/证件号在网点 {ownerNet} 下没有账户,可能该客户不属于该网点。 |
|
|
53
|
+
|
|
54
|
+
### 错误语义(重要)
|
|
55
|
+
|
|
56
|
+
| 场景 | MCP 返回 | 模型应有的行为 |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| 查不到客户 | `content`,正文为「未查询到该客户信息。」 | 直接回答未查询到,**禁止推测** |
|
|
59
|
+
| 命中多个会员 | `content`,正文为候选会员号清单 | 请用户确认后带 `memberId` 重查,**不要自行挑一个** |
|
|
60
|
+
| 参数不合法 | `isError: true` | 向用户澄清需要手机号、身份证号或会员号 |
|
|
61
|
+
| 接口失败 / 超时 | `isError: true` | 向用户说明查询失败,不要编造数据 |
|
|
62
|
+
|
|
63
|
+
「查不到」和「命中多个」刻意**不**作为错误返回:一旦标记成错误,模型会认为工具故障并自行编造客户资产。
|
|
64
|
+
|
|
65
|
+
## 安装
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm install
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
发布到私有 registry 后即可用 `npx` 直接拉起,无需本地安装。
|
|
72
|
+
|
|
73
|
+
## 接入 Claude Code
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
claude mcp add-json lead-mcp '{
|
|
77
|
+
"command": "npx",
|
|
78
|
+
"args": ["-y", "@leadfund/lead-mcp", "--env", "sit", "--operator", "E10086"]
|
|
79
|
+
}'
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 环境
|
|
83
|
+
|
|
84
|
+
| 环境 | 服务地址 |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `local` | `http://localhost:8081` |
|
|
87
|
+
| `sit` | `http://10.1.1.197:8081` |
|
|
88
|
+
| `prd` | `http://10.32.30.208:8081`、`http://10.32.30.209:8081` |
|
|
89
|
+
|
|
90
|
+
- 默认环境是 `local` —— 打错环境名不至于直接连到生产。
|
|
91
|
+
- **未知环境名直接报错退出**,不会静默回落到默认环境。
|
|
92
|
+
- 生产是双节点:只有网络错误、超时、5xx 这类「节点不可用」才会自动切到下一个节点;
|
|
93
|
+
查不到、参数错误这类业务失败不切换,直接返回。
|
|
94
|
+
- 上次成功的节点会被记住并优先使用,避免每次都从第一个节点试探。
|
|
95
|
+
|
|
96
|
+
配置项:
|
|
97
|
+
|
|
98
|
+
| 配置 | 来源 | 默认值 |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| `--env` / `LEAD_MCP_ENV` | 命令行 / 环境变量 | `local` |
|
|
101
|
+
| `--base-url` / `LEAD_MCP_BASE_URL` | 命令行 / 环境变量 | 无(用 `--env` 的地址) |
|
|
102
|
+
| `--timeout` / `LEAD_MCP_TIMEOUT_MS` | 命令行 / 环境变量 | `10000` |
|
|
103
|
+
| `--token` / `LEAD_MCP_TOKEN` | 命令行 / 环境变量 | 无 |
|
|
104
|
+
| `--operator` / `LEAD_MCP_OPERATOR` | 命令行 / 环境变量 | 无配置时退化为操作系统账号 |
|
|
105
|
+
|
|
106
|
+
> `--base-url` 保留,且**优先级高于 `--env`**,逗号分隔多个地址时同样按顺序故障转移:
|
|
107
|
+
> `--base-url http://a:8081,http://b:8081`
|
|
108
|
+
|
|
109
|
+
> 密钥类配置请走环境变量,不要写进 MCP 配置文件——`claude mcp add-json` 的内容会明文落到 `~/.claude.json`。
|
|
110
|
+
|
|
111
|
+
### 调用方身份与环境
|
|
112
|
+
|
|
113
|
+
MCP 协议本身不传递登录身份,本 Server 会把操作人信息与环境标识放进请求头带给后端:
|
|
114
|
+
|
|
115
|
+
| 请求头 | 来源 | 用途 |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `X-Operator` | `--operator` / `LEAD_MCP_OPERATOR`,未配置时为操作系统账号 | 服务端限流维度 + 审计记录 |
|
|
118
|
+
| `X-Operator-Os` | 操作系统账号(`os.userInfo().username`) | 便于后端把 OS 账号二次解析成工号 |
|
|
119
|
+
| `X-Lead-Env` | 当前环境(`local` / `sit` / `prd` / `custom`) | 审计里区分查询打在哪个环境 |
|
|
120
|
+
|
|
121
|
+
**建议显式配置工号**,否则审计里只能看到操作系统账号:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
claude mcp add-json lead-mcp '{
|
|
125
|
+
"command": "npx",
|
|
126
|
+
"args": ["-y", "@leadfund/lead-mcp", "--env", "sit", "--operator", "E10086"]
|
|
127
|
+
}'
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## 本地调试
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
node src/cli.js --help
|
|
134
|
+
|
|
135
|
+
# 可视化调试
|
|
136
|
+
npx @modelcontextprotocol/inspector node src/cli.js --base-url http://127.0.0.1:8081
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 测试
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
npm test # 配置解析单元测试 + 端到端冒烟测试
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- `test/config.test.mjs` —— 环境选择、地址优先级、未知环境报错等纯逻辑
|
|
146
|
+
- `test/smoke.mjs` —— 拉起 mock 的 dmmp-system,再以子进程方式拉起真实的 MCP Server,
|
|
147
|
+
走完整协议握手,覆盖命中 / 未命中 / 多会员 / 参数非法 / 下游异常 / 操作人透传 /
|
|
148
|
+
环境透传 / **节点故障转移**
|
|
149
|
+
|
|
150
|
+
## 与后端的关系
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
Claude Code
|
|
154
|
+
│ stdio (JSON-RPC)
|
|
155
|
+
▼
|
|
156
|
+
lead-mcp (Node/JS) ← 本项目
|
|
157
|
+
│ HTTP POST /ai/account/query
|
|
158
|
+
▼
|
|
159
|
+
dmmp-system (系统 id 275) ← 业务真源:鉴权、白名单、脱敏、审计
|
|
160
|
+
│ Dubbo
|
|
161
|
+
▼
|
|
162
|
+
MES MemberQueryFacade
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
工具清单、参数 Schema、脱敏规则以 dmmp-system 侧
|
|
166
|
+
`dmmp-system/src/main/java/com/lead/system/controller/ai/README.md` 为准。
|
|
167
|
+
|
|
168
|
+
## 目录结构
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
lead-mcp/
|
|
172
|
+
├── package.json
|
|
173
|
+
├── src/
|
|
174
|
+
│ ├── cli.js # 入口,stdio 传输
|
|
175
|
+
│ ├── server.js # MCP Server 与工具注册
|
|
176
|
+
│ ├── client.js # dmmp-system HTTP 客户端
|
|
177
|
+
│ ├── config.js # 配置解析
|
|
178
|
+
│ └── logger.js # stderr 日志
|
|
179
|
+
└── test/
|
|
180
|
+
└── smoke.mjs # 端到端冒烟测试
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## 约束
|
|
184
|
+
|
|
185
|
+
- **只读**:本 Server 只提供查询工具,不含任何写操作。
|
|
186
|
+
- **日志走 stderr**:写 stdout 会破坏 JSON-RPC 协议。
|
|
187
|
+
- **脱敏在服务端完成**:本项目不做脱敏,直接透传后端已脱敏的结果。
|
|
188
|
+
- **限流在服务端做**:客户端侧计数可被绕过,服务端按「操作人」和「查询目标」两个维度限流。
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leadfund/lead-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "202610.1.0",
|
|
4
|
+
"description": "利得零售系统 MCP Server —— 客户账户信息只读查询",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lead-mcp": "./src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/cli.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"test",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node src/cli.js",
|
|
20
|
+
"inspector": "npx @modelcontextprotocol/inspector node src/cli.js",
|
|
21
|
+
"test": "node test/config.test.mjs && node test/smoke.mjs",
|
|
22
|
+
"smoke": "node test/smoke.mjs",
|
|
23
|
+
"release": "npm test && npm publish --registry=https://registry.npmjs.org/ --access public"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"registry": "https://registry.npmjs.org/"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"mcp",
|
|
31
|
+
"model-context-protocol",
|
|
32
|
+
"leadbank",
|
|
33
|
+
"lead-mcp"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
38
|
+
"zod": "^3.25.76 || ^4.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { USAGE, isHelpRequested, loadConfig } from "./config.js";
|
|
4
|
+
import { logger } from "./logger.js";
|
|
5
|
+
import { createServer } from "./server.js";
|
|
6
|
+
|
|
7
|
+
async function main() {
|
|
8
|
+
if (isHelpRequested()) {
|
|
9
|
+
// --help 是给人看的,走 stdout;正常运行期 stdout 属于 JSON-RPC 通道
|
|
10
|
+
process.stdout.write(USAGE);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const config = loadConfig();
|
|
15
|
+
const server = createServer(config);
|
|
16
|
+
const transport = new StdioServerTransport();
|
|
17
|
+
await server.connect(transport);
|
|
18
|
+
logger.info(
|
|
19
|
+
`已启动,环境=${config.env},服务地址=${config.baseUrls.join(", ")},超时=${config.timeoutMs}ms`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
main().catch((error) => {
|
|
24
|
+
logger.error("启动失败", error);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
});
|
package/src/client.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* dmmp-system 账户信息查询接口
|
|
5
|
+
*
|
|
6
|
+
* <p>对应 dmmp-system 的 {@code POST /ai/account/query},
|
|
7
|
+
* 返回 {@code DataResult<AiMcpAccountResponse>}。</p>
|
|
8
|
+
*
|
|
9
|
+
* <p>生产环境是双节点,这里按地址顺序做故障转移:只有网络错误、超时、
|
|
10
|
+
* 5xx 这类「节点不可用」才会切到下一个节点;业务失败(查不到、参数错误)
|
|
11
|
+
* 直接返回,不做无谓的重试。</p>
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} AccountProfile
|
|
16
|
+
* @property {boolean} [found] 是否查询到客户
|
|
17
|
+
* @property {string} [memberId] 会员 id,命中多个会员时为空
|
|
18
|
+
* @property {string[]} [memberIdList] 命中的会员 id 列表,长度大于 1 表示命中多个会员
|
|
19
|
+
* @property {string} [name] 姓名
|
|
20
|
+
* @property {string} [mobile] 手机号
|
|
21
|
+
* @property {string} [mobileFormat] 手机号(脱敏)
|
|
22
|
+
* @property {string} [idType] 证件类型
|
|
23
|
+
* @property {string} [idTypeFormat] 证件类型(中文名)
|
|
24
|
+
* @property {string} [idNo] 证件号码
|
|
25
|
+
* @property {string} [idNoFormat] 证件号码(脱敏)
|
|
26
|
+
* @property {string} [idValidity] 证件有效期
|
|
27
|
+
* @property {string} [idValidityFormat] 证件有效期(格式化)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} AccountQueryParams
|
|
32
|
+
* @property {string} [mobile] 完整手机号
|
|
33
|
+
* @property {string} [idNo] 完整身份证号
|
|
34
|
+
* @property {string} [ownerNet] 网点号,可选
|
|
35
|
+
* @property {string} [memberId] 会员号,命中多个会员时确认后带上再查
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {Object} ApiResult
|
|
40
|
+
* @property {number} code
|
|
41
|
+
* @property {string} [message]
|
|
42
|
+
* @property {AccountProfile} [data]
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 上一次成功的服务地址,下次优先尝试
|
|
47
|
+
*
|
|
48
|
+
* <p>避免每次都从第一个节点开始试探,也避免第一个节点故障时把
|
|
49
|
+
* 故障转移的额外延迟带到每次调用上。</p>
|
|
50
|
+
*/
|
|
51
|
+
let preferredBaseUrl = null;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 接口调用异常
|
|
55
|
+
*/
|
|
56
|
+
export class LeadApiError extends Error {
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} message
|
|
59
|
+
* @param {string} [detail]
|
|
60
|
+
* @param {boolean} [failoverable] 是否可以切换到下一个节点重试
|
|
61
|
+
*/
|
|
62
|
+
constructor(message, detail, failoverable = false) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = "LeadApiError";
|
|
65
|
+
/** @type {string|undefined} */
|
|
66
|
+
this.detail = detail;
|
|
67
|
+
/** @type {boolean} */
|
|
68
|
+
this.failoverable = failoverable;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 调整地址顺序,把上次成功的节点放到最前面
|
|
74
|
+
*
|
|
75
|
+
* @param {string[]} baseUrls 地址列表
|
|
76
|
+
* @returns {string[]} 调整后的地址列表
|
|
77
|
+
*/
|
|
78
|
+
function orderBaseUrls(baseUrls) {
|
|
79
|
+
if (!preferredBaseUrl || !baseUrls.includes(preferredBaseUrl)) {
|
|
80
|
+
return baseUrls;
|
|
81
|
+
}
|
|
82
|
+
return [preferredBaseUrl, ...baseUrls.filter((url) => url !== preferredBaseUrl)];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 查询客户账户信息
|
|
87
|
+
*
|
|
88
|
+
* @param {import("./config.js").LeadMcpConfig} config
|
|
89
|
+
* @param {AccountQueryParams} params
|
|
90
|
+
* @returns {Promise<AccountProfile>}
|
|
91
|
+
* @throws {LeadApiError} 所有节点都不可用或接口返回业务错误
|
|
92
|
+
*/
|
|
93
|
+
export async function queryAccount(config, params) {
|
|
94
|
+
const urls = orderBaseUrls(config.baseUrls);
|
|
95
|
+
let lastError = null;
|
|
96
|
+
|
|
97
|
+
for (const baseUrl of urls) {
|
|
98
|
+
try {
|
|
99
|
+
const profile = await requestOnce(config, baseUrl, params);
|
|
100
|
+
preferredBaseUrl = baseUrl;
|
|
101
|
+
return profile;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (!(error instanceof LeadApiError) || !error.failoverable) {
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
lastError = error;
|
|
107
|
+
logger.warn("节点不可用,尝试下一个", { baseUrl, error: error.message });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
throw (
|
|
112
|
+
lastError ??
|
|
113
|
+
new LeadApiError("没有可用的服务地址", undefined, true)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 向单个节点发起一次查询
|
|
119
|
+
*
|
|
120
|
+
* @param {import("./config.js").LeadMcpConfig} config
|
|
121
|
+
* @param {string} baseUrl 服务地址
|
|
122
|
+
* @param {AccountQueryParams} params 查询参数
|
|
123
|
+
* @returns {Promise<AccountProfile>}
|
|
124
|
+
*/
|
|
125
|
+
async function requestOnce(config, baseUrl, params) {
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
128
|
+
|
|
129
|
+
/** @type {Record<string, string>} */
|
|
130
|
+
const headers = { "Content-Type": "application/json" };
|
|
131
|
+
if (config.token) {
|
|
132
|
+
headers.Authorization = `Bearer ${config.token}`;
|
|
133
|
+
}
|
|
134
|
+
// 调用方身份:服务端据此限流与审计,MCP 协议本身不传递登录身份
|
|
135
|
+
if (config.operator) {
|
|
136
|
+
headers["X-Operator"] = config.operator;
|
|
137
|
+
}
|
|
138
|
+
if (config.operatorOs) {
|
|
139
|
+
headers["X-Operator-Os"] = config.operatorOs;
|
|
140
|
+
}
|
|
141
|
+
// 环境标识:服务端审计需要区分查询打在哪个环境(生产数据尤其敏感)
|
|
142
|
+
headers["X-Lead-Env"] = config.env;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const response = await fetch(`${baseUrl}/ai/account/query`, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers,
|
|
148
|
+
body: JSON.stringify(params),
|
|
149
|
+
signal: controller.signal,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
// 5xx 视为节点故障可切换,4xx 是请求本身有问题,换节点也没用
|
|
154
|
+
const failoverable = response.status >= 500;
|
|
155
|
+
throw new LeadApiError(
|
|
156
|
+
`接口返回 HTTP ${response.status}`,
|
|
157
|
+
`${response.status} ${response.statusText}`,
|
|
158
|
+
failoverable,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const body = /** @type {ApiResult} */ (await response.json());
|
|
163
|
+
if (body.code !== 0) {
|
|
164
|
+
throw new LeadApiError(body.message || `接口返回错误码 ${body.code}`, `code=${body.code}`, false);
|
|
165
|
+
}
|
|
166
|
+
return body.data ?? {};
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error instanceof LeadApiError) {
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
172
|
+
throw new LeadApiError(`请求超时(${config.timeoutMs}ms)`, undefined, true);
|
|
173
|
+
}
|
|
174
|
+
throw new LeadApiError(
|
|
175
|
+
`请求 dmmp-system 失败:${error instanceof Error ? error.message : String(error)}`,
|
|
176
|
+
undefined,
|
|
177
|
+
true,
|
|
178
|
+
);
|
|
179
|
+
} finally {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 运行配置
|
|
5
|
+
*
|
|
6
|
+
* <p>配置来源优先级:命令行参数 > 环境变量 > 默认值。</p>
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} LeadMcpConfig
|
|
11
|
+
* @property {string} env 环境标识:local / sit / prd / custom
|
|
12
|
+
* @property {string[]} baseUrls dmmp-system(系统 id 275)服务地址列表,多个时按顺序故障转移
|
|
13
|
+
* @property {number} timeoutMs 单次请求超时(毫秒)
|
|
14
|
+
* @property {string|undefined} token 访问令牌,可选
|
|
15
|
+
* @property {string|undefined} operator 操作人标识(工号),用于服务端限流与审计
|
|
16
|
+
* @property {string|undefined} operatorOs 操作人操作系统账号
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 各环境的服务地址
|
|
21
|
+
*
|
|
22
|
+
* <p>生产为双节点,客户端会按顺序故障转移,避免单点故障导致工具不可用。</p>
|
|
23
|
+
*/
|
|
24
|
+
export const ENVIRONMENTS = {
|
|
25
|
+
local: {
|
|
26
|
+
label: "本地开发",
|
|
27
|
+
baseUrls: ["http://localhost:8081"],
|
|
28
|
+
},
|
|
29
|
+
sit: {
|
|
30
|
+
label: "集成测试",
|
|
31
|
+
baseUrls: ["http://10.1.1.197:8081"],
|
|
32
|
+
},
|
|
33
|
+
prd: {
|
|
34
|
+
label: "生产",
|
|
35
|
+
baseUrls: ["http://10.32.30.208:8081", "http://10.32.30.209:8081"],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 默认环境
|
|
41
|
+
*
|
|
42
|
+
* <p>默认走 local:打错环境名不至于直接连到生产。</p>
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_ENV = "local";
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_TIMEOUT_MS = 10000;
|
|
47
|
+
|
|
48
|
+
export const USAGE = `lead-mcp —— 利得零售系统 MCP Server
|
|
49
|
+
|
|
50
|
+
用法:
|
|
51
|
+
lead-mcp [选项]
|
|
52
|
+
|
|
53
|
+
选项:
|
|
54
|
+
--env <环境> 目标环境,默认 ${DEFAULT_ENV}
|
|
55
|
+
${Object.entries(ENVIRONMENTS)
|
|
56
|
+
.map(([name, env]) => `${name}(${env.label}) ${env.baseUrls.join(",")}`)
|
|
57
|
+
.join("\n ")}
|
|
58
|
+
也可用环境变量 LEAD_MCP_ENV;未知环境名会直接报错退出
|
|
59
|
+
--base-url <url> 直接指定服务地址,逗号分隔多个时按顺序故障转移
|
|
60
|
+
优先级高于 --env,也可用环境变量 LEAD_MCP_BASE_URL
|
|
61
|
+
--timeout <ms> 单次请求超时毫秒数,默认 ${DEFAULT_TIMEOUT_MS}
|
|
62
|
+
也可用环境变量 LEAD_MCP_TIMEOUT_MS
|
|
63
|
+
--token <token> 访问令牌,可选,也可用环境变量 LEAD_MCP_TOKEN
|
|
64
|
+
--operator <工号> 操作人标识,用于服务端限流与审计
|
|
65
|
+
也可用环境变量 LEAD_MCP_OPERATOR;未配置时退化为操作系统账号
|
|
66
|
+
-h, --help 查看帮助
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 解析操作人的操作系统账号
|
|
71
|
+
*
|
|
72
|
+
* @returns {string|undefined}
|
|
73
|
+
*/
|
|
74
|
+
export function resolveOperatorOs() {
|
|
75
|
+
try {
|
|
76
|
+
const info = os.userInfo();
|
|
77
|
+
if (info && info.username) {
|
|
78
|
+
return info.username;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// 容器环境下可能拿不到,忽略
|
|
82
|
+
}
|
|
83
|
+
return process.env.USER || process.env.USERNAME || undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 解析 --key value 形式的参数
|
|
88
|
+
*
|
|
89
|
+
* @param {string[]} argv
|
|
90
|
+
* @returns {Map<string, string>}
|
|
91
|
+
*/
|
|
92
|
+
export function parseArgs(argv) {
|
|
93
|
+
const result = new Map();
|
|
94
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
95
|
+
const arg = argv[i];
|
|
96
|
+
if (!arg.startsWith("--")) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const key = arg.slice(2);
|
|
100
|
+
const next = argv[i + 1];
|
|
101
|
+
if (next === undefined || next.startsWith("--")) {
|
|
102
|
+
result.set(key, "true");
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
result.set(key, next);
|
|
106
|
+
i += 1;
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 归一化环境名并校验
|
|
113
|
+
*
|
|
114
|
+
* @param {string} name 环境名
|
|
115
|
+
* @returns {string} 环境名
|
|
116
|
+
* @throws {Error} 环境名非法
|
|
117
|
+
*/
|
|
118
|
+
export function normalizeEnv(name) {
|
|
119
|
+
const env = String(name).trim().toLowerCase();
|
|
120
|
+
if (!ENVIRONMENTS[env]) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`未知环境 "${name}",可选值:${Object.keys(ENVIRONMENTS).join(" / ")}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return env;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 解析服务地址列表
|
|
130
|
+
*
|
|
131
|
+
* @param {string} value 逗号分隔的地址
|
|
132
|
+
* @returns {string[]} 地址列表
|
|
133
|
+
*/
|
|
134
|
+
export function splitBaseUrls(value) {
|
|
135
|
+
return String(value)
|
|
136
|
+
.split(",")
|
|
137
|
+
.map((item) => item.trim().replace(/\/+$/, ""))
|
|
138
|
+
.filter((item) => item.length > 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 加载配置
|
|
143
|
+
*
|
|
144
|
+
* @param {string[]} [argv]
|
|
145
|
+
* @returns {LeadMcpConfig}
|
|
146
|
+
* @throws {Error} 环境名非法
|
|
147
|
+
*/
|
|
148
|
+
export function loadConfig(argv = process.argv.slice(2)) {
|
|
149
|
+
const args = parseArgs(argv);
|
|
150
|
+
|
|
151
|
+
const rawEnv = args.get("env") ?? process.env.LEAD_MCP_ENV;
|
|
152
|
+
const rawBaseUrl = args.get("base-url") ?? process.env.LEAD_MCP_BASE_URL;
|
|
153
|
+
const hasBaseUrl = Boolean(rawBaseUrl) && rawBaseUrl !== "true";
|
|
154
|
+
|
|
155
|
+
// 环境名非法直接失败:静默回落到默认环境可能把请求打到错误的环境
|
|
156
|
+
const env =
|
|
157
|
+
rawEnv && rawEnv !== "true"
|
|
158
|
+
? normalizeEnv(rawEnv)
|
|
159
|
+
: hasBaseUrl
|
|
160
|
+
? "custom"
|
|
161
|
+
: DEFAULT_ENV;
|
|
162
|
+
|
|
163
|
+
// 显式指定的地址优先级更高,可直接指定多个做故障转移
|
|
164
|
+
const baseUrls = hasBaseUrl ? splitBaseUrls(rawBaseUrl) : ENVIRONMENTS[env].baseUrls;
|
|
165
|
+
if (baseUrls.length === 0) {
|
|
166
|
+
throw new Error("没有可用的服务地址,请检查 --env / --base-url 配置");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const timeoutRaw = args.get("timeout") ?? process.env.LEAD_MCP_TIMEOUT_MS;
|
|
170
|
+
const timeoutMs = timeoutRaw ? Number.parseInt(timeoutRaw, 10) : DEFAULT_TIMEOUT_MS;
|
|
171
|
+
const token = args.get("token") ?? process.env.LEAD_MCP_TOKEN;
|
|
172
|
+
const operatorRaw = args.get("operator") ?? process.env.LEAD_MCP_OPERATOR;
|
|
173
|
+
const operator = operatorRaw && operatorRaw !== "true" ? operatorRaw.trim() : undefined;
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
env,
|
|
177
|
+
baseUrls,
|
|
178
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS,
|
|
179
|
+
token: token && token !== "true" ? token : undefined,
|
|
180
|
+
// 未显式配置工号时退化为操作系统账号,保证服务端审计至少有「谁」
|
|
181
|
+
operator: operator ?? resolveOperatorOs(),
|
|
182
|
+
operatorOs: resolveOperatorOs(),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 是否请求帮助
|
|
188
|
+
*
|
|
189
|
+
* @param {string[]} [argv]
|
|
190
|
+
* @returns {boolean}
|
|
191
|
+
*/
|
|
192
|
+
export function isHelpRequested(argv = process.argv.slice(2)) {
|
|
193
|
+
return argv.includes("--help") || argv.includes("-h");
|
|
194
|
+
}
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日志工具
|
|
3
|
+
*
|
|
4
|
+
* <p><b>重要</b>:stdio 传输下 stdout 是 JSON-RPC 协议通道,
|
|
5
|
+
* 任何往 stdout 写的日志都会破坏协议。所有日志必须走 stderr。</p>
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} message
|
|
10
|
+
* @param {unknown[]} args
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
function format(message, args) {
|
|
14
|
+
if (!args || args.length === 0) {
|
|
15
|
+
return message;
|
|
16
|
+
}
|
|
17
|
+
const details = args.map((arg) => {
|
|
18
|
+
if (arg instanceof Error) {
|
|
19
|
+
return `${arg.name}: ${arg.message}`;
|
|
20
|
+
}
|
|
21
|
+
if (typeof arg === "object" && arg !== null) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.stringify(arg);
|
|
24
|
+
} catch {
|
|
25
|
+
return String(arg);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return String(arg);
|
|
29
|
+
});
|
|
30
|
+
return `${message} ${details.join(" ")}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} level
|
|
35
|
+
* @param {string} message
|
|
36
|
+
* @param {unknown[]} args
|
|
37
|
+
*/
|
|
38
|
+
function write(level, message, args) {
|
|
39
|
+
process.stderr.write(`[lead-mcp] ${level} ${format(message, args)}\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const logger = {
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} message
|
|
45
|
+
* @param {...unknown} args
|
|
46
|
+
*/
|
|
47
|
+
info(message, ...args) {
|
|
48
|
+
write("INFO", message, args);
|
|
49
|
+
},
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} message
|
|
52
|
+
* @param {...unknown} args
|
|
53
|
+
*/
|
|
54
|
+
warn(message, ...args) {
|
|
55
|
+
write("WARN", message, args);
|
|
56
|
+
},
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} message
|
|
59
|
+
* @param {...unknown} args
|
|
60
|
+
*/
|
|
61
|
+
error(message, ...args) {
|
|
62
|
+
write("ERROR", message, args);
|
|
63
|
+
},
|
|
64
|
+
};
|
package/src/server.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { LeadApiError, queryAccount } from "./client.js";
|
|
4
|
+
import { logger } from "./logger.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* MCP Server
|
|
8
|
+
*
|
|
9
|
+
* <p>本 Server 只提供<b>只读</b>工具,不做任何写操作。
|
|
10
|
+
* 工具的真实实现全部在 dmmp-system 侧,本层仅做协议适配与结果转述。</p>
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const SERVER_NAME = "lead-mcp";
|
|
14
|
+
export const SERVER_VERSION = "202610.1.0";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 查询客户账户信息 工具名
|
|
18
|
+
*/
|
|
19
|
+
export const TOOL_QUERY_ACCOUNT = "dmmp_query_account";
|
|
20
|
+
|
|
21
|
+
export const TOOL_DESCRIPTION = [
|
|
22
|
+
"根据手机号或身份证号查询客户账户信息,返回会员 id、姓名、手机号、证件类型、证件号码、证件有效期(均为脱敏值)。",
|
|
23
|
+
"手机号、身份证号、会员号三者至少提供一项;网点号可选。",
|
|
24
|
+
"网点号用于限定客户所属网点:指定网点号后结果唯一,要么查到唯一账户、要么查不到;查不到说明该客户不在这个网点下。",
|
|
25
|
+
"一个手机号或证件号可能对应多个会员。若返回多个候选会员号,必须请用户确认要查询哪一个,再带上 memberId 重新查询,不要自行挑一个。",
|
|
26
|
+
"若返回「未查询到该客户信息」,直接回答未查询到,禁止推测或补充任何未返回的内容。",
|
|
27
|
+
].join("");
|
|
28
|
+
|
|
29
|
+
const queryAccountInputSchema = {
|
|
30
|
+
mobile: z
|
|
31
|
+
.string()
|
|
32
|
+
.regex(/^1[3-9]\d{9}$/, "手机号格式不正确")
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("完整手机号,11 位且以 1 开头。与 idNo / memberId 至少提供一项。"),
|
|
35
|
+
idNo: z
|
|
36
|
+
.string()
|
|
37
|
+
.regex(/^(\d{17}[\dXx]|\d{15})$/, "证件号码格式不正确")
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("完整身份证号,15 位或 18 位。与 mobile / memberId 至少提供一项。"),
|
|
40
|
+
ownerNet: z
|
|
41
|
+
.string()
|
|
42
|
+
.min(1)
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("网点号,可选。传入后仅返回该网点下的客户,结果唯一。"),
|
|
45
|
+
memberId: z
|
|
46
|
+
.string()
|
|
47
|
+
.min(1)
|
|
48
|
+
.optional()
|
|
49
|
+
.describe(
|
|
50
|
+
"会员号,可选。命中多个会员时,先让用户确认要哪一个,再带上该会员号重新查询。",
|
|
51
|
+
),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const NOT_FOUND_TEXT = "未查询到该客户信息。";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 指定网点但查不到时的提示
|
|
58
|
+
*
|
|
59
|
+
* @param {string} ownerNet 网点号
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function formatNotFoundWithNet(ownerNet) {
|
|
63
|
+
return `未查询到该客户信息。该手机号/证件号在网点 ${ownerNet} 下没有账户,可能该客户不属于该网点。`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 命中多个会员时的提示
|
|
68
|
+
*
|
|
69
|
+
* @param {string[]} memberIdList 候选会员号
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
export function formatMultiMatch(memberIdList) {
|
|
73
|
+
return [
|
|
74
|
+
"该手机号/证件号在系统中匹配到多个会员,无法确定具体是哪一个。",
|
|
75
|
+
"请让用户确认需要查询哪一个会员,然后带上 memberId 重新查询,不要自行选择其中一个。",
|
|
76
|
+
"候选会员号:",
|
|
77
|
+
...memberIdList.map((id) => `- ${id}`),
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 把客户档案转成便于阅读的文本
|
|
83
|
+
*
|
|
84
|
+
* @param {import("./client.js").AccountProfile} profile
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
export function formatProfile(profile) {
|
|
88
|
+
const lines = [];
|
|
89
|
+
if (profile.memberId) {
|
|
90
|
+
lines.push(`会员id:${profile.memberId}`);
|
|
91
|
+
}
|
|
92
|
+
if (profile.name) {
|
|
93
|
+
lines.push(`姓名:${profile.name}`);
|
|
94
|
+
}
|
|
95
|
+
if (profile.mobileFormat) {
|
|
96
|
+
lines.push(`手机号:${profile.mobileFormat}`);
|
|
97
|
+
}
|
|
98
|
+
if (profile.idTypeFormat || profile.idType) {
|
|
99
|
+
lines.push(`证件类型:${profile.idTypeFormat || profile.idType}`);
|
|
100
|
+
}
|
|
101
|
+
if (profile.idNoFormat) {
|
|
102
|
+
lines.push(`证件号码:${profile.idNoFormat}`);
|
|
103
|
+
}
|
|
104
|
+
if (profile.idValidityFormat || profile.idValidity) {
|
|
105
|
+
lines.push(`证件有效期:${profile.idValidityFormat || profile.idValidity}`);
|
|
106
|
+
}
|
|
107
|
+
return lines.length > 0 ? lines.join("\n") : JSON.stringify(profile);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 创建 MCP Server
|
|
112
|
+
*
|
|
113
|
+
* @param {import("./config.js").LeadMcpConfig} config
|
|
114
|
+
* @returns {McpServer}
|
|
115
|
+
*/
|
|
116
|
+
export function createServer(config) {
|
|
117
|
+
const server = new McpServer({
|
|
118
|
+
name: SERVER_NAME,
|
|
119
|
+
version: SERVER_VERSION,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
server.registerTool(
|
|
123
|
+
TOOL_QUERY_ACCOUNT,
|
|
124
|
+
{
|
|
125
|
+
title: "查询客户账户信息",
|
|
126
|
+
description: TOOL_DESCRIPTION,
|
|
127
|
+
inputSchema: queryAccountInputSchema,
|
|
128
|
+
annotations: {
|
|
129
|
+
readOnlyHint: true,
|
|
130
|
+
destructiveHint: false,
|
|
131
|
+
idempotentHint: true,
|
|
132
|
+
openWorldHint: false,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
async (args) => {
|
|
136
|
+
const { mobile, idNo, ownerNet, memberId } = args;
|
|
137
|
+
|
|
138
|
+
if (!mobile && !idNo && !memberId) {
|
|
139
|
+
logger.warn("工具调用参数缺失,mobile、idNo、memberId 至少需要提供一项");
|
|
140
|
+
return {
|
|
141
|
+
isError: true,
|
|
142
|
+
content: [
|
|
143
|
+
{
|
|
144
|
+
type: "text",
|
|
145
|
+
text: "参数错误:手机号、身份证号、会员号至少需要提供一项。",
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const profile = await queryAccount(config, { mobile, idNo, ownerNet, memberId });
|
|
153
|
+
const memberIdList = profile.memberIdList ?? [];
|
|
154
|
+
|
|
155
|
+
// 一个手机号/证件号可能对应多个会员,此时后端只返回候选会员号
|
|
156
|
+
if (memberIdList.length > 1) {
|
|
157
|
+
logger.info("客户信息命中多个会员", { memberIdList });
|
|
158
|
+
return {
|
|
159
|
+
content: [{ type: "text", text: formatMultiMatch(memberIdList) }],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!profile.found) {
|
|
164
|
+
logger.info("客户信息未命中", { mobile, ownerNet, memberId });
|
|
165
|
+
return {
|
|
166
|
+
content: [
|
|
167
|
+
{
|
|
168
|
+
type: "text",
|
|
169
|
+
text: ownerNet ? formatNotFoundWithNet(ownerNet) : NOT_FOUND_TEXT,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
logger.info("客户信息查询成功", { memberId: profile.memberId });
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text", text: formatProfile(profile) }],
|
|
177
|
+
};
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const message = error instanceof LeadApiError ? error.message : String(error);
|
|
180
|
+
logger.error("客户信息查询失败", error);
|
|
181
|
+
return {
|
|
182
|
+
isError: true,
|
|
183
|
+
content: [{ type: "text", text: `查询失败:${message}` }],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return server;
|
|
190
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置解析单元测试
|
|
3
|
+
*
|
|
4
|
+
* <p>运行:node test/config.test.mjs</p>
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { ENVIRONMENTS, loadConfig, normalizeEnv, splitBaseUrls } from "../src/config.js";
|
|
9
|
+
|
|
10
|
+
let passed = 0;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 断言
|
|
14
|
+
*
|
|
15
|
+
* @param {string} name 用例名
|
|
16
|
+
* @param {Function} fn 断言函数
|
|
17
|
+
*/
|
|
18
|
+
function test(name, fn) {
|
|
19
|
+
try {
|
|
20
|
+
fn();
|
|
21
|
+
passed += 1;
|
|
22
|
+
console.log(`✓ ${name}`);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error(`✗ ${name}\n ${error.message}`);
|
|
25
|
+
process.exitCode = 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("环境名归一化", () => {
|
|
30
|
+
assert.equal(normalizeEnv("PRD"), "prd");
|
|
31
|
+
assert.equal(normalizeEnv(" sit "), "sit");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("未知环境名直接报错,不静默回落", () => {
|
|
35
|
+
assert.throws(() => normalizeEnv("pro"), /未知环境/);
|
|
36
|
+
assert.throws(() => normalizeEnv(""), /未知环境/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("默认环境是 local", () => {
|
|
40
|
+
const config = loadConfig([]);
|
|
41
|
+
assert.equal(config.env, "local");
|
|
42
|
+
assert.deepEqual(config.baseUrls, ENVIRONMENTS.local.baseUrls);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("--env 选择环境", () => {
|
|
46
|
+
const config = loadConfig(["--env", "sit"]);
|
|
47
|
+
assert.equal(config.env, "sit");
|
|
48
|
+
assert.deepEqual(config.baseUrls, ["http://10.1.1.197:8081"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("生产环境带两个节点", () => {
|
|
52
|
+
const config = loadConfig(["--env", "prd"]);
|
|
53
|
+
assert.equal(config.env, "prd");
|
|
54
|
+
assert.equal(config.baseUrls.length, 2);
|
|
55
|
+
assert.deepEqual(config.baseUrls, [
|
|
56
|
+
"http://10.32.30.208:8081",
|
|
57
|
+
"http://10.32.30.209:8081",
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("--base-url 优先级高于 --env", () => {
|
|
62
|
+
const config = loadConfig(["--env", "prd", "--base-url", "http://example.com:8081"]);
|
|
63
|
+
assert.deepEqual(config.baseUrls, ["http://example.com:8081"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("--base-url 支持逗号分隔多节点", () => {
|
|
67
|
+
const config = loadConfig([
|
|
68
|
+
"--base-url",
|
|
69
|
+
"http://a:8081, http://b:8081/ ,",
|
|
70
|
+
]);
|
|
71
|
+
assert.deepEqual(config.baseUrls, ["http://a:8081", "http://b:8081"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("未指定 --base-url 时环境标识为 custom", () => {
|
|
75
|
+
const config = loadConfig(["--base-url", "http://example.com:8081"]);
|
|
76
|
+
assert.equal(config.env, "custom");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("环境变量优先于默认值", () => {
|
|
80
|
+
process.env.LEAD_MCP_ENV = "sit";
|
|
81
|
+
try {
|
|
82
|
+
const config = loadConfig([]);
|
|
83
|
+
assert.equal(config.env, "sit");
|
|
84
|
+
} finally {
|
|
85
|
+
delete process.env.LEAD_MCP_ENV;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("地址拆分工具", () => {
|
|
90
|
+
assert.deepEqual(splitBaseUrls("http://a:8081"), ["http://a:8081"]);
|
|
91
|
+
assert.deepEqual(splitBaseUrls(" , "), []);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (process.exitCode) {
|
|
95
|
+
console.error("\n有用例失败 ❌");
|
|
96
|
+
} else {
|
|
97
|
+
console.log(`\n全部通过(${passed} 个用例)✅`);
|
|
98
|
+
}
|
package/test/smoke.mjs
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 端到端冒烟测试
|
|
3
|
+
*
|
|
4
|
+
* <p>起一个 mock 的 dmmp-system,再以子进程方式拉起真实的 MCP Server,
|
|
5
|
+
* 走完整的 MCP 协议握手(initialize → tools/list → tools/call),
|
|
6
|
+
* 校验工具清单、命中、未命中、参数非法、下游异常五种场景的返回语义。</p>
|
|
7
|
+
*
|
|
8
|
+
* <p>运行:node test/smoke.mjs</p>
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const CLI = path.join(__dirname, "..", "src", "cli.js");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* mock 的 dmmp-system
|
|
22
|
+
*/
|
|
23
|
+
function startMockBackend() {
|
|
24
|
+
const state = { lastHeaders: null };
|
|
25
|
+
const server = createServer((req, res) => {
|
|
26
|
+
state.lastHeaders = req.headers;
|
|
27
|
+
let body = "";
|
|
28
|
+
req.on("data", (chunk) => {
|
|
29
|
+
body += chunk;
|
|
30
|
+
});
|
|
31
|
+
req.on("end", () => {
|
|
32
|
+
const params = body ? JSON.parse(body) : {};
|
|
33
|
+
res.setHeader("Content-Type", "application/json");
|
|
34
|
+
|
|
35
|
+
if (params.mobile === "13800000000") {
|
|
36
|
+
res.end(
|
|
37
|
+
JSON.stringify({
|
|
38
|
+
code: 0,
|
|
39
|
+
message: "接口调用成功",
|
|
40
|
+
data: {
|
|
41
|
+
found: true,
|
|
42
|
+
memberId: "M10086",
|
|
43
|
+
name: "张*",
|
|
44
|
+
mobile: "13800000000",
|
|
45
|
+
mobileFormat: "138****0000",
|
|
46
|
+
idType: "01",
|
|
47
|
+
idTypeFormat: "身份证",
|
|
48
|
+
idNo: "310101199001011234",
|
|
49
|
+
idNoFormat: "310101********1234",
|
|
50
|
+
idValidity: "9999-12-31",
|
|
51
|
+
idValidityFormat: "长期有效",
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (params.memberId === "M20001") {
|
|
58
|
+
// 带上 memberId 复查:唯一命中
|
|
59
|
+
res.end(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
code: 0,
|
|
62
|
+
message: "接口调用成功",
|
|
63
|
+
data: {
|
|
64
|
+
found: true,
|
|
65
|
+
memberId: "M20001",
|
|
66
|
+
memberIdList: ["M20001"],
|
|
67
|
+
name: "李*",
|
|
68
|
+
mobileFormat: "136****0000",
|
|
69
|
+
idTypeFormat: "身份证",
|
|
70
|
+
idNoFormat: "310101********5678",
|
|
71
|
+
idValidityFormat: "长期有效",
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (params.mobile === "13600000000") {
|
|
78
|
+
// 一个手机号对应多个会员:后端只返回候选会员号,不返回档案
|
|
79
|
+
res.end(
|
|
80
|
+
JSON.stringify({
|
|
81
|
+
code: 0,
|
|
82
|
+
message: "接口调用成功",
|
|
83
|
+
data: { found: true, memberIdList: ["M20001", "M20002"] },
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (params.mobile === "13900000000") {
|
|
89
|
+
res.statusCode = 500;
|
|
90
|
+
res.end(JSON.stringify({ code: 500, message: "下游服务异常" }));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// 未命中
|
|
94
|
+
res.end(JSON.stringify({ code: 0, message: "接口调用成功", data: { found: false } }));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
server.listen(0, "127.0.0.1", () => {
|
|
99
|
+
resolve({ server, port: server.address().port, state });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 以 stdio 方式拉起 MCP Server,并提供 JSON-RPC 调用能力
|
|
106
|
+
*/
|
|
107
|
+
function startMcpServer(baseUrl, extraArgs = []) {
|
|
108
|
+
const child = spawn(
|
|
109
|
+
process.execPath,
|
|
110
|
+
[CLI, "--base-url", baseUrl, "--operator", "E10086", ...extraArgs],
|
|
111
|
+
{ stdio: ["pipe", "pipe", "pipe"] },
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const pending = new Map();
|
|
115
|
+
let buffer = "";
|
|
116
|
+
let stderr = "";
|
|
117
|
+
|
|
118
|
+
child.stdout.on("data", (chunk) => {
|
|
119
|
+
buffer += chunk.toString();
|
|
120
|
+
let index;
|
|
121
|
+
while ((index = buffer.indexOf("\n")) >= 0) {
|
|
122
|
+
const line = buffer.slice(0, index).trim();
|
|
123
|
+
buffer = buffer.slice(index + 1);
|
|
124
|
+
if (!line) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const message = JSON.parse(line);
|
|
128
|
+
const resolver = pending.get(message.id);
|
|
129
|
+
if (resolver) {
|
|
130
|
+
pending.delete(message.id);
|
|
131
|
+
resolver(message);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
child.stderr.on("data", (chunk) => {
|
|
136
|
+
stderr += chunk.toString();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
let nextId = 1;
|
|
140
|
+
function send(method, params) {
|
|
141
|
+
const id = nextId;
|
|
142
|
+
nextId += 1;
|
|
143
|
+
const message = { jsonrpc: "2.0", id, method, params };
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
pending.delete(id);
|
|
147
|
+
reject(new Error(`请求超时: ${method}\n${stderr}`));
|
|
148
|
+
}, 10000);
|
|
149
|
+
pending.set(id, (response) => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
resolve(response);
|
|
152
|
+
});
|
|
153
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function notify(method, params) {
|
|
158
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { child, send, notify, getStderr: () => stderr };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 取一个已被占用的端口(先监听再关闭),用来模拟不可用的节点
|
|
166
|
+
*
|
|
167
|
+
* @returns {Promise<number>} 端口
|
|
168
|
+
*/
|
|
169
|
+
async function getClosedPort() {
|
|
170
|
+
return new Promise((resolve) => {
|
|
171
|
+
const server = createServer();
|
|
172
|
+
server.listen(0, "127.0.0.1", () => {
|
|
173
|
+
const { port } = server.address();
|
|
174
|
+
server.close(() => resolve(port));
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 故障转移:第一个节点不可用时自动切到第二个
|
|
181
|
+
*/
|
|
182
|
+
async function testFailover() {
|
|
183
|
+
const closedPort = await getClosedPort();
|
|
184
|
+
const { server: mock, port } = await startMockBackend();
|
|
185
|
+
const mcp = startMcpServer(`http://127.0.0.1:${closedPort},http://127.0.0.1:${port}`);
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const init = await mcp.send("initialize", {
|
|
189
|
+
protocolVersion: "2024-11-05",
|
|
190
|
+
capabilities: {},
|
|
191
|
+
clientInfo: { name: "smoke-test", version: "0.0.1" },
|
|
192
|
+
});
|
|
193
|
+
assert.equal(init.result.serverInfo.name, "lead-mcp");
|
|
194
|
+
mcp.notify("notifications/initialized");
|
|
195
|
+
|
|
196
|
+
const hit = await mcp.send("tools/call", {
|
|
197
|
+
name: "dmmp_query_account",
|
|
198
|
+
arguments: { mobile: "13800000000" },
|
|
199
|
+
});
|
|
200
|
+
assert.ok(!hit.result.isError, "首节点不可用时应自动切到第二个节点");
|
|
201
|
+
assert.ok(hit.result.content[0].text.includes("M10086"), "应命中第二个节点的数据");
|
|
202
|
+
console.log("✓ 节点故障转移(首个节点不可用 → 自动切换)");
|
|
203
|
+
} finally {
|
|
204
|
+
mcp.child.kill();
|
|
205
|
+
mock.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function main() {
|
|
210
|
+
const { server: mock, port, state } = await startMockBackend();
|
|
211
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
212
|
+
const mcp = startMcpServer(baseUrl);
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
// 1. initialize
|
|
216
|
+
const init = await mcp.send("initialize", {
|
|
217
|
+
protocolVersion: "2024-11-05",
|
|
218
|
+
capabilities: {},
|
|
219
|
+
clientInfo: { name: "smoke-test", version: "0.0.1" },
|
|
220
|
+
});
|
|
221
|
+
assert.equal(init.result.serverInfo.name, "lead-mcp", "serverInfo.name");
|
|
222
|
+
console.log("✓ initialize", JSON.stringify(init.result.serverInfo));
|
|
223
|
+
mcp.notify("notifications/initialized");
|
|
224
|
+
|
|
225
|
+
// 2. tools/list
|
|
226
|
+
const list = await mcp.send("tools/list", {});
|
|
227
|
+
const tools = list.result.tools;
|
|
228
|
+
assert.equal(tools.length, 1, "应当只有一个工具");
|
|
229
|
+
const tool = tools[0];
|
|
230
|
+
assert.equal(tool.name, "dmmp_query_account");
|
|
231
|
+
assert.ok(tool.description.includes("未查询到"), "描述里要写清未查询到的处理方式");
|
|
232
|
+
const props = tool.inputSchema.properties;
|
|
233
|
+
assert.ok(props.mobile && props.idNo && props.ownerNet, "参数应包含 mobile/idNo/ownerNet");
|
|
234
|
+
assert.equal(tool.inputSchema.required, undefined, "mobile/idNo 非必填,由服务端校验二者其一");
|
|
235
|
+
console.log("✓ tools/list", tool.name, JSON.stringify(tool.inputSchema.required ?? []));
|
|
236
|
+
|
|
237
|
+
// 3. 命中
|
|
238
|
+
const hit = await mcp.send("tools/call", {
|
|
239
|
+
name: "dmmp_query_account",
|
|
240
|
+
arguments: { mobile: "13800000000" },
|
|
241
|
+
});
|
|
242
|
+
const hitText = hit.result.content[0].text;
|
|
243
|
+
assert.ok(!hit.result.isError, "命中不应是错误");
|
|
244
|
+
assert.ok(hitText.includes("M10086"), "应返回会员id");
|
|
245
|
+
assert.ok(hitText.includes("138****0000"), "应返回脱敏手机号");
|
|
246
|
+
assert.ok(hitText.includes("310101********1234"), "应返回脱敏证件号");
|
|
247
|
+
assert.ok(hitText.includes("长期有效"), "应返回证件有效期");
|
|
248
|
+
console.log("✓ tools/call 命中\n", hitText.split("\n").map((l) => ` ${l}`).join("\n"));
|
|
249
|
+
|
|
250
|
+
// 4. 未命中:正常返回,不是错误
|
|
251
|
+
const miss = await mcp.send("tools/call", {
|
|
252
|
+
name: "dmmp_query_account",
|
|
253
|
+
arguments: { mobile: "13700000000" },
|
|
254
|
+
});
|
|
255
|
+
assert.ok(!miss.result.isError, "未命中不应标记为错误");
|
|
256
|
+
assert.equal(miss.result.content[0].text, "未查询到该客户信息。");
|
|
257
|
+
console.log("✓ tools/call 未命中(非错误)", miss.result.content[0].text);
|
|
258
|
+
|
|
259
|
+
// 5. 参数非法:缺少手机号、身份证号与会员号
|
|
260
|
+
const invalid = await mcp.send("tools/call", {
|
|
261
|
+
name: "dmmp_query_account",
|
|
262
|
+
arguments: { ownerNet: "0001" },
|
|
263
|
+
});
|
|
264
|
+
assert.equal(invalid.result.isError, true, "参数非法应为错误");
|
|
265
|
+
assert.ok(invalid.result.content[0].text.includes("至少"));
|
|
266
|
+
console.log("✓ tools/call 参数非法(isError)", invalid.result.content[0].text);
|
|
267
|
+
|
|
268
|
+
// 6. 下游异常:isError
|
|
269
|
+
const broken = await mcp.send("tools/call", {
|
|
270
|
+
name: "dmmp_query_account",
|
|
271
|
+
arguments: { mobile: "13900000000" },
|
|
272
|
+
});
|
|
273
|
+
assert.equal(broken.result.isError, true, "下游异常应为错误");
|
|
274
|
+
console.log("✓ tools/call 下游异常(isError)", broken.result.content[0].text);
|
|
275
|
+
|
|
276
|
+
// 7. 命中多个会员:不是错误,要列出候选会员号让用户确认
|
|
277
|
+
const multi = await mcp.send("tools/call", {
|
|
278
|
+
name: "dmmp_query_account",
|
|
279
|
+
arguments: { mobile: "13600000000" },
|
|
280
|
+
});
|
|
281
|
+
assert.ok(!multi.result.isError, "多会员命中不应标记为错误");
|
|
282
|
+
const multiText = multi.result.content[0].text;
|
|
283
|
+
assert.ok(multiText.includes("M20001"), "应列出候选会员号");
|
|
284
|
+
assert.ok(multiText.includes("M20002"), "应列出候选会员号");
|
|
285
|
+
assert.ok(multiText.includes("memberId"), "应提示用 memberId 重新查询");
|
|
286
|
+
console.log("✓ tools/call 多会员命中(非错误)\n" + multiText.split("\n").map((l) => ` ${l}`).join("\n"));
|
|
287
|
+
|
|
288
|
+
// 8. 指定网点却查不到:提示该客户不在该网点下
|
|
289
|
+
const missWithNet = await mcp.send("tools/call", {
|
|
290
|
+
name: "dmmp_query_account",
|
|
291
|
+
arguments: { mobile: "13700000000", ownerNet: "0001" },
|
|
292
|
+
});
|
|
293
|
+
assert.ok(!missWithNet.result.isError, "未命中不应标记为错误");
|
|
294
|
+
assert.ok(
|
|
295
|
+
missWithNet.result.content[0].text.includes("0001"),
|
|
296
|
+
"指定网点未命中时应说明是网点范围问题",
|
|
297
|
+
);
|
|
298
|
+
console.log("✓ tools/call 指定网点未命中", missWithNet.result.content[0].text);
|
|
299
|
+
|
|
300
|
+
// 9. 带上 memberId 复查:应命中唯一账户
|
|
301
|
+
const byMemberId = await mcp.send("tools/call", {
|
|
302
|
+
name: "dmmp_query_account",
|
|
303
|
+
arguments: { memberId: "M20001", mobile: "13600000000" },
|
|
304
|
+
});
|
|
305
|
+
assert.ok(!byMemberId.result.isError, "按 memberId 复查不应是错误");
|
|
306
|
+
assert.ok(byMemberId.result.content[0].text.includes("会员id:M20001"), "按 memberId 应命中唯一账户");
|
|
307
|
+
console.log("✓ tools/call 带 memberId 复查", byMemberId.result.content[0].text.split("\n")[0]);
|
|
308
|
+
|
|
309
|
+
// 10. 调用方身份必须随请求透传给后端(限流与审计依赖它)
|
|
310
|
+
const headers = state.lastHeaders ?? {};
|
|
311
|
+
assert.equal(headers["x-operator"], "E10086", "应透传操作人标识");
|
|
312
|
+
assert.ok(headers["x-operator-os"], "应透传操作人操作系统账号");
|
|
313
|
+
console.log(
|
|
314
|
+
"✓ 操作人身份透传",
|
|
315
|
+
`X-Operator=${headers["x-operator"]} X-Operator-Os=${headers["x-operator-os"]}`,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// 11. 环境标识必须随请求透传,服务端审计要能区分打在哪个环境
|
|
319
|
+
assert.equal(headers["x-lead-env"], "custom", "应透传环境标识");
|
|
320
|
+
console.log("✓ 环境标识透传", `X-Lead-Env=${headers["x-lead-env"]}`);
|
|
321
|
+
|
|
322
|
+
// 12. 日志必须走 stderr:stdout 全程只承载 JSON-RPC(上面的解析已经证明)
|
|
323
|
+
assert.ok(
|
|
324
|
+
mcp.getStderr().includes("[lead-mcp]"),
|
|
325
|
+
"日志应输出到 stderr",
|
|
326
|
+
);
|
|
327
|
+
assert.ok(
|
|
328
|
+
mcp.getStderr().includes("已启动"),
|
|
329
|
+
"启动日志应输出到 stderr",
|
|
330
|
+
);
|
|
331
|
+
console.log("✓ 日志输出位置 stderr(stdout 仅承载 JSON-RPC)");
|
|
332
|
+
} finally {
|
|
333
|
+
mcp.child.kill();
|
|
334
|
+
mock.close();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
console.log("\n全部通过 ✅");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
main()
|
|
341
|
+
.then(() => testFailover())
|
|
342
|
+
.then(() => {
|
|
343
|
+
console.log("故障转移用例通过 ✅");
|
|
344
|
+
})
|
|
345
|
+
.catch((error) => {
|
|
346
|
+
console.error("冒烟测试失败:", error);
|
|
347
|
+
process.exit(1);
|
|
348
|
+
});
|