@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 +283 -2
- package/package.json +47 -4
- package/src/cli.js +34 -0
- package/src/client.js +182 -0
- package/src/config.js +297 -0
- package/src/http.js +224 -0
- package/src/logger.js +64 -0
- package/src/server.js +190 -0
- package/test/config.test.mjs +157 -0
- package/test/e2e-published.mjs +118 -0
- package/test/http-curl-demo.sh +67 -0
- package/test/http.test.mjs +284 -0
- package/test/smoke.mjs +348 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP(S) 传输端到端测试
|
|
3
|
+
*
|
|
4
|
+
* <p>覆盖两种云端协议:</p>
|
|
5
|
+
* <ul>
|
|
6
|
+
* <li>{@code sse} —— GET /sse 建流 + POST /messages 回传</li>
|
|
7
|
+
* <li>{@code http} —— Streamable HTTP 的 POST /mcp</li>
|
|
8
|
+
* </ul>
|
|
9
|
+
*
|
|
10
|
+
* <p>同时验证鉴权:没有 Bearer 令牌一律 401。</p>
|
|
11
|
+
*
|
|
12
|
+
* <p>运行:node test/http.test.mjs</p>
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { createServer } from "node:http";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
|
|
22
|
+
const CLI = path.join(
|
|
23
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
24
|
+
"..",
|
|
25
|
+
"src",
|
|
26
|
+
"cli.js",
|
|
27
|
+
);
|
|
28
|
+
const AUTH_TOKEN = "test-token-123";
|
|
29
|
+
|
|
30
|
+
let passed = 0;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 断言
|
|
34
|
+
*
|
|
35
|
+
* @param {string} name 用例名
|
|
36
|
+
* @param {Function} fn 断言函数
|
|
37
|
+
*/
|
|
38
|
+
async function test(name, fn) {
|
|
39
|
+
try {
|
|
40
|
+
await fn();
|
|
41
|
+
passed += 1;
|
|
42
|
+
console.log(`✓ ${name}`);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(`✗ ${name}\n ${error.message}`);
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 起一个 mock 的 dmmp-system
|
|
51
|
+
*/
|
|
52
|
+
async function startMockBackend() {
|
|
53
|
+
const server = createServer((req, res) => {
|
|
54
|
+
let body = "";
|
|
55
|
+
req.on("data", (c) => {
|
|
56
|
+
body += c;
|
|
57
|
+
});
|
|
58
|
+
req.on("end", () => {
|
|
59
|
+
res.setHeader("Content-Type", "application/json");
|
|
60
|
+
res.end(
|
|
61
|
+
JSON.stringify({
|
|
62
|
+
code: 0,
|
|
63
|
+
message: "接口调用成功",
|
|
64
|
+
data: {
|
|
65
|
+
found: true,
|
|
66
|
+
memberId: "M10086",
|
|
67
|
+
memberIdList: ["M10086"],
|
|
68
|
+
name: "张*",
|
|
69
|
+
mobileFormat: "138****0000",
|
|
70
|
+
idTypeFormat: "身份证",
|
|
71
|
+
idNoFormat: "310101********1234",
|
|
72
|
+
idValidityFormat: "长期有效",
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
79
|
+
return { server, port: server.address().port };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 起一个 lead-mcp 的 HTTP 模式实例
|
|
84
|
+
*/
|
|
85
|
+
async function startHttpMode(transport, extraArgs = []) {
|
|
86
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lead-mcp-http-"));
|
|
87
|
+
const child = spawn(
|
|
88
|
+
process.execPath,
|
|
89
|
+
[
|
|
90
|
+
CLI,
|
|
91
|
+
"--transport",
|
|
92
|
+
transport,
|
|
93
|
+
"--auth-token",
|
|
94
|
+
AUTH_TOKEN,
|
|
95
|
+
"--http-port",
|
|
96
|
+
"0",
|
|
97
|
+
...extraArgs,
|
|
98
|
+
],
|
|
99
|
+
{ stdio: ["pipe", "pipe", "pipe"] },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
let stderr = "";
|
|
103
|
+
child.stderr.on("data", (c) => {
|
|
104
|
+
stderr += c.toString();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// 从启动日志里拿实际端口(--http-port 0 表示随机端口)
|
|
108
|
+
const port = await new Promise((resolve, reject) => {
|
|
109
|
+
const timer = setTimeout(
|
|
110
|
+
() => reject(new Error(`启动超时\n${stderr}`)),
|
|
111
|
+
15000,
|
|
112
|
+
);
|
|
113
|
+
child.stderr.on("data", () => {
|
|
114
|
+
const match = stderr.match(/端点=http:\/\/[^:]+:(\d+)/);
|
|
115
|
+
if (match) {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
resolve(Number(match[1]));
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return { child, port, getStderr: () => stderr };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* SSE:建立流,拿到 sessionId,再通过 POST /messages 发 initialize
|
|
127
|
+
*/
|
|
128
|
+
async function sseHandshake(port, withAuth = true) {
|
|
129
|
+
const headers = withAuth ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {};
|
|
130
|
+
const controller = new AbortController();
|
|
131
|
+
const res = await fetch(`http://127.0.0.1:${port}/sse`, {
|
|
132
|
+
headers,
|
|
133
|
+
signal: controller.signal,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const reader = res.body.getReader();
|
|
137
|
+
const decoder = new TextDecoder();
|
|
138
|
+
let buffer = "";
|
|
139
|
+
let sessionId = null;
|
|
140
|
+
let firstMessage = null;
|
|
141
|
+
|
|
142
|
+
// 读第一帧:event: endpoint 里带 sessionId
|
|
143
|
+
while (!sessionId) {
|
|
144
|
+
const { value, done } = await reader.read();
|
|
145
|
+
if (done) break;
|
|
146
|
+
buffer += decoder.decode(value, { stream: true });
|
|
147
|
+
const match = buffer.match(/sessionId=([0-9a-f-]+)/);
|
|
148
|
+
if (match) {
|
|
149
|
+
sessionId = match[1];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const post = async (message) => {
|
|
154
|
+
await fetch(`http://127.0.0.1:${port}/messages?sessionId=${sessionId}`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
157
|
+
body: JSON.stringify(message),
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
await post({
|
|
162
|
+
jsonrpc: "2.0",
|
|
163
|
+
id: 1,
|
|
164
|
+
method: "initialize",
|
|
165
|
+
params: {
|
|
166
|
+
protocolVersion: "2024-11-05",
|
|
167
|
+
capabilities: {},
|
|
168
|
+
clientInfo: { name: "http-test", version: "0.0.1" },
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// 从流里读 initialize 的响应
|
|
173
|
+
const deadline = Date.now() + 10000;
|
|
174
|
+
while (!firstMessage && Date.now() < deadline) {
|
|
175
|
+
const { value, done } = await reader.read();
|
|
176
|
+
if (done) break;
|
|
177
|
+
buffer += decoder.decode(value, { stream: true });
|
|
178
|
+
const match = buffer.match(/data: (\{.*?\})\r?\n/);
|
|
179
|
+
if (match) {
|
|
180
|
+
firstMessage = JSON.parse(match[1]);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
controller.abort();
|
|
185
|
+
return { sessionId, firstMessage };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function main() {
|
|
189
|
+
const mock = await startMockBackend();
|
|
190
|
+
const baseUrl = `http://127.0.0.1:${mock.port}`;
|
|
191
|
+
|
|
192
|
+
// 配置文件里指定后端地址
|
|
193
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lead-mcp-cfg-"));
|
|
194
|
+
const configFile = path.join(tmpDir, "config.json");
|
|
195
|
+
fs.writeFileSync(
|
|
196
|
+
configFile,
|
|
197
|
+
JSON.stringify({ environments: { test: [baseUrl] } }),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// ---- SSE ----
|
|
201
|
+
const sse = await startHttpMode("sse", [
|
|
202
|
+
"--base-url",
|
|
203
|
+
baseUrl,
|
|
204
|
+
"--env",
|
|
205
|
+
"test",
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
await test("SSE:未带令牌返回 401", async () => {
|
|
209
|
+
const res = await fetch(`http://127.0.0.1:${sse.port}/sse`);
|
|
210
|
+
assert.equal(res.status, 401);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await test("SSE:健康检查不鉴权", async () => {
|
|
214
|
+
const res = await fetch(`http://127.0.0.1:${sse.port}/health`);
|
|
215
|
+
assert.equal(res.status, 200);
|
|
216
|
+
const body = await res.json();
|
|
217
|
+
assert.equal(body.ok, true);
|
|
218
|
+
assert.equal(body.transport, "sse");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await test("SSE:握手并能查到客户信息", async () => {
|
|
222
|
+
const { sessionId, firstMessage } = await sseHandshake(sse.port);
|
|
223
|
+
assert.ok(sessionId, "应拿到 sessionId");
|
|
224
|
+
assert.ok(firstMessage, "应收到 initialize 响应");
|
|
225
|
+
assert.equal(firstMessage.result.serverInfo.name, "lead-mcp");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
sse.child.kill();
|
|
229
|
+
|
|
230
|
+
// ---- Streamable HTTP ----
|
|
231
|
+
const stream = await startHttpMode("http", ["--base-url", baseUrl]);
|
|
232
|
+
|
|
233
|
+
await test("HTTP:未带令牌返回 401", async () => {
|
|
234
|
+
const res = await fetch(`http://127.0.0.1:${stream.port}/mcp`, {
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: { "Content-Type": "application/json" },
|
|
237
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize" }),
|
|
238
|
+
});
|
|
239
|
+
assert.equal(res.status, 401);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await test("HTTP:initialize 返回服务器信息", async () => {
|
|
243
|
+
const res = await fetch(`http://127.0.0.1:${stream.port}/mcp`, {
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers: {
|
|
246
|
+
"Content-Type": "application/json",
|
|
247
|
+
Accept: "application/json, text/event-stream",
|
|
248
|
+
Authorization: `Bearer ${AUTH_TOKEN}`,
|
|
249
|
+
},
|
|
250
|
+
body: JSON.stringify({
|
|
251
|
+
jsonrpc: "2.0",
|
|
252
|
+
id: 1,
|
|
253
|
+
method: "initialize",
|
|
254
|
+
params: {
|
|
255
|
+
protocolVersion: "2024-11-05",
|
|
256
|
+
capabilities: {},
|
|
257
|
+
clientInfo: { name: "http-test", version: "0.0.1" },
|
|
258
|
+
},
|
|
259
|
+
}),
|
|
260
|
+
});
|
|
261
|
+
assert.equal(res.status, 200);
|
|
262
|
+
const text = await res.text();
|
|
263
|
+
// Streamable HTTP 可能以 SSE 帧返回,data 后面是完整的嵌套 JSON
|
|
264
|
+
const match = text.match(/data: (\{[\s\S]*\})/) ?? text.match(/(\{[\s\S]*\})/);
|
|
265
|
+
assert.ok(match, `应返回 MCP 响应,实际: ${text.slice(0, 200)}`);
|
|
266
|
+
const body = JSON.parse(match[1]);
|
|
267
|
+
assert.equal(body.result.serverInfo.name, "lead-mcp");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
stream.child.kill();
|
|
271
|
+
mock.server.close();
|
|
272
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
273
|
+
|
|
274
|
+
if (process.exitCode) {
|
|
275
|
+
console.error("\n有用例失败 ❌");
|
|
276
|
+
} else {
|
|
277
|
+
console.log(`\n全部通过(${passed} 个用例)✅`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
main().catch((error) => {
|
|
282
|
+
console.error("HTTP 测试失败:", error);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
});
|
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
|
+
});
|