@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/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,157 @@
1
+ /**
2
+ * 配置解析单元测试
3
+ *
4
+ * <p>运行:node test/config.test.mjs</p>
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import {
12
+ BUILTIN_ENVIRONMENTS,
13
+ loadConfig,
14
+ normalizeTransport,
15
+ resolveConfigFile,
16
+ splitBaseUrls,
17
+ } from "../src/config.js";
18
+
19
+ let passed = 0;
20
+
21
+ /**
22
+ * 断言
23
+ *
24
+ * @param {string} name 用例名
25
+ * @param {Function} fn 断言函数
26
+ */
27
+ function test(name, fn) {
28
+ try {
29
+ fn();
30
+ passed += 1;
31
+ console.log(`✓ ${name}`);
32
+ } catch (error) {
33
+ console.error(`✗ ${name}\n ${error.message}`);
34
+ process.exitCode = 1;
35
+ }
36
+ }
37
+
38
+ // 用临时配置文件,避免污染真实环境
39
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lead-mcp-test-"));
40
+ const configFile = path.join(tmpDir, "config.json");
41
+ fs.writeFileSync(
42
+ configFile,
43
+ JSON.stringify({
44
+ defaultEnvironment: "sit",
45
+ environments: {
46
+ sit: ["http://10.1.1.197:8081"],
47
+ prd: ["http://10.32.30.208:8081", "http://10.32.30.209:8081"],
48
+ },
49
+ }),
50
+ );
51
+ process.env.LEAD_MCP_CONFIG = configFile;
52
+
53
+ test("内置只有 local,功能代码不依赖内网地址", () => {
54
+ assert.deepEqual(Object.keys(BUILTIN_ENVIRONMENTS), ["local"]);
55
+ const builtins = JSON.stringify(BUILTIN_ENVIRONMENTS);
56
+ assert.ok(
57
+ !/10\.\d+\.\d+\.\d+/.test(builtins),
58
+ "内置环境表里不应出现内网地址",
59
+ );
60
+ });
61
+
62
+ test("配置文件路径可覆盖", () => {
63
+ assert.equal(resolveConfigFile(), configFile);
64
+ });
65
+
66
+ test("默认用配置文件里的 defaultEnvironment", () => {
67
+ const config = loadConfig([]);
68
+ assert.equal(config.env, "sit");
69
+ assert.deepEqual(config.baseUrls, ["http://10.1.1.197:8081"]);
70
+ });
71
+
72
+ test("--env 从用户配置里取,支持多地址", () => {
73
+ const config = loadConfig(["--env", "prd"]);
74
+ assert.equal(config.env, "prd");
75
+ assert.deepEqual(config.baseUrls, [
76
+ "http://10.32.30.208:8081",
77
+ "http://10.32.30.209:8081",
78
+ ]);
79
+ });
80
+
81
+ test("未知环境直接报错并提示怎么配", () => {
82
+ assert.throws(() => loadConfig(["--env", "pro"]), /未知环境/);
83
+ assert.throws(() => loadConfig(["--env", "pro"]), /config\.json/);
84
+ });
85
+
86
+ test("--base-url 优先级高于 --env", () => {
87
+ const config = loadConfig(["--env", "prd", "--base-url", "http://example.com:8081"]);
88
+ assert.deepEqual(config.baseUrls, ["http://example.com:8081"]);
89
+ });
90
+
91
+ test("--base-url 支持逗号分隔多节点", () => {
92
+ const config = loadConfig([
93
+ "--base-url",
94
+ "http://a:8081, http://b:8081/ ,",
95
+ ]);
96
+ assert.deepEqual(config.baseUrls, ["http://a:8081", "http://b:8081"]);
97
+ });
98
+
99
+ test("未指定 --base-url 时环境标识跟随实际环境", () => {
100
+ const config = loadConfig(["--base-url", "http://example.com:8081"]);
101
+ assert.equal(config.env, "custom");
102
+ });
103
+
104
+ test("传输方式归一化", () => {
105
+ assert.equal(normalizeTransport("SSE"), "sse");
106
+ assert.equal(normalizeTransport(" http "), "http");
107
+ assert.throws(() => normalizeTransport("grpc"), /未知传输方式/);
108
+ });
109
+
110
+ test("默认 stdio 传输", () => {
111
+ const config = loadConfig([]);
112
+ assert.equal(config.transport, "stdio");
113
+ });
114
+
115
+ test("HTTP 模式必须配置访问令牌", () => {
116
+ assert.throws(
117
+ () => loadConfig(["--transport", "sse"]),
118
+ /必须配置访问令牌/,
119
+ );
120
+ const config = loadConfig(["--transport", "sse", "--auth-token", "secret"]);
121
+ assert.equal(config.transport, "sse");
122
+ assert.equal(config.authToken, "secret");
123
+ assert.equal(config.httpPath, "/sse");
124
+ });
125
+
126
+ test("http 传输默认端点是 /mcp", () => {
127
+ const config = loadConfig(["--transport", "http", "--auth-token", "secret"]);
128
+ assert.equal(config.httpPath, "/mcp");
129
+ });
130
+
131
+ test("HTTP 监听参数可覆盖", () => {
132
+ const config = loadConfig([
133
+ "--transport",
134
+ "sse",
135
+ "--auth-token",
136
+ "secret",
137
+ "--http-port",
138
+ "8088",
139
+ "--http-host",
140
+ "0.0.0.0",
141
+ ]);
142
+ assert.equal(config.httpPort, 8088);
143
+ assert.equal(config.httpHost, "0.0.0.0");
144
+ });
145
+
146
+ test("地址拆分工具", () => {
147
+ assert.deepEqual(splitBaseUrls("http://a:8081"), ["http://a:8081"]);
148
+ assert.deepEqual(splitBaseUrls(" , "), []);
149
+ });
150
+
151
+ fs.rmSync(tmpDir, { recursive: true, force: true });
152
+
153
+ if (process.exitCode) {
154
+ console.error("\n有用例失败 ❌");
155
+ } else {
156
+ console.log(`\n全部通过(${passed} 个用例)✅`);
157
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 已发布包的端到端验证
3
+ *
4
+ * <p>用公网 npm 上的 @leadfund/lead-mcp(而不是本地源码)拉起 MCP Server,
5
+ * 打到 sit 环境,走完整协议握手,确认「公网安装 → MCP → 275 → MES」整条链路通。</p>
6
+ *
7
+ * <p>运行:node test/e2e-published.mjs [mobile]</p>
8
+ */
9
+
10
+ import { spawn } from "node:child_process";
11
+ import os from "node:os";
12
+
13
+ const MOBILE = process.argv[2] ?? "13800000000";
14
+ const ENV = process.env.LEAD_MCP_ENV ?? "sit";
15
+ const OPERATOR = process.env.LEAD_MCP_OPERATOR ?? "dhh_6234";
16
+
17
+ function start() {
18
+ // 直接用 npx 拉公网上的包,和 Claude Code 实际执行的命令一致
19
+ const child = spawn(
20
+ "npx",
21
+ ["-y", "--registry=https://registry.npmjs.org/", "@leadfund/lead-mcp", "--env", ENV],
22
+ {
23
+ stdio: ["pipe", "pipe", "pipe"],
24
+ // 不能在包自己的源码目录里跑:npx 会把本地 package.json 当作「已安装」,
25
+ // 直接去找 node_modules/.bin/lead-mcp,而它不是自己的依赖,软链不存在 → exit 127
26
+ cwd: os.tmpdir(),
27
+ },
28
+ );
29
+
30
+ const pending = new Map();
31
+ let buffer = "";
32
+ let stderr = "";
33
+
34
+ child.stdout.on("data", (chunk) => {
35
+ buffer += chunk.toString();
36
+ let index;
37
+ while ((index = buffer.indexOf("\n")) >= 0) {
38
+ const line = buffer.slice(0, index).trim();
39
+ buffer = buffer.slice(index + 1);
40
+ if (!line) continue;
41
+ const message = JSON.parse(line);
42
+ const resolver = pending.get(message.id);
43
+ if (resolver) {
44
+ pending.delete(message.id);
45
+ resolver(message);
46
+ }
47
+ }
48
+ });
49
+ child.stderr.on("data", (chunk) => {
50
+ stderr += chunk.toString();
51
+ });
52
+
53
+ let nextId = 1;
54
+ function send(method, params) {
55
+ const id = nextId++;
56
+ return new Promise((resolve, reject) => {
57
+ const timer = setTimeout(() => {
58
+ pending.delete(id);
59
+ reject(new Error(`超时: ${method}\n${stderr}`));
60
+ }, 60000);
61
+ pending.set(id, (response) => {
62
+ clearTimeout(timer);
63
+ resolve(response);
64
+ });
65
+ child.stdin.write(
66
+ `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`,
67
+ );
68
+ });
69
+ }
70
+ function notify(method, params) {
71
+ child.stdin.write(
72
+ `${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`,
73
+ );
74
+ }
75
+ return { child, send, notify, getStderr: () => stderr };
76
+ }
77
+
78
+ const mcp = start();
79
+ try {
80
+ const init = await mcp.send("initialize", {
81
+ protocolVersion: "2024-11-05",
82
+ capabilities: {},
83
+ clientInfo: { name: "e2e-published", version: "0.0.1" },
84
+ });
85
+ console.log("✓ initialize", JSON.stringify(init.result.serverInfo));
86
+ mcp.notify("notifications/initialized");
87
+
88
+ const list = await mcp.send("tools/list", {});
89
+ console.log(
90
+ "✓ tools/list",
91
+ list.result.tools.map((t) => t.name).join(", "),
92
+ );
93
+
94
+ const call = await mcp.send("tools/call", {
95
+ name: "dmmp_query_account",
96
+ arguments: { mobile: MOBILE },
97
+ });
98
+ console.log("✓ tools/call");
99
+ console.log(
100
+ call.result.content
101
+ .map((c) => c.text)
102
+ .join("\n")
103
+ .split("\n")
104
+ .map((l) => ` ${l}`)
105
+ .join("\n"),
106
+ );
107
+
108
+ const err = mcp.getStderr();
109
+ const envLine = err.split("\n").find((l) => l.includes("环境="));
110
+ if (envLine) {
111
+ console.log("✓ 启动日志", envLine.replace("[lead-mcp] INFO ", ""));
112
+ }
113
+ } catch (error) {
114
+ console.error("端到端验证失败:", error.message);
115
+ process.exitCode = 1;
116
+ } finally {
117
+ mcp.child.kill();
118
+ }
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bash
2
+ # 用 curl 完整走一遍 MCP over SSE 协议(不依赖任何 MCP 客户端)
3
+ #
4
+ # 用法:
5
+ # ./test/http-curl-demo.sh [端口] [令牌] [手机号]
6
+ #
7
+ # 默认:端口 3100、令牌 demo-token-abc、手机号 13800000000
8
+
9
+ set -euo pipefail
10
+
11
+ PORT="${1:-3100}"
12
+ TOKEN="${2:-demo-token-abc}"
13
+ MOBILE="${3:-13800000000}"
14
+ BASE="http://127.0.0.1:${PORT}"
15
+ STREAM="$(mktemp)"
16
+ trap 'rm -f "$STREAM"' EXIT
17
+
18
+ echo "▶ 1) 建立 SSE 流并取 sessionId"
19
+ curl -s -N --max-time 20 -H "Authorization: Bearer ${TOKEN}" "${BASE}/sse" > "$STREAM" &
20
+ CURL_PID=$!
21
+
22
+ SESSION_ID=""
23
+ for _ in $(seq 1 50); do
24
+ SESSION_ID=$(grep -o 'sessionId=[0-9a-f-]*' "$STREAM" 2>/dev/null | head -1 | cut -d= -f2 || true)
25
+ [ -n "$SESSION_ID" ] && break
26
+ sleep 0.2
27
+ done
28
+ [ -z "$SESSION_ID" ] && { echo "✗ 未能取得 sessionId"; kill $CURL_PID 2>/dev/null || true; exit 1; }
29
+ echo " sessionId=${SESSION_ID}"
30
+
31
+ echo "▶ 2) initialize"
32
+ curl -s -X POST "${BASE}/messages?sessionId=${SESSION_ID}" \
33
+ -H "Authorization: Bearer ${TOKEN}" \
34
+ -H "Content-Type: application/json" \
35
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-demo","version":"0.0.1"}}}' \
36
+ > /dev/null
37
+ sleep 1
38
+ grep -o '"serverInfo":{[^}]*}' "$STREAM" | head -1 | sed 's/^/ /' || echo " (未读到 initialize 响应)"
39
+
40
+ echo "▶ 3) notifications/initialized"
41
+ curl -s -X POST "${BASE}/messages?sessionId=${SESSION_ID}" \
42
+ -H "Authorization: Bearer ${TOKEN}" \
43
+ -H "Content-Type: application/json" \
44
+ -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' > /dev/null
45
+ sleep 1
46
+
47
+ echo "▶ 4) tools/list"
48
+ curl -s -X POST "${BASE}/messages?sessionId=${SESSION_ID}" \
49
+ -H "Authorization: Bearer ${TOKEN}" \
50
+ -H "Content-Type: application/json" \
51
+ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' > /dev/null
52
+ sleep 1
53
+ grep -o '"name":"dmmp_query_account"' "$STREAM" | head -1 | sed 's/^/ 工具: /' || echo " (未读到 tools/list 响应)"
54
+
55
+ echo "▶ 5) tools/call mobile=${MOBILE}"
56
+ curl -s -X POST "${BASE}/messages?sessionId=${SESSION_ID}" \
57
+ -H "Authorization: Bearer ${TOKEN}" \
58
+ -H "Content-Type: application/json" \
59
+ -d "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"dmmp_query_account\",\"arguments\":{\"mobile\":\"${MOBILE}\"}}}" > /dev/null
60
+ sleep 2
61
+ grep -o '"text":"[^"]*"' "$STREAM" | tail -1 | sed 's/^/ /' || echo " (未读到 tools/call 响应)"
62
+ echo " --- 原始帧 ---"
63
+ grep -A1 '"id":3' "$STREAM" | tail -1 | sed 's/^/ /' || true
64
+
65
+ kill $CURL_PID 2>/dev/null || true
66
+ echo
67
+ echo "✓ 完成"