@leadfund/lead-mcp 202610.1.0 → 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/http.js ADDED
@@ -0,0 +1,224 @@
1
+ import crypto from "node:crypto";
2
+ import http from "node:http";
3
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
+ import { createServer } from "./server.js";
6
+ import { logger } from "./logger.js";
7
+
8
+ /**
9
+ * HTTP(S) 传输
10
+ *
11
+ * <p>为云端 MCP 服务(如阿里云百炼)提供入口。百炼支持两种协议:</p>
12
+ * <ul>
13
+ * <li>{@code sse} —— {@code GET /sse} 建流,{@code POST /messages?sessionId=} 回传消息</li>
14
+ * <li>{@code http} —— Streamable HTTP,{@code POST /mcp} 一站式</li>
15
+ * </ul>
16
+ *
17
+ * <p><b>鉴权</b>:所有业务端点校验 {@code Authorization: Bearer <token>},
18
+ * 令牌由 {@code --auth-token} 配置,HTTP 模式下必填。</p>
19
+ */
20
+
21
+ /**
22
+ * 定长安全比较
23
+ *
24
+ * @param {string} a
25
+ * @param {string} b
26
+ * @returns {boolean} 是否相等
27
+ */
28
+ function timingSafeEqual(a, b) {
29
+ const bufA = Buffer.from(a ?? "", "utf8");
30
+ const bufB = Buffer.from(b ?? "", "utf8");
31
+ if (bufA.length !== bufB.length) {
32
+ return false;
33
+ }
34
+ return crypto.timingSafeEqual(bufA, bufB);
35
+ }
36
+
37
+ /**
38
+ * 校验 Authorization 头
39
+ *
40
+ * @param {http.IncomingMessage} req 请求
41
+ * @param {import("./config.js").LeadMcpConfig} config 配置
42
+ * @returns {boolean} 是否通过
43
+ */
44
+ function checkAuth(req, config) {
45
+ if (!config.authToken) {
46
+ return true;
47
+ }
48
+ const header = req.headers.authorization ?? "";
49
+ return timingSafeEqual(header, `Bearer ${config.authToken}`);
50
+ }
51
+
52
+ /**
53
+ * 读取并解析 JSON 请求体
54
+ *
55
+ * @param {http.IncomingMessage} req 请求
56
+ * @returns {Promise<unknown>} 解析后的 body
57
+ */
58
+ async function readJsonBody(req) {
59
+ const chunks = [];
60
+ for await (const chunk of req) {
61
+ chunks.push(chunk);
62
+ }
63
+ if (chunks.length === 0) {
64
+ return undefined;
65
+ }
66
+ const raw = Buffer.concat(chunks).toString("utf8");
67
+ if (!raw.trim()) {
68
+ return undefined;
69
+ }
70
+ return JSON.parse(raw);
71
+ }
72
+
73
+ /**
74
+ * 启动 HTTP 服务
75
+ *
76
+ * @param {import("./config.js").LeadMcpConfig} config 配置
77
+ * @returns {Promise<http.Server>} HTTP Server
78
+ */
79
+ export async function startHttpServer(config) {
80
+ /** @type {Map<string, {transport: any, server: any}>} */
81
+ const sessions = new Map();
82
+
83
+ const server = http.createServer(async (req, res) => {
84
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
85
+
86
+ // 健康检查不鉴权,方便探活
87
+ if (req.method === "GET" && url.pathname === "/health") {
88
+ res.writeHead(200, { "Content-Type": "application/json" });
89
+ res.end(
90
+ JSON.stringify({
91
+ ok: true,
92
+ transport: config.transport,
93
+ env: config.env,
94
+ sessions: sessions.size,
95
+ }),
96
+ );
97
+ return;
98
+ }
99
+
100
+ if (!checkAuth(req, config)) {
101
+ logger.warn("鉴权失败,已拒绝", { path: url.pathname, ip: req.socket.remoteAddress });
102
+ res.writeHead(401, { "Content-Type": "application/json" });
103
+ res.end(JSON.stringify({ error: "unauthorized" }));
104
+ return;
105
+ }
106
+
107
+ try {
108
+ if (config.transport === "sse") {
109
+ await handleSse(url, req, res, config, sessions);
110
+ } else {
111
+ await handleStreamableHttp(url, req, res, config, sessions);
112
+ }
113
+ } catch (error) {
114
+ logger.error("HTTP 请求处理失败", error);
115
+ if (!res.headersSent) {
116
+ res.writeHead(500, { "Content-Type": "application/json" });
117
+ res.end(JSON.stringify({ error: "internal error" }));
118
+ }
119
+ }
120
+ });
121
+
122
+ await new Promise((resolve, reject) => {
123
+ server.once("error", reject);
124
+ server.listen(config.httpPort, config.httpHost, resolve);
125
+ });
126
+
127
+ const displayHost =
128
+ config.httpHost === "0.0.0.0" || config.httpHost === "::" ? "127.0.0.1" : config.httpHost;
129
+ logger.info(
130
+ `HTTP 服务已启动,传输=${config.transport},端点=http://${displayHost}:${config.httpPort}${config.httpPath}` +
131
+ `,后端环境=${config.env},地址=${config.baseUrls.join(", ")}`,
132
+ );
133
+ if (config.httpHost === "0.0.0.0") {
134
+ logger.info("已监听所有网卡,可用 ng/隧道工具暴露到公网");
135
+ }
136
+
137
+ return server;
138
+ }
139
+
140
+ /**
141
+ * SSE 传输:GET 建流,POST /messages 回传
142
+ *
143
+ * @param {URL} url
144
+ * @param {http.IncomingMessage} req
145
+ * @param {http.ServerResponse} res
146
+ * @param {import("./config.js").LeadMcpConfig} config
147
+ * @param {Map<string, any>} sessions
148
+ */
149
+ async function handleSse(url, req, res, config, sessions) {
150
+ if (req.method === "GET" && url.pathname === config.httpPath) {
151
+ const transport = new SSEServerTransport("/messages", res);
152
+ const mcpServer = createServer(config);
153
+ sessions.set(transport.sessionId, { transport, server: mcpServer });
154
+ res.on("close", () => {
155
+ sessions.delete(transport.sessionId);
156
+ logger.info("SSE 连接关闭", { sessionId: transport.sessionId });
157
+ });
158
+ await mcpServer.connect(transport);
159
+ logger.info("SSE 连接建立", { sessionId: transport.sessionId });
160
+ return;
161
+ }
162
+
163
+ if (req.method === "POST" && url.pathname === "/messages") {
164
+ const sessionId = url.searchParams.get("sessionId") ?? "";
165
+ const entry = sessions.get(sessionId);
166
+ if (!entry) {
167
+ res.writeHead(404, { "Content-Type": "application/json" });
168
+ res.end(JSON.stringify({ error: "session not found" }));
169
+ return;
170
+ }
171
+ const body = await readJsonBody(req);
172
+ await entry.transport.handlePostMessage(req, res, body);
173
+ return;
174
+ }
175
+
176
+ res.writeHead(404, { "Content-Type": "application/json" });
177
+ res.end(JSON.stringify({ error: "not found" }));
178
+ }
179
+
180
+ /**
181
+ * Streamable HTTP 传输
182
+ *
183
+ * @param {URL} url
184
+ * @param {http.IncomingMessage} req
185
+ * @param {http.ServerResponse} res
186
+ * @param {import("./config.js").LeadMcpConfig} config
187
+ * @param {Map<string, any>} sessions
188
+ */
189
+ async function handleStreamableHttp(url, req, res, config, sessions) {
190
+ if (url.pathname !== config.httpPath) {
191
+ res.writeHead(404, { "Content-Type": "application/json" });
192
+ res.end(JSON.stringify({ error: "not found" }));
193
+ return;
194
+ }
195
+
196
+ const sessionId = req.headers["mcp-session-id"];
197
+ let entry = sessionId ? sessions.get(sessionId) : undefined;
198
+
199
+ if (!entry) {
200
+ if (req.method !== "POST") {
201
+ // 没有会话的 GET/DELETE 直接拒绝
202
+ res.writeHead(400, { "Content-Type": "application/json" });
203
+ res.end(JSON.stringify({ error: "missing mcp-session-id" }));
204
+ return;
205
+ }
206
+ const transport = new StreamableHTTPServerTransport({
207
+ sessionIdGenerator: () => crypto.randomUUID(),
208
+ onsessioninitialized: (id) => {
209
+ sessions.set(id, { transport, server: mcpServer });
210
+ logger.info("HTTP 会话建立", { sessionId: id });
211
+ },
212
+ onsessionclosed: (id) => {
213
+ sessions.delete(id);
214
+ logger.info("HTTP 会话关闭", { sessionId: id });
215
+ },
216
+ });
217
+ const mcpServer = createServer(config);
218
+ await mcpServer.connect(transport);
219
+ entry = { transport, server: mcpServer };
220
+ }
221
+
222
+ const body = req.method === "POST" ? await readJsonBody(req) : undefined;
223
+ await entry.transport.handleRequest(req, res, body);
224
+ }
@@ -5,7 +5,16 @@
5
5
  */
