@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.
Files changed (2) hide show
  1. package/dist/index.js +71 -306
  2. 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 t = str(item.text);
147
- if (t) texts.push(t);
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
- `MCP server "${server}" \u9700\u8981 OAuth \u767B\u5F55\uFF1A\u5148\u8DD1 epoch mcp login ${server}` + (detail ? `\uFF08${detail}\uFF09` : "")
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, "\u5DF2\u6709\u51ED\u636E\u65E0\u6CD5\u7EED\u671F\u6216\u5C1A\u672A\u6388\u6743");
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, "PKCE code_verifier \u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55");
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 || "token \u5DF2\u5931\u6548\u4E14\u65E0\u6CD5\u7EED\u671F").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
- `MCP \u51ED\u636E ${this.filePath} \u5199\u5165\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}`
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("mcp-auth.json \u662F\u5BC6\u6587\uFF0C\u4F46\u5F53\u524D\u6CA1\u6709\u53EF\u7528\u7684\u6570\u636E\u5BC6\u94A5");
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
- collectProcessTree,
433
+ collectProcessTreeStamped,
493
434
  createStreamDecoder,
494
- killPids
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(`\u65E0\u6548\u7684 MCP URL: ${config.url}`);
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 collectProcessTree(pid);
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 killPids(orphans, { escalate: true });
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((t) => {
662
- const tool = t;
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("OAuth \u56DE\u8C03\u91CC\u6CA1\u6709\u6388\u6743\u7801"));
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(`\u7B49\u5F85 OAuth \u56DE\u8C03\u8D85\u65F6\uFF08${timeoutMs / 1e3}s\uFF09\uFF0C\u767B\u5F55\u672A\u5B8C\u6210`));
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, "\u6388\u6743\u5931\u8D25", "\u672C\u6B21\u767B\u5F55\u5C1A\u672A\u5F00\u59CB\uFF0C\u8FD9\u4E2A\u56DE\u8C03\u4E0D\u5C5E\u4E8E\u4EFB\u4F55\u6D41\u7A0B");
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, "\u6388\u6743\u5931\u8D25", detail ?? oauthError);
867
+ respond(res, 400, t6("mcp_oauth.page_failed"), detail ?? oauthError);
1014
868
  emit2({
1015
- error: new Error(`\u6388\u6743\u670D\u52A1\u5668\u8FD4\u56DE ${oauthError}${detail ? `\uFF1A${detail}` : ""}`)
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, "\u6388\u6743\u5931\u8D25", "state \u4E0D\u5339\u914D\uFF0C\u8FD9\u6B21\u56DE\u8C03\u5DF2\u88AB\u62D2\u7EDD");
1022
- emit2({ error: new Error("OAuth \u56DE\u8C03\u7684 state \u4E0D\u5339\u914D\uFF08\u53EF\u80FD\u662F CSRF\uFF09\uFF0C\u5DF2\u62D2\u7EDD") });
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, "\u6388\u6743\u5931\u8D25", "\u56DE\u8C03\u91CC\u6CA1\u6709 code");
1028
- emit2({ error: new Error("OAuth \u56DE\u8C03\u91CC\u6CA1\u6709\u6388\u6743\u7801") });
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, "\u6388\u6743\u6210\u529F", "\u53EF\u4EE5\u5173\u6389\u8FD9\u4E2A\u9875\u9762\u56DE\u5230\u7EC8\u7AEF\u4E86\u3002");
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("\u56DE\u8C03\u670D\u52A1\u5668\u542F\u52A8\u5931\u8D25\uFF1A\u62FF\u4E0D\u5230\u7AEF\u53E3"));
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="zh"><head><meta charset="utf-8">
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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(`\u5728\u6D4F\u89C8\u5668\u91CC\u5B8C\u6210\u6388\u6743\uFF1A
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(`\u6388\u6743\u672A\u5B8C\u6210\uFF08SDK \u8FD4\u56DE ${second}\uFF09`);
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 { servers: [], issues: [{ path: label, message: `JSON \u8BED\u6CD5\u9519\u8BEF\uFF1A${detail}` }] };
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: "\u68C0\u6D4B\u5230 `mcpServers` \u5B57\u6BB5\uFF1Bepoch \u8BFB\u7684\u662F `servers`\uFF0C\u8BF7\u6539\u952E\u540D"
1259
+ message: t8("mcp_config_parse.wrong_key")
1417
1260
  });
1418
1261
  } else if (issues.length === 0) {
1419
- issues.push({ path: label, message: "\u7F3A\u5C11 `servers` \u5B57\u6BB5\uFF0C\u6CA1\u6709\u53EF\u7528\u7684 MCP server" });
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: "\u914D\u7F6E\u65E0\u6548\uFF0C\u8BE5 server \u5DF2\u8DF3\u8FC7" });
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: "stdio \u4F20\u8F93\u7F3A\u5C11 command\uFF0C\u8BE5 server \u5DF2\u8DF3\u8FC7" });
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({ path: `${at}.url`, message: `${transport} \u4F20\u8F93\u7F3A\u5C11 url\uFF0C\u8BE5 server \u5DF2\u8DF3\u8FC7` });
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({ path: `${at}.url`, message: `\u4E0D\u662F\u5408\u6CD5\u7684 URL\uFF1A${raw.url}\uFF0C\u8BE5 server \u5DF2\u8DF3\u8FC7` });
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: "\u672A\u77E5\u5B57\u6BB5\uFF0C\u5DF2\u5FFD\u7565\uFF08\u62FC\u5199\u9519\u4E86\uFF1F\uFF09" }));
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 { servers: [], issues: [{ path, message: `\u8BFB\u53D6\u5931\u8D25\uFF1A${detail}` }] };
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.1.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/protocol": "0.1.0",
27
- "@epoch-agent/infra": "0.1.0"
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",