@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
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>配置来源优先级:命令行参数 > 环境变量 > 用户配置文件 > 内置默认值。</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
|
+
};
|