6
6
 
7
7
  import assert from "node:assert/strict";
8
- import { ENVIRONMENTS, loadConfig, normalizeEnv, splitBaseUrls } from "../src/config.js";
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";
9
18
 
10
19
  let passed = 0;
11
20
 
@@ -26,38 +35,54 @@ function test(name, fn) {
26
35
  }
27
36
  }
28
37
 
29
- test("环境名归一化", () => {
30
- assert.equal(normalizeEnv("PRD"), "prd");
31
- assert.equal(normalizeEnv(" sit "), "sit");
32
- });
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;
33
52
 
34
- test("未知环境名直接报错,不静默回落", () => {
35
- assert.throws(() => normalizeEnv("pro"), /未知环境/);
36
- assert.throws(() => normalizeEnv(""), /未知环境/);
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
+ );
37
60
  });
38
61
 
39
- test("默认环境是 local", () => {
40
- const config = loadConfig([]);
41
- assert.equal(config.env, "local");
42
- assert.deepEqual(config.baseUrls, ENVIRONMENTS.local.baseUrls);
62
+ test("配置文件路径可覆盖", () => {
63
+ assert.equal(resolveConfigFile(), configFile);
43
64
  });
44
65
 
45
- test("--env 选择环境", () => {
46
- const config = loadConfig(["--env", "sit"]);
66
+ test("默认用配置文件里的 defaultEnvironment", () => {
67
+ const config = loadConfig([]);
47
68
  assert.equal(config.env, "sit");
48
69
  assert.deepEqual(config.baseUrls, ["http://10.1.1.197:8081"]);
49
70
  });
50
71
 
51
- test("生产环境带两个节点", () => {
72
+ test("--env 从用户配置里取,支持多地址", () => {
52
73
  const config = loadConfig(["--env", "prd"]);
53
74
  assert.equal(config.env, "prd");
54
- assert.equal(config.baseUrls.length, 2);
55
75
  assert.deepEqual(config.baseUrls, [
56
76
  "http://10.32.30.208:8081",
57
77
  "http://10.32.30.209:8081",
58
78
  ]);
59
79
  });
60
80
 
81
+ test("未知环境直接报错并提示怎么配", () => {
82
+ assert.throws(() => loadConfig(["--env", "pro"]), /未知环境/);
83
+ assert.throws(() => loadConfig(["--env", "pro"]), /config\.json/);
84
+ });
85
+
61
86
  test("--base-url 优先级高于 --env", () => {
62
87
  const config = loadConfig(["--env", "prd", "--base-url", "http://example.com:8081"]);
63
88
  assert.deepEqual(config.baseUrls, ["http://example.com:8081"]);
@@ -71,19 +96,51 @@ test("--base-url 支持逗号分隔多节点", () => {
71
96
  assert.deepEqual(config.baseUrls, ["http://a:8081", "http://b:8081"]);
72
97
  });
73
98
 
74
- test("未指定 --base-url 时环境标识为 custom", () => {
99
+ test("未指定 --base-url 时环境标识跟随实际环境", () => {
75
100
  const config = loadConfig(["--base-url", "http://example.com:8081"]);
76
101
  assert.equal(config.env, "custom");
77
102
  });
78
103
 
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
- }
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");
87
144
  });
88
145
 
89
146
  test("地址拆分工具", () => {
@@ -91,6 +148,8 @@ test("地址拆分工具", () => {
91
148
  assert.deepEqual(splitBaseUrls(" , "), []);
92
149
  });
93
150
 
151
+ fs.rmSync(tmpDir, { recursive: true, force: true });
152
+
94
153
  if (process.exitCode) {
95
154
  console.error("\n有用例失败 ❌");
96
155
  } else {
@@ -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 "✓ 完成"