@epoch-agent/plugin-mcp 0.1.0 → 0.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/dist/index.js +71 -306
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -9,7 +9,6 @@ function configFingerprint(config) {
|
|
|
9
9
|
args: config.args ?? [],
|
|
10
10
|
url: config.url ?? null,
|
|
11
11
|
streamable: config.streamable ?? false,
|
|
12
|
-
// header 的值可能是 token,指纹只取键名 + 值的哈希,不落明文
|
|
13
12
|
headers: Object.keys(config.headers ?? {}).sort(),
|
|
14
13
|
headersDigest: hash(JSON.stringify(config.headers ?? {})),
|
|
15
14
|
env: Object.keys(config.env ?? {}).sort()
|
|
@@ -20,34 +19,25 @@ function hash(input) {
|
|
|
20
19
|
return createHash("sha256").update(input, "utf-8").digest("hex");
|
|
21
20
|
}
|
|
22
21
|
var McpSchemaCache = class {
|
|
23
|
-
/** @param cacheFile 磁盘缓存文件路径;传 null 表示只用内存(测试 / 无家目录场景) */
|
|
24
22
|
constructor(cacheFile = null) {
|
|
25
23
|
this.cacheFile = cacheFile;
|
|
26
24
|
}
|
|
27
25
|
cacheFile;
|
|
28
26
|
memory = /* @__PURE__ */ new Map();
|
|
29
27
|
disk = null;
|
|
30
|
-
/** 内存命中才算「新鲜」—— 磁盘的走 {@link getPersisted} */
|
|
31
28
|
getFresh(name, fingerprint) {
|
|
32
29
|
const entry = this.memory.get(name);
|
|
33
30
|
return entry && entry.fingerprint === fingerprint ? entry.tools : null;
|
|
34
31
|
}
|
|
35
|
-
/** 上次成功落盘的 schema,仅在 live 调用失败时作为降级使用 */
|
|
36
32
|
getPersisted(name, fingerprint) {
|
|
37
33
|
const entry = this.loadDisk()[name];
|
|
38
34
|
return entry && entry.fingerprint === fingerprint ? entry.tools : null;
|
|
39
35
|
}
|
|
40
|
-
/** live 拉取成功后写入两层 */
|
|
41
36
|
set(name, fingerprint, tools) {
|
|
42
37
|
const entry = { fingerprint, tools };
|
|
43
38
|
this.memory.set(name, entry);
|
|
44
39
|
this.writeDisk(name, entry);
|
|
45
40
|
}
|
|
46
|
-
/**
|
|
47
|
-
* 显式失效。不传 name 清全部。
|
|
48
|
-
*
|
|
49
|
-
* 调用方:`McpRegistry.reconnect()`,以及将来的 `notifications/tools/list_changed`。
|
|
50
|
-
*/
|
|
51
41
|
invalidate(name) {
|
|
52
42
|
if (name === void 0) {
|
|
53
43
|
this.memory.clear();
|
|
@@ -55,7 +45,6 @@ var McpSchemaCache = class {
|
|
|
55
45
|
}
|
|
56
46
|
this.memory.delete(name);
|
|
57
47
|
}
|
|
58
|
-
// ---- 磁盘 ----
|
|
59
48
|
loadDisk() {
|
|
60
49
|
if (this.disk) return this.disk;
|
|
61
50
|
if (!this.cacheFile) {
|
|
@@ -90,11 +79,9 @@ function isDiskShape(value) {
|
|
|
90
79
|
(entry) => typeof entry === "object" && entry !== null && typeof entry.fingerprint === "string" && Array.isArray(entry.tools)
|
|
91
80
|
);
|
|
92
81
|
}
|
|
93
|
-
|
|
94
82
|
// src/client.ts
|
|
95
83
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
96
84
|
import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
97
|
-
|
|
98
85
|
// src/content.ts
|
|
99
86
|
var DEFAULT_MEDIA_TYPE = "application/octet-stream";
|
|
100
87
|
function str(v) {
|
|
@@ -143,8 +130,8 @@ function convertMcpContent(content) {
|
|
|
143
130
|
const type = typeof item?.type === "string" ? item.type : void 0;
|
|
144
131
|
switch (type) {
|
|
145
132
|
case "text": {
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
133
|
+
const t10 = str(item.text);
|
|
134
|
+
if (t10) texts.push(t10);
|
|
148
135
|
break;
|
|
149
136
|
}
|
|
150
137
|
case "image":
|
|
@@ -170,45 +157,40 @@ function convertMcpContent(content) {
|
|
|
170
157
|
}
|
|
171
158
|
return { text: texts.join("\n"), artifacts };
|
|
172
159
|
}
|
|
173
|
-
|
|
174
160
|
// src/oauth/attach.ts
|
|
175
161
|
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
176
|
-
|
|
162
|
+
import { t as t3 } from "@epoch-agent/infra";
|
|
177
163
|
// src/oauth/provider.ts
|
|
178
164
|
import { randomBytes } from "crypto";
|
|
179
|
-
|
|
165
|
+
import { t as t2 } from "@epoch-agent/infra";
|
|
180
166
|
// src/oauth/types.ts
|
|
167
|
+
import { t } from "@epoch-agent/infra";
|
|
181
168
|
var McpLoginRequiredError = class extends Error {
|
|
182
169
|
constructor(server, detail) {
|
|
183
170
|
super(
|
|
184
|
-
|
|
171
|
+
t("mcp_oauth.needs_login", { server }) + (detail ? t("mcp_oauth.needs_login_detail", { detail }) : "")
|
|
185
172
|
);
|
|
186
173
|
this.server = server;
|
|
187
174
|
this.name = "McpLoginRequiredError";
|
|
188
175
|
}
|
|
189
176
|
server;
|
|
190
177
|
};
|
|
191
|
-
|
|
192
178
|
// src/oauth/provider.ts
|
|
193
179
|
var CLIENT_NAME = "epoch-agent";
|
|
194
180
|
var CLIENT_URI = "https://gitlab.linkworld.cn/chenboxuan/epoch-agent";
|
|
195
181
|
var PLACEHOLDER_REDIRECT_URI = "http://127.0.0.1:0/callback";
|
|
196
182
|
var McpOAuthProvider = class {
|
|
197
183
|
opts;
|
|
198
|
-
/** 本次流程生成的 state,回调时用来验 CSRF */
|
|
199
184
|
currentState;
|
|
200
185
|
constructor(opts) {
|
|
201
186
|
this.opts = opts;
|
|
202
187
|
}
|
|
203
|
-
/** 本次授权流程用的 state,回调服务器拿它比对 */
|
|
204
188
|
get expectedState() {
|
|
205
189
|
return this.currentState;
|
|
206
190
|
}
|
|
207
|
-
/** 见 `PLACEHOLDER_REDIRECT_URI`:这里返回 undefined 会让 SDK 跳过 refresh */
|
|
208
191
|
get redirectUrl() {
|
|
209
192
|
return this.opts.redirectUri ?? PLACEHOLDER_REDIRECT_URI;
|
|
210
193
|
}
|
|
211
|
-
/** 真的能跳转吗(交互式登录才能)。占位地址不算 */
|
|
212
194
|
get interactive() {
|
|
213
195
|
return this.opts.onRedirect !== void 0;
|
|
214
196
|
}
|
|
@@ -223,13 +205,6 @@ var McpOAuthProvider = class {
|
|
|
223
205
|
...this.opts.scope ? { scope: this.opts.scope } : {}
|
|
224
206
|
};
|
|
225
207
|
}
|
|
226
|
-
/**
|
|
227
|
-
* OAuth 的 `state` 参数。
|
|
228
|
-
*
|
|
229
|
-
* SDK 把它当**生成器**调(每次授权一个新值),不是「读一个固定值」——
|
|
230
|
-
* opencode `src/mcp/oauth-provider.ts` 里踩过这个:写成只读缓存的话,
|
|
231
|
-
* 两次登录会拿到同一个 state,回调分不清是哪一次。
|
|
232
|
-
*/
|
|
233
208
|
state() {
|
|
234
209
|
this.currentState = randomBytes(32).toString("base64url");
|
|
235
210
|
return this.currentState;
|
|
@@ -245,13 +220,6 @@ var McpOAuthProvider = class {
|
|
|
245
220
|
tokens() {
|
|
246
221
|
return this.entry()?.tokens;
|
|
247
222
|
}
|
|
248
|
-
/**
|
|
249
|
-
* 存 token。
|
|
250
|
-
*
|
|
251
|
-
* 顺手把 `expires_in`(相对秒数)换算成绝对时刻落盘 —— 相对值一重启就是错的。
|
|
252
|
-
* 同时清掉 `codeVerifier`:它只在「跳转了、还没换到 token」那段窗口有用,
|
|
253
|
-
* 换到了就是一份没有用处的秘密,留着只是多一处泄漏点。
|
|
254
|
-
*/
|
|
255
223
|
saveTokens(tokens) {
|
|
256
224
|
const expiresAt = typeof tokens.expires_in === "number" && tokens.expires_in > 0 ? Date.now() + tokens.expires_in * 1e3 : void 0;
|
|
257
225
|
this.opts.store.update(this.opts.server, this.opts.serverUrl, {
|
|
@@ -263,7 +231,7 @@ var McpOAuthProvider = class {
|
|
|
263
231
|
}
|
|
264
232
|
async redirectToAuthorization(url) {
|
|
265
233
|
if (!this.opts.onRedirect) {
|
|
266
|
-
throw new McpLoginRequiredError(this.opts.server, "
|
|
234
|
+
throw new McpLoginRequiredError(this.opts.server, t2("mcp_oauth.no_refresh"));
|
|
267
235
|
}
|
|
268
236
|
await this.opts.onRedirect(url);
|
|
269
237
|
}
|
|
@@ -273,16 +241,10 @@ var McpOAuthProvider = class {
|
|
|
273
241
|
codeVerifier() {
|
|
274
242
|
const verifier = this.entry()?.codeVerifier;
|
|
275
243
|
if (!verifier) {
|
|
276
|
-
throw new McpLoginRequiredError(this.opts.server, "
|
|
244
|
+
throw new McpLoginRequiredError(this.opts.server, t2("mcp_oauth.pkce_lost"));
|
|
277
245
|
}
|
|
278
246
|
return verifier;
|
|
279
247
|
}
|
|
280
|
-
/**
|
|
281
|
-
* 服务端说凭据不好使了,SDK 调这里让我们删掉。
|
|
282
|
-
*
|
|
283
|
-
* 不实现的话用户要自己去删 `~/.epoch/mcp-auth.json` —— 而「client 注册
|
|
284
|
-
* 被服务端撤销」这种情况下,不删就会一直拿同一个失效 client_id 重试。
|
|
285
|
-
*/
|
|
286
248
|
invalidateCredentials(scope) {
|
|
287
249
|
if (scope === "all") {
|
|
288
250
|
this.opts.store.remove(this.opts.server);
|
|
@@ -308,7 +270,6 @@ var McpOAuthProvider = class {
|
|
|
308
270
|
return this.opts.store.get(this.opts.server, this.opts.serverUrl);
|
|
309
271
|
}
|
|
310
272
|
};
|
|
311
|
-
|
|
312
273
|
// src/oauth/attach.ts
|
|
313
274
|
function createAuthProvider(config, store) {
|
|
314
275
|
if (config.transport === "stdio" || !config.url) return void 0;
|
|
@@ -319,7 +280,6 @@ function createAuthProvider(config, store) {
|
|
|
319
280
|
serverUrl: config.url,
|
|
320
281
|
store,
|
|
321
282
|
scope: config.oauth?.scope
|
|
322
|
-
// 刻意不给 onRedirect:连接期绝不弹浏览器,需要授权就抛「请先 login」
|
|
323
283
|
});
|
|
324
284
|
}
|
|
325
285
|
function hasAuthorizationHeader(headers) {
|
|
@@ -329,13 +289,12 @@ function hasAuthorizationHeader(headers) {
|
|
|
329
289
|
function describeAuthFailure(err, server) {
|
|
330
290
|
if (err instanceof McpLoginRequiredError) return err.message;
|
|
331
291
|
if (err instanceof UnauthorizedError) {
|
|
332
|
-
return new McpLoginRequiredError(server, err.message || "
|
|
292
|
+
return new McpLoginRequiredError(server, err.message || t3("mcp_oauth.token_dead")).message;
|
|
333
293
|
}
|
|
334
294
|
const cause = err?.cause;
|
|
335
295
|
if (cause !== void 0 && cause !== err) return describeAuthFailure(cause, server);
|
|
336
296
|
return null;
|
|
337
297
|
}
|
|
338
|
-
|
|
339
298
|
// src/oauth/store.ts
|
|
340
299
|
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
341
300
|
import { dirname as dirname2 } from "path";
|
|
@@ -344,42 +303,33 @@ import {
|
|
|
344
303
|
isEnvelope,
|
|
345
304
|
mcpAuthPath,
|
|
346
305
|
openEnvelope,
|
|
347
|
-
sealEnvelope
|
|
306
|
+
sealEnvelope,
|
|
307
|
+
t as t4
|
|
348
308
|
} from "@epoch-agent/infra";
|
|
349
309
|
var FILE_VERSION = 1;
|
|
350
310
|
var REFRESH_SKEW_MS = 3e4;
|
|
351
311
|
var McpAuthStore = class {
|
|
352
312
|
filePath;
|
|
353
313
|
cache = null;
|
|
354
|
-
/** 磁盘上是密文、但本次运行拿不到数据密钥。此时**只读不写**,见 load() / save() */
|
|
355
314
|
locked = false;
|
|
356
|
-
/** @param filePath 显式路径(测试用);不传走 infra 的路径真源 */
|
|
357
315
|
constructor(filePath, homeDir) {
|
|
358
316
|
this.filePath = filePath ?? mcpAuthPath(homeDir);
|
|
359
317
|
}
|
|
360
318
|
get path() {
|
|
361
319
|
return this.filePath;
|
|
362
320
|
}
|
|
363
|
-
/**
|
|
364
|
-
* 读一个 server 的凭据。
|
|
365
|
-
*
|
|
366
|
-
* @param serverUrl 当前配置里的 URL。和落盘时记的对不上就返回 undefined ——
|
|
367
|
-
* 换了地址的 token 不能拿去打新地址。
|
|
368
|
-
*/
|
|
369
321
|
get(server, serverUrl) {
|
|
370
322
|
const entry = this.load().servers[server];
|
|
371
323
|
if (!entry) return void 0;
|
|
372
324
|
if (serverUrl !== void 0 && !sameEndpoint(entry.serverUrl, serverUrl)) return void 0;
|
|
373
325
|
return entry;
|
|
374
326
|
}
|
|
375
|
-
/** 不比对 URL 的读法,只给 `epoch mcp status` 展示用 */
|
|
376
327
|
getRaw(server) {
|
|
377
328
|
return this.load().servers[server];
|
|
378
329
|
}
|
|
379
330
|
list() {
|
|
380
331
|
return Object.entries(this.load().servers);
|
|
381
332
|
}
|
|
382
|
-
/** 局部更新一个 server 的条目;`serverUrl` 变了会先清空旧凭据 */
|
|
383
333
|
update(server, serverUrl, patch) {
|
|
384
334
|
const file = this.load();
|
|
385
335
|
this.assertWritable();
|
|
@@ -388,7 +338,6 @@ var McpAuthStore = class {
|
|
|
388
338
|
file.servers[server] = { ...base, ...patch, serverUrl };
|
|
389
339
|
this.save(file);
|
|
390
340
|
}
|
|
391
|
-
/** 删掉一个 server 的全部凭据。@returns 之前是否存在 */
|
|
392
341
|
remove(server) {
|
|
393
342
|
const file = this.load();
|
|
394
343
|
this.assertWritable();
|
|
@@ -397,11 +346,9 @@ var McpAuthStore = class {
|
|
|
397
346
|
this.save(file);
|
|
398
347
|
return true;
|
|
399
348
|
}
|
|
400
|
-
/** 强制下次 `get` 重新读盘。外部改过文件(另一个进程登录过)时用 */
|
|
401
349
|
invalidate() {
|
|
402
350
|
this.cache = null;
|
|
403
351
|
}
|
|
404
|
-
// ---- 内部 ----
|
|
405
352
|
load() {
|
|
406
353
|
if (this.cache) return this.cache;
|
|
407
354
|
if (!existsSync(this.filePath)) {
|
|
@@ -417,18 +364,9 @@ var McpAuthStore = class {
|
|
|
417
364
|
}
|
|
418
365
|
return this.cache;
|
|
419
366
|
}
|
|
420
|
-
/**
|
|
421
|
-
* 磁盘上是密文而当前解不开时,任何写操作都必须**当场失败**。
|
|
422
|
-
*
|
|
423
|
-
* 在改动内存状态之前就拦(而不是等到 save()):`update()` 是先改缓存再落盘的,
|
|
424
|
-
* 到 save() 才抛会留下一个「内存里改了、盘上没改」的错位状态,
|
|
425
|
-
* 下一次读会拿到一份不存在于磁盘的凭据。
|
|
426
|
-
*/
|
|
427
367
|
assertWritable() {
|
|
428
368
|
if (!this.locked) return;
|
|
429
|
-
throw new Error(
|
|
430
|
-
`${this.filePath} \u662F\u52A0\u5BC6\u7684\uFF0C\u4F46\u5F53\u524D\u62FF\u4E0D\u5230\u6570\u636E\u5BC6\u94A5\uFF0C\u62D2\u7EDD\u5199\u5165\u4EE5\u514D\u8986\u76D6\u5DF2\u6709\u51ED\u636E\u3002\u8DD1 epoch doctor \u770B Secret \u90A3\u4E00\u884C\u662F\u4EC0\u4E48\u539F\u56E0\u3002`
|
|
431
|
-
);
|
|
369
|
+
throw new Error(t4("mcp_oauth.store_encrypted_no_key", { path: this.filePath }));
|
|
432
370
|
}
|
|
433
371
|
save(file) {
|
|
434
372
|
this.assertWritable();
|
|
@@ -443,7 +381,11 @@ var McpAuthStore = class {
|
|
|
443
381
|
} catch (err) {
|
|
444
382
|
rmSync(tmp, { force: true });
|
|
445
383
|
throw new Error(
|
|
446
|
-
|
|
384
|
+
t4("mcp_oauth.store_write_failed", {
|
|
385
|
+
label: t4("mcp_oauth.store_label"),
|
|
386
|
+
path: this.filePath,
|
|
387
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
388
|
+
})
|
|
447
389
|
);
|
|
448
390
|
}
|
|
449
391
|
this.cache = file;
|
|
@@ -456,7 +398,7 @@ function encrypt(json) {
|
|
|
456
398
|
function decrypt(raw) {
|
|
457
399
|
if (!isEnvelope(raw)) return raw;
|
|
458
400
|
const key = getDataKey();
|
|
459
|
-
if (!key) throw new Error("
|
|
401
|
+
if (!key) throw new Error(t4("mcp_oauth.store_read_no_key"));
|
|
460
402
|
return openEnvelope(key, raw);
|
|
461
403
|
}
|
|
462
404
|
function isExpired(entry, now = Date.now()) {
|
|
@@ -483,15 +425,15 @@ function normalize(parsed) {
|
|
|
483
425
|
}
|
|
484
426
|
return { version: FILE_VERSION, servers: out };
|
|
485
427
|
}
|
|
486
|
-
|
|
487
428
|
// src/transport/index.ts
|
|
488
429
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
489
430
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
490
431
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
491
432
|
import {
|
|
492
|
-
|
|
433
|
+
collectProcessTreeStamped,
|
|
493
434
|
createStreamDecoder,
|
|
494
|
-
|
|
435
|
+
killStampedPids,
|
|
436
|
+
t as t5
|
|
495
437
|
} from "@epoch-agent/infra";
|
|
496
438
|
function createStdioTransport(config, onStderr) {
|
|
497
439
|
const transport = new StdioClientTransport({
|
|
@@ -516,7 +458,7 @@ function createHttpTransport(config, authProvider) {
|
|
|
516
458
|
try {
|
|
517
459
|
parsedUrl = new URL(config.url ?? "");
|
|
518
460
|
} catch {
|
|
519
|
-
throw new Error(
|
|
461
|
+
throw new Error(t5("mcp_config_parse.invalid_mcp_url", { url: config.url ?? "" }));
|
|
520
462
|
}
|
|
521
463
|
const requestInit = config.headers ? { headers: config.headers } : void 0;
|
|
522
464
|
const opts = { requestInit, authProvider };
|
|
@@ -529,14 +471,13 @@ function stdioChildPid(transport) {
|
|
|
529
471
|
}
|
|
530
472
|
async function closeTransportAndReap(transport) {
|
|
531
473
|
const pid = stdioChildPid(transport);
|
|
532
|
-
const orphans = pid === null ? [] : await
|
|
474
|
+
const orphans = pid === null ? [] : await collectProcessTreeStamped(pid);
|
|
533
475
|
try {
|
|
534
476
|
await transport.close();
|
|
535
477
|
} catch {
|
|
536
478
|
}
|
|
537
|
-
if (orphans.length > 0) await
|
|
479
|
+
if (orphans.length > 0) await killStampedPids(orphans, { escalate: true });
|
|
538
480
|
}
|
|
539
|
-
|
|
540
481
|
// src/types.ts
|
|
541
482
|
var DEFAULTS = {
|
|
542
483
|
timeout: 300,
|
|
@@ -547,7 +488,6 @@ var DEFAULTS = {
|
|
|
547
488
|
supportsParallel: false,
|
|
548
489
|
skipPreflight: false
|
|
549
490
|
};
|
|
550
|
-
|
|
551
491
|
// src/client.ts
|
|
552
492
|
var MAX_RECONNECT = 3;
|
|
553
493
|
var RECONNECT_BACKOFF = [1e3, 2e3, 4e3];
|
|
@@ -564,31 +504,13 @@ var McpClient = class {
|
|
|
564
504
|
lastError = null;
|
|
565
505
|
toolCount = 0;
|
|
566
506
|
stderrTail = [];
|
|
567
|
-
/** 上一个 chunk 末尾那截还没等到换行的文本,见 {@link collectStderr} */
|
|
568
507
|
stderrPartial = "";
|
|
569
|
-
/** 主动 disconnect 时置位,避免自己触发的 onclose 又去排一次重连 */
|
|
570
508
|
closingIntentionally = false;
|
|
571
|
-
/** 认证过不去。重连解决不了这个问题,只有 `epoch mcp login` 能 */
|
|
572
509
|
needsLogin = false;
|
|
573
510
|
cache;
|
|
574
511
|
fingerprint;
|
|
575
512
|
authProvider;
|
|
576
|
-
/**
|
|
577
|
-
* 收到 `notifications/tools/list_changed` 时的回调。
|
|
578
|
-
*
|
|
579
|
-
* McpRegistry 用它把「工具表已经不是启动时那份了」冒泡给宿主。
|
|
580
|
-
* 注意**这不是热更新**:AgentLoop 的工具集是构造参数,本进程内换不掉。
|
|
581
|
-
* 详见 registry 的 `refresh()`。
|
|
582
|
-
*/
|
|
583
513
|
onToolsChanged;
|
|
584
|
-
/**
|
|
585
|
-
* 这台是谁带进来的(方案 44 PR-2)。
|
|
586
|
-
*
|
|
587
|
-
* **由 `McpRegistry.connectAll()` 那一侧说了算,不从 `config` 里读** ——
|
|
588
|
-
* `McpServerConfig` 是用户手写的 `mcp.json` 解析出来的形状,来源要是它的一个
|
|
589
|
-
* 字段,用户就能在自己的文件里写 `"source": "host"` 给自己那台贴上宿主的标。
|
|
590
|
-
* 判据同角色那条「命名空间由装配层拼,宿主自己写的不算数」。
|
|
591
|
-
*/
|
|
592
514
|
source;
|
|
593
515
|
constructor(config, cache, authStore, source = "user") {
|
|
594
516
|
this.config = { ...DEFAULTS, ...config };
|
|
@@ -608,7 +530,6 @@ var McpClient = class {
|
|
|
608
530
|
source: this.source
|
|
609
531
|
};
|
|
610
532
|
}
|
|
611
|
-
/** 底层 SDK client,`null` 表示没连上。resources / prompts 那几个原语要用 */
|
|
612
533
|
get raw() {
|
|
613
534
|
return this.client;
|
|
614
535
|
}
|
|
@@ -622,12 +543,6 @@ var McpClient = class {
|
|
|
622
543
|
this.startLifetime();
|
|
623
544
|
}
|
|
624
545
|
}
|
|
625
|
-
/**
|
|
626
|
-
* 取本 server 的工具 schema —— 缓存优先。
|
|
627
|
-
*
|
|
628
|
-
* 三级:内存缓存 → live `tools/list` → 上次落盘的 schema(降级)。
|
|
629
|
-
* 详见 [cache.ts](./cache.ts) 里两层分工的说明。
|
|
630
|
-
*/
|
|
631
546
|
async getToolSchemas() {
|
|
632
547
|
const name = this.config.name;
|
|
633
548
|
const cached = this.cache.getFresh(name, this.fingerprint);
|
|
@@ -649,7 +564,6 @@ var McpClient = class {
|
|
|
649
564
|
}
|
|
650
565
|
return [];
|
|
651
566
|
}
|
|
652
|
-
/** live 拉取;失败返回 null(错误记进 lastError,由调用方决定降不降级) */
|
|
653
567
|
async fetchToolSchemas() {
|
|
654
568
|
if (!this.client) return null;
|
|
655
569
|
this.resetIdle();
|
|
@@ -658,8 +572,8 @@ var McpClient = class {
|
|
|
658
572
|
this.client.listTools(),
|
|
659
573
|
(this.config.timeout ?? DEFAULTS.timeout) * 1e3
|
|
660
574
|
);
|
|
661
|
-
return (result.tools ?? []).map((
|
|
662
|
-
const tool =
|
|
575
|
+
return (result.tools ?? []).map((t10) => {
|
|
576
|
+
const tool = t10;
|
|
663
577
|
return {
|
|
664
578
|
name: tool.name,
|
|
665
579
|
description: tool.description,
|
|
@@ -673,13 +587,6 @@ var McpClient = class {
|
|
|
673
587
|
return null;
|
|
674
588
|
}
|
|
675
589
|
}
|
|
676
|
-
/**
|
|
677
|
-
* 调用工具。
|
|
678
|
-
*
|
|
679
|
-
* **断线不在这里等重连**:以前这里 `await this.reconnect()`,而 reconnect 是
|
|
680
|
-
* 1s + 2s + 4s 的 backoff 循环 —— 工具执行路径上最多阻塞 7 秒,用户看到的是
|
|
681
|
-
* agent 卡住。现在改成排一次后台重连、立刻返回一条可重试的错误。
|
|
682
|
-
*/
|
|
683
590
|
async callTool(name, args) {
|
|
684
591
|
if (!this.client) {
|
|
685
592
|
this.scheduleReconnect();
|
|
@@ -717,17 +624,6 @@ var McpClient = class {
|
|
|
717
624
|
this.client = null;
|
|
718
625
|
this.closingIntentionally = false;
|
|
719
626
|
}
|
|
720
|
-
// ---- 内部 ----
|
|
721
|
-
/**
|
|
722
|
-
* 建连。**握手成功之前不写 this.client**。
|
|
723
|
-
*
|
|
724
|
-
* 以前是先 `this.client = new Client(...)` 再 await 握手,于是
|
|
725
|
-
* `status.connected`(判据就是 `client !== null`)在握手还没跑完时就报 true ——
|
|
726
|
-
* 调用方据此以为能用了,实际 SDK 会抛 "Not connected"。
|
|
727
|
-
*
|
|
728
|
-
* 失败路径也补上了 `transport.close()`:以前 connect 超时只是把字段置 null,
|
|
729
|
-
* stdio 已经起来的子进程没人收,直接漏成孤儿进程。
|
|
730
|
-
*/
|
|
731
627
|
async connectInner() {
|
|
732
628
|
const transport = this.config.transport === "stdio" || !this.config.url ? createStdioTransport(this.config, (text) => this.collectStderr(text)) : createHttpTransport(this.config, this.authProvider);
|
|
733
629
|
const client = new Client({ name: "epoch-agent", version: "0.0.0" }, { capabilities: {} });
|
|
@@ -759,18 +655,6 @@ var McpClient = class {
|
|
|
759
655
|
this.lastError = null;
|
|
760
656
|
this.needsLogin = false;
|
|
761
657
|
}
|
|
762
|
-
/**
|
|
763
|
-
* 攒起 stderr 的尾部若干**行**。
|
|
764
|
-
*
|
|
765
|
-
* ⚠️ **`text` 是一个 chunk,不是一行。** 一行长过一个 chunk(管道一次 64 KB)
|
|
766
|
-
* 时它会被切开,而不留半截的话每一截都变成尾部里独立的一条 —— 既占掉好几个
|
|
767
|
-
* 名额,`stderrHint()` 拼出来还像是好几件事(`……前半 | 后半`)。
|
|
768
|
-
* 一个把栈打进 stderr 的 server 很容易做到这一点。
|
|
769
|
-
*
|
|
770
|
-
* ⚠️ 别把 `cmd.exe` 那句「不是内部或外部命令」的两行当成这个 bug 的例子 ——
|
|
771
|
-
* 实测过,**那句话本身就是两行**(「……可运行的程序」后面真有一个 `\r\n`),
|
|
772
|
-
* 拼出来带 `|` 是忠实的。
|
|
773
|
-
*/
|
|
774
658
|
collectStderr(text) {
|
|
775
659
|
const parts = (this.stderrPartial + text).split("\n");
|
|
776
660
|
this.stderrPartial = parts.pop() ?? "";
|
|
@@ -782,32 +666,15 @@ var McpClient = class {
|
|
|
782
666
|
this.stderrTail = this.stderrTail.slice(-STDERR_TAIL_LINES);
|
|
783
667
|
}
|
|
784
668
|
}
|
|
785
|
-
/**
|
|
786
|
-
* 最后三行,给错误消息当尾巴。
|
|
787
|
-
*
|
|
788
|
-
* 把还没等到换行的那半行也算进来:server 崩在半句话上时,那半句往往正是
|
|
789
|
-
* 最要紧的一句(而它永远等不到自己的 `\n`)。
|
|
790
|
-
*/
|
|
791
669
|
stderrHint() {
|
|
792
670
|
const partial = this.stderrPartial.trim();
|
|
793
671
|
const lines = partial.length > 0 ? [...this.stderrTail, partial] : this.stderrTail;
|
|
794
672
|
return lines.slice(-3).join(" | ");
|
|
795
673
|
}
|
|
796
|
-
/**
|
|
797
|
-
* server 说工具表变了。
|
|
798
|
-
*
|
|
799
|
-
* 只做两件事:**失效 schema 缓存**(下次 `getToolSchemas()` 会真去拉一次),
|
|
800
|
-
* 然后把事件冒泡出去。刻意不在这里自己去 `listTools()` —— 通知可能连着来,
|
|
801
|
-
* 每条都拉一次是在替 server 打自己。
|
|
802
|
-
*/
|
|
803
674
|
onToolsListChanged() {
|
|
804
675
|
this.cache.invalidate(this.config.name);
|
|
805
676
|
this.onToolsChanged?.();
|
|
806
677
|
}
|
|
807
|
-
/**
|
|
808
|
-
* @param closed 触发 onclose 的那个 Client。重连成功后旧连接的迟到通知
|
|
809
|
-
* 不能把新连接连坐掉,所以要比对身份。
|
|
810
|
-
*/
|
|
811
678
|
onTransportClosed(closed) {
|
|
812
679
|
if (this.closingIntentionally) return;
|
|
813
680
|
if (this.client !== null && this.client !== closed) return;
|
|
@@ -817,12 +684,6 @@ var McpClient = class {
|
|
|
817
684
|
this.lastError = `\u8FDE\u63A5\u5DF2\u65AD\u5F00${this.stderrTail.length > 0 ? `\uFF1Bstderr: ${this.stderrHint()}` : ""}`;
|
|
818
685
|
this.scheduleReconnect();
|
|
819
686
|
}
|
|
820
|
-
/**
|
|
821
|
-
* 排一次后台重连(幂等)。
|
|
822
|
-
*
|
|
823
|
-
* 看门狗语义:重连全程不占用调用方的时间片,失败就按 backoff 再排一次,
|
|
824
|
-
* 到 MAX_RECONNECT 为止把 server 标成不可用。
|
|
825
|
-
*/
|
|
826
687
|
scheduleReconnect() {
|
|
827
688
|
if (this.reconnectTimer || this.client) return;
|
|
828
689
|
if (this.needsLogin) return;
|
|
@@ -886,13 +747,6 @@ var McpClient = class {
|
|
|
886
747
|
this.keepaliveTimer = null;
|
|
887
748
|
}
|
|
888
749
|
}
|
|
889
|
-
/**
|
|
890
|
-
* 起 idle / max-lifetime 两个自动断连定时器。
|
|
891
|
-
*
|
|
892
|
-
* 这里刻意**不**调 `clearTimers()`:它连 keepalive 一起清,而调用顺序是
|
|
893
|
-
* `startKeepalive(); startLifetime();` —— 等于刚起的 keepalive 立刻被自己清掉,
|
|
894
|
-
* keepalive 从来没真正跑过。只清自己的两个定时器。
|
|
895
|
-
*/
|
|
896
750
|
startLifetime() {
|
|
897
751
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
898
752
|
if (this.lifetimeTimer) clearTimeout(this.lifetimeTimer);
|
|
@@ -938,9 +792,9 @@ var McpClient = class {
|
|
|
938
792
|
}
|
|
939
793
|
}
|
|
940
794
|
};
|
|
941
|
-
|
|
942
795
|
// src/oauth/callback.ts
|
|
943
796
|
import { createServer } from "http";
|
|
797
|
+
import { t as t6 } from "@epoch-agent/infra";
|
|
944
798
|
var CALLBACK_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
945
799
|
var CALLBACK_PATH = "/callback";
|
|
946
800
|
async function startCallbackServer(port, timeoutMs = CALLBACK_TIMEOUT_MS) {
|
|
@@ -977,7 +831,7 @@ async function startCallbackServer(port, timeoutMs = CALLBACK_TIMEOUT_MS) {
|
|
|
977
831
|
setTimeout(close, 100).unref?.();
|
|
978
832
|
if (result.error) reject(result.error);
|
|
979
833
|
else if (result.code) resolve(result.code);
|
|
980
|
-
else reject(new Error("
|
|
834
|
+
else reject(new Error(t6("mcp_oauth.err_no_code")));
|
|
981
835
|
};
|
|
982
836
|
if (pending) {
|
|
983
837
|
settle(pending);
|
|
@@ -985,7 +839,7 @@ async function startCallbackServer(port, timeoutMs = CALLBACK_TIMEOUT_MS) {
|
|
|
985
839
|
}
|
|
986
840
|
const timer = setTimeout(() => {
|
|
987
841
|
close();
|
|
988
|
-
reject(new Error(
|
|
842
|
+
reject(new Error(t6("mcp_oauth.err_timeout", { seconds: timeoutMs / 1e3 })));
|
|
989
843
|
}, timeoutMs);
|
|
990
844
|
timer.unref?.();
|
|
991
845
|
onResult = (result) => {
|
|
@@ -1003,32 +857,37 @@ async function startCallbackServer(port, timeoutMs = CALLBACK_TIMEOUT_MS) {
|
|
|
1003
857
|
return;
|
|
1004
858
|
}
|
|
1005
859
|
if (!armed) {
|
|
1006
|
-
respond(res, 400, "
|
|
860
|
+
respond(res, 400, t6("mcp_oauth.page_failed"), t6("mcp_oauth.page_not_started"));
|
|
1007
861
|
return;
|
|
1008
862
|
}
|
|
1009
863
|
const params = url.searchParams;
|
|
1010
864
|
const oauthError = params.get("error");
|
|
1011
865
|
if (oauthError) {
|
|
1012
866
|
const detail = params.get("error_description");
|
|
1013
|
-
respond(res, 400, "
|
|
867
|
+
respond(res, 400, t6("mcp_oauth.page_failed"), detail ?? oauthError);
|
|
1014
868
|
emit2({
|
|
1015
|
-
error: new Error(
|
|
869
|
+
error: new Error(
|
|
870
|
+
t6("mcp_oauth.err_server_returned", {
|
|
871
|
+
error: oauthError,
|
|
872
|
+
detail: detail ? t6("mcp_oauth.err_server_returned_detail", { detail }) : ""
|
|
873
|
+
})
|
|
874
|
+
)
|
|
1016
875
|
});
|
|
1017
876
|
return;
|
|
1018
877
|
}
|
|
1019
878
|
const state = params.get("state");
|
|
1020
879
|
if (expectedState !== void 0 && state !== expectedState) {
|
|
1021
|
-
respond(res, 400, "
|
|
1022
|
-
emit2({ error: new Error("
|
|
880
|
+
respond(res, 400, t6("mcp_oauth.page_failed"), t6("mcp_oauth.page_state_mismatch"));
|
|
881
|
+
emit2({ error: new Error(t6("mcp_oauth.err_state_mismatch")) });
|
|
1023
882
|
return;
|
|
1024
883
|
}
|
|
1025
884
|
const code = params.get("code");
|
|
1026
885
|
if (!code) {
|
|
1027
|
-
respond(res, 400, "
|
|
1028
|
-
emit2({ error: new Error("
|
|
886
|
+
respond(res, 400, t6("mcp_oauth.page_failed"), t6("mcp_oauth.page_no_code"));
|
|
887
|
+
emit2({ error: new Error(t6("mcp_oauth.err_no_code")) });
|
|
1029
888
|
return;
|
|
1030
889
|
}
|
|
1031
|
-
respond(res, 200, "
|
|
890
|
+
respond(res, 200, t6("mcp_oauth.page_ok"), t6("mcp_oauth.page_ok_detail"));
|
|
1032
891
|
emit2({ code });
|
|
1033
892
|
}
|
|
1034
893
|
}
|
|
@@ -1038,7 +897,7 @@ function listen(server, port) {
|
|
|
1038
897
|
server.listen(port ?? 0, "127.0.0.1", () => {
|
|
1039
898
|
const address = server.address();
|
|
1040
899
|
if (!address) {
|
|
1041
|
-
reject(new Error("
|
|
900
|
+
reject(new Error(t6("mcp_oauth.err_no_port")));
|
|
1042
901
|
return;
|
|
1043
902
|
}
|
|
1044
903
|
server.removeListener("error", reject);
|
|
@@ -1047,7 +906,7 @@ function listen(server, port) {
|
|
|
1047
906
|
});
|
|
1048
907
|
}
|
|
1049
908
|
function respond(res, status, title, detail) {
|
|
1050
|
-
const body = `<!doctype html><html lang="
|
|
909
|
+
const body = `<!doctype html><html lang="${t6("mcp_oauth.html_lang")}"><head><meta charset="utf-8">
|
|
1051
910
|
<title>${title}</title></head>
|
|
1052
911
|
<body style="font-family:system-ui;max-width:32rem;margin:6rem auto;text-align:center">
|
|
1053
912
|
<h1>${title}</h1><p>${escapeHtml(detail)}</p><p style="color:#888">epoch-agent</p>
|
|
@@ -1060,10 +919,10 @@ function escapeHtml(text) {
|
|
|
1060
919
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c
|
|
1061
920
|
);
|
|
1062
921
|
}
|
|
1063
|
-
|
|
1064
922
|
// src/oauth/login.ts
|
|
1065
923
|
import { spawn } from "child_process";
|
|
1066
924
|
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
925
|
+
import { t as t7 } from "@epoch-agent/infra";
|
|
1067
926
|
async function loginToMcpServer(opts) {
|
|
1068
927
|
const store = opts.store ?? new McpAuthStore();
|
|
1069
928
|
const print = opts.print ?? (() => {
|
|
@@ -1080,8 +939,8 @@ async function loginToMcpServer(opts) {
|
|
|
1080
939
|
onRedirect: async (url) => {
|
|
1081
940
|
redirected = true;
|
|
1082
941
|
callback.expect(provider.expectedState);
|
|
1083
|
-
print(
|
|
1084
|
-
${url.toString()}`);
|
|
942
|
+
print(t7("mcp_oauth.open_browser", { url: `
|
|
943
|
+
${url.toString()}` }));
|
|
1085
944
|
await openBrowser(url.toString());
|
|
1086
945
|
}
|
|
1087
946
|
});
|
|
@@ -1099,7 +958,7 @@ async function loginToMcpServer(opts) {
|
|
|
1099
958
|
scope: opts.scope
|
|
1100
959
|
});
|
|
1101
960
|
if (second !== "AUTHORIZED") {
|
|
1102
|
-
throw new Error(
|
|
961
|
+
throw new Error(t7("mcp_oauth.incomplete", { result: second }));
|
|
1103
962
|
}
|
|
1104
963
|
return { server: opts.server, refreshedOnly: false };
|
|
1105
964
|
} finally {
|
|
@@ -1169,7 +1028,6 @@ async function openInBrowser(url) {
|
|
|
1169
1028
|
} catch {
|
|
1170
1029
|
}
|
|
1171
1030
|
}
|
|
1172
|
-
|
|
1173
1031
|
// src/primitives.ts
|
|
1174
1032
|
var METHOD_NOT_FOUND = -32601;
|
|
1175
1033
|
var MAX_PAGES = 50;
|
|
@@ -1229,11 +1087,9 @@ async function paginate(fetchPage) {
|
|
|
1229
1087
|
function isMethodNotFound(err) {
|
|
1230
1088
|
return err?.code === METHOD_NOT_FOUND;
|
|
1231
1089
|
}
|
|
1232
|
-
|
|
1233
1090
|
// src/registry.ts
|
|
1234
1091
|
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
1235
|
-
import { mcpConfigPath, mcpSchemaCachePath } from "@epoch-agent/infra";
|
|
1236
|
-
|
|
1092
|
+
import { mcpConfigPath, mcpSchemaCachePath, t as t9 } from "@epoch-agent/infra";
|
|
1237
1093
|
// src/tool-adapter.ts
|
|
1238
1094
|
function prefixedName(serverName, toolName) {
|
|
1239
1095
|
return `mcp__${serverName}__${toolName}`;
|
|
@@ -1261,7 +1117,6 @@ function toEpochTool(serverName, schema, call) {
|
|
|
1261
1117
|
return {
|
|
1262
1118
|
success: result.success,
|
|
1263
1119
|
output: result.output,
|
|
1264
|
-
// 原样往上交,不在这里判「模型认不认图」—— 那是 ToolExecutor 的四道闸门
|
|
1265
1120
|
...result.artifacts && result.artifacts.length > 0 ? { artifacts: result.artifacts } : {},
|
|
1266
1121
|
duration: Math.round(performance.now() - startedAt)
|
|
1267
1122
|
};
|
|
@@ -1275,7 +1130,6 @@ function stripTitle(annotations) {
|
|
|
1275
1130
|
const given = Object.entries(hints).filter(([, v]) => v !== void 0);
|
|
1276
1131
|
return given.length > 0 ? Object.fromEntries(given) : void 0;
|
|
1277
1132
|
}
|
|
1278
|
-
|
|
1279
1133
|
// src/resource-tools.ts
|
|
1280
1134
|
var MAX_LISTED = 200;
|
|
1281
1135
|
function createResourceTools(serverName, getClient) {
|
|
@@ -1286,9 +1140,6 @@ function createResourceTools(serverName, getClient) {
|
|
|
1286
1140
|
name: prefixedName(serverName, "list_resources"),
|
|
1287
1141
|
description: `\u5217\u51FA MCP server\u300C${serverName}\u300D\u63D0\u4F9B\u7684\u6240\u6709\u8D44\u6E90\uFF08URI + \u8BF4\u660E\uFF09\u3002\u60F3\u8BFB\u67D0\u4E2A\u8D44\u6E90\u7684\u5185\u5BB9\uFF0C\u518D\u8C03 ${prefixedName(serverName, "read_resource")}\u3002`,
|
|
1288
1142
|
parameters: { type: "object", properties: {} },
|
|
1289
|
-
// 这两个 hint 是**我们自己**的声明,不是 server 给的:按 MCP 规范
|
|
1290
|
-
// resources/list 与 resources/read 都是只读操作。server 声明的 annotations
|
|
1291
|
-
// 走 tool-adapter.ts,两条路不会混
|
|
1292
1143
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
1293
1144
|
execute: async () => {
|
|
1294
1145
|
return run(async () => {
|
|
@@ -1348,10 +1199,9 @@ async function run(fn) {
|
|
|
1348
1199
|
};
|
|
1349
1200
|
}
|
|
1350
1201
|
}
|
|
1351
|
-
|
|
1352
1202
|
// src/schema.ts
|
|
1353
1203
|
import { z } from "zod";
|
|
1354
|
-
import { parseLenient } from "@epoch-agent/infra";
|
|
1204
|
+
import { parseLenient, t as t8 } from "@epoch-agent/infra";
|
|
1355
1205
|
var seconds = z.number().positive().finite();
|
|
1356
1206
|
var OAuthSchema = z.object({
|
|
1357
1207
|
enabled: z.boolean().optional(),
|
|
@@ -1360,16 +1210,6 @@ var OAuthSchema = z.object({
|
|
|
1360
1210
|
});
|
|
1361
1211
|
var RawServerSchema = z.object({
|
|
1362
1212
|
transport: z.enum(["stdio", "sse", "http"]).optional(),
|
|
1363
|
-
/**
|
|
1364
|
-
* `transport` 的别名。**MCP 生态里的通用写法是 `type`**(Claude Desktop /
|
|
1365
|
-
* Cursor 等客户端的 mcp.json 都是它),我们自己的名字是 `transport`。
|
|
1366
|
-
*
|
|
1367
|
-
* 不收它的代价不是「多一条警告」而是**静默选错传输**:`type` 落进未知字段被丢掉,
|
|
1368
|
-
* 下面那行 `raw.url ? 'sse' : 'stdio'` 于是把一个 `{"type":"http","url":…}` 的
|
|
1369
|
-
* Streamable HTTP server 连成 SSE。这正是本文件下面那句注释说已经堵掉的坑
|
|
1370
|
-
* (「写 `transport: 'http'` 的用户会静默走到 SSE 上去」),当时只堵了我们自己
|
|
1371
|
-
* 这个字段名的那一半。2026-08-12 拿真实配置试出来的。
|
|
1372
|
-
*/
|
|
1373
1213
|
type: z.enum(["stdio", "sse", "http"]).optional(),
|
|
1374
1214
|
command: z.string().min(1).optional(),
|
|
1375
1215
|
args: z.array(z.string()).optional(),
|
|
@@ -1403,7 +1243,10 @@ function parseMcpConfig(raw, label) {
|
|
|
1403
1243
|
json = JSON.parse(raw);
|
|
1404
1244
|
} catch (err) {
|
|
1405
1245
|
const detail = err instanceof Error ? err.message : String(err);
|
|
1406
|
-
return {
|
|
1246
|
+
return {
|
|
1247
|
+
servers: [],
|
|
1248
|
+
issues: [{ path: label, message: t8("mcp_config_parse.json_error", { reason: detail }) }]
|
|
1249
|
+
};
|
|
1407
1250
|
}
|
|
1408
1251
|
const issues = [];
|
|
1409
1252
|
const top = parseLenient(RawConfigSchema, json, {});
|
|
@@ -1413,10 +1256,10 @@ function parseMcpConfig(raw, label) {
|
|
|
1413
1256
|
if (top.value.mcpServers) {
|
|
1414
1257
|
issues.push({
|
|
1415
1258
|
path: label,
|
|
1416
|
-
message: "
|
|
1259
|
+
message: t8("mcp_config_parse.wrong_key")
|
|
1417
1260
|
});
|
|
1418
1261
|
} else if (issues.length === 0) {
|
|
1419
|
-
issues.push({ path: label, message: "
|
|
1262
|
+
issues.push({ path: label, message: t8("mcp_config_parse.no_servers") });
|
|
1420
1263
|
}
|
|
1421
1264
|
return { servers: [], issues };
|
|
1422
1265
|
}
|
|
@@ -1426,7 +1269,7 @@ function parseMcpConfig(raw, label) {
|
|
|
1426
1269
|
const parsed = parseLenient(RawServerSchema.nullable(), value, null);
|
|
1427
1270
|
if (!parsed.value) {
|
|
1428
1271
|
issues.push(...prefix(parsed.issues, at));
|
|
1429
|
-
issues.push({ path: at, message: "
|
|
1272
|
+
issues.push({ path: at, message: t8("mcp_config_parse.invalid_server") });
|
|
1430
1273
|
continue;
|
|
1431
1274
|
}
|
|
1432
1275
|
issues.push(...unknownKeyIssues(value, at));
|
|
@@ -1440,13 +1283,19 @@ function normalize2(name, raw, at) {
|
|
|
1440
1283
|
const issues = [];
|
|
1441
1284
|
const transport = raw.transport ?? raw.type ?? (raw.url ? "sse" : "stdio");
|
|
1442
1285
|
if (transport === "stdio" && !raw.command) {
|
|
1443
|
-
issues.push({ path: `${at}.command`, message: "
|
|
1286
|
+
issues.push({ path: `${at}.command`, message: t8("mcp_config_parse.stdio_no_command") });
|
|
1444
1287
|
}
|
|
1445
1288
|
if (transport !== "stdio") {
|
|
1446
1289
|
if (!raw.url) {
|
|
1447
|
-
issues.push({
|
|
1290
|
+
issues.push({
|
|
1291
|
+
path: `${at}.url`,
|
|
1292
|
+
message: t8("mcp_config_parse.no_url", { transport })
|
|
1293
|
+
});
|
|
1448
1294
|
} else if (!isValidUrl(raw.url)) {
|
|
1449
|
-
issues.push({
|
|
1295
|
+
issues.push({
|
|
1296
|
+
path: `${at}.url`,
|
|
1297
|
+
message: t8("mcp_config_parse.bad_url", { url: raw.url })
|
|
1298
|
+
});
|
|
1450
1299
|
}
|
|
1451
1300
|
}
|
|
1452
1301
|
if (issues.length > 0) return { config: null, issues };
|
|
@@ -1475,7 +1324,7 @@ function normalize2(name, raw, at) {
|
|
|
1475
1324
|
}
|
|
1476
1325
|
function unknownKeyIssues(value, at) {
|
|
1477
1326
|
if (typeof value !== "object" || value === null) return [];
|
|
1478
|
-
return Object.keys(value).filter((k) => !KNOWN_KEYS.has(k)).map((k) => ({ path: `${at}.${k}`, message: "
|
|
1327
|
+
return Object.keys(value).filter((k) => !KNOWN_KEYS.has(k)).map((k) => ({ path: `${at}.${k}`, message: t8("mcp_config_parse.unknown_field") }));
|
|
1479
1328
|
}
|
|
1480
1329
|
function isValidUrl(url) {
|
|
1481
1330
|
try {
|
|
@@ -1491,34 +1340,17 @@ function prefix(issues, at) {
|
|
|
1491
1340
|
message: i.message
|
|
1492
1341
|
}));
|
|
1493
1342
|
}
|
|
1494
|
-
|
|
1495
1343
|
// src/registry.ts
|
|
1496
1344
|
var McpRegistry = class {
|
|
1497
1345
|
clients = /* @__PURE__ */ new Map();
|
|
1498
1346
|
cache;
|
|
1499
1347
|
authStore;
|
|
1500
|
-
/**
|
|
1501
|
-
* 某个 server 的工具表变了(收到 `notifications/tools/list_changed`)。
|
|
1502
|
-
*
|
|
1503
|
-
* registry 这层只负责把缓存失效掉并转发这个信号 —— **真正的热更新做不到**:
|
|
1504
|
-
* `AgentLoop` 的工具集是构造参数,一次性传进去的,没有「运行中换一套工具」
|
|
1505
|
-
* 的入口。要做需要先给 loop 引入 `ToolProvider` 接口,那是另一个方案的事,
|
|
1506
|
-
* 本方案明确不碰 loop.ts。当前的效果是:下一次 `getAllTools()` / `refresh()`
|
|
1507
|
-
* 能拿到新工具表,正在跑的那一轮不受影响。
|
|
1508
|
-
*/
|
|
1509
1348
|
onToolsChanged;
|
|
1510
1349
|
constructor(options = {}) {
|
|
1511
1350
|
const cacheFile = options.cacheFile !== void 0 ? options.cacheFile : mcpSchemaCachePath(options.homeDir);
|
|
1512
1351
|
this.cache = new McpSchemaCache(cacheFile);
|
|
1513
1352
|
this.authStore = options.authStore ?? new McpAuthStore(void 0, options.homeDir);
|
|
1514
1353
|
}
|
|
1515
|
-
/**
|
|
1516
|
-
* 从配置文件加载所有 MCP servers。
|
|
1517
|
-
*
|
|
1518
|
-
* 返回值带 `issues`:以前是 `catch { return [] }`,mcp.json 写错一个字符
|
|
1519
|
-
* 用户只会发现「MCP 没了」,看不到任何提示。调用方**必须**把 issues
|
|
1520
|
-
* 写进 diagnostics(见 runtime/tools.ts)。
|
|
1521
|
-
*/
|
|
1522
1354
|
loadFromConfig(configPath) {
|
|
1523
1355
|
const path = configPath ?? mcpConfigPath();
|
|
1524
1356
|
if (!existsSync2(path)) return { servers: [], issues: [] };
|
|
@@ -1527,24 +1359,13 @@ var McpRegistry = class {
|
|
|
1527
1359
|
raw = readFileSync3(path, "utf-8");
|
|
1528
1360
|
} catch (err) {
|
|
1529
1361
|
const detail = err instanceof Error ? err.message : String(err);
|
|
1530
|
-
return {
|
|
1362
|
+
return {
|
|
1363
|
+
servers: [],
|
|
1364
|
+
issues: [{ path, message: t9("mcp_config_parse.read_failed", { reason: detail }) }]
|
|
1365
|
+
};
|
|
1531
1366
|
}
|
|
1532
1367
|
return parseMcpConfig(raw, path);
|
|
1533
1368
|
}
|
|
1534
|
-
/**
|
|
1535
|
-
* 连接所有已配置的 servers。
|
|
1536
|
-
*
|
|
1537
|
-
* @param source 这一批是谁带进来的(方案 44 PR-2)。**由调用方说了算,不从
|
|
1538
|
-
* `configs` 里读** —— 判据在 `McpClient.source` 上:`McpServerConfig` 是
|
|
1539
|
-
* 用户手写文件解析出来的形状,来源要是它的一个字段,用户就能给自己那台
|
|
1540
|
-
* 贴上宿主的标。缺省 `'user'`,于是老调用点(`epoch mcp` 那几条、用例)
|
|
1541
|
-
* 一个字都不用改
|
|
1542
|
-
*
|
|
1543
|
-
* ⚠️ **可以调多次(两批 server 各一发),但名字必须在批与批之间唯一** ——
|
|
1544
|
-
* `clients` 是一张按名字索引的表,重名的后一发会把前一发整个顶掉,而这里
|
|
1545
|
-
* 拿不到诊断口说不出这件事。去重归调用方,`runtime/src/tools.ts` 的
|
|
1546
|
-
* `registerMcpTools` 就是这么做的(用户那批先到、宿主重名的那台被跳过并报一条)。
|
|
1547
|
-
*/
|
|
1548
1369
|
async connectAll(configs, source = "user") {
|
|
1549
1370
|
const promises = configs.map(async (cfg) => {
|
|
1550
1371
|
await this.clients.get(cfg.name)?.disconnect();
|
|
@@ -1560,12 +1381,6 @@ var McpRegistry = class {
|
|
|
1560
1381
|
});
|
|
1561
1382
|
return Promise.all(promises);
|
|
1562
1383
|
}
|
|
1563
|
-
/**
|
|
1564
|
-
* 获取所有 MCP 工具(扁平化)。
|
|
1565
|
-
*
|
|
1566
|
-
* schema 走缓存,第二次调用不再打网络;工具本身每次重建
|
|
1567
|
-
* (execute 闭包要绑到当前的 client 实例上,重连后不能还指向旧连接)。
|
|
1568
|
-
*/
|
|
1569
1384
|
async getAllTools() {
|
|
1570
1385
|
const allTools = [];
|
|
1571
1386
|
for (const [name, client] of this.clients) {
|
|
@@ -1575,43 +1390,25 @@ var McpRegistry = class {
|
|
|
1575
1390
|
}
|
|
1576
1391
|
return allTools;
|
|
1577
1392
|
}
|
|
1578
|
-
/**
|
|
1579
|
-
* 丢掉 schema 缓存后重新取一遍工具表。
|
|
1580
|
-
*
|
|
1581
|
-
* 给 `onToolsChanged` 的消费方用:收到通知 → `refresh()` → 拿到新工具表。
|
|
1582
|
-
* 不传 name 就刷所有 server。
|
|
1583
|
-
*/
|
|
1584
1393
|
async refresh(name) {
|
|
1585
1394
|
this.cache.invalidate(name);
|
|
1586
1395
|
return this.getAllTools();
|
|
1587
1396
|
}
|
|
1588
|
-
/**
|
|
1589
|
-
* 列出某个 server 的 prompts。
|
|
1590
|
-
*
|
|
1591
|
-
* **prompts 不包成工具**,与 resources 的处理刻意不同:MCP 规范把 prompts
|
|
1592
|
-
* 定义成 user-controlled(用户主动挑一个模板来用),tools 才是 model-controlled。
|
|
1593
|
-
* gemini 也是这么分的 —— 它把 prompts 注册成斜杠命令(`PromptRegistry`),
|
|
1594
|
-
* 不给模型。所以这里只提供查询 API,暴露方式交给 CLI/TUI。
|
|
1595
|
-
*/
|
|
1596
1397
|
async listPrompts(name) {
|
|
1597
1398
|
const client = this.requireClient(name);
|
|
1598
1399
|
return client.raw ? listPrompts(client.raw) : [];
|
|
1599
1400
|
}
|
|
1600
|
-
/** 取一个 prompt 渲染后的文本 */
|
|
1601
1401
|
async getPrompt(name, promptName, args = {}) {
|
|
1602
1402
|
const client = this.requireClient(name);
|
|
1603
1403
|
if (!client.raw) throw new Error(`MCP server "${name}" \u5F53\u524D\u672A\u8FDE\u63A5`);
|
|
1604
1404
|
return getPromptText(client.raw, promptName, args);
|
|
1605
1405
|
}
|
|
1606
|
-
/** 各 server 的登录态。纯读本地凭据文件,不打网络 */
|
|
1607
1406
|
getAuthStatus() {
|
|
1608
1407
|
return [...this.clients.values()].map((c) => mcpAuthStatus(c.serverConfig, this.authStore));
|
|
1609
1408
|
}
|
|
1610
|
-
/** 凭据存储。`epoch mcp login/logout` 要用同一份,否则缓存不一致 */
|
|
1611
1409
|
get auth() {
|
|
1612
1410
|
return this.authStore;
|
|
1613
1411
|
}
|
|
1614
|
-
/** 重连指定 server。工具表可能已变,顺带失效它的 schema 缓存 */
|
|
1615
1412
|
async reconnect(name) {
|
|
1616
1413
|
const client = this.requireClient(name);
|
|
1617
1414
|
this.cache.invalidate(name);
|
|
@@ -1619,32 +1416,9 @@ var McpRegistry = class {
|
|
|
1619
1416
|
await client.connect();
|
|
1620
1417
|
return client.status;
|
|
1621
1418
|
}
|
|
1622
|
-
/**
|
|
1623
|
-
* 显式失效 schema 缓存,不传 name 清全部。
|
|
1624
|
-
*
|
|
1625
|
-
* 给 `epoch mcp reconnect` 和将来的 `notifications/tools/list_changed` 用。
|
|
1626
|
-
*/
|
|
1627
1419
|
invalidateSchemaCache(name) {
|
|
1628
1420
|
this.cache.invalidate(name);
|
|
1629
1421
|
}
|
|
1630
|
-
/**
|
|
1631
|
-
* 断开**一台**并把它从表里删掉。返回 false 表示本来就没有这一台。
|
|
1632
|
-
*
|
|
1633
|
-
* ## 为什么在 `disconnectAll()` 之外单开这一个(2026-08-18)
|
|
1634
|
-
*
|
|
1635
|
-
* 在这之前,「让一台 server 在这个进程里消失」**做不到** —— 只能把所有连接一起
|
|
1636
|
-
* 断掉。而 `mcp.json` 的一次编辑真的会删掉一台(runtime 的 `applyMcpConfig`),
|
|
1637
|
-
* 那时全断再全连是错的:宿主注入的和插件带来的那两批也会被连带断开,而它们
|
|
1638
|
-
* 一个字节都不在这份文件里。
|
|
1639
|
-
*
|
|
1640
|
-
* ⚠️ **它连带失效 schema 缓存**:那台下次要是又被配回来,缓存里躺着的是上一份
|
|
1641
|
-
* 工具表。同 `reconnect()` 里那一下,理由逐字相同。
|
|
1642
|
-
*
|
|
1643
|
-
* ⚠️ **工具表不在这一层清**。这里只管连接;把 `mcp:<名字>` 那一组工具从
|
|
1644
|
-
* `ToolRegistry` 里摘掉是装配层的事(`ToolSync.replace`,判据在 runtime 的
|
|
1645
|
-
* `ToolSync` 上:三条路必须排同一条队)。少了那一下的表现是**工具表里留着一批
|
|
1646
|
-
* 指向已断连接的工具**,模型下一轮照着调,报一个它读不懂的错。
|
|
1647
|
-
*/
|
|
1648
1422
|
async disconnect(name) {
|
|
1649
1423
|
const client = this.clients.get(name);
|
|
1650
1424
|
if (!client) return false;
|
|
@@ -1653,24 +1427,15 @@ var McpRegistry = class {
|
|
|
1653
1427
|
this.clients.delete(name);
|
|
1654
1428
|
return true;
|
|
1655
1429
|
}
|
|
1656
|
-
/**
|
|
1657
|
-
* 这台 server 在**这个进程里此刻按的是哪份配置**(2026-08-18)。
|
|
1658
|
-
*
|
|
1659
|
-
* 给「盘上那份和进程里这份差在哪」用(runtime 的 `applyMcpConfig` 拿它做 diff)。
|
|
1660
|
-
* ⚠️ 真源必须是 client 手上那一份,**不能是调用方自己记的一份快照**:那份快照
|
|
1661
|
-
* 会和这里分叉,而分叉的表现是「明明改了配置,应用之后说没动」。
|
|
1662
|
-
*/
|
|
1663
1430
|
configOf(name) {
|
|
1664
1431
|
return this.clients.get(name)?.serverConfig ?? null;
|
|
1665
1432
|
}
|
|
1666
|
-
/** 断开所有连接 */
|
|
1667
1433
|
async disconnectAll() {
|
|
1668
1434
|
for (const client of this.clients.values()) {
|
|
1669
1435
|
await client.disconnect();
|
|
1670
1436
|
}
|
|
1671
1437
|
this.clients.clear();
|
|
1672
1438
|
}
|
|
1673
|
-
/** 获取状态 */
|
|
1674
1439
|
getStatus(name) {
|
|
1675
1440
|
return this.clients.get(name)?.status ?? null;
|
|
1676
1441
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@epoch-agent/plugin-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "epoch-agent MCP 客户端插件",
|
|
6
6
|
"repository": {
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
25
25
|
"zod": "^4.4.3",
|
|
26
|
-
"@epoch-agent/
|
|
27
|
-
"@epoch-agent/
|
|
26
|
+
"@epoch-agent/infra": "0.2.0",
|
|
27
|
+
"@epoch-agent/protocol": "0.2.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "^26.1.2",
|