@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/config.js ADDED
@@ -0,0 +1,297 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * 运行配置
7
+ *
8
+ * <p>配置来源优先级:命令行参数 &gt; 环境变量 &gt; 用户配置文件 &gt; 内置默认值。</p>
9
+ *
10
+ * <p><b>服务地址不在包里</b>:包内只保留 local 默认地址,
11
+ * sit / prd 等环境地址由使用方在自己的配置文件
12
+ * {@code ~/.lead-mcp/config.json} 或 {@code --base-url} 里指定。</p>
13
+ */
14
+
15
+ /**
16
+ * @typedef {Object} LeadMcpConfig
17
+ * @property {string} env 环境标识,会随请求透传给后端用于审计
18
+ * @property {string[]} baseUrls dmmp-system(系统 id 275)服务地址列表,多个时按顺序故障转移
19
+ * @property {number} timeoutMs 单次请求超时(毫秒)
20
+ * @property {string|undefined} token 访问令牌,可选
21
+ * @property {string|undefined} operator 操作人标识(工号),用于服务端限流与审计
22
+ * @property {string|undefined} operatorOs 操作人操作系统账号
23
+ * @property {"stdio"|"sse"|"http"} transport 传输方式
24
+ * @property {number} httpPort HTTP 监听端口
25
+ * @property {string} httpHost HTTP 监听地址
26
+ * @property {string} httpPath HTTP 端点路径
27
+ * @property {string|undefined} authToken HTTP 访问令牌(Bearer),HTTP 模式下必填
28
+ */
29
+
30
+ /**
31
+ * 内置环境
32
+ *
33
+ * <p>只保留本地开发地址。内网地址一律由使用方配置,避免随包公开。</p>
34
+ */
35
+ export const BUILTIN_ENVIRONMENTS = {
36
+ local: {
37
+ label: "本地开发",
38
+ baseUrls: ["http://localhost:8081"],
39
+ },
40
+ };
41
+
42
+ export const DEFAULT_ENV = "local";
43
+ export const DEFAULT_TIMEOUT_MS = 10000;
44
+ export const DEFAULT_HTTP_PORT = 3100;
45
+ export const DEFAULT_HTTP_HOST = "127.0.0.1";
46
+
47
+ /**
48
+ * 传输方式
49
+ */
50
+ export const TRANSPORTS = ["stdio", "sse", "http"];
51
+
52
+ export const USAGE = `lead-mcp —— 利得零售系统 MCP Server
53
+
54
+ 用法:
55
+ lead-mcp [选项]
56
+
57
+ 服务地址(dmmp-system 275)
58
+ --env <环境> 目标环境名,从用户配置文件的 environments 里取
59
+ 也可用环境变量 LEAD_MCP_ENV
60
+ --base-url <url> 直接指定服务地址,逗号分隔多个时按顺序故障转移
61
+ 优先级高于 --env,也可用环境变量 LEAD_MCP_BASE_URL
62
+ 例:--base-url http://10.1.1.197:8081
63
+ --base-url http://a:8081,http://b:8081
64
+
65
+ 传输方式
66
+ --transport <方式> stdio(默认,本地 MCP Client)/ sse / http
67
+ 也可用环境变量 LEAD_MCP_TRANSPORT
68
+ --http-port <端口> HTTP 监听端口,默认 ${DEFAULT_HTTP_PORT}
69
+ --http-host <地址> HTTP 监听地址,默认 ${DEFAULT_HTTP_HOST}
70
+ 需要对外暴露时传 0.0.0.0
71
+ --http-path <路径> HTTP 端点路径,默认 sse 用 /sse、http 用 /mcp
72
+ --auth-token <token> HTTP 访问令牌,HTTP 模式下必填(Bearer 认证)
73
+ 也可用环境变量 LEAD_MCP_AUTH_TOKEN
74
+
75
+ 通用
76
+ --timeout <ms> 单次请求超时毫秒数,默认 ${DEFAULT_TIMEOUT_MS}
77
+ --token <token> dmmp-system 访问令牌,可选
78
+ --operator <工号> 操作人标识,用于服务端限流与审计;不配置则用操作系统账号
79
+ -h, --help 查看帮助
80
+
81
+ 用户配置文件(默认 ~/.lead-mcp/config.json,可用 LEAD_MCP_CONFIG 指定):
82
+ {
83
+ "defaultEnvironment": "sit",
84
+ "environments": {
85
+ "sit": ["http://10.1.1.197:8081"],
86
+ "prd": ["http://10.32.30.208:8081", "http://10.32.30.209:8081"]
87
+ }
88
+ }
89
+ `;
90
+
91
+ /**
92
+ * 配置文件路径
93
+ *
94
+ * @returns {string} 路径
95
+ */
96
+ export function resolveConfigFile() {
97
+ return (
98
+ process.env.LEAD_MCP_CONFIG ??
99
+ path.join(os.homedir(), ".lead-mcp", "config.json")
100
+ );
101
+ }
102
+
103
+ /**
104
+ * 读取用户配置文件
105
+ *
106
+ * @returns {{defaultEnvironment?: string, environments?: Record<string, string[]>}} 配置
107
+ * @throws {Error} 配置文件存在但格式非法
108
+ */
109
+ export function loadUserConfig() {
110
+ const file = resolveConfigFile();
111
+ try {
112
+ const raw = fs.readFileSync(file, "utf8");
113
+ const parsed = JSON.parse(raw);
114
+ if (!parsed || typeof parsed !== "object") {
115
+ throw new Error("格式必须是 JSON 对象");
116
+ }
117
+ return parsed;
118
+ } catch (error) {
119
+ if (error.code === "ENOENT") {
120
+ return {};
121
+ }
122
+ throw new Error(`读取配置文件失败 ${file}:${error.message}`);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * 解析操作人的操作系统账号
128
+ *
129
+ * @returns {string|undefined}
130
+ */
131
+ export function resolveOperatorOs() {
132
+ try {
133
+ const info = os.userInfo();
134
+ if (info && info.username) {
135
+ return info.username;
136
+ }
137
+ } catch {
138
+ // 容器环境下可能拿不到,忽略
139
+ }
140
+ return process.env.USER || process.env.USERNAME || undefined;
141
+ }
142
+
143
+ /**
144
+ * 解析 --key value 形式的参数
145
+ *
146
+ * @param {string[]} argv
147
+ * @returns {Map<string, string>}
148
+ */
149
+ export function parseArgs(argv) {
150
+ const result = new Map();
151
+ for (let i = 0; i < argv.length; i += 1) {
152
+ const arg = argv[i];
153
+ if (!arg.startsWith("--")) {
154
+ continue;
155
+ }
156
+ const key = arg.slice(2);
157
+ const next = argv[i + 1];
158
+ if (next === undefined || next.startsWith("--")) {
159
+ result.set(key, "true");
160
+ continue;
161
+ }
162
+ result.set(key, next);
163
+ i += 1;
164
+ }
165
+ return result;
166
+ }
167
+
168
+ /**
169
+ * 解析服务地址列表
170
+ *
171
+ * @param {string} value 逗号分隔的地址
172
+ * @returns {string[]} 地址列表
173
+ */
174
+ export function splitBaseUrls(value) {
175
+ return String(value)
176
+ .split(",")
177
+ .map((item) => item.trim().replace(/\/+$/, ""))
178
+ .filter((item) => item.length > 0);
179
+ }
180
+
181
+ /**
182
+ * 解析传输方式
183
+ *
184
+ * @param {string} name 传输方式
185
+ * @returns {"stdio"|"sse"|"http"} 传输方式
186
+ * @throws {Error} 非法
187
+ */
188
+ export function normalizeTransport(name) {
189
+ const transport = String(name).trim().toLowerCase();
190
+ if (!TRANSPORTS.includes(transport)) {
191
+ throw new Error(`未知传输方式 "${name}",可选值:${TRANSPORTS.join(" / ")}`);
192
+ }
193
+ return transport;
194
+ }
195
+
196
+ /**
197
+ * 加载配置
198
+ *
199
+ * @param {string[]} [argv]
200
+ * @returns {LeadMcpConfig}
201
+ * @throws {Error} 配置非法
202
+ */
203
+ export function loadConfig(argv = process.argv.slice(2)) {
204
+ const args = parseArgs(argv);
205
+ const userConfig = loadUserConfig();
206
+
207
+ const rawEnv = args.get("env") ?? process.env.LEAD_MCP_ENV;
208
+ const rawBaseUrl = args.get("base-url") ?? process.env.LEAD_MCP_BASE_URL;
209
+ const hasBaseUrl = Boolean(rawBaseUrl) && rawBaseUrl !== "true";
210
+
211
+ // 解析服务地址:显式地址 > 用户配置的环境 > 内置 local
212
+ let env;
213
+ let baseUrls;
214
+ if (hasBaseUrl) {
215
+ env = rawEnv && rawEnv !== "true" ? String(rawEnv).trim() : "custom";
216
+ baseUrls = splitBaseUrls(rawBaseUrl);
217
+ } else {
218
+ const envName =
219
+ (rawEnv && rawEnv !== "true" ? rawEnv : undefined) ??
220
+ userConfig.defaultEnvironment ??
221
+ DEFAULT_ENV;
222
+ const configured = userConfig.environments?.[envName];
223
+ const builtin = BUILTIN_ENVIRONMENTS[envName];
224
+ if (!configured && !builtin) {
225
+ throw new Error(
226
+ `未知环境 "${envName}"。请在 ${resolveConfigFile()} 的 environments 里配置,` +
227
+ `或直接用 --base-url 指定地址`,
228
+ );
229
+ }
230
+ env = envName;
231
+ baseUrls = configured ?? builtin.baseUrls;
232
+ }
233
+ if (!Array.isArray(baseUrls) || baseUrls.length === 0) {
234
+ throw new Error(`环境 "${env}" 没有配置服务地址`);
235
+ }
236
+
237
+ // 传输方式
238
+ const rawTransport = args.get("transport") ?? process.env.LEAD_MCP_TRANSPORT;
239
+ const transport = normalizeTransport(
240
+ rawTransport && rawTransport !== "true" ? rawTransport : "stdio",
241
+ );
242
+
243
+ const portRaw = args.get("http-port") ?? process.env.LEAD_MCP_HTTP_PORT;
244
+ const httpPort = portRaw ? Number.parseInt(portRaw, 10) : DEFAULT_HTTP_PORT;
245
+ const hostRaw = args.get("http-host") ?? process.env.LEAD_MCP_HTTP_HOST;
246
+ const httpHost = hostRaw && hostRaw !== "true" ? hostRaw : DEFAULT_HTTP_HOST;
247
+ const pathRaw = args.get("http-path");
248
+ const httpPath =
249
+ pathRaw && pathRaw !== "true"
250
+ ? pathRaw
251
+ : transport === "sse"
252
+ ? "/sse"
253
+ : transport === "http"
254
+ ? "/mcp"
255
+ : "/mcp";
256
+
257
+ const authRaw = args.get("auth-token") ?? process.env.LEAD_MCP_AUTH_TOKEN;
258
+ const authToken = authRaw && authRaw !== "true" ? authRaw : undefined;
259
+
260
+ // HTTP 模式会被暴露到公网(典型是走 ng 隧道),没有令牌等于不设防
261
+ if (transport !== "stdio" && !authToken) {
262
+ throw new Error(
263
+ "HTTP 模式必须配置访问令牌:请用 --auth-token 或环境变量 LEAD_MCP_AUTH_TOKEN",
264
+ );
265
+ }
266
+
267
+ const timeoutRaw = args.get("timeout") ?? process.env.LEAD_MCP_TIMEOUT_MS;
268
+ const timeoutMs = timeoutRaw ? Number.parseInt(timeoutRaw, 10) : DEFAULT_TIMEOUT_MS;
269
+ const token = args.get("token") ?? process.env.LEAD_MCP_TOKEN;
270
+ const operatorRaw = args.get("operator") ?? process.env.LEAD_MCP_OPERATOR;
271
+ const operator = operatorRaw && operatorRaw !== "true" ? operatorRaw.trim() : undefined;
272
+
273
+ return {
274
+ env,
275
+ baseUrls,
276
+ timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS,
277
+ token: token && token !== "true" ? token : undefined,
278
+ // 未显式配置工号时退化为操作系统账号,保证服务端审计至少有「谁」
279
+ operator: operator ?? resolveOperatorOs(),
280
+ operatorOs: resolveOperatorOs(),
281
+ transport,
282
+ httpPort: Number.isFinite(httpPort) && httpPort > 0 ? httpPort : DEFAULT_HTTP_PORT,
283
+ httpHost,
284
+ httpPath,
285
+ authToken,
286
+ };
287
+ }
288
+
289
+ /**
290
+ * 是否请求帮助
291
+ *
292
+ * @param {string[]} [argv]
293
+ * @returns {boolean}
294
+ */
295
+ export function isHelpRequested(argv = process.argv.slice(2)) {
296
+ return argv.includes("--help") || argv.includes("-h");
297
+ }
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
+ }
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
+ };