@epoch-agent/plugin-mcp 0.1.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/LICENSE +219 -0
- package/README.md +197 -0
- package/dist/index.d.ts +980 -0
- package/dist/index.js +1709 -0
- package/package.json +44 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1709 @@
|
|
|
1
|
+
// src/cache.ts
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
4
|
+
import { dirname } from "path";
|
|
5
|
+
function configFingerprint(config) {
|
|
6
|
+
const payload = JSON.stringify({
|
|
7
|
+
transport: config.transport,
|
|
8
|
+
command: config.command ?? null,
|
|
9
|
+
args: config.args ?? [],
|
|
10
|
+
url: config.url ?? null,
|
|
11
|
+
streamable: config.streamable ?? false,
|
|
12
|
+
// header 的值可能是 token,指纹只取键名 + 值的哈希,不落明文
|
|
13
|
+
headers: Object.keys(config.headers ?? {}).sort(),
|
|
14
|
+
headersDigest: hash(JSON.stringify(config.headers ?? {})),
|
|
15
|
+
env: Object.keys(config.env ?? {}).sort()
|
|
16
|
+
});
|
|
17
|
+
return hash(payload).slice(0, 16);
|
|
18
|
+
}
|
|
19
|
+
function hash(input) {
|
|
20
|
+
return createHash("sha256").update(input, "utf-8").digest("hex");
|
|
21
|
+
}
|
|
22
|
+
var McpSchemaCache = class {
|
|
23
|
+
/** @param cacheFile 磁盘缓存文件路径;传 null 表示只用内存(测试 / 无家目录场景) */
|
|
24
|
+
constructor(cacheFile = null) {
|
|
25
|
+
this.cacheFile = cacheFile;
|
|
26
|
+
}
|
|
27
|
+
cacheFile;
|
|
28
|
+
memory = /* @__PURE__ */ new Map();
|
|
29
|
+
disk = null;
|
|
30
|
+
/** 内存命中才算「新鲜」—— 磁盘的走 {@link getPersisted} */
|
|
31
|
+
getFresh(name, fingerprint) {
|
|
32
|
+
const entry = this.memory.get(name);
|
|
33
|
+
return entry && entry.fingerprint === fingerprint ? entry.tools : null;
|
|
34
|
+
}
|
|
35
|
+
/** 上次成功落盘的 schema,仅在 live 调用失败时作为降级使用 */
|
|
36
|
+
getPersisted(name, fingerprint) {
|
|
37
|
+
const entry = this.loadDisk()[name];
|
|
38
|
+
return entry && entry.fingerprint === fingerprint ? entry.tools : null;
|
|
39
|
+
}
|
|
40
|
+
/** live 拉取成功后写入两层 */
|
|
41
|
+
set(name, fingerprint, tools) {
|
|
42
|
+
const entry = { fingerprint, tools };
|
|
43
|
+
this.memory.set(name, entry);
|
|
44
|
+
this.writeDisk(name, entry);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 显式失效。不传 name 清全部。
|
|
48
|
+
*
|
|
49
|
+
* 调用方:`McpRegistry.reconnect()`,以及将来的 `notifications/tools/list_changed`。
|
|
50
|
+
*/
|
|
51
|
+
invalidate(name) {
|
|
52
|
+
if (name === void 0) {
|
|
53
|
+
this.memory.clear();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this.memory.delete(name);
|
|
57
|
+
}
|
|
58
|
+
// ---- 磁盘 ----
|
|
59
|
+
loadDisk() {
|
|
60
|
+
if (this.disk) return this.disk;
|
|
61
|
+
if (!this.cacheFile) {
|
|
62
|
+
this.disk = {};
|
|
63
|
+
return this.disk;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(readFileSync(this.cacheFile, "utf-8"));
|
|
67
|
+
this.disk = isDiskShape(parsed) ? parsed : {};
|
|
68
|
+
} catch {
|
|
69
|
+
this.disk = {};
|
|
70
|
+
}
|
|
71
|
+
return this.disk;
|
|
72
|
+
}
|
|
73
|
+
writeDisk(name, entry) {
|
|
74
|
+
if (!this.cacheFile) return;
|
|
75
|
+
const data = this.loadDisk();
|
|
76
|
+
if (JSON.stringify(data[name]) === JSON.stringify(entry)) return;
|
|
77
|
+
data[name] = entry;
|
|
78
|
+
try {
|
|
79
|
+
mkdirSync(dirname(this.cacheFile), { recursive: true });
|
|
80
|
+
const tmp = `${this.cacheFile}.tmp`;
|
|
81
|
+
writeFileSync(tmp, JSON.stringify(data), { encoding: "utf-8", mode: 384 });
|
|
82
|
+
renameSync(tmp, this.cacheFile);
|
|
83
|
+
} catch {
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
function isDiskShape(value) {
|
|
88
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
89
|
+
return Object.values(value).every(
|
|
90
|
+
(entry) => typeof entry === "object" && entry !== null && typeof entry.fingerprint === "string" && Array.isArray(entry.tools)
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/client.ts
|
|
95
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
96
|
+
import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
97
|
+
|
|
98
|
+
// src/content.ts
|
|
99
|
+
var DEFAULT_MEDIA_TYPE = "application/octet-stream";
|
|
100
|
+
function str(v) {
|
|
101
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
102
|
+
}
|
|
103
|
+
function kindOf(mediaType) {
|
|
104
|
+
if (mediaType.startsWith("image/")) return "image";
|
|
105
|
+
if (mediaType.startsWith("audio/")) return "audio";
|
|
106
|
+
return "file";
|
|
107
|
+
}
|
|
108
|
+
function lastSegment(uri) {
|
|
109
|
+
if (!uri) return void 0;
|
|
110
|
+
const trimmed = uri.split(/[?#]/, 1)[0].replace(/\/+$/, "");
|
|
111
|
+
const seg = trimmed.slice(trimmed.lastIndexOf("/") + 1);
|
|
112
|
+
return seg.length > 0 ? seg : void 0;
|
|
113
|
+
}
|
|
114
|
+
function fromMedia(item, fallbackKind) {
|
|
115
|
+
const data = str(item.data);
|
|
116
|
+
if (!data) return null;
|
|
117
|
+
const mediaType = str(item.mimeType) ?? (fallbackKind === "image" ? "image/png" : "audio/wav");
|
|
118
|
+
return { kind: kindOf(mediaType), mediaType, base64: data };
|
|
119
|
+
}
|
|
120
|
+
function fromResource(item) {
|
|
121
|
+
const res = item.resource;
|
|
122
|
+
if (!res) return {};
|
|
123
|
+
const text = str(res.text);
|
|
124
|
+
if (text) return { text };
|
|
125
|
+
const blob = str(res.blob);
|
|
126
|
+
if (!blob) return {};
|
|
127
|
+
const mediaType = str(res.mimeType) ?? DEFAULT_MEDIA_TYPE;
|
|
128
|
+
const filename = lastSegment(str(res.uri));
|
|
129
|
+
return {
|
|
130
|
+
artifact: {
|
|
131
|
+
kind: kindOf(mediaType),
|
|
132
|
+
mediaType,
|
|
133
|
+
base64: blob,
|
|
134
|
+
...filename ? { filename } : {}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function convertMcpContent(content) {
|
|
139
|
+
const items = Array.isArray(content) ? content : [];
|
|
140
|
+
const texts = [];
|
|
141
|
+
const artifacts = [];
|
|
142
|
+
for (const item of items) {
|
|
143
|
+
const type = typeof item?.type === "string" ? item.type : void 0;
|
|
144
|
+
switch (type) {
|
|
145
|
+
case "text": {
|
|
146
|
+
const t = str(item.text);
|
|
147
|
+
if (t) texts.push(t);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case "image":
|
|
151
|
+
case "audio": {
|
|
152
|
+
const a = fromMedia(item, type);
|
|
153
|
+
if (a) artifacts.push(a);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "resource": {
|
|
157
|
+
const { text, artifact } = fromResource(item);
|
|
158
|
+
if (text) texts.push(text);
|
|
159
|
+
if (artifact) artifacts.push(artifact);
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case "resource_link": {
|
|
163
|
+
const uri = str(item.uri);
|
|
164
|
+
if (uri) texts.push(`${str(item.title) ?? str(item.name) ?? "\u8D44\u6E90"}: ${uri}`);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
default:
|
|
168
|
+
texts.push(`[\u672A\u77E5 content \u7C7B\u578B ${type ?? "(\u7F3A\u5931)"}\uFF0C\u5DF2\u5FFD\u7565]`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { text: texts.join("\n"), artifacts };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/oauth/attach.ts
|
|
175
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
176
|
+
|
|
177
|
+
// src/oauth/provider.ts
|
|
178
|
+
import { randomBytes } from "crypto";
|
|
179
|
+
|
|
180
|
+
// src/oauth/types.ts
|
|
181
|
+
var McpLoginRequiredError = class extends Error {
|
|
182
|
+
constructor(server, detail) {
|
|
183
|
+
super(
|
|
184
|
+
`MCP server "${server}" \u9700\u8981 OAuth \u767B\u5F55\uFF1A\u5148\u8DD1 epoch mcp login ${server}` + (detail ? `\uFF08${detail}\uFF09` : "")
|
|
185
|
+
);
|
|
186
|
+
this.server = server;
|
|
187
|
+
this.name = "McpLoginRequiredError";
|
|
188
|
+
}
|
|
189
|
+
server;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// src/oauth/provider.ts
|
|
193
|
+
var CLIENT_NAME = "epoch-agent";
|
|
194
|
+
var CLIENT_URI = "https://gitlab.linkworld.cn/chenboxuan/epoch-agent";
|
|
195
|
+
var PLACEHOLDER_REDIRECT_URI = "http://127.0.0.1:0/callback";
|
|
196
|
+
var McpOAuthProvider = class {
|
|
197
|
+
opts;
|
|
198
|
+
/** 本次流程生成的 state,回调时用来验 CSRF */
|
|
199
|
+
currentState;
|
|
200
|
+
constructor(opts) {
|
|
201
|
+
this.opts = opts;
|
|
202
|
+
}
|
|
203
|
+
/** 本次授权流程用的 state,回调服务器拿它比对 */
|
|
204
|
+
get expectedState() {
|
|
205
|
+
return this.currentState;
|
|
206
|
+
}
|
|
207
|
+
/** 见 `PLACEHOLDER_REDIRECT_URI`:这里返回 undefined 会让 SDK 跳过 refresh */
|
|
208
|
+
get redirectUrl() {
|
|
209
|
+
return this.opts.redirectUri ?? PLACEHOLDER_REDIRECT_URI;
|
|
210
|
+
}
|
|
211
|
+
/** 真的能跳转吗(交互式登录才能)。占位地址不算 */
|
|
212
|
+
get interactive() {
|
|
213
|
+
return this.opts.onRedirect !== void 0;
|
|
214
|
+
}
|
|
215
|
+
get clientMetadata() {
|
|
216
|
+
return {
|
|
217
|
+
client_name: CLIENT_NAME,
|
|
218
|
+
client_uri: CLIENT_URI,
|
|
219
|
+
redirect_uris: [this.redirectUrl],
|
|
220
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
221
|
+
response_types: ["code"],
|
|
222
|
+
token_endpoint_auth_method: "none",
|
|
223
|
+
...this.opts.scope ? { scope: this.opts.scope } : {}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* OAuth 的 `state` 参数。
|
|
228
|
+
*
|
|
229
|
+
* SDK 把它当**生成器**调(每次授权一个新值),不是「读一个固定值」——
|
|
230
|
+
* opencode `src/mcp/oauth-provider.ts` 里踩过这个:写成只读缓存的话,
|
|
231
|
+
* 两次登录会拿到同一个 state,回调分不清是哪一次。
|
|
232
|
+
*/
|
|
233
|
+
state() {
|
|
234
|
+
this.currentState = randomBytes(32).toString("base64url");
|
|
235
|
+
return this.currentState;
|
|
236
|
+
}
|
|
237
|
+
clientInformation() {
|
|
238
|
+
return this.entry()?.client;
|
|
239
|
+
}
|
|
240
|
+
saveClientInformation(info) {
|
|
241
|
+
this.opts.store.update(this.opts.server, this.opts.serverUrl, {
|
|
242
|
+
client: info
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
tokens() {
|
|
246
|
+
return this.entry()?.tokens;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 存 token。
|
|
250
|
+
*
|
|
251
|
+
* 顺手把 `expires_in`(相对秒数)换算成绝对时刻落盘 —— 相对值一重启就是错的。
|
|
252
|
+
* 同时清掉 `codeVerifier`:它只在「跳转了、还没换到 token」那段窗口有用,
|
|
253
|
+
* 换到了就是一份没有用处的秘密,留着只是多一处泄漏点。
|
|
254
|
+
*/
|
|
255
|
+
saveTokens(tokens) {
|
|
256
|
+
const expiresAt = typeof tokens.expires_in === "number" && tokens.expires_in > 0 ? Date.now() + tokens.expires_in * 1e3 : void 0;
|
|
257
|
+
this.opts.store.update(this.opts.server, this.opts.serverUrl, {
|
|
258
|
+
tokens,
|
|
259
|
+
expiresAt,
|
|
260
|
+
codeVerifier: void 0,
|
|
261
|
+
updatedAt: Date.now()
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
async redirectToAuthorization(url) {
|
|
265
|
+
if (!this.opts.onRedirect) {
|
|
266
|
+
throw new McpLoginRequiredError(this.opts.server, "\u5DF2\u6709\u51ED\u636E\u65E0\u6CD5\u7EED\u671F\u6216\u5C1A\u672A\u6388\u6743");
|
|
267
|
+
}
|
|
268
|
+
await this.opts.onRedirect(url);
|
|
269
|
+
}
|
|
270
|
+
saveCodeVerifier(codeVerifier) {
|
|
271
|
+
this.opts.store.update(this.opts.server, this.opts.serverUrl, { codeVerifier });
|
|
272
|
+
}
|
|
273
|
+
codeVerifier() {
|
|
274
|
+
const verifier = this.entry()?.codeVerifier;
|
|
275
|
+
if (!verifier) {
|
|
276
|
+
throw new McpLoginRequiredError(this.opts.server, "PKCE code_verifier \u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55");
|
|
277
|
+
}
|
|
278
|
+
return verifier;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* 服务端说凭据不好使了,SDK 调这里让我们删掉。
|
|
282
|
+
*
|
|
283
|
+
* 不实现的话用户要自己去删 `~/.epoch/mcp-auth.json` —— 而「client 注册
|
|
284
|
+
* 被服务端撤销」这种情况下,不删就会一直拿同一个失效 client_id 重试。
|
|
285
|
+
*/
|
|
286
|
+
invalidateCredentials(scope) {
|
|
287
|
+
if (scope === "all") {
|
|
288
|
+
this.opts.store.remove(this.opts.server);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const patch = {
|
|
292
|
+
client: { client: void 0 },
|
|
293
|
+
tokens: { tokens: void 0, expiresAt: void 0 },
|
|
294
|
+
verifier: { codeVerifier: void 0 },
|
|
295
|
+
discovery: { discovery: void 0 }
|
|
296
|
+
}[scope];
|
|
297
|
+
this.opts.store.update(this.opts.server, this.opts.serverUrl, patch);
|
|
298
|
+
}
|
|
299
|
+
saveDiscoveryState(state) {
|
|
300
|
+
this.opts.store.update(this.opts.server, this.opts.serverUrl, {
|
|
301
|
+
discovery: state
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
discoveryState() {
|
|
305
|
+
return this.entry()?.discovery;
|
|
306
|
+
}
|
|
307
|
+
entry() {
|
|
308
|
+
return this.opts.store.get(this.opts.server, this.opts.serverUrl);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// src/oauth/attach.ts
|
|
313
|
+
function createAuthProvider(config, store) {
|
|
314
|
+
if (config.transport === "stdio" || !config.url) return void 0;
|
|
315
|
+
if (config.oauth?.enabled === false) return void 0;
|
|
316
|
+
if (hasAuthorizationHeader(config.headers)) return void 0;
|
|
317
|
+
return new McpOAuthProvider({
|
|
318
|
+
server: config.name,
|
|
319
|
+
serverUrl: config.url,
|
|
320
|
+
store,
|
|
321
|
+
scope: config.oauth?.scope
|
|
322
|
+
// 刻意不给 onRedirect:连接期绝不弹浏览器,需要授权就抛「请先 login」
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
function hasAuthorizationHeader(headers) {
|
|
326
|
+
if (!headers) return false;
|
|
327
|
+
return Object.keys(headers).some((k) => k.toLowerCase() === "authorization");
|
|
328
|
+
}
|
|
329
|
+
function describeAuthFailure(err, server) {
|
|
330
|
+
if (err instanceof McpLoginRequiredError) return err.message;
|
|
331
|
+
if (err instanceof UnauthorizedError) {
|
|
332
|
+
return new McpLoginRequiredError(server, err.message || "token \u5DF2\u5931\u6548\u4E14\u65E0\u6CD5\u7EED\u671F").message;
|
|
333
|
+
}
|
|
334
|
+
const cause = err?.cause;
|
|
335
|
+
if (cause !== void 0 && cause !== err) return describeAuthFailure(cause, server);
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/oauth/store.ts
|
|
340
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
341
|
+
import { dirname as dirname2 } from "path";
|
|
342
|
+
import {
|
|
343
|
+
getDataKey,
|
|
344
|
+
isEnvelope,
|
|
345
|
+
mcpAuthPath,
|
|
346
|
+
openEnvelope,
|
|
347
|
+
sealEnvelope
|
|
348
|
+
} from "@epoch-agent/infra";
|
|
349
|
+
var FILE_VERSION = 1;
|
|
350
|
+
var REFRESH_SKEW_MS = 3e4;
|
|
351
|
+
var McpAuthStore = class {
|
|
352
|
+
filePath;
|
|
353
|
+
cache = null;
|
|
354
|
+
/** 磁盘上是密文、但本次运行拿不到数据密钥。此时**只读不写**,见 load() / save() */
|
|
355
|
+
locked = false;
|
|
356
|
+
/** @param filePath 显式路径(测试用);不传走 infra 的路径真源 */
|
|
357
|
+
constructor(filePath, homeDir) {
|
|
358
|
+
this.filePath = filePath ?? mcpAuthPath(homeDir);
|
|
359
|
+
}
|
|
360
|
+
get path() {
|
|
361
|
+
return this.filePath;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* 读一个 server 的凭据。
|
|
365
|
+
*
|
|
366
|
+
* @param serverUrl 当前配置里的 URL。和落盘时记的对不上就返回 undefined ——
|
|
367
|
+
* 换了地址的 token 不能拿去打新地址。
|
|
368
|
+
*/
|
|
369
|
+
get(server, serverUrl) {
|
|
370
|
+
const entry = this.load().servers[server];
|
|
371
|
+
if (!entry) return void 0;
|
|
372
|
+
if (serverUrl !== void 0 && !sameEndpoint(entry.serverUrl, serverUrl)) return void 0;
|
|
373
|
+
return entry;
|
|
374
|
+
}
|
|
375
|
+
/** 不比对 URL 的读法,只给 `epoch mcp status` 展示用 */
|
|
376
|
+
getRaw(server) {
|
|
377
|
+
return this.load().servers[server];
|
|
378
|
+
}
|
|
379
|
+
list() {
|
|
380
|
+
return Object.entries(this.load().servers);
|
|
381
|
+
}
|
|
382
|
+
/** 局部更新一个 server 的条目;`serverUrl` 变了会先清空旧凭据 */
|
|
383
|
+
update(server, serverUrl, patch) {
|
|
384
|
+
const file = this.load();
|
|
385
|
+
this.assertWritable();
|
|
386
|
+
const existing = file.servers[server];
|
|
387
|
+
const base = existing && sameEndpoint(existing.serverUrl, serverUrl) ? existing : { serverUrl };
|
|
388
|
+
file.servers[server] = { ...base, ...patch, serverUrl };
|
|
389
|
+
this.save(file);
|
|
390
|
+
}
|
|
391
|
+
/** 删掉一个 server 的全部凭据。@returns 之前是否存在 */
|
|
392
|
+
remove(server) {
|
|
393
|
+
const file = this.load();
|
|
394
|
+
this.assertWritable();
|
|
395
|
+
if (!(server in file.servers)) return false;
|
|
396
|
+
delete file.servers[server];
|
|
397
|
+
this.save(file);
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
/** 强制下次 `get` 重新读盘。外部改过文件(另一个进程登录过)时用 */
|
|
401
|
+
invalidate() {
|
|
402
|
+
this.cache = null;
|
|
403
|
+
}
|
|
404
|
+
// ---- 内部 ----
|
|
405
|
+
load() {
|
|
406
|
+
if (this.cache) return this.cache;
|
|
407
|
+
if (!existsSync(this.filePath)) {
|
|
408
|
+
this.cache = { version: FILE_VERSION, servers: {} };
|
|
409
|
+
return this.cache;
|
|
410
|
+
}
|
|
411
|
+
const raw = readFileSync2(this.filePath, "utf-8");
|
|
412
|
+
this.locked = isEnvelope(raw) && getDataKey() === null;
|
|
413
|
+
try {
|
|
414
|
+
this.cache = normalize(JSON.parse(this.locked ? "{}" : decrypt(raw)));
|
|
415
|
+
} catch {
|
|
416
|
+
this.cache = { version: FILE_VERSION, servers: {} };
|
|
417
|
+
}
|
|
418
|
+
return this.cache;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* 磁盘上是密文而当前解不开时,任何写操作都必须**当场失败**。
|
|
422
|
+
*
|
|
423
|
+
* 在改动内存状态之前就拦(而不是等到 save()):`update()` 是先改缓存再落盘的,
|
|
424
|
+
* 到 save() 才抛会留下一个「内存里改了、盘上没改」的错位状态,
|
|
425
|
+
* 下一次读会拿到一份不存在于磁盘的凭据。
|
|
426
|
+
*/
|
|
427
|
+
assertWritable() {
|
|
428
|
+
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
|
+
);
|
|
432
|
+
}
|
|
433
|
+
save(file) {
|
|
434
|
+
this.assertWritable();
|
|
435
|
+
mkdirSync2(dirname2(this.filePath), { recursive: true });
|
|
436
|
+
const tmp = `${this.filePath}.${process.pid}.tmp`;
|
|
437
|
+
try {
|
|
438
|
+
writeFileSync2(tmp, encrypt(JSON.stringify(file, null, 2)), {
|
|
439
|
+
encoding: "utf-8",
|
|
440
|
+
mode: 384
|
|
441
|
+
});
|
|
442
|
+
renameSync2(tmp, this.filePath);
|
|
443
|
+
} catch (err) {
|
|
444
|
+
rmSync(tmp, { force: true });
|
|
445
|
+
throw new Error(
|
|
446
|
+
`MCP \u51ED\u636E ${this.filePath} \u5199\u5165\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
this.cache = file;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
function encrypt(json) {
|
|
453
|
+
const key = getDataKey();
|
|
454
|
+
return key ? sealEnvelope(key, json) : json;
|
|
455
|
+
}
|
|
456
|
+
function decrypt(raw) {
|
|
457
|
+
if (!isEnvelope(raw)) return raw;
|
|
458
|
+
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");
|
|
460
|
+
return openEnvelope(key, raw);
|
|
461
|
+
}
|
|
462
|
+
function isExpired(entry, now = Date.now()) {
|
|
463
|
+
if (!entry?.expiresAt) return false;
|
|
464
|
+
return now + REFRESH_SKEW_MS >= entry.expiresAt;
|
|
465
|
+
}
|
|
466
|
+
function sameEndpoint(a, b) {
|
|
467
|
+
return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
|
468
|
+
}
|
|
469
|
+
function normalize(parsed) {
|
|
470
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
471
|
+
return { version: FILE_VERSION, servers: {} };
|
|
472
|
+
}
|
|
473
|
+
const servers = parsed.servers;
|
|
474
|
+
if (typeof servers !== "object" || servers === null) {
|
|
475
|
+
return { version: FILE_VERSION, servers: {} };
|
|
476
|
+
}
|
|
477
|
+
const out = {};
|
|
478
|
+
for (const [name, value] of Object.entries(servers)) {
|
|
479
|
+
if (typeof value !== "object" || value === null) continue;
|
|
480
|
+
const url = value.serverUrl;
|
|
481
|
+
if (typeof url !== "string" || url.length === 0) continue;
|
|
482
|
+
out[name] = value;
|
|
483
|
+
}
|
|
484
|
+
return { version: FILE_VERSION, servers: out };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/transport/index.ts
|
|
488
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
489
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
490
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
491
|
+
import {
|
|
492
|
+
collectProcessTree,
|
|
493
|
+
createStreamDecoder,
|
|
494
|
+
killPids
|
|
495
|
+
} from "@epoch-agent/infra";
|
|
496
|
+
function createStdioTransport(config, onStderr) {
|
|
497
|
+
const transport = new StdioClientTransport({
|
|
498
|
+
command: config.command ?? "node",
|
|
499
|
+
args: config.args,
|
|
500
|
+
env: config.env,
|
|
501
|
+
stderr: "pipe"
|
|
502
|
+
});
|
|
503
|
+
let decoder = null;
|
|
504
|
+
transport.stderr?.on("data", (chunk) => {
|
|
505
|
+
decoder ??= createStreamDecoder();
|
|
506
|
+
onStderr(decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
507
|
+
});
|
|
508
|
+
transport.stderr?.on("end", () => {
|
|
509
|
+
const rest = decoder?.end() ?? "";
|
|
510
|
+
if (rest.length > 0) onStderr(rest);
|
|
511
|
+
});
|
|
512
|
+
return transport;
|
|
513
|
+
}
|
|
514
|
+
function createHttpTransport(config, authProvider) {
|
|
515
|
+
let parsedUrl;
|
|
516
|
+
try {
|
|
517
|
+
parsedUrl = new URL(config.url ?? "");
|
|
518
|
+
} catch {
|
|
519
|
+
throw new Error(`\u65E0\u6548\u7684 MCP URL: ${config.url}`);
|
|
520
|
+
}
|
|
521
|
+
const requestInit = config.headers ? { headers: config.headers } : void 0;
|
|
522
|
+
const opts = { requestInit, authProvider };
|
|
523
|
+
return config.streamable || config.transport === "http" ? new StreamableHTTPClientTransport(parsedUrl, opts) : new SSEClientTransport(parsedUrl, opts);
|
|
524
|
+
}
|
|
525
|
+
function stdioChildPid(transport) {
|
|
526
|
+
const proc = transport._process;
|
|
527
|
+
const pid = proc?.pid;
|
|
528
|
+
return typeof pid === "number" && Number.isInteger(pid) && pid > 1 ? pid : null;
|
|
529
|
+
}
|
|
530
|
+
async function closeTransportAndReap(transport) {
|
|
531
|
+
const pid = stdioChildPid(transport);
|
|
532
|
+
const orphans = pid === null ? [] : await collectProcessTree(pid);
|
|
533
|
+
try {
|
|
534
|
+
await transport.close();
|
|
535
|
+
} catch {
|
|
536
|
+
}
|
|
537
|
+
if (orphans.length > 0) await killPids(orphans, { escalate: true });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/types.ts
|
|
541
|
+
var DEFAULTS = {
|
|
542
|
+
timeout: 300,
|
|
543
|
+
connectTimeout: 60,
|
|
544
|
+
keepaliveInterval: 180,
|
|
545
|
+
idleTimeoutSeconds: 0,
|
|
546
|
+
maxLifetimeSeconds: 0,
|
|
547
|
+
supportsParallel: false,
|
|
548
|
+
skipPreflight: false
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// src/client.ts
|
|
552
|
+
var MAX_RECONNECT = 3;
|
|
553
|
+
var RECONNECT_BACKOFF = [1e3, 2e3, 4e3];
|
|
554
|
+
var STDERR_TAIL_LINES = 20;
|
|
555
|
+
var McpClient = class {
|
|
556
|
+
config;
|
|
557
|
+
client = null;
|
|
558
|
+
transport = null;
|
|
559
|
+
keepaliveTimer = null;
|
|
560
|
+
lifetimeTimer = null;
|
|
561
|
+
idleTimer = null;
|
|
562
|
+
reconnectTimer = null;
|
|
563
|
+
reconnectAttempts = 0;
|
|
564
|
+
lastError = null;
|
|
565
|
+
toolCount = 0;
|
|
566
|
+
stderrTail = [];
|
|
567
|
+
/** 上一个 chunk 末尾那截还没等到换行的文本,见 {@link collectStderr} */
|
|
568
|
+
stderrPartial = "";
|
|
569
|
+
/** 主动 disconnect 时置位,避免自己触发的 onclose 又去排一次重连 */
|
|
570
|
+
closingIntentionally = false;
|
|
571
|
+
/** 认证过不去。重连解决不了这个问题,只有 `epoch mcp login` 能 */
|
|
572
|
+
needsLogin = false;
|
|
573
|
+
cache;
|
|
574
|
+
fingerprint;
|
|
575
|
+
authProvider;
|
|
576
|
+
/**
|
|
577
|
+
* 收到 `notifications/tools/list_changed` 时的回调。
|
|
578
|
+
*
|
|
579
|
+
* McpRegistry 用它把「工具表已经不是启动时那份了」冒泡给宿主。
|
|
580
|
+
* 注意**这不是热更新**:AgentLoop 的工具集是构造参数,本进程内换不掉。
|
|
581
|
+
* 详见 registry 的 `refresh()`。
|
|
582
|
+
*/
|
|
583
|
+
onToolsChanged;
|
|
584
|
+
/**
|
|
585
|
+
* 这台是谁带进来的(方案 44 PR-2)。
|
|
586
|
+
*
|
|
587
|
+
* **由 `McpRegistry.connectAll()` 那一侧说了算,不从 `config` 里读** ——
|
|
588
|
+
* `McpServerConfig` 是用户手写的 `mcp.json` 解析出来的形状,来源要是它的一个
|
|
589
|
+
* 字段,用户就能在自己的文件里写 `"source": "host"` 给自己那台贴上宿主的标。
|
|
590
|
+
* 判据同角色那条「命名空间由装配层拼,宿主自己写的不算数」。
|
|
591
|
+
*/
|
|
592
|
+
source;
|
|
593
|
+
constructor(config, cache, authStore, source = "user") {
|
|
594
|
+
this.config = { ...DEFAULTS, ...config };
|
|
595
|
+
this.cache = cache ?? new McpSchemaCache(null);
|
|
596
|
+
this.fingerprint = configFingerprint(this.config);
|
|
597
|
+
this.authProvider = createAuthProvider(this.config, authStore ?? new McpAuthStore());
|
|
598
|
+
this.source = source;
|
|
599
|
+
}
|
|
600
|
+
get status() {
|
|
601
|
+
return {
|
|
602
|
+
name: this.config.name,
|
|
603
|
+
connected: this.client !== null,
|
|
604
|
+
toolCount: this.toolCount,
|
|
605
|
+
lastError: this.lastError ?? void 0,
|
|
606
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
607
|
+
needsLogin: this.needsLogin,
|
|
608
|
+
source: this.source
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
/** 底层 SDK client,`null` 表示没连上。resources / prompts 那几个原语要用 */
|
|
612
|
+
get raw() {
|
|
613
|
+
return this.client;
|
|
614
|
+
}
|
|
615
|
+
get serverConfig() {
|
|
616
|
+
return this.config;
|
|
617
|
+
}
|
|
618
|
+
async connect() {
|
|
619
|
+
await this.connectInner();
|
|
620
|
+
if (this.client) {
|
|
621
|
+
this.startKeepalive();
|
|
622
|
+
this.startLifetime();
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* 取本 server 的工具 schema —— 缓存优先。
|
|
627
|
+
*
|
|
628
|
+
* 三级:内存缓存 → live `tools/list` → 上次落盘的 schema(降级)。
|
|
629
|
+
* 详见 [cache.ts](./cache.ts) 里两层分工的说明。
|
|
630
|
+
*/
|
|
631
|
+
async getToolSchemas() {
|
|
632
|
+
const name = this.config.name;
|
|
633
|
+
const cached = this.cache.getFresh(name, this.fingerprint);
|
|
634
|
+
if (cached) {
|
|
635
|
+
this.toolCount = cached.length;
|
|
636
|
+
return cached;
|
|
637
|
+
}
|
|
638
|
+
const live = await this.fetchToolSchemas();
|
|
639
|
+
if (live) {
|
|
640
|
+
this.cache.set(name, this.fingerprint, live);
|
|
641
|
+
this.toolCount = live.length;
|
|
642
|
+
return live;
|
|
643
|
+
}
|
|
644
|
+
const persisted = this.cache.getPersisted(name, this.fingerprint);
|
|
645
|
+
if (persisted) {
|
|
646
|
+
this.toolCount = persisted.length;
|
|
647
|
+
this.lastError = `${this.lastError ?? "tools/list \u5931\u8D25"}\uFF08\u5DF2\u964D\u7EA7\u7528\u4E0A\u6B21\u7F13\u5B58\u7684 ${persisted.length} \u4E2A\u5DE5\u5177\uFF09`;
|
|
648
|
+
return persisted;
|
|
649
|
+
}
|
|
650
|
+
return [];
|
|
651
|
+
}
|
|
652
|
+
/** live 拉取;失败返回 null(错误记进 lastError,由调用方决定降不降级) */
|
|
653
|
+
async fetchToolSchemas() {
|
|
654
|
+
if (!this.client) return null;
|
|
655
|
+
this.resetIdle();
|
|
656
|
+
try {
|
|
657
|
+
const result = await this.withTimeout(
|
|
658
|
+
this.client.listTools(),
|
|
659
|
+
(this.config.timeout ?? DEFAULTS.timeout) * 1e3
|
|
660
|
+
);
|
|
661
|
+
return (result.tools ?? []).map((t) => {
|
|
662
|
+
const tool = t;
|
|
663
|
+
return {
|
|
664
|
+
name: tool.name,
|
|
665
|
+
description: tool.description,
|
|
666
|
+
inputSchema: tool.inputSchema,
|
|
667
|
+
title: tool.title,
|
|
668
|
+
annotations: tool.annotations
|
|
669
|
+
};
|
|
670
|
+
});
|
|
671
|
+
} catch (err) {
|
|
672
|
+
this.lastError = err instanceof Error ? err.message : String(err);
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* 调用工具。
|
|
678
|
+
*
|
|
679
|
+
* **断线不在这里等重连**:以前这里 `await this.reconnect()`,而 reconnect 是
|
|
680
|
+
* 1s + 2s + 4s 的 backoff 循环 —— 工具执行路径上最多阻塞 7 秒,用户看到的是
|
|
681
|
+
* agent 卡住。现在改成排一次后台重连、立刻返回一条可重试的错误。
|
|
682
|
+
*/
|
|
683
|
+
async callTool(name, args) {
|
|
684
|
+
if (!this.client) {
|
|
685
|
+
this.scheduleReconnect();
|
|
686
|
+
return { success: false, output: this.unavailableMessage() };
|
|
687
|
+
}
|
|
688
|
+
this.resetIdle();
|
|
689
|
+
try {
|
|
690
|
+
const result = await this.withTimeout(
|
|
691
|
+
this.client.callTool({ name, arguments: args }),
|
|
692
|
+
(this.config.timeout ?? DEFAULTS.timeout) * 1e3
|
|
693
|
+
);
|
|
694
|
+
const { text, artifacts } = convertMcpContent(result.content);
|
|
695
|
+
return {
|
|
696
|
+
success: !result.isError,
|
|
697
|
+
output: text,
|
|
698
|
+
...artifacts.length > 0 ? { artifacts } : {}
|
|
699
|
+
};
|
|
700
|
+
} catch (err) {
|
|
701
|
+
const authHint = describeAuthFailure(err, this.config.name);
|
|
702
|
+
if (authHint) {
|
|
703
|
+
this.needsLogin = true;
|
|
704
|
+
this.lastError = authHint;
|
|
705
|
+
return { success: false, output: authHint };
|
|
706
|
+
}
|
|
707
|
+
return { success: false, output: err instanceof Error ? err.message : String(err) };
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
async disconnect() {
|
|
711
|
+
this.closingIntentionally = true;
|
|
712
|
+
this.clearTimers();
|
|
713
|
+
if (this.transport) {
|
|
714
|
+
await closeTransportAndReap(this.transport);
|
|
715
|
+
this.transport = null;
|
|
716
|
+
}
|
|
717
|
+
this.client = null;
|
|
718
|
+
this.closingIntentionally = false;
|
|
719
|
+
}
|
|
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
|
+
async connectInner() {
|
|
732
|
+
const transport = this.config.transport === "stdio" || !this.config.url ? createStdioTransport(this.config, (text) => this.collectStderr(text)) : createHttpTransport(this.config, this.authProvider);
|
|
733
|
+
const client = new Client({ name: "epoch-agent", version: "0.0.0" }, { capabilities: {} });
|
|
734
|
+
client.onclose = () => this.onTransportClosed(client);
|
|
735
|
+
client.setNotificationHandler(ToolListChangedNotificationSchema, () => {
|
|
736
|
+
this.onToolsListChanged();
|
|
737
|
+
});
|
|
738
|
+
const connectTimeoutMs = (this.config.connectTimeout ?? DEFAULTS.connectTimeout) * 1e3;
|
|
739
|
+
try {
|
|
740
|
+
await this.withTimeout(
|
|
741
|
+
client.connect(transport, { timeout: connectTimeoutMs }),
|
|
742
|
+
connectTimeoutMs
|
|
743
|
+
);
|
|
744
|
+
} catch (err) {
|
|
745
|
+
await closeTransportAndReap(transport);
|
|
746
|
+
const authHint = describeAuthFailure(err, this.config.name);
|
|
747
|
+
if (authHint) {
|
|
748
|
+
this.needsLogin = true;
|
|
749
|
+
this.lastError = authHint;
|
|
750
|
+
throw err;
|
|
751
|
+
}
|
|
752
|
+
const base = err instanceof Error ? err.message : String(err);
|
|
753
|
+
this.lastError = this.stderrTail.length > 0 ? `${base}\uFF1Bstderr: ${this.stderrHint()}` : base;
|
|
754
|
+
throw err;
|
|
755
|
+
}
|
|
756
|
+
this.transport = transport;
|
|
757
|
+
this.client = client;
|
|
758
|
+
this.reconnectAttempts = 0;
|
|
759
|
+
this.lastError = null;
|
|
760
|
+
this.needsLogin = false;
|
|
761
|
+
}
|
|
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
|
+
collectStderr(text) {
|
|
775
|
+
const parts = (this.stderrPartial + text).split("\n");
|
|
776
|
+
this.stderrPartial = parts.pop() ?? "";
|
|
777
|
+
for (const line of parts) {
|
|
778
|
+
if (line.trim().length === 0) continue;
|
|
779
|
+
this.stderrTail.push(line.trim());
|
|
780
|
+
}
|
|
781
|
+
if (this.stderrTail.length > STDERR_TAIL_LINES) {
|
|
782
|
+
this.stderrTail = this.stderrTail.slice(-STDERR_TAIL_LINES);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* 最后三行,给错误消息当尾巴。
|
|
787
|
+
*
|
|
788
|
+
* 把还没等到换行的那半行也算进来:server 崩在半句话上时,那半句往往正是
|
|
789
|
+
* 最要紧的一句(而它永远等不到自己的 `\n`)。
|
|
790
|
+
*/
|
|
791
|
+
stderrHint() {
|
|
792
|
+
const partial = this.stderrPartial.trim();
|
|
793
|
+
const lines = partial.length > 0 ? [...this.stderrTail, partial] : this.stderrTail;
|
|
794
|
+
return lines.slice(-3).join(" | ");
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* server 说工具表变了。
|
|
798
|
+
*
|
|
799
|
+
* 只做两件事:**失效 schema 缓存**(下次 `getToolSchemas()` 会真去拉一次),
|
|
800
|
+
* 然后把事件冒泡出去。刻意不在这里自己去 `listTools()` —— 通知可能连着来,
|
|
801
|
+
* 每条都拉一次是在替 server 打自己。
|
|
802
|
+
*/
|
|
803
|
+
onToolsListChanged() {
|
|
804
|
+
this.cache.invalidate(this.config.name);
|
|
805
|
+
this.onToolsChanged?.();
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* @param closed 触发 onclose 的那个 Client。重连成功后旧连接的迟到通知
|
|
809
|
+
* 不能把新连接连坐掉,所以要比对身份。
|
|
810
|
+
*/
|
|
811
|
+
onTransportClosed(closed) {
|
|
812
|
+
if (this.closingIntentionally) return;
|
|
813
|
+
if (this.client !== null && this.client !== closed) return;
|
|
814
|
+
this.client = null;
|
|
815
|
+
this.transport = null;
|
|
816
|
+
this.stopKeepalive();
|
|
817
|
+
this.lastError = `\u8FDE\u63A5\u5DF2\u65AD\u5F00${this.stderrTail.length > 0 ? `\uFF1Bstderr: ${this.stderrHint()}` : ""}`;
|
|
818
|
+
this.scheduleReconnect();
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* 排一次后台重连(幂等)。
|
|
822
|
+
*
|
|
823
|
+
* 看门狗语义:重连全程不占用调用方的时间片,失败就按 backoff 再排一次,
|
|
824
|
+
* 到 MAX_RECONNECT 为止把 server 标成不可用。
|
|
825
|
+
*/
|
|
826
|
+
scheduleReconnect() {
|
|
827
|
+
if (this.reconnectTimer || this.client) return;
|
|
828
|
+
if (this.needsLogin) return;
|
|
829
|
+
if (this.reconnectAttempts >= MAX_RECONNECT) {
|
|
830
|
+
this.lastError = `\u91CD\u8FDE ${MAX_RECONNECT} \u6B21\u5931\u8D25\uFF0C${this.config.name} \u6807\u8BB0\u4E3A\u4E0D\u53EF\u7528`;
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const delay = RECONNECT_BACKOFF[this.reconnectAttempts] ?? 4e3;
|
|
834
|
+
this.reconnectTimer = setTimeout(() => {
|
|
835
|
+
this.reconnectTimer = null;
|
|
836
|
+
void this.attemptReconnect();
|
|
837
|
+
}, delay);
|
|
838
|
+
this.reconnectTimer.unref?.();
|
|
839
|
+
}
|
|
840
|
+
async attemptReconnect() {
|
|
841
|
+
this.reconnectAttempts++;
|
|
842
|
+
try {
|
|
843
|
+
await this.connectInner();
|
|
844
|
+
} catch {
|
|
845
|
+
this.scheduleReconnect();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (this.client) {
|
|
849
|
+
this.cache.invalidate(this.config.name);
|
|
850
|
+
this.onToolsChanged?.();
|
|
851
|
+
this.startKeepalive();
|
|
852
|
+
this.startLifetime();
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
unavailableMessage() {
|
|
856
|
+
if (this.needsLogin) {
|
|
857
|
+
return this.lastError ?? `MCP server ${this.config.name} \u9700\u8981 OAuth \u767B\u5F55`;
|
|
858
|
+
}
|
|
859
|
+
if (this.reconnectAttempts >= MAX_RECONNECT) {
|
|
860
|
+
return `MCP server ${this.config.name} \u4E0D\u53EF\u7528\uFF08\u91CD\u8FDE ${MAX_RECONNECT} \u6B21\u5931\u8D25\uFF09${this.lastError ? `\uFF1A${this.lastError}` : ""}`;
|
|
861
|
+
}
|
|
862
|
+
return `MCP server ${this.config.name} \u8FDE\u63A5\u5DF2\u65AD\u5F00\uFF0C\u6B63\u5728\u540E\u53F0\u91CD\u8FDE\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5${this.lastError ? `\uFF08\u4E0A\u6B21\u9519\u8BEF\uFF1A${this.lastError}\uFF09` : ""}`;
|
|
863
|
+
}
|
|
864
|
+
startKeepalive() {
|
|
865
|
+
this.stopKeepalive();
|
|
866
|
+
const interval = Math.max(this.config.keepaliveInterval ?? DEFAULTS.keepaliveInterval, 5) * 1e3;
|
|
867
|
+
this.keepaliveTimer = setInterval(() => {
|
|
868
|
+
void this.pingOnce();
|
|
869
|
+
}, interval);
|
|
870
|
+
this.keepaliveTimer.unref?.();
|
|
871
|
+
}
|
|
872
|
+
async pingOnce() {
|
|
873
|
+
if (!this.client) return;
|
|
874
|
+
try {
|
|
875
|
+
await this.withTimeout(this.client.listTools(), 1e4);
|
|
876
|
+
} catch {
|
|
877
|
+
this.lastError = "keepalive \u5931\u8D25";
|
|
878
|
+
this.client = null;
|
|
879
|
+
this.stopKeepalive();
|
|
880
|
+
this.scheduleReconnect();
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
stopKeepalive() {
|
|
884
|
+
if (this.keepaliveTimer) {
|
|
885
|
+
clearInterval(this.keepaliveTimer);
|
|
886
|
+
this.keepaliveTimer = null;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* 起 idle / max-lifetime 两个自动断连定时器。
|
|
891
|
+
*
|
|
892
|
+
* 这里刻意**不**调 `clearTimers()`:它连 keepalive 一起清,而调用顺序是
|
|
893
|
+
* `startKeepalive(); startLifetime();` —— 等于刚起的 keepalive 立刻被自己清掉,
|
|
894
|
+
* keepalive 从来没真正跑过。只清自己的两个定时器。
|
|
895
|
+
*/
|
|
896
|
+
startLifetime() {
|
|
897
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
898
|
+
if (this.lifetimeTimer) clearTimeout(this.lifetimeTimer);
|
|
899
|
+
this.idleTimer = null;
|
|
900
|
+
this.lifetimeTimer = null;
|
|
901
|
+
if ((this.config.idleTimeoutSeconds ?? 0) > 0) {
|
|
902
|
+
this.idleTimer = this.armDisconnect(this.config.idleTimeoutSeconds ?? 3600);
|
|
903
|
+
}
|
|
904
|
+
if ((this.config.maxLifetimeSeconds ?? 0) > 0) {
|
|
905
|
+
this.lifetimeTimer = this.armDisconnect(this.config.maxLifetimeSeconds ?? 86400);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
resetIdle() {
|
|
909
|
+
if ((this.config.idleTimeoutSeconds ?? 0) > 0) {
|
|
910
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
911
|
+
this.idleTimer = this.armDisconnect(this.config.idleTimeoutSeconds ?? 3600);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
armDisconnect(seconds2) {
|
|
915
|
+
const timer = setTimeout(() => void this.disconnect(), seconds2 * 1e3);
|
|
916
|
+
timer.unref?.();
|
|
917
|
+
return timer;
|
|
918
|
+
}
|
|
919
|
+
clearTimers() {
|
|
920
|
+
this.stopKeepalive();
|
|
921
|
+
for (const timer of [this.idleTimer, this.lifetimeTimer, this.reconnectTimer]) {
|
|
922
|
+
if (timer) clearTimeout(timer);
|
|
923
|
+
}
|
|
924
|
+
this.idleTimer = null;
|
|
925
|
+
this.lifetimeTimer = null;
|
|
926
|
+
this.reconnectTimer = null;
|
|
927
|
+
}
|
|
928
|
+
async withTimeout(promise, ms) {
|
|
929
|
+
let timer;
|
|
930
|
+
const timeout = new Promise((_, reject) => {
|
|
931
|
+
timer = setTimeout(() => reject(new Error(`\u8D85\u65F6 (${ms}ms)`)), ms);
|
|
932
|
+
timer.unref?.();
|
|
933
|
+
});
|
|
934
|
+
try {
|
|
935
|
+
return await Promise.race([promise, timeout]);
|
|
936
|
+
} finally {
|
|
937
|
+
if (timer) clearTimeout(timer);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
// src/oauth/callback.ts
|
|
943
|
+
import { createServer } from "http";
|
|
944
|
+
var CALLBACK_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
945
|
+
var CALLBACK_PATH = "/callback";
|
|
946
|
+
async function startCallbackServer(port, timeoutMs = CALLBACK_TIMEOUT_MS) {
|
|
947
|
+
let onResult = null;
|
|
948
|
+
let pending = null;
|
|
949
|
+
let expectedState;
|
|
950
|
+
let armed = false;
|
|
951
|
+
const emit = (result) => {
|
|
952
|
+
if (onResult) onResult(result);
|
|
953
|
+
else pending = result;
|
|
954
|
+
};
|
|
955
|
+
const server = createServer((req, res) => {
|
|
956
|
+
handleRequest(req, res, emit);
|
|
957
|
+
});
|
|
958
|
+
const actualPort = await listen(server, port);
|
|
959
|
+
const redirectUri = `http://127.0.0.1:${actualPort}${CALLBACK_PATH}`;
|
|
960
|
+
let closed = false;
|
|
961
|
+
const close = () => {
|
|
962
|
+
if (closed) return;
|
|
963
|
+
closed = true;
|
|
964
|
+
server.close();
|
|
965
|
+
server.closeAllConnections?.();
|
|
966
|
+
};
|
|
967
|
+
return {
|
|
968
|
+
redirectUri,
|
|
969
|
+
close,
|
|
970
|
+
expect(state) {
|
|
971
|
+
expectedState = state;
|
|
972
|
+
armed = true;
|
|
973
|
+
},
|
|
974
|
+
waitForCode() {
|
|
975
|
+
return new Promise((resolve, reject) => {
|
|
976
|
+
const settle = (result) => {
|
|
977
|
+
setTimeout(close, 100).unref?.();
|
|
978
|
+
if (result.error) reject(result.error);
|
|
979
|
+
else if (result.code) resolve(result.code);
|
|
980
|
+
else reject(new Error("OAuth \u56DE\u8C03\u91CC\u6CA1\u6709\u6388\u6743\u7801"));
|
|
981
|
+
};
|
|
982
|
+
if (pending) {
|
|
983
|
+
settle(pending);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
const timer = setTimeout(() => {
|
|
987
|
+
close();
|
|
988
|
+
reject(new Error(`\u7B49\u5F85 OAuth \u56DE\u8C03\u8D85\u65F6\uFF08${timeoutMs / 1e3}s\uFF09\uFF0C\u767B\u5F55\u672A\u5B8C\u6210`));
|
|
989
|
+
}, timeoutMs);
|
|
990
|
+
timer.unref?.();
|
|
991
|
+
onResult = (result) => {
|
|
992
|
+
clearTimeout(timer);
|
|
993
|
+
onResult = null;
|
|
994
|
+
settle(result);
|
|
995
|
+
};
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
function handleRequest(req, res, emit2) {
|
|
1000
|
+
const url = new URL(req.url ?? "/", redirectUri);
|
|
1001
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
1002
|
+
res.writeHead(404).end("Not Found");
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
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");
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const params = url.searchParams;
|
|
1010
|
+
const oauthError = params.get("error");
|
|
1011
|
+
if (oauthError) {
|
|
1012
|
+
const detail = params.get("error_description");
|
|
1013
|
+
respond(res, 400, "\u6388\u6743\u5931\u8D25", detail ?? oauthError);
|
|
1014
|
+
emit2({
|
|
1015
|
+
error: new Error(`\u6388\u6743\u670D\u52A1\u5668\u8FD4\u56DE ${oauthError}${detail ? `\uFF1A${detail}` : ""}`)
|
|
1016
|
+
});
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
const state = params.get("state");
|
|
1020
|
+
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") });
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const code = params.get("code");
|
|
1026
|
+
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") });
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
respond(res, 200, "\u6388\u6743\u6210\u529F", "\u53EF\u4EE5\u5173\u6389\u8FD9\u4E2A\u9875\u9762\u56DE\u5230\u7EC8\u7AEF\u4E86\u3002");
|
|
1032
|
+
emit2({ code });
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
function listen(server, port) {
|
|
1036
|
+
return new Promise((resolve, reject) => {
|
|
1037
|
+
server.once("error", reject);
|
|
1038
|
+
server.listen(port ?? 0, "127.0.0.1", () => {
|
|
1039
|
+
const address = server.address();
|
|
1040
|
+
if (!address) {
|
|
1041
|
+
reject(new Error("\u56DE\u8C03\u670D\u52A1\u5668\u542F\u52A8\u5931\u8D25\uFF1A\u62FF\u4E0D\u5230\u7AEF\u53E3"));
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
server.removeListener("error", reject);
|
|
1045
|
+
resolve(address.port);
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
function respond(res, status, title, detail) {
|
|
1050
|
+
const body = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
|
1051
|
+
<title>${title}</title></head>
|
|
1052
|
+
<body style="font-family:system-ui;max-width:32rem;margin:6rem auto;text-align:center">
|
|
1053
|
+
<h1>${title}</h1><p>${escapeHtml(detail)}</p><p style="color:#888">epoch-agent</p>
|
|
1054
|
+
</body></html>`;
|
|
1055
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(body);
|
|
1056
|
+
}
|
|
1057
|
+
function escapeHtml(text) {
|
|
1058
|
+
return text.replace(
|
|
1059
|
+
/[&<>"']/g,
|
|
1060
|
+
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// src/oauth/login.ts
|
|
1065
|
+
import { spawn } from "child_process";
|
|
1066
|
+
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
1067
|
+
async function loginToMcpServer(opts) {
|
|
1068
|
+
const store = opts.store ?? new McpAuthStore();
|
|
1069
|
+
const print = opts.print ?? (() => {
|
|
1070
|
+
});
|
|
1071
|
+
const openBrowser = opts.openBrowser ?? openInBrowser;
|
|
1072
|
+
const callback = await startCallbackServer(opts.port);
|
|
1073
|
+
let redirected = false;
|
|
1074
|
+
const provider = new McpOAuthProvider({
|
|
1075
|
+
server: opts.server,
|
|
1076
|
+
serverUrl: opts.serverUrl,
|
|
1077
|
+
store,
|
|
1078
|
+
redirectUri: callback.redirectUri,
|
|
1079
|
+
scope: opts.scope,
|
|
1080
|
+
onRedirect: async (url) => {
|
|
1081
|
+
redirected = true;
|
|
1082
|
+
callback.expect(provider.expectedState);
|
|
1083
|
+
print(`\u5728\u6D4F\u89C8\u5668\u91CC\u5B8C\u6210\u6388\u6743\uFF1A
|
|
1084
|
+
${url.toString()}`);
|
|
1085
|
+
await openBrowser(url.toString());
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
try {
|
|
1089
|
+
provider.invalidateCredentials("verifier");
|
|
1090
|
+
dropUnusableClient(provider, store, opts, callback.redirectUri);
|
|
1091
|
+
const first = await auth(provider, { serverUrl: opts.serverUrl, scope: opts.scope });
|
|
1092
|
+
if (first === "AUTHORIZED" && !redirected) {
|
|
1093
|
+
return { server: opts.server, refreshedOnly: true };
|
|
1094
|
+
}
|
|
1095
|
+
const code = await callback.waitForCode();
|
|
1096
|
+
const second = await auth(provider, {
|
|
1097
|
+
serverUrl: opts.serverUrl,
|
|
1098
|
+
authorizationCode: code,
|
|
1099
|
+
scope: opts.scope
|
|
1100
|
+
});
|
|
1101
|
+
if (second !== "AUTHORIZED") {
|
|
1102
|
+
throw new Error(`\u6388\u6743\u672A\u5B8C\u6210\uFF08SDK \u8FD4\u56DE ${second}\uFF09`);
|
|
1103
|
+
}
|
|
1104
|
+
return { server: opts.server, refreshedOnly: false };
|
|
1105
|
+
} finally {
|
|
1106
|
+
callback.close();
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
function dropUnusableClient(provider, store, opts, redirectUri) {
|
|
1110
|
+
const entry = store.get(opts.server, opts.serverUrl);
|
|
1111
|
+
if (!entry?.client || entry.tokens?.refresh_token) return;
|
|
1112
|
+
const registered = entry.client.redirect_uris;
|
|
1113
|
+
if (Array.isArray(registered) && registered.includes(redirectUri)) return;
|
|
1114
|
+
provider.invalidateCredentials("client");
|
|
1115
|
+
}
|
|
1116
|
+
function logoutFromMcpServer(server, store) {
|
|
1117
|
+
return (store ?? new McpAuthStore()).remove(server);
|
|
1118
|
+
}
|
|
1119
|
+
function mcpAuthStatus(config, store) {
|
|
1120
|
+
const s = store ?? new McpAuthStore();
|
|
1121
|
+
if (config.transport === "stdio" || !config.url) {
|
|
1122
|
+
return { server: config.name, state: "not-applicable" };
|
|
1123
|
+
}
|
|
1124
|
+
if (hasAuthorizationHeader2(config.headers)) {
|
|
1125
|
+
return { server: config.name, state: "bearer-header", url: config.url };
|
|
1126
|
+
}
|
|
1127
|
+
const entry = s.get(config.name, config.url);
|
|
1128
|
+
const base = { server: config.name, url: config.url };
|
|
1129
|
+
if (!entry?.tokens?.access_token) {
|
|
1130
|
+
return { ...base, state: "logged-out" };
|
|
1131
|
+
}
|
|
1132
|
+
const state = !isExpired(entry) ? "authorized" : entry.tokens.refresh_token ? "refreshable" : "logged-out";
|
|
1133
|
+
return {
|
|
1134
|
+
...base,
|
|
1135
|
+
state,
|
|
1136
|
+
expiresAt: entry.expiresAt,
|
|
1137
|
+
updatedAt: entry.updatedAt,
|
|
1138
|
+
scope: entry.tokens.scope
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
function hasAuthorizationHeader2(headers) {
|
|
1142
|
+
if (!headers) return false;
|
|
1143
|
+
return Object.keys(headers).some((k) => k.toLowerCase() === "authorization");
|
|
1144
|
+
}
|
|
1145
|
+
function isBrowsableUrl(url) {
|
|
1146
|
+
try {
|
|
1147
|
+
const { protocol } = new URL(url);
|
|
1148
|
+
return protocol === "http:" || protocol === "https:";
|
|
1149
|
+
} catch {
|
|
1150
|
+
return false;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
function browserLaunchArgv(url, platform = process.platform) {
|
|
1154
|
+
if (!isBrowsableUrl(url)) return null;
|
|
1155
|
+
if (platform === "darwin") return { command: "open", args: [url] };
|
|
1156
|
+
if (platform === "win32") {
|
|
1157
|
+
return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
|
1158
|
+
}
|
|
1159
|
+
return { command: "xdg-open", args: [url] };
|
|
1160
|
+
}
|
|
1161
|
+
async function openInBrowser(url) {
|
|
1162
|
+
const launch = browserLaunchArgv(url);
|
|
1163
|
+
if (!launch) return;
|
|
1164
|
+
try {
|
|
1165
|
+
const child = spawn(launch.command, launch.args, { stdio: "ignore", detached: true });
|
|
1166
|
+
child.unref();
|
|
1167
|
+
child.on("error", () => {
|
|
1168
|
+
});
|
|
1169
|
+
} catch {
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// src/primitives.ts
|
|
1174
|
+
var METHOD_NOT_FOUND = -32601;
|
|
1175
|
+
var MAX_PAGES = 50;
|
|
1176
|
+
async function listResources(client) {
|
|
1177
|
+
if (!client.getServerCapabilities()?.resources) return [];
|
|
1178
|
+
return paginate(async (cursor) => {
|
|
1179
|
+
const res = await client.listResources(cursor ? { cursor } : {});
|
|
1180
|
+
return { items: res.resources ?? [], nextCursor: res.nextCursor };
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
async function listPrompts(client) {
|
|
1184
|
+
if (!client.getServerCapabilities()?.prompts) return [];
|
|
1185
|
+
return paginate(async (cursor) => {
|
|
1186
|
+
const res = await client.listPrompts(cursor ? { cursor } : {});
|
|
1187
|
+
return { items: res.prompts ?? [], nextCursor: res.nextCursor };
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
async function readResourceText(client, uri) {
|
|
1191
|
+
const result = await client.readResource({ uri });
|
|
1192
|
+
const parts = [];
|
|
1193
|
+
for (const content of result.contents ?? []) {
|
|
1194
|
+
if ("text" in content && typeof content.text === "string") {
|
|
1195
|
+
parts.push(content.text);
|
|
1196
|
+
} else if ("blob" in content && typeof content.blob === "string") {
|
|
1197
|
+
const mime = typeof content.mimeType === "string" ? content.mimeType : "\u672A\u77E5\u7C7B\u578B";
|
|
1198
|
+
parts.push(`[\u4E8C\u8FDB\u5236\u5185\u5BB9 ${mime}\uFF0C${content.blob.length} \u5B57\u8282 base64\uFF0C\u672A\u5C55\u5F00]`);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
return parts.join("\n");
|
|
1202
|
+
}
|
|
1203
|
+
async function getPromptText(client, name, args = {}) {
|
|
1204
|
+
const result = await client.getPrompt({ name, arguments: args });
|
|
1205
|
+
return (result.messages ?? []).map((msg) => {
|
|
1206
|
+
const { text, artifacts } = convertMcpContent([msg.content]);
|
|
1207
|
+
const placeholders = artifacts.map(
|
|
1208
|
+
(a) => `[${a.kind} ${a.mediaType}\uFF0C${a.base64.length} \u5B57\u8282 base64\uFF0C\u672A\u5C55\u5F00]`
|
|
1209
|
+
);
|
|
1210
|
+
return `[${msg.role}] ${[text, ...placeholders].filter(Boolean).join("\n")}`;
|
|
1211
|
+
}).join("\n");
|
|
1212
|
+
}
|
|
1213
|
+
async function paginate(fetchPage) {
|
|
1214
|
+
const all = [];
|
|
1215
|
+
let cursor;
|
|
1216
|
+
try {
|
|
1217
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
1218
|
+
const { items, nextCursor } = await fetchPage(cursor);
|
|
1219
|
+
all.push(...items);
|
|
1220
|
+
if (!nextCursor) break;
|
|
1221
|
+
cursor = nextCursor;
|
|
1222
|
+
}
|
|
1223
|
+
} catch (err) {
|
|
1224
|
+
if (isMethodNotFound(err)) return [];
|
|
1225
|
+
throw err;
|
|
1226
|
+
}
|
|
1227
|
+
return all;
|
|
1228
|
+
}
|
|
1229
|
+
function isMethodNotFound(err) {
|
|
1230
|
+
return err?.code === METHOD_NOT_FOUND;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// src/registry.ts
|
|
1234
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
1235
|
+
import { mcpConfigPath, mcpSchemaCachePath } from "@epoch-agent/infra";
|
|
1236
|
+
|
|
1237
|
+
// src/tool-adapter.ts
|
|
1238
|
+
function prefixedName(serverName, toolName) {
|
|
1239
|
+
return `mcp__${serverName}__${toolName}`;
|
|
1240
|
+
}
|
|
1241
|
+
function toEpochTools(serverName, schemas, call) {
|
|
1242
|
+
return schemas.map((schema) => toEpochTool(serverName, schema, call));
|
|
1243
|
+
}
|
|
1244
|
+
function toEpochTool(serverName, schema, call) {
|
|
1245
|
+
const title = schema.annotations?.title ?? schema.title;
|
|
1246
|
+
const description = [
|
|
1247
|
+
title && title !== schema.name ? `${title} \u2014\u2014 ` : "",
|
|
1248
|
+
schema.description ?? `MCP \u5DE5\u5177 ${schema.name}\uFF08\u6765\u81EA ${serverName}\uFF09`
|
|
1249
|
+
].join("");
|
|
1250
|
+
return {
|
|
1251
|
+
name: prefixedName(serverName, schema.name),
|
|
1252
|
+
description,
|
|
1253
|
+
parameters: schema.inputSchema ?? {
|
|
1254
|
+
type: "object",
|
|
1255
|
+
properties: {}
|
|
1256
|
+
},
|
|
1257
|
+
annotations: schema.annotations ? stripTitle(schema.annotations) : void 0,
|
|
1258
|
+
execute: async (args) => {
|
|
1259
|
+
const startedAt = performance.now();
|
|
1260
|
+
const result = await call(schema.name, args);
|
|
1261
|
+
return {
|
|
1262
|
+
success: result.success,
|
|
1263
|
+
output: result.output,
|
|
1264
|
+
// 原样往上交,不在这里判「模型认不认图」—— 那是 ToolExecutor 的四道闸门
|
|
1265
|
+
...result.artifacts && result.artifacts.length > 0 ? { artifacts: result.artifacts } : {},
|
|
1266
|
+
duration: Math.round(performance.now() - startedAt)
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
function stripTitle(annotations) {
|
|
1272
|
+
if (!annotations) return void 0;
|
|
1273
|
+
const { readOnlyHint, destructiveHint, idempotentHint, openWorldHint } = annotations;
|
|
1274
|
+
const hints = { readOnlyHint, destructiveHint, idempotentHint, openWorldHint };
|
|
1275
|
+
const given = Object.entries(hints).filter(([, v]) => v !== void 0);
|
|
1276
|
+
return given.length > 0 ? Object.fromEntries(given) : void 0;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// src/resource-tools.ts
|
|
1280
|
+
var MAX_LISTED = 200;
|
|
1281
|
+
function createResourceTools(serverName, getClient) {
|
|
1282
|
+
const client = getClient();
|
|
1283
|
+
if (!client?.getServerCapabilities()?.resources) return [];
|
|
1284
|
+
return [
|
|
1285
|
+
{
|
|
1286
|
+
name: prefixedName(serverName, "list_resources"),
|
|
1287
|
+
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
|
+
parameters: { type: "object", properties: {} },
|
|
1289
|
+
// 这两个 hint 是**我们自己**的声明,不是 server 给的:按 MCP 规范
|
|
1290
|
+
// resources/list 与 resources/read 都是只读操作。server 声明的 annotations
|
|
1291
|
+
// 走 tool-adapter.ts,两条路不会混
|
|
1292
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
1293
|
+
execute: async () => {
|
|
1294
|
+
return run(async () => {
|
|
1295
|
+
const current = requireClient(getClient, serverName);
|
|
1296
|
+
const resources = await listResources(current);
|
|
1297
|
+
if (resources.length === 0) return `${serverName} \u6CA1\u6709\u63D0\u4F9B\u4EFB\u4F55\u8D44\u6E90`;
|
|
1298
|
+
const shown = resources.slice(0, MAX_LISTED);
|
|
1299
|
+
const lines = shown.map(
|
|
1300
|
+
(r) => [
|
|
1301
|
+
r.uri,
|
|
1302
|
+
r.name && r.name !== r.uri ? `\uFF08${r.name}\uFF09` : "",
|
|
1303
|
+
r.description ? ` \u2014 ${r.description}` : ""
|
|
1304
|
+
].join("")
|
|
1305
|
+
);
|
|
1306
|
+
const omitted = resources.length - shown.length;
|
|
1307
|
+
if (omitted > 0) lines.push(`\u2026\u2026 \u53E6\u6709 ${omitted} \u6761\u672A\u5217\u51FA`);
|
|
1308
|
+
return lines.join("\n");
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
},
|
|
1312
|
+
{
|
|
1313
|
+
name: prefixedName(serverName, "read_resource"),
|
|
1314
|
+
description: `\u8BFB\u53D6 MCP server\u300C${serverName}\u300D\u4E0A\u67D0\u4E2A\u8D44\u6E90\u7684\u5185\u5BB9\u3002uri \u4ECE ${prefixedName(serverName, "list_resources")} \u62FF\u3002`,
|
|
1315
|
+
parameters: {
|
|
1316
|
+
type: "object",
|
|
1317
|
+
properties: { uri: { type: "string", description: "\u8D44\u6E90 URI" } },
|
|
1318
|
+
required: ["uri"]
|
|
1319
|
+
},
|
|
1320
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
1321
|
+
execute: async (args) => {
|
|
1322
|
+
return run(async () => {
|
|
1323
|
+
const uri = args["uri"];
|
|
1324
|
+
if (typeof uri !== "string" || uri.length === 0) throw new Error("\u7F3A\u5C11 uri \u53C2\u6570");
|
|
1325
|
+
const current = requireClient(getClient, serverName);
|
|
1326
|
+
const text = await readResourceText(current, uri);
|
|
1327
|
+
return text.length > 0 ? text : `\u8D44\u6E90 ${uri} \u6CA1\u6709\u53EF\u8BFB\u7684\u6587\u672C\u5185\u5BB9`;
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
];
|
|
1332
|
+
}
|
|
1333
|
+
function requireClient(getClient, serverName) {
|
|
1334
|
+
const client = getClient();
|
|
1335
|
+
if (!client) throw new Error(`MCP server ${serverName} \u5F53\u524D\u672A\u8FDE\u63A5`);
|
|
1336
|
+
return client;
|
|
1337
|
+
}
|
|
1338
|
+
async function run(fn) {
|
|
1339
|
+
const startedAt = performance.now();
|
|
1340
|
+
try {
|
|
1341
|
+
const output = await fn();
|
|
1342
|
+
return { success: true, output, duration: Math.round(performance.now() - startedAt) };
|
|
1343
|
+
} catch (err) {
|
|
1344
|
+
return {
|
|
1345
|
+
success: false,
|
|
1346
|
+
output: err instanceof Error ? err.message : String(err),
|
|
1347
|
+
duration: Math.round(performance.now() - startedAt)
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// src/schema.ts
|
|
1353
|
+
import { z } from "zod";
|
|
1354
|
+
import { parseLenient } from "@epoch-agent/infra";
|
|
1355
|
+
var seconds = z.number().positive().finite();
|
|
1356
|
+
var OAuthSchema = z.object({
|
|
1357
|
+
enabled: z.boolean().optional(),
|
|
1358
|
+
scope: z.string().optional(),
|
|
1359
|
+
callbackPort: z.number().int().min(1024).max(65535).optional()
|
|
1360
|
+
});
|
|
1361
|
+
var RawServerSchema = z.object({
|
|
1362
|
+
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
|
+
type: z.enum(["stdio", "sse", "http"]).optional(),
|
|
1374
|
+
command: z.string().min(1).optional(),
|
|
1375
|
+
args: z.array(z.string()).optional(),
|
|
1376
|
+
url: z.string().min(1).optional(),
|
|
1377
|
+
streamable: z.boolean().optional(),
|
|
1378
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
1379
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
1380
|
+
timeout: seconds.optional(),
|
|
1381
|
+
connect_timeout: seconds.optional(),
|
|
1382
|
+
connectTimeout: seconds.optional(),
|
|
1383
|
+
keepalive_interval: seconds.optional(),
|
|
1384
|
+
keepaliveInterval: seconds.optional(),
|
|
1385
|
+
idle_timeout_seconds: seconds.optional(),
|
|
1386
|
+
idleTimeoutSeconds: seconds.optional(),
|
|
1387
|
+
max_lifetime_seconds: seconds.optional(),
|
|
1388
|
+
maxLifetimeSeconds: seconds.optional(),
|
|
1389
|
+
supports_parallel: z.boolean().optional(),
|
|
1390
|
+
supportsParallel: z.boolean().optional(),
|
|
1391
|
+
skip_preflight: z.boolean().optional(),
|
|
1392
|
+
skipPreflight: z.boolean().optional(),
|
|
1393
|
+
oauth: OAuthSchema.optional()
|
|
1394
|
+
});
|
|
1395
|
+
var KNOWN_KEYS = new Set(Object.keys(RawServerSchema.shape));
|
|
1396
|
+
var RawConfigSchema = z.object({
|
|
1397
|
+
servers: z.record(z.string(), z.unknown()).optional(),
|
|
1398
|
+
mcpServers: z.record(z.string(), z.unknown()).optional()
|
|
1399
|
+
});
|
|
1400
|
+
function parseMcpConfig(raw, label) {
|
|
1401
|
+
let json;
|
|
1402
|
+
try {
|
|
1403
|
+
json = JSON.parse(raw);
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1406
|
+
return { servers: [], issues: [{ path: label, message: `JSON \u8BED\u6CD5\u9519\u8BEF\uFF1A${detail}` }] };
|
|
1407
|
+
}
|
|
1408
|
+
const issues = [];
|
|
1409
|
+
const top = parseLenient(RawConfigSchema, json, {});
|
|
1410
|
+
issues.push(...prefix(top.issues, label));
|
|
1411
|
+
const entries = top.value.servers;
|
|
1412
|
+
if (!entries) {
|
|
1413
|
+
if (top.value.mcpServers) {
|
|
1414
|
+
issues.push({
|
|
1415
|
+
path: label,
|
|
1416
|
+
message: "\u68C0\u6D4B\u5230 `mcpServers` \u5B57\u6BB5\uFF1Bepoch \u8BFB\u7684\u662F `servers`\uFF0C\u8BF7\u6539\u952E\u540D"
|
|
1417
|
+
});
|
|
1418
|
+
} else if (issues.length === 0) {
|
|
1419
|
+
issues.push({ path: label, message: "\u7F3A\u5C11 `servers` \u5B57\u6BB5\uFF0C\u6CA1\u6709\u53EF\u7528\u7684 MCP server" });
|
|
1420
|
+
}
|
|
1421
|
+
return { servers: [], issues };
|
|
1422
|
+
}
|
|
1423
|
+
const servers = [];
|
|
1424
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
1425
|
+
const at = `${label}.servers.${name}`;
|
|
1426
|
+
const parsed = parseLenient(RawServerSchema.nullable(), value, null);
|
|
1427
|
+
if (!parsed.value) {
|
|
1428
|
+
issues.push(...prefix(parsed.issues, at));
|
|
1429
|
+
issues.push({ path: at, message: "\u914D\u7F6E\u65E0\u6548\uFF0C\u8BE5 server \u5DF2\u8DF3\u8FC7" });
|
|
1430
|
+
continue;
|
|
1431
|
+
}
|
|
1432
|
+
issues.push(...unknownKeyIssues(value, at));
|
|
1433
|
+
const { config, issues: crossIssues } = normalize2(name, parsed.value, at);
|
|
1434
|
+
issues.push(...crossIssues);
|
|
1435
|
+
if (config) servers.push(config);
|
|
1436
|
+
}
|
|
1437
|
+
return { servers, issues };
|
|
1438
|
+
}
|
|
1439
|
+
function normalize2(name, raw, at) {
|
|
1440
|
+
const issues = [];
|
|
1441
|
+
const transport = raw.transport ?? raw.type ?? (raw.url ? "sse" : "stdio");
|
|
1442
|
+
if (transport === "stdio" && !raw.command) {
|
|
1443
|
+
issues.push({ path: `${at}.command`, message: "stdio \u4F20\u8F93\u7F3A\u5C11 command\uFF0C\u8BE5 server \u5DF2\u8DF3\u8FC7" });
|
|
1444
|
+
}
|
|
1445
|
+
if (transport !== "stdio") {
|
|
1446
|
+
if (!raw.url) {
|
|
1447
|
+
issues.push({ path: `${at}.url`, message: `${transport} \u4F20\u8F93\u7F3A\u5C11 url\uFF0C\u8BE5 server \u5DF2\u8DF3\u8FC7` });
|
|
1448
|
+
} 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` });
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
if (issues.length > 0) return { config: null, issues };
|
|
1453
|
+
const streamable = raw.streamable ?? transport === "http";
|
|
1454
|
+
return {
|
|
1455
|
+
config: {
|
|
1456
|
+
name,
|
|
1457
|
+
transport,
|
|
1458
|
+
command: raw.command,
|
|
1459
|
+
args: raw.args,
|
|
1460
|
+
url: raw.url,
|
|
1461
|
+
streamable,
|
|
1462
|
+
headers: raw.headers,
|
|
1463
|
+
env: raw.env,
|
|
1464
|
+
timeout: raw.timeout,
|
|
1465
|
+
connectTimeout: raw.connectTimeout ?? raw.connect_timeout,
|
|
1466
|
+
keepaliveInterval: raw.keepaliveInterval ?? raw.keepalive_interval,
|
|
1467
|
+
idleTimeoutSeconds: raw.idleTimeoutSeconds ?? raw.idle_timeout_seconds,
|
|
1468
|
+
maxLifetimeSeconds: raw.maxLifetimeSeconds ?? raw.max_lifetime_seconds,
|
|
1469
|
+
supportsParallel: raw.supportsParallel ?? raw.supports_parallel,
|
|
1470
|
+
skipPreflight: raw.skipPreflight ?? raw.skip_preflight,
|
|
1471
|
+
oauth: raw.oauth
|
|
1472
|
+
},
|
|
1473
|
+
issues
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
function unknownKeyIssues(value, at) {
|
|
1477
|
+
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" }));
|
|
1479
|
+
}
|
|
1480
|
+
function isValidUrl(url) {
|
|
1481
|
+
try {
|
|
1482
|
+
new URL(url);
|
|
1483
|
+
return true;
|
|
1484
|
+
} catch {
|
|
1485
|
+
return false;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
function prefix(issues, at) {
|
|
1489
|
+
return issues.map((i) => ({
|
|
1490
|
+
path: i.path === "(root)" ? at : `${at}.${i.path}`,
|
|
1491
|
+
message: i.message
|
|
1492
|
+
}));
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// src/registry.ts
|
|
1496
|
+
var McpRegistry = class {
|
|
1497
|
+
clients = /* @__PURE__ */ new Map();
|
|
1498
|
+
cache;
|
|
1499
|
+
authStore;
|
|
1500
|
+
/**
|
|
1501
|
+
* 某个 server 的工具表变了(收到 `notifications/tools/list_changed`)。
|
|
1502
|
+
*
|
|
1503
|
+
* registry 这层只负责把缓存失效掉并转发这个信号 —— **真正的热更新做不到**:
|
|
1504
|
+
* `AgentLoop` 的工具集是构造参数,一次性传进去的,没有「运行中换一套工具」
|
|
1505
|
+
* 的入口。要做需要先给 loop 引入 `ToolProvider` 接口,那是另一个方案的事,
|
|
1506
|
+
* 本方案明确不碰 loop.ts。当前的效果是:下一次 `getAllTools()` / `refresh()`
|
|
1507
|
+
* 能拿到新工具表,正在跑的那一轮不受影响。
|
|
1508
|
+
*/
|
|
1509
|
+
onToolsChanged;
|
|
1510
|
+
constructor(options = {}) {
|
|
1511
|
+
const cacheFile = options.cacheFile !== void 0 ? options.cacheFile : mcpSchemaCachePath(options.homeDir);
|
|
1512
|
+
this.cache = new McpSchemaCache(cacheFile);
|
|
1513
|
+
this.authStore = options.authStore ?? new McpAuthStore(void 0, options.homeDir);
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* 从配置文件加载所有 MCP servers。
|
|
1517
|
+
*
|
|
1518
|
+
* 返回值带 `issues`:以前是 `catch { return [] }`,mcp.json 写错一个字符
|
|
1519
|
+
* 用户只会发现「MCP 没了」,看不到任何提示。调用方**必须**把 issues
|
|
1520
|
+
* 写进 diagnostics(见 runtime/tools.ts)。
|
|
1521
|
+
*/
|
|
1522
|
+
loadFromConfig(configPath) {
|
|
1523
|
+
const path = configPath ?? mcpConfigPath();
|
|
1524
|
+
if (!existsSync2(path)) return { servers: [], issues: [] };
|
|
1525
|
+
let raw;
|
|
1526
|
+
try {
|
|
1527
|
+
raw = readFileSync3(path, "utf-8");
|
|
1528
|
+
} catch (err) {
|
|
1529
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1530
|
+
return { servers: [], issues: [{ path, message: `\u8BFB\u53D6\u5931\u8D25\uFF1A${detail}` }] };
|
|
1531
|
+
}
|
|
1532
|
+
return parseMcpConfig(raw, path);
|
|
1533
|
+
}
|
|
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
|
+
async connectAll(configs, source = "user") {
|
|
1549
|
+
const promises = configs.map(async (cfg) => {
|
|
1550
|
+
await this.clients.get(cfg.name)?.disconnect();
|
|
1551
|
+
const client = new McpClient(cfg, this.cache, this.authStore, source);
|
|
1552
|
+
client.onToolsChanged = () => this.onToolsChanged?.(cfg.name);
|
|
1553
|
+
this.clients.set(cfg.name, client);
|
|
1554
|
+
try {
|
|
1555
|
+
await client.connect();
|
|
1556
|
+
return client.status;
|
|
1557
|
+
} catch {
|
|
1558
|
+
return client.status;
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
return Promise.all(promises);
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* 获取所有 MCP 工具(扁平化)。
|
|
1565
|
+
*
|
|
1566
|
+
* schema 走缓存,第二次调用不再打网络;工具本身每次重建
|
|
1567
|
+
* (execute 闭包要绑到当前的 client 实例上,重连后不能还指向旧连接)。
|
|
1568
|
+
*/
|
|
1569
|
+
async getAllTools() {
|
|
1570
|
+
const allTools = [];
|
|
1571
|
+
for (const [name, client] of this.clients) {
|
|
1572
|
+
const schemas = await client.getToolSchemas();
|
|
1573
|
+
allTools.push(...toEpochTools(name, schemas, (tool, args) => client.callTool(tool, args)));
|
|
1574
|
+
allTools.push(...createResourceTools(name, () => client.raw));
|
|
1575
|
+
}
|
|
1576
|
+
return allTools;
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* 丢掉 schema 缓存后重新取一遍工具表。
|
|
1580
|
+
*
|
|
1581
|
+
* 给 `onToolsChanged` 的消费方用:收到通知 → `refresh()` → 拿到新工具表。
|
|
1582
|
+
* 不传 name 就刷所有 server。
|
|
1583
|
+
*/
|
|
1584
|
+
async refresh(name) {
|
|
1585
|
+
this.cache.invalidate(name);
|
|
1586
|
+
return this.getAllTools();
|
|
1587
|
+
}
|
|
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
|
+
async listPrompts(name) {
|
|
1597
|
+
const client = this.requireClient(name);
|
|
1598
|
+
return client.raw ? listPrompts(client.raw) : [];
|
|
1599
|
+
}
|
|
1600
|
+
/** 取一个 prompt 渲染后的文本 */
|
|
1601
|
+
async getPrompt(name, promptName, args = {}) {
|
|
1602
|
+
const client = this.requireClient(name);
|
|
1603
|
+
if (!client.raw) throw new Error(`MCP server "${name}" \u5F53\u524D\u672A\u8FDE\u63A5`);
|
|
1604
|
+
return getPromptText(client.raw, promptName, args);
|
|
1605
|
+
}
|
|
1606
|
+
/** 各 server 的登录态。纯读本地凭据文件,不打网络 */
|
|
1607
|
+
getAuthStatus() {
|
|
1608
|
+
return [...this.clients.values()].map((c) => mcpAuthStatus(c.serverConfig, this.authStore));
|
|
1609
|
+
}
|
|
1610
|
+
/** 凭据存储。`epoch mcp login/logout` 要用同一份,否则缓存不一致 */
|
|
1611
|
+
get auth() {
|
|
1612
|
+
return this.authStore;
|
|
1613
|
+
}
|
|
1614
|
+
/** 重连指定 server。工具表可能已变,顺带失效它的 schema 缓存 */
|
|
1615
|
+
async reconnect(name) {
|
|
1616
|
+
const client = this.requireClient(name);
|
|
1617
|
+
this.cache.invalidate(name);
|
|
1618
|
+
await client.disconnect();
|
|
1619
|
+
await client.connect();
|
|
1620
|
+
return client.status;
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* 显式失效 schema 缓存,不传 name 清全部。
|
|
1624
|
+
*
|
|
1625
|
+
* 给 `epoch mcp reconnect` 和将来的 `notifications/tools/list_changed` 用。
|
|
1626
|
+
*/
|
|
1627
|
+
invalidateSchemaCache(name) {
|
|
1628
|
+
this.cache.invalidate(name);
|
|
1629
|
+
}
|
|
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
|
+
async disconnect(name) {
|
|
1649
|
+
const client = this.clients.get(name);
|
|
1650
|
+
if (!client) return false;
|
|
1651
|
+
this.cache.invalidate(name);
|
|
1652
|
+
await client.disconnect();
|
|
1653
|
+
this.clients.delete(name);
|
|
1654
|
+
return true;
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* 这台 server 在**这个进程里此刻按的是哪份配置**(2026-08-18)。
|
|
1658
|
+
*
|
|
1659
|
+
* 给「盘上那份和进程里这份差在哪」用(runtime 的 `applyMcpConfig` 拿它做 diff)。
|
|
1660
|
+
* ⚠️ 真源必须是 client 手上那一份,**不能是调用方自己记的一份快照**:那份快照
|
|
1661
|
+
* 会和这里分叉,而分叉的表现是「明明改了配置,应用之后说没动」。
|
|
1662
|
+
*/
|
|
1663
|
+
configOf(name) {
|
|
1664
|
+
return this.clients.get(name)?.serverConfig ?? null;
|
|
1665
|
+
}
|
|
1666
|
+
/** 断开所有连接 */
|
|
1667
|
+
async disconnectAll() {
|
|
1668
|
+
for (const client of this.clients.values()) {
|
|
1669
|
+
await client.disconnect();
|
|
1670
|
+
}
|
|
1671
|
+
this.clients.clear();
|
|
1672
|
+
}
|
|
1673
|
+
/** 获取状态 */
|
|
1674
|
+
getStatus(name) {
|
|
1675
|
+
return this.clients.get(name)?.status ?? null;
|
|
1676
|
+
}
|
|
1677
|
+
getAllStatus() {
|
|
1678
|
+
return [...this.clients.values()].map((c) => c.status);
|
|
1679
|
+
}
|
|
1680
|
+
requireClient(name) {
|
|
1681
|
+
const client = this.clients.get(name);
|
|
1682
|
+
if (!client) throw new Error(`MCP server "${name}" not found`);
|
|
1683
|
+
return client;
|
|
1684
|
+
}
|
|
1685
|
+
};
|
|
1686
|
+
export {
|
|
1687
|
+
CALLBACK_TIMEOUT_MS,
|
|
1688
|
+
McpAuthStore,
|
|
1689
|
+
McpClient,
|
|
1690
|
+
McpLoginRequiredError,
|
|
1691
|
+
McpOAuthProvider,
|
|
1692
|
+
McpRegistry,
|
|
1693
|
+
McpSchemaCache,
|
|
1694
|
+
REFRESH_SKEW_MS,
|
|
1695
|
+
configFingerprint,
|
|
1696
|
+
createResourceTools,
|
|
1697
|
+
getPromptText,
|
|
1698
|
+
isExpired,
|
|
1699
|
+
listPrompts,
|
|
1700
|
+
listResources,
|
|
1701
|
+
loginToMcpServer,
|
|
1702
|
+
logoutFromMcpServer,
|
|
1703
|
+
mcpAuthStatus,
|
|
1704
|
+
parseMcpConfig,
|
|
1705
|
+
prefixedName,
|
|
1706
|
+
readResourceText,
|
|
1707
|
+
startCallbackServer,
|
|
1708
|
+
toEpochTools
|
|
1709
|
+
};
|