@leadfund/lead-mcp 0.0.0-stage → 202610.2.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 CHANGED
@@ -1,3 +1,284 @@
1
- # Temporary Holding Version
1
+ # lead-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ **必须在公司网络或已连接 VPN 的环境下使用。**
15
+
16
+ 本包发布在公共 npm 上,任何人都能安装;但**后端 dmmp-system(275)部署在公司内网**,
17
+ `10.1.1.197` / `10.32.30.208` / `10.32.30.209` 公网不可路由。
18
+
19
+ - 包能正常下载安装 ✓
20
+ - 但脱离内网后查询会超时失败(生产双节点会依次尝试后报错)
21
+
22
+ 包本身不含任何凭证,脱离内网也无法访问到客户数据。
23
+
24
+ ## 工具
25
+
26
+ | 工具 | 说明 |
27
+ |---|---|
28
+ | `dmmp_query_account` | 根据手机号或身份证号查询客户账户信息,网点号可选 |
29
+
30
+ **入参**
31
+
32
+ | 参数 | 类型 | 必填 | 说明 |
33
+ |---|---|---|---|
34
+ | `mobile` | string | 与 `idNo` / `memberId` 至少提供一项 | 完整手机号,11 位且以 1 开头 |
35
+ | `idNo` | string | 与 `mobile` / `memberId` 至少提供一项 | 完整身份证号,15 位或 18 位 |
36
+ | `ownerNet` | string | 否 | 网点号,传入后仅返回该网点下的客户 |
37
+ | `memberId` | string | 否 | 会员号,命中多个会员时确认后带上再查 |
38
+
39
+ **返回**:会员 id、姓名、手机号(脱敏)、证件类型、证件号码(脱敏)、证件有效期。
40
+
41
+ ### 一号多会员
42
+
43
+ 一个手机号/证件号可能对应多个会员(MES 的 `T_MES_BASE.REG_ACCOUNT` 不唯一)。
44
+
45
+ 命中多个时后端只返回候选会员号,**不返回任何档案字段**,避免臆断。
46
+ 本 Server 会转成一段明确的确认提示:
47
+
48
+ ```
49
+ 该手机号/证件号在系统中匹配到多个会员,无法确定具体是哪一个。
50
+ 请让用户确认需要查询哪一个会员,然后带上 memberId 重新查询,不要自行选择其中一个。
51
+ 候选会员号:
52
+ - M20001
53
+ - M20002
54
+ ```
55
+
56
+ ### 网点号的语义
57
+
58
+ 传入网点号后结果唯一 —— 手机号/证件号在该网点下要么查到唯一账户、要么查不到。
59
+ 查不到时的提示会区分两种情况:
60
+
61
+ | 场景 | 提示 |
62
+ |---|---|
63
+ | 未传网点号 | 未查询到该客户信息。 |
64
+ | 传了网点号 | 未查询到该客户信息。该手机号/证件号在网点 {ownerNet} 下没有账户,可能该客户不属于该网点。 |
65
+
66
+ ### 错误语义(重要)
67
+
68
+ | 场景 | MCP 返回 | 模型应有的行为 |
69
+ |---|---|---|
70
+ | 查不到客户 | `content`,正文为「未查询到该客户信息。」 | 直接回答未查询到,**禁止推测** |
71
+ | 命中多个会员 | `content`,正文为候选会员号清单 | 请用户确认后带 `memberId` 重查,**不要自行挑一个** |
72
+ | 参数不合法 | `isError: true` | 向用户澄清需要手机号、身份证号或会员号 |
73
+ | 接口失败 / 超时 | `isError: true` | 向用户说明查询失败,不要编造数据 |
74
+
75
+ 「查不到」和「命中多个」刻意**不**作为错误返回:一旦标记成错误,模型会认为工具故障并自行编造客户资产。
76
+
77
+ ## 安装
78
+
79
+ ```bash
80
+ npm install
81
+ ```
82
+
83
+ 发布到私有 registry 后即可用 `npx` 直接拉起,无需本地安装。
84
+
85
+ ## 接入 Claude Code
86
+
87
+ ### stdio 方式(本地 MCP Client)
88
+
89
+ ```bash
90
+ claude mcp add-json lead-mcp '{
91
+ "command": "npx",
92
+ "args": ["-y", "--registry=https://registry.npmjs.org/", "@leadfund/lead-mcp", "--env", "sit"],
93
+ "env": {"LEAD_MCP_ENV": "sit"}
94
+ }'
95
+ ```
96
+
97
+ ### 指定服务地址
98
+
99
+ 服务地址**不在包里**,由使用方指定。两种方式:
100
+
101
+ **方式一:命令行参数(支持多个,逗号分隔)**
102
+
103
+ ```bash
104
+ # 单个
105
+ lead-mcp --base-url http://10.1.1.197:8081
106
+
107
+ # 多个(按顺序故障转移,前一个不通自动切下一个)
108
+ lead-mcp --base-url http://10.32.30.208:8081,http://10.32.30.209:8081
109
+ ```
110
+
111
+ 也可以用环境变量 `LEAD_MCP_BASE_URL`。**`--base-url` 优先级高于 `--env`。**
112
+
113
+ **方式二:用户配置文件**(推荐,环境多时更清晰)
114
+
115
+ 默认路径 `~/.lead-mcp/config.json`,可用 `LEAD_MCP_CONFIG` 指定别处:
116
+
117
+ ```json
118
+ {
119
+ "defaultEnvironment": "sit",
120
+ "environments": {
121
+ "local": ["http://localhost:8081"],
122
+ "sit": ["http://10.1.1.197:8081"],
123
+ "prd": ["http://10.32.30.208:8081", "http://10.32.30.209:8081"]
124
+ }
125
+ }
126
+ ```
127
+
128
+ 之后用 `--env sit` / `--env prd` 引用;不写 `--env` 就用 `defaultEnvironment`。
129
+ 配置文件缺失时只有内置的 `local` 可用。
130
+
131
+ ```bash
132
+ lead-mcp --env prd
133
+ ```
134
+
135
+ ### 为什么指定官方源
136
+
137
+ ```json
138
+ "args": ["-y", "--registry=https://registry.npmjs.org/", "@leadfund/lead-mcp"]
139
+ ```
140
+
141
+ npm 默认走淘宝源(`registry.npmmirror.com`),而**镜像源对新发布的包有同步延迟**——
142
+ 本包发布后淘宝源一度返回 404,导致 `npx` 装上却没有 bin,报 `lead-mcp: command not found`。
143
+ 显式指定官方源可以完全避开这个问题(官方源是源,镜像只是它的副本)。
144
+
145
+ ### operator 要不要配
146
+
147
+ **默认不用配。** 没传 `--operator` 时,Server 会自动用操作系统账号(`os.userInfo().username`)作为操作人,
148
+ 限流维度和审计记录都有值。
149
+
150
+ 想要审计里直接是工号而不是机器账号,才需要显式配置:
151
+
152
+ ```json
153
+ "args": ["-y", "--registry=https://registry.npmjs.org/", "@leadfund/lead-mcp", "--env", "sit", "--operator", "E10086"]
154
+ ```
155
+
156
+ ## HTTP(S) 模式(云端 MCP 服务)
157
+
158
+ stdio 只适用于本地 MCP Client。要接入云端服务(如阿里云百炼),用 HTTP 模式。
159
+
160
+ ### 启动
161
+
162
+ ```bash
163
+ # SSE 协议
164
+ lead-mcp --transport sse --http-port 3100 --http-host 0.0.0.0 \
165
+ --auth-token <你的token> --env sit
166
+
167
+ # Streamable HTTP 协议
168
+ lead-mcp --transport http --http-port 3100 --http-host 0.0.0.0 \
169
+ --auth-token <你的token> --env sit
170
+ ```
171
+
172
+ | 传输 | 端点 | 说明 |
173
+ |---|---|---|
174
+ | `stdio` | — | 默认,本地 MCP Client |
175
+ | `sse` | `GET /sse` + `POST /messages?sessionId=` | 云端 MCP 常用 |
176
+ | `http` | `POST /mcp` | Streamable HTTP,一站式 |
177
+
178
+ **HTTP 模式下 `--auth-token` 必填**(没有令牌直接拒绝启动)—— 这套服务通常要暴露到公网,
179
+ 不设令牌等于不设防。客户端通过 `Authorization: Bearer <token>` 认证。
180
+ `GET /health` 不鉴权,方便探活。
181
+
182
+ ### 百炼云端配置
183
+
184
+ ```json
185
+ {
186
+ "type": "mcp",
187
+ "server_protocol": "sse",
188
+ "server_label": "lead-mcp",
189
+ "server_url": "https://你的域名/sse",
190
+ "headers": { "Authorization": "Bearer 你的token" }
191
+ }
192
+ ```
193
+
194
+ `server_protocol` 换成 `http` 时,`server_url` 用 `https://你的域名/mcp`。
195
+
196
+ ### 暴露到公网(ng 隧道示例)
197
+
198
+ ```bash
199
+ lead-mcp --transport sse --http-host 0.0.0.0 --http-port 3100 \
200
+ --auth-token <你的token> --env sit
201
+
202
+ # 另一个终端
203
+ ng http 3100
204
+ # 拿到 https://xxxx.ngrok-free.app
205
+ ```
206
+
207
+ 然后百炼里填 `https://xxxx.ngrok-free.app/sse` 和 Bearer 令牌。
208
+
209
+ > ⚠️ 暴露到公网前请确认:
210
+ > - `--auth-token` 已配置且足够随机
211
+ > - 后端地址走 `--env` 指向内网,**不要**把 275 的直连地址暴露出去
212
+ > - ng 隧道是临时地址,重启会变
213
+
214
+ ## 本地调试
215
+
216
+ ```bash
217
+ node src/cli.js --help
218
+
219
+ # 可视化调试
220
+ npx @modelcontextprotocol/inspector node src/cli.js --base-url http://127.0.0.1:8081
221
+ ```
222
+
223
+ ### 测试
224
+
225
+ ```bash
226
+ npm test # 配置解析单元测试 + HTTP 端到端 + 冒烟测试
227
+ node test/e2e-published.mjs [手机号] # 用公网已发布的包跑真实链路(默认 sit)
228
+ ```
229
+
230
+ > ⚠️ **在本包源码目录下不要用 `npx` 启动**:npx 会把本地 `package.json` 当作「已安装」,
231
+ > 直接去找 `node_modules/.bin/lead-mcp`,而它不是自己的依赖、软链不存在,
232
+ > 结果是 `sh: lead-mcp: command not found`(exit 127)。
233
+ > 开发时用 `node src/cli.js`,或换到别的目录执行。
234
+
235
+ - `test/config.test.mjs` —— 环境选择、地址优先级、未知环境报错等纯逻辑
236
+ - `test/smoke.mjs` —— 拉起 mock 的 dmmp-system,再以子进程方式拉起真实的 MCP Server,
237
+ 走完整协议握手,覆盖命中 / 未命中 / 多会员 / 参数非法 / 下游异常 / 操作人透传 /
238
+ 环境透传 / **节点故障转移**
239
+
240
+ ## 与后端的关系
241
+
242
+ ```
243
+ Claude Code
244
+ │ stdio (JSON-RPC)
245
+ ▼
246
+ lead-mcp (Node/JS) ← 本项目
247
+ │ HTTP POST /ai/account/query
248
+ ▼
249
+ dmmp-system (系统 id 275) ← 业务真源:鉴权、白名单、脱敏、审计
250
+ │ Dubbo
251
+ ▼
252
+ MES MemberQueryFacade
253
+ ```
254
+
255
+ 工具清单、参数 Schema、脱敏规则以 dmmp-system 侧
256
+ `dmmp-system/src/main/java/com/lead/system/controller/ai/README.md` 为准。
257
+
258
+ ## 目录结构
259
+
260
+ ```
261
+ lead-mcp/
262
+ ├── package.json
263
+ ├── src/
264
+ │ ├── cli.js # 入口,按 transport 分派
265
+ │ ├── http.js # HTTP(S) 传输:SSE / Streamable HTTP
266
+ │ ├── server.js # MCP Server 与工具注册
267
+ │ ├── client.js # dmmp-system HTTP 客户端
268
+ │ ├── config.js # 配置解析
269
+ │ └── logger.js # stderr 日志
270
+ └── test/
271
+ ├── config.test.mjs # 配置解析单元测试
272
+ ├── http.test.mjs # HTTP(S) 传输端到端测试
273
+ ├── smoke.mjs # 端到端冒烟测试
274
+ └── e2e-published.mjs # 用公网已发布的包跑真实链路
275
+ ```
276
+
277
+ ## 约束
278
+
279
+ - **只读**:本 Server 只提供查询工具,不含任何写操作。
280
+ - **日志走 stderr**:写 stdout 会破坏 JSON-RPC 协议。
281
+ - **脱敏在服务端完成**:本项目不做脱敏,直接透传后端已脱敏的结果。
282
+ - **限流在服务端做**:客户端侧计数可被绕过,服务端按「操作人」和「查询目标」两个维度限流。
283
+ - **包内不含内网地址**:服务地址由使用方通过 `--base-url` 或 `~/.lead-mcp/config.json` 指定。
284
+ - **HTTP 模式必须带令牌**:`--auth-token` 未配置时直接拒绝启动。
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@leadfund/lead-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "202610.2.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/http.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
+ "test:http": "node test/http.test.mjs"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public",
28
+ "registry": "https://registry.npmjs.org/"
29
+ },
30
+ "keywords": [
31
+ "mcp",
32
+ "model-context-protocol",
33
+ "leadbank",
34
+ "lead-mcp"
35
+ ],
36
+ "license": "MIT",
37
+ "dependencies": {
38
+ "@modelcontextprotocol/sdk": "^1.30.0",
39
+ "zod": "^3.25.76 || ^4.0.0"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "http://10.1.1.90/root/lead-mcp.git"
44
+ },
45
+ "homepage": "http://10.1.1.90/root/lead-mcp",
46
+ "bugs": {
47
+ "url": "http://10.1.1.90/root/lead-mcp/-/issues"
48
+ }
49
+ }
package/src/cli.js ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { USAGE, isHelpRequested, loadConfig } from "./config.js";
4
+ import { startHttpServer } from "./http.js";
5
+ import { logger } from "./logger.js";
6
+ import { createServer } from "./server.js";
7
+
8
+ async function main() {
9
+ if (isHelpRequested()) {
10
+ // --help 是给人看的,走 stdout;正常运行期 stdout 属于 JSON-RPC 通道
11
+ process.stdout.write(USAGE);
12
+ return;
13
+ }
14
+
15
+ const config = loadConfig();
16
+
17
+ if (config.transport === "stdio") {
18
+ const server = createServer(config);
19
+ const transport = new StdioServerTransport();
20
+ await server.connect(transport);
21
+ logger.info(
22
+ `已启动(stdio),环境=${config.env},服务地址=${config.baseUrls.join(", ")},超时=${config.timeoutMs}ms`,
23
+ );
24
+ return;
25
+ }
26
+
27
+ // HTTP 模式常驻,直到进程被结束
28
+ await startHttpServer(config);
29
+ }
30
+
31
+ main().catch((error) => {
32
+ logger.error("启动失败", error);
33
+ process.exit(1);
34
+ });
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
+ }