@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.
@@ -0,0 +1,980 @@
1
+ import { ParseIssue } from '@epoch-agent/infra';
2
+ import { ToolAnnotations, ToolArtifactInput, EpochTool } from '@epoch-agent/protocol';
3
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4
+ import { OAuthClientInformationFull, OAuthTokens, OAuthClientMetadata, OAuthClientInformationMixed } from '@modelcontextprotocol/sdk/shared/auth.js';
5
+ import { OAuthClientProvider, OAuthDiscoveryState } from '@modelcontextprotocol/sdk/client/auth.js';
6
+
7
+ /**
8
+ * MCP 插件 — 类型与配置。
9
+ *
10
+ * 参考 Hermes tools/mcp_tool.py。
11
+ */
12
+
13
+ type McpTransport = 'stdio' | 'sse' | 'http';
14
+ interface McpServerConfig {
15
+ name: string;
16
+ transport: McpTransport;
17
+ command?: string;
18
+ args?: string[];
19
+ url?: string;
20
+ /** SSE 还是 Streamable HTTP(默认 SSE) */
21
+ streamable?: boolean;
22
+ /** SSE/HTTP transport 的额外 headers(如 auth) */
23
+ headers?: Record<string, string>;
24
+ env?: Record<string, string>;
25
+ timeout?: number;
26
+ connectTimeout?: number;
27
+ keepaliveInterval?: number;
28
+ idleTimeoutSeconds?: number;
29
+ maxLifetimeSeconds?: number;
30
+ supportsParallel?: boolean;
31
+ skipPreflight?: boolean;
32
+ /** OAuth 设置。远程 server 默认启用(有凭据就用、没有就等 401),见 `McpOAuthConfig` */
33
+ oauth?: McpOAuthConfig;
34
+ }
35
+ /**
36
+ * 单个 server 的 OAuth 设置。
37
+ *
38
+ * 默认全空就够用了 —— 远程 server 一律挂上 OAuth provider,服务端不要求授权时
39
+ * 它一次也不会被调用。**要显式配的只有两种情况**:
40
+ * 用 `enabled: false` 关掉(比如服务端要的是别的认证方式,不想看到 401 时的
41
+ * 「请先 login」提示),或者授权服务器要求 redirect_uri 事先登记 ——
42
+ * 那时必须固定 `callbackPort`,临时端口对不上登记值。
43
+ */
44
+ interface McpOAuthConfig {
45
+ enabled?: boolean;
46
+ /** 申请的 scope(空格分隔)。不给就用授权服务器的默认值 */
47
+ scope?: string;
48
+ /** 固定回调端口,给要求预先登记 redirect_uri 的授权服务器用 */
49
+ callbackPort?: number;
50
+ }
51
+ /**
52
+ * 一台 server 是从哪儿来的(方案 44 §2.1 b 第三行)。
53
+ *
54
+ * - `user` —— `~/.epoch/mcp.json` 里配的。今天绝大多数是这一档;
55
+ * - `host` —— 嵌入宿主在 `buildRuntime({hostCapabilities})` 里递进来的,
56
+ * 一个字节都不落盘,用户在自己的 `~/.epoch/` 里找不到它;
57
+ * - `plugin` —— 已装插件根下那份 `mcp.json` 带来的(方案 44 PR-3)。用户能在
58
+ * `epoch plugin list` 里查到它是谁带来的,也能 `epoch plugin disable` 掉。
59
+ *
60
+ * ⚠️ **这个联合是封闭集,加一格要连着改能力页那张 `MCP_GROUPS` 数组**
61
+ * (`web/src/capability/connectors.tsx`)—— 那是 `satisfies readonly {…}[]`,
62
+ * 多一格不会编译红,而页面是「按组过滤后渲染」,漏改的表现是**那一档一行都不
63
+ * 显示、也没有任何报错**。PR-2 拿 `MCP_GROUPS` 实测复现过这个症状,PR-3 加
64
+ * `'plugin'` 时照着改了一行。**下一格还得这么办。**
65
+ */
66
+ type McpServerSource = 'user' | 'host' | 'plugin';
67
+ interface McpServerStatus {
68
+ name: string;
69
+ connected: boolean;
70
+ toolCount: number;
71
+ lastError?: string;
72
+ reconnectAttempts: number;
73
+ /** 认证过不去,需要 `epoch mcp login <name>`。重连不解决这个 */
74
+ needsLogin?: boolean;
75
+ /**
76
+ * 这台 server 是谁带进来的(方案 44 PR-2)。
77
+ *
78
+ * ## 宿主注入的 server 名怎么加命名空间:`<namespace>__<server>`
79
+ *
80
+ * 装配层(`runtime/src/host-capabilities.ts`)按宿主给的 `namespace` 拼,
81
+ * 用**两个下划线**,于是 `namespace: 'acme'` + `name: 'github'` 那台的状态名
82
+ * 是 `acme__github`、它的工具叫 `mcp__acme__github__search_issues`。
83
+ * 同 PR-1 那条:**前缀由装配层拼,宿主自己在 `name` 上写的不算数**。
84
+ *
85
+ * ## ⚠️ 为什么这个前缀和 PR-1 给专家 / 技能拼的那个长得不一样
86
+ *
87
+ * PR-1 拼的是 `<namespace>:`(`acme:triage`),这里是 `<namespace>__`。
88
+ * **不是两套风格,是两条路的终点不同**:
89
+ *
90
+ * - **角色名 / 技能名不上网线进 provider。** 它们只在我们自己进程里当键用
91
+ * (`delegate_task` 的 `role` 入参、技能索引的那一行),所以拼一个
92
+ * `AGENT_ROLE_NAME_PATTERN` 本身不允许的 `:` 是安全的 —— 那条正则管的是
93
+ * 宿主**写进来**的名字,前缀在校验**之后**拼(顺序是 `loadPluginRoles` 立的);
94
+ * - **server 名**会变成工具名的一部分(`tool-adapter.ts` 的 `prefixedName()`
95
+ * 直接字符串拼 `mcp__<server>__<tool>`),而工具名是**真的发给 provider** 的。
96
+ * 方案 44 §2.2 第 5 处 2026-08-17 实测过:带 `:` 的工具名被硬拒 ——
97
+ * `tools.0.custom.name: String should match pattern '^[a-zA-Z0-9_-]{1,128}$'`,
98
+ * 同一发换成 `mcp__gh__search_issues` 就 200 正常回。所以这里没有第二个选项。
99
+ *
100
+ * 拿 PR-1 那条去类推工具名,得到的是一条今天就红的路 —— 这段话存在的理由
101
+ * 就是拦住那次类推。
102
+ *
103
+ * ## 连带一条:宿主给的 server 名收得比 `mcp.json` 严
104
+ *
105
+ * 装配层拿 `[a-z0-9][a-z0-9-]*`(逐字同 `AGENT_ROLE_NAME_PATTERN`)卡宿主
106
+ * 那一批,而用户自己写在 `mcp.json` 里的名字**一个字都没多管**。两条判据:
107
+ * 那张正则是 provider 那个 `[a-zA-Z0-9_-]{1,128}` 的真子集,所以拼出来的
108
+ * 工具名一定合法;而它**不含 `_`**,于是 `__` 当分隔符不会有第二种读法。
109
+ * 只收紧宿主那一批,是因为那批名字是宿主自己起的(改得动),而用户的
110
+ * `mcp.json` 是既有文件 —— 收紧它等于让一份昨天还能用的配置今天开始报错。
111
+ *
112
+ * ## 插件带来的 server 名:`<插件名>__<server>`(方案 44 PR-3)
113
+ *
114
+ * 分隔符和宿主那批**一样是 `__`**,理由也一样(它进工具名)。**前缀的来路
115
+ * 不一样,而这才是要一眼看得出来的那处差别**:
116
+ *
117
+ * | 谁带来的 | 前缀是什么 | 谁定的 | 什么时候卡的形状 |
118
+ * | -------- | ---------------------- | ----------------------------------- | ---------------------------- |
119
+ * | 宿主 | `buildRuntime` 的参数 | 宿主自己(编译期写死在应用里) | **装配时**,每次启动现场校验 |
120
+ * | 插件 | **插件名**(安装记录) | 用户敲 `epoch plugin install` 那一刻 | **安装时**,`readPluginManifest` |
121
+ *
122
+ * 插件那一侧因此不用在装配时再校验一遍前缀:`PLUGIN_NAME_PATTERN` 和
123
+ * `AGENT_ROLE_NAME_PATTERN` **逐字相同**(core 的 `plugin/manifest.ts` 明写着
124
+ * 「不是巧合」—— 插件名必须能安全地拼进角色名),所以上面那两条判据
125
+ * (provider 正则的真子集、不含 `_`)在插件那侧是**安装那一刻**就成立的。
126
+ * 一个名字不合法的插件压根装不进来。
127
+ *
128
+ * ⚠️ **但后半截要卡。** 插件 `mcp.json` 里的 server 名是**插件作者**写的,
129
+ * 和用户自己那份一样宽 —— 一台叫 `My Server` 的会拼出带空格的工具名。所以
130
+ * 装配层(`runtime/src/plugin-mcp-servers.ts`)拿同一张正则卡它,坏的那台只跳过它自己。
131
+ * **这和「用户那批一个字不多管」不矛盾**,判据是两条:
132
+ *
133
+ * - 插件带 mcp 是**新开的一条路**,全仓今天零个这样的文件 —— 收紧它谁也不影响,
134
+ * 而用户的 `~/.epoch/mcp.json` 是既有文件;
135
+ * - 放着不管的代价**不是只坏它自己**:一个非法工具名会让 provider 拒掉**整份
136
+ * 工具表**(同下面那条限长 128 的账),于是一台名字写歪的插件 server 能把
137
+ * 用户所有的工具一起带走。
138
+ *
139
+ * ## 为什么这一格是**必填**
140
+ *
141
+ * 加一个可选字段是**宽化** —— `server/src/capability.ts` 的结构镜像 `McpView`
142
+ * 只会把它忽略掉,一声不吭地不下发,而这一整条链上没有任何东西会变红
143
+ * (方案 44 §3.4 点名的就是这一样)。写成必填之后链条反过来由编译器扛着:
144
+ * `WireMcpConnector.source` 必填 → `wireFields` 逼着字段清单列它 →
145
+ * `toWireMcp` 不产出它就编译不过 → `McpView` 不声明它 `toWireMcp` 就读不到 →
146
+ * `McpView` 声明了而 runtime 不给,`EpochRuntime` 当场不满足 `WebRuntimeView`。
147
+ */
148
+ source: McpServerSource;
149
+ }
150
+ /**
151
+ * MCP server 返回的 annotations。
152
+ *
153
+ * 比 protocol 的 `ToolAnnotations` 多一个 `title` —— 它是展示用的名字,
154
+ * 不是风险声明,所以不进 protocol 的契约(那里只放权限系统要消费的字段)。
155
+ */
156
+ interface McpToolAnnotations extends ToolAnnotations {
157
+ /** 人类可读的展示名,比 `mcp__foo__bar` 友好 */
158
+ title?: string;
159
+ }
160
+ /**
161
+ * `tools/list` 里单个工具的形状(只取我们用得上的字段)。
162
+ *
163
+ * 这一层是**可缓存**的纯数据:schema 缓存存的就是它,
164
+ * 从缓存里也能原样重建出 EpochTool,不必再打网络。
165
+ */
166
+ interface McpToolSchema {
167
+ name: string;
168
+ description?: string;
169
+ inputSchema?: Record<string, unknown>;
170
+ /** MCP 规范 2025-06-18 起 Tool 顶层也有 title,优先级低于 annotations.title */
171
+ title?: string;
172
+ annotations?: McpToolAnnotations;
173
+ }
174
+ /**
175
+ * `loadFromConfig` 的结果。
176
+ *
177
+ * 以前这个函数返回裸数组、`catch { return [] }`:mcp.json 少一个逗号
178
+ * 等于「静默地没有 MCP」。现在把校验问题一并带出来,由调用方写进 diagnostics。
179
+ */
180
+ interface McpConfigLoadResult {
181
+ servers: McpServerConfig[];
182
+ issues: ParseIssue[];
183
+ }
184
+
185
+ /**
186
+ * MCP 工具 schema 缓存 —— 内存 + 磁盘两层。
187
+ *
188
+ * 参考 Hermes tools/mcp_schema_cache.py(按 server 名 + 连接配置指纹存盘)。
189
+ *
190
+ * 为什么要缓存:`getAllTools()` 以前每次都对每个 client 打一次 `tools/list`,
191
+ * 一次网络往返换一份没变过的 schema。
192
+ *
193
+ * 两层的分工是刻意的:
194
+ * - **内存**是权威的。同一个进程里连上之后拉过一次就够了。
195
+ * - **磁盘**只做降级兜底:live 调用失败(server 挂了 / 网络抖)时,
196
+ * 拿上次成功的 schema 顶上,让工具至少还在工具表里。
197
+ * 不拿磁盘做首选是因为它会过期 —— 注册一个 server 已经删掉的工具,
198
+ * 等于把失败从启动期推迟到调用期。
199
+ */
200
+
201
+ /**
202
+ * 连接配置指纹 —— 只取「决定连到哪、连什么」的字段。
203
+ *
204
+ * timeout / keepalive 这类不影响工具表的字段刻意排除在外,
205
+ * 否则调一下超时就白白丢一次缓存。
206
+ */
207
+ declare function configFingerprint(config: McpServerConfig): string;
208
+ declare class McpSchemaCache {
209
+ private readonly cacheFile;
210
+ private memory;
211
+ private disk;
212
+ /** @param cacheFile 磁盘缓存文件路径;传 null 表示只用内存(测试 / 无家目录场景) */
213
+ constructor(cacheFile?: string | null);
214
+ /** 内存命中才算「新鲜」—— 磁盘的走 {@link getPersisted} */
215
+ getFresh(name: string, fingerprint: string): McpToolSchema[] | null;
216
+ /** 上次成功落盘的 schema,仅在 live 调用失败时作为降级使用 */
217
+ getPersisted(name: string, fingerprint: string): McpToolSchema[] | null;
218
+ /** live 拉取成功后写入两层 */
219
+ set(name: string, fingerprint: string, tools: McpToolSchema[]): void;
220
+ /**
221
+ * 显式失效。不传 name 清全部。
222
+ *
223
+ * 调用方:`McpRegistry.reconnect()`,以及将来的 `notifications/tools/list_changed`。
224
+ */
225
+ invalidate(name?: string): void;
226
+ private loadDisk;
227
+ private writeDisk;
228
+ }
229
+
230
+ /**
231
+ * MCP OAuth —— 类型。
232
+ *
233
+ * [对标 codex rmcp-client/src/oauth.rs 的 `StoredOAuthTokens`
234
+ * 与 auth_status.rs 的 `McpAuthState`]
235
+ *
236
+ * 两个刻意的设计点,三家竞品是一致的:
237
+ *
238
+ * 1. **凭据和 URL 绑死**。存的时候记下 `serverUrl`,读的时候对不上就当没有。
239
+ * 配置里把 url 从 A 改成 B,A 的 access token 绝不能拿去打 B。
240
+ * codex 把 url 的哈希编进存储 key,opencode 是 `getForUrl(url)`,
241
+ * hermes 是 `entry.server_url != server_url → 丢弃缓存`。
242
+ * 2. **落盘的是绝对时刻 `expiresAt` 而不是 OAuth 响应里的 `expires_in`**。
243
+ * `expires_in` 是「从拿到那一刻起还有多少秒」,进程重启后这个数就是错的
244
+ * (hermes mcp_oauth.py:497 的注释说的就是这件事)。
245
+ */
246
+
247
+ /** 落盘的单个 server 的 OAuth 状态 */
248
+ interface McpAuthEntry {
249
+ /** 拿到这份凭据时用的 server URL。和当前配置对不上就整条作废 */
250
+ serverUrl: string;
251
+ /** 动态注册(RFC 7591)拿到的 client_id / client_secret */
252
+ client?: OAuthClientInformationFull;
253
+ tokens?: OAuthTokens;
254
+ /** access token 的绝对过期时刻(epoch 毫秒)。没有就是「不知道」 */
255
+ expiresAt?: number;
256
+ /**
257
+ * PKCE 的 code_verifier。
258
+ *
259
+ * 只在「已跳转授权页、还没换到 token」这段窗口里有值,换到就清掉 ——
260
+ * 它和 code 一起才有意义,留着只是多一份可被读取的秘密。
261
+ */
262
+ codeVerifier?: string;
263
+ /** SDK 的发现结果缓存,省掉每次连接的 RFC 9728 往返 */
264
+ discovery?: Record<string, unknown>;
265
+ /** 上次成功保存 token 的时刻,只用于 `epoch mcp status` 展示 */
266
+ updatedAt?: number;
267
+ }
268
+ interface McpAuthFile {
269
+ version: 1;
270
+ servers: Record<string, McpAuthEntry>;
271
+ }
272
+ /**
273
+ * 一个 server 当前的认证状态。
274
+ *
275
+ * [对标 codex auth_status.rs 的 `McpAuthState`],去掉了它的 `Unsupported`
276
+ * —— 那个值要靠一次 RFC 9728 探测才能得出,而我们不想让 `epoch mcp status`
277
+ * 变成一条会打网络的命令。「没凭据」和「服务端根本不要 OAuth」在我们这里
278
+ * 都落在 `logged-out`,由实际连接结果来区分。
279
+ */
280
+ type McpAuthState =
281
+ /** 不是远程 server(stdio),OAuth 无从谈起 */
282
+ 'not-applicable'
283
+ /** 配置里自带 Authorization header,走静态 bearer,不需要 OAuth */
284
+ | 'bearer-header'
285
+ /** 有可用的 access token */
286
+ | 'authorized'
287
+ /** access token 过期了,但有 refresh token,下次连接会自动换 */
288
+ | 'refreshable'
289
+ /** 没凭据或 refresh 也没了,需要 `epoch mcp login` */
290
+ | 'logged-out';
291
+ interface McpAuthStatus {
292
+ server: string;
293
+ state: McpAuthState;
294
+ /** 当前配置里的 URL;stdio server 为 undefined */
295
+ url?: string;
296
+ expiresAt?: number;
297
+ updatedAt?: number;
298
+ scope?: string;
299
+ }
300
+ /**
301
+ * 需要用户跑一次 `epoch mcp login` 才能继续。
302
+ *
303
+ * 单独一个错误类型是为了让工具执行路径能把它和普通网络错误区分开:
304
+ * 前者要告诉用户去登录,后者只是「重试一下」。
305
+ * **绝不在工具执行中途弹浏览器** —— agent 跑到一半开个窗口是恶劣体验,
306
+ * 而且 headless / CI 下根本没有浏览器可弹。
307
+ */
308
+ declare class McpLoginRequiredError extends Error {
309
+ readonly server: string;
310
+ constructor(server: string, detail?: string);
311
+ }
312
+
313
+ /**
314
+ * MCP OAuth token 存储 —— `~/.epoch/mcp-auth.json`,权限 0600。
315
+ *
316
+ * [对标 codex rmcp-client/src/oauth.rs 的 file fallback(`.credentials.json`)
317
+ * 与 hermes tools/mcp_oauth.py 的 `_write_secure_json`]
318
+ *
319
+ * 三件事是从竞品那儿学来的、不能省的:
320
+ *
321
+ * 1. **权限在创建时就是 0600,不是写完再 chmod**。hermes mcp_oauth.py:388
322
+ * 专门为这个改过一版:先 `write_text` 再 `chmod` 中间有个 TOCTOU 窗口,
323
+ * 这段时间里同机器的其他用户能把 token 读走。`writeFileSync` 的 `mode`
324
+ * 参数是建文件时生效的,用它。
325
+ * 2. **原子写**:临时文件 + rename。写到一半崩溃留下半个 JSON 的话,
326
+ * 所有 server 的登录状态一起消失。
327
+ * 3. **凭据和 URL 绑死**:读的时候比对 `serverUrl`,对不上当没有。
328
+ *
329
+ * ## 加密(方案 11 落地后新增)
330
+ *
331
+ * 这里原来写的是「不做 keyring……等凭据加密方案落地时两边一起搬」——
332
+ * 现在搬了,但**不是**把整个文件塞进钥匙串,而是**信封加密**:
333
+ *
334
+ * - 钥匙串里只放一把 32 字节随机数据密钥(装配层预取一次,见
335
+ * [infra/secret/envelope.ts](../../../../infra/src/secret/envelope.ts))
336
+ * - 这个文件的内容用它做 AES-256-GCM,加解密由 `node:crypto` **同步**完成
337
+ *
338
+ * 为什么不直接 `await secretStore.set(...)`:本类的调用方是 MCP SDK 的
339
+ * `OAuthClientProvider`,整套接口是**同步 void**(`saveTokens(t): void`),
340
+ * 没有地方可以 await。fire-and-forget 也不行 —— `epoch mcp login` 写完 token
341
+ * 立刻退进程,异步的 shell out 可能还没落地,用户的登录就丢了。信封把
342
+ * 「异步只发生一次(取密钥)」和「写盘永远同步」分开,上面那三条
343
+ * (0600 建文件、原子写、URL 绑死)一个字都没变。
344
+ *
345
+ * **拿不到数据密钥就照旧写明文**(没有钥匙串的机器)。这不是静默降级:
346
+ * 启动诊断里那行 `Secret: WARN ...` 已经把整体明文这件事说清楚了。
347
+ */
348
+
349
+ /** access token 提前多久算「该刷新了」[对标 codex REFRESH_SKEW_MILLIS = 30_000] */
350
+ declare const REFRESH_SKEW_MS = 30000;
351
+ declare class McpAuthStore {
352
+ private readonly filePath;
353
+ private cache;
354
+ /** 磁盘上是密文、但本次运行拿不到数据密钥。此时**只读不写**,见 load() / save() */
355
+ private locked;
356
+ /** @param filePath 显式路径(测试用);不传走 infra 的路径真源 */
357
+ constructor(filePath?: string, homeDir?: string);
358
+ get path(): string;
359
+ /**
360
+ * 读一个 server 的凭据。
361
+ *
362
+ * @param serverUrl 当前配置里的 URL。和落盘时记的对不上就返回 undefined ——
363
+ * 换了地址的 token 不能拿去打新地址。
364
+ */
365
+ get(server: string, serverUrl?: string): McpAuthEntry | undefined;
366
+ /** 不比对 URL 的读法,只给 `epoch mcp status` 展示用 */
367
+ getRaw(server: string): McpAuthEntry | undefined;
368
+ list(): Array<[string, McpAuthEntry]>;
369
+ /** 局部更新一个 server 的条目;`serverUrl` 变了会先清空旧凭据 */
370
+ update(server: string, serverUrl: string, patch: Partial<McpAuthEntry>): void;
371
+ /** 删掉一个 server 的全部凭据。@returns 之前是否存在 */
372
+ remove(server: string): boolean;
373
+ /** 强制下次 `get` 重新读盘。外部改过文件(另一个进程登录过)时用 */
374
+ invalidate(): void;
375
+ private load;
376
+ /**
377
+ * 磁盘上是密文而当前解不开时,任何写操作都必须**当场失败**。
378
+ *
379
+ * 在改动内存状态之前就拦(而不是等到 save()):`update()` 是先改缓存再落盘的,
380
+ * 到 save() 才抛会留下一个「内存里改了、盘上没改」的错位状态,
381
+ * 下一次读会拿到一份不存在于磁盘的凭据。
382
+ */
383
+ private assertWritable;
384
+ private save;
385
+ }
386
+ /**
387
+ * access token 是否已经(快)过期。
388
+ *
389
+ * 提前 30 秒算过期:token 刚好在请求飞在路上时到期是最难查的一类故障。
390
+ */
391
+ declare function isExpired(entry: McpAuthEntry | undefined, now?: number): boolean;
392
+
393
+ /**
394
+ * MCP 客户端 —— 连接状态机:握手、重连、保活 / 生存期 / 空闲计时器、工具调用。
395
+ *
396
+ * transport **怎么造出来、怎么关干净**在 [transport/](./transport/index.ts),
397
+ * 那部分不持有状态,跟这里的状态机是两件事。
398
+ *
399
+ * 参考 Hermes tools/mcp_tool.py + @modelcontextprotocol/sdk。
400
+ */
401
+
402
+ /** 一次工具调用的结果。`artifacts` 是**未过闸门**的原始形态,闸门在 core 侧 */
403
+ interface McpToolCallResult {
404
+ success: boolean;
405
+ output: string;
406
+ artifacts?: ToolArtifactInput[];
407
+ }
408
+ declare class McpClient {
409
+ private config;
410
+ private client;
411
+ private transport;
412
+ private keepaliveTimer;
413
+ private lifetimeTimer;
414
+ private idleTimer;
415
+ private reconnectTimer;
416
+ private reconnectAttempts;
417
+ private lastError;
418
+ private toolCount;
419
+ private stderrTail;
420
+ /** 上一个 chunk 末尾那截还没等到换行的文本,见 {@link collectStderr} */
421
+ private stderrPartial;
422
+ /** 主动 disconnect 时置位,避免自己触发的 onclose 又去排一次重连 */
423
+ private closingIntentionally;
424
+ /** 认证过不去。重连解决不了这个问题,只有 `epoch mcp login` 能 */
425
+ private needsLogin;
426
+ private readonly cache;
427
+ private readonly fingerprint;
428
+ private readonly authProvider;
429
+ /**
430
+ * 收到 `notifications/tools/list_changed` 时的回调。
431
+ *
432
+ * McpRegistry 用它把「工具表已经不是启动时那份了」冒泡给宿主。
433
+ * 注意**这不是热更新**:AgentLoop 的工具集是构造参数,本进程内换不掉。
434
+ * 详见 registry 的 `refresh()`。
435
+ */
436
+ onToolsChanged: (() => void) | undefined;
437
+ /**
438
+ * 这台是谁带进来的(方案 44 PR-2)。
439
+ *
440
+ * **由 `McpRegistry.connectAll()` 那一侧说了算,不从 `config` 里读** ——
441
+ * `McpServerConfig` 是用户手写的 `mcp.json` 解析出来的形状,来源要是它的一个
442
+ * 字段,用户就能在自己的文件里写 `"source": "host"` 给自己那台贴上宿主的标。
443
+ * 判据同角色那条「命名空间由装配层拼,宿主自己写的不算数」。
444
+ */
445
+ private readonly source;
446
+ constructor(config: McpServerConfig, cache?: McpSchemaCache, authStore?: McpAuthStore, source?: McpServerSource);
447
+ get status(): McpServerStatus;
448
+ /** 底层 SDK client,`null` 表示没连上。resources / prompts 那几个原语要用 */
449
+ get raw(): Client | null;
450
+ get serverConfig(): McpServerConfig;
451
+ connect(): Promise<void>;
452
+ /**
453
+ * 取本 server 的工具 schema —— 缓存优先。
454
+ *
455
+ * 三级:内存缓存 → live `tools/list` → 上次落盘的 schema(降级)。
456
+ * 详见 [cache.ts](./cache.ts) 里两层分工的说明。
457
+ */
458
+ getToolSchemas(): Promise<McpToolSchema[]>;
459
+ /** live 拉取;失败返回 null(错误记进 lastError,由调用方决定降不降级) */
460
+ private fetchToolSchemas;
461
+ /**
462
+ * 调用工具。
463
+ *
464
+ * **断线不在这里等重连**:以前这里 `await this.reconnect()`,而 reconnect 是
465
+ * 1s + 2s + 4s 的 backoff 循环 —— 工具执行路径上最多阻塞 7 秒,用户看到的是
466
+ * agent 卡住。现在改成排一次后台重连、立刻返回一条可重试的错误。
467
+ */
468
+ callTool(name: string, args: Record<string, unknown>): Promise<McpToolCallResult>;
469
+ disconnect(): Promise<void>;
470
+ /**
471
+ * 建连。**握手成功之前不写 this.client**。
472
+ *
473
+ * 以前是先 `this.client = new Client(...)` 再 await 握手,于是
474
+ * `status.connected`(判据就是 `client !== null`)在握手还没跑完时就报 true ——
475
+ * 调用方据此以为能用了,实际 SDK 会抛 "Not connected"。
476
+ *
477
+ * 失败路径也补上了 `transport.close()`:以前 connect 超时只是把字段置 null,
478
+ * stdio 已经起来的子进程没人收,直接漏成孤儿进程。
479
+ */
480
+ private connectInner;
481
+ /**
482
+ * 攒起 stderr 的尾部若干**行**。
483
+ *
484
+ * ⚠️ **`text` 是一个 chunk,不是一行。** 一行长过一个 chunk(管道一次 64 KB)
485
+ * 时它会被切开,而不留半截的话每一截都变成尾部里独立的一条 —— 既占掉好几个
486
+ * 名额,`stderrHint()` 拼出来还像是好几件事(`……前半 | 后半`)。
487
+ * 一个把栈打进 stderr 的 server 很容易做到这一点。
488
+ *
489
+ * ⚠️ 别把 `cmd.exe` 那句「不是内部或外部命令」的两行当成这个 bug 的例子 ——
490
+ * 实测过,**那句话本身就是两行**(「……可运行的程序」后面真有一个 `\r\n`),
491
+ * 拼出来带 `|` 是忠实的。
492
+ */
493
+ private collectStderr;
494
+ /**
495
+ * 最后三行,给错误消息当尾巴。
496
+ *
497
+ * 把还没等到换行的那半行也算进来:server 崩在半句话上时,那半句往往正是
498
+ * 最要紧的一句(而它永远等不到自己的 `\n`)。
499
+ */
500
+ private stderrHint;
501
+ /**
502
+ * server 说工具表变了。
503
+ *
504
+ * 只做两件事:**失效 schema 缓存**(下次 `getToolSchemas()` 会真去拉一次),
505
+ * 然后把事件冒泡出去。刻意不在这里自己去 `listTools()` —— 通知可能连着来,
506
+ * 每条都拉一次是在替 server 打自己。
507
+ */
508
+ private onToolsListChanged;
509
+ /**
510
+ * @param closed 触发 onclose 的那个 Client。重连成功后旧连接的迟到通知
511
+ * 不能把新连接连坐掉,所以要比对身份。
512
+ */
513
+ private onTransportClosed;
514
+ /**
515
+ * 排一次后台重连(幂等)。
516
+ *
517
+ * 看门狗语义:重连全程不占用调用方的时间片,失败就按 backoff 再排一次,
518
+ * 到 MAX_RECONNECT 为止把 server 标成不可用。
519
+ */
520
+ private scheduleReconnect;
521
+ private attemptReconnect;
522
+ private unavailableMessage;
523
+ private startKeepalive;
524
+ private pingOnce;
525
+ private stopKeepalive;
526
+ /**
527
+ * 起 idle / max-lifetime 两个自动断连定时器。
528
+ *
529
+ * 这里刻意**不**调 `clearTimers()`:它连 keepalive 一起清,而调用顺序是
530
+ * `startKeepalive(); startLifetime();` —— 等于刚起的 keepalive 立刻被自己清掉,
531
+ * keepalive 从来没真正跑过。只清自己的两个定时器。
532
+ */
533
+ private startLifetime;
534
+ private resetIdle;
535
+ private armDisconnect;
536
+ private clearTimers;
537
+ private withTimeout;
538
+ }
539
+
540
+ /**
541
+ * MCP OAuth —— 收授权回调的一次性本地 HTTP server。
542
+ *
543
+ * [对标 opencode packages/opencode/src/mcp/oauth-callback.ts
544
+ * 与 codex rmcp-client/src/perform_oauth_login.rs 的 `CallbackServerGuard`]
545
+ *
546
+ * 三条都是安全项,不是打磨:
547
+ *
548
+ * 1. **只监听 127.0.0.1**。绑 `0.0.0.0` 的话,同一个局域网里的任何人都能
549
+ * 往这个端口投一个 code,把自己的账号挂到用户的 epoch 上。
550
+ * 2. **校验 `state`**。不校验就是标准的 OAuth CSRF:攻击者诱导用户点开一个
551
+ * 带他自己 code 的回调链接,用户的 MCP 连接就连到攻击者的账号上去了。
552
+ * opencode / codex 两家都在回调处第一件事就是比对 state。
553
+ * 3. **超时自己收摊**(默认 5 分钟,两家的默认值也是 300 秒)。用户在授权页
554
+ * 上关掉浏览器走人是常态,端口不能一直开着。
555
+ */
556
+ /** [对标 codex DEFAULT_OAUTH_TIMEOUT_SECS = 300 / opencode 的 5 分钟] */
557
+ declare const CALLBACK_TIMEOUT_MS: number;
558
+ interface CallbackServer {
559
+ /** 注册给授权服务器的 redirect_uri */
560
+ redirectUri: string;
561
+ /**
562
+ * 登记本次流程的 `state`,**必须在把用户送去授权页之前**调用。
563
+ *
564
+ * 不能等到 `waitForCode()` 再登记:授权服务器上已经有会话的用户根本不会看到
565
+ * 同意页,浏览器一个 302 就回来了 —— 回调完全可能比 `waitForCode()` 先到。
566
+ * 那时既没有 state 可比对,也没人接住这个 code。(这条是被 e2e 实跑撞出来的:
567
+ * 假浏览器自动跟随跳转,code 秒回,登录卡在这里干等 300 秒超时。)
568
+ */
569
+ expect(state: string | undefined): void;
570
+ /** 等回调;拿到授权码就 resolve,出错 / 超时就 reject。无论如何都会关掉服务器 */
571
+ waitForCode(): Promise<string>;
572
+ /** 提前收摊(登录流程在拿到 code 之前失败了) */
573
+ close(): void;
574
+ }
575
+ /**
576
+ * 起回调服务器。
577
+ *
578
+ * @param port 固定端口。不传用临时端口 —— 但很多授权服务器要求 redirect_uri
579
+ * 事先登记,那种情况必须指定一个固定端口才对得上。
580
+ * @param timeoutMs 等回调的上限。只有测试会改它 —— 用例不可能真等 5 分钟
581
+ */
582
+ declare function startCallbackServer(port?: number, timeoutMs?: number): Promise<CallbackServer>;
583
+
584
+ /**
585
+ * MCP OAuth —— 登录 / 登出 / 状态查询。
586
+ *
587
+ * [对标 codex rmcp-client/src/perform_oauth_login.rs 的状态机]
588
+ *
589
+ * 流程就三步,其中两步是 SDK 做的:
590
+ *
591
+ * 1. 起本地回调服务器,拿到 `redirect_uri`
592
+ * 2. `auth(provider, { serverUrl })` —— SDK 去发现元数据、必要时动态注册客户端、
593
+ * 生成 PKCE,然后回调 `redirectToAuthorization`。它返回 `'AUTHORIZED'`
594
+ * 说明**光靠 refresh token 就续上了**,压根不用开浏览器;返回 `'REDIRECT'`
595
+ * 才需要用户真去授权页点一下
596
+ * 3. 收到 code 后再调一次 `auth(provider, { serverUrl, authorizationCode })`
597
+ * 换 token
598
+ *
599
+ * 第 2 步返回 `'AUTHORIZED'` 就提前收工这件事很容易漏 —— 漏了的话每次
600
+ * `epoch mcp login` 都会强开一次浏览器,哪怕 refresh token 明明还好用。
601
+ */
602
+
603
+ interface McpLoginOptions {
604
+ /** server 名(凭据存储 key) */
605
+ server: string;
606
+ /** server URL */
607
+ serverUrl: string;
608
+ store?: McpAuthStore;
609
+ /** 固定回调端口。授权服务器要求预先登记 redirect_uri 时必须指定 */
610
+ port?: number;
611
+ scope?: string;
612
+ /** 打开浏览器;测试里换成假的 */
613
+ openBrowser?: (url: string) => void | Promise<void>;
614
+ /** 打印进度 */
615
+ print?: (message: string) => void;
616
+ }
617
+ interface McpLoginResult {
618
+ server: string;
619
+ /** true 表示只靠 refresh token 就续上了,没开浏览器 */
620
+ refreshedOnly: boolean;
621
+ }
622
+ declare function loginToMcpServer(opts: McpLoginOptions): Promise<McpLoginResult>;
623
+ /** 删掉一个 server 的全部 OAuth 凭据。@returns 之前是否存在 */
624
+ declare function logoutFromMcpServer(server: string, store?: McpAuthStore): boolean;
625
+ /**
626
+ * 查一个 server 现在的认证状态。
627
+ *
628
+ * **不打网络**:只看配置和落盘的凭据。
629
+ * [对标 codex auth_status.rs 的 `auth_status_before_discovery`] —— 它把
630
+ * 「不打网络就能确定的」和「必须探测才知道的」明确分开,前者能覆盖绝大多数
631
+ * 情况。我们只做前者,理由见 types.ts 里 `McpAuthState` 的注释。
632
+ */
633
+ declare function mcpAuthStatus(config: McpServerConfig, store?: McpAuthStore): McpAuthStatus;
634
+
635
+ /**
636
+ * MCP OAuth —— `OAuthClientProvider` 实现。
637
+ *
638
+ * SDK(`@modelcontextprotocol/sdk/client/auth.js` 的 `auth()`)已经把
639
+ * RFC 9728 发现、动态客户端注册(RFC 7591)、PKCE、授权码换 token、
640
+ * refresh token 续期全实现了。**我们要做的只是持久化 + 「跳转」怎么跳**。
641
+ * 先确认 SDK 提供了什么再决定自己写多少 —— 方案里点名要求的这一步,
642
+ * 结论就是这个类只有 ~150 行。
643
+ *
644
+ * 最要紧的一个决定:**这个 provider 有交互和非交互两副面孔**。
645
+ *
646
+ * - 连接 MCP server 时用的是非交互版:有 token 就用,过期就让 SDK 拿
647
+ * refresh token 去换,**换不动就抛 `McpLoginRequiredError`**,绝不弹浏览器。
648
+ * agent 跑到一半开个授权窗口是恶劣体验,headless / CI 下更是根本没窗口可开。
649
+ * - `epoch mcp login` 用交互版:允许跳转,配一个本地回调服务器收 code。
650
+ *
651
+ * codex 是同一个道理,只是它把两条路分成了 `perform_oauth_login`(交互)
652
+ * 和 `AuthorizationManager`(连接期)两套入口。
653
+ */
654
+
655
+ interface McpOAuthProviderOptions {
656
+ /** MCP server 在配置里的名字,也是凭据的存储 key */
657
+ server: string;
658
+ /** 当前配置里的 URL。凭据和它绑死 */
659
+ serverUrl: string;
660
+ store: McpAuthStore;
661
+ /** 本地回调地址;只有交互式登录才有 */
662
+ redirectUri?: string;
663
+ /** 跳转授权页的动作。不给就是非交互 —— 需要跳转时直接抛「请先 login」 */
664
+ onRedirect?: (url: URL) => void | Promise<void>;
665
+ /** 申请的 scope;不给就由服务端的默认值决定 */
666
+ scope?: string;
667
+ }
668
+ declare class McpOAuthProvider implements OAuthClientProvider {
669
+ private readonly opts;
670
+ /** 本次流程生成的 state,回调时用来验 CSRF */
671
+ private currentState;
672
+ constructor(opts: McpOAuthProviderOptions);
673
+ /** 本次授权流程用的 state,回调服务器拿它比对 */
674
+ get expectedState(): string | undefined;
675
+ /** 见 `PLACEHOLDER_REDIRECT_URI`:这里返回 undefined 会让 SDK 跳过 refresh */
676
+ get redirectUrl(): string;
677
+ /** 真的能跳转吗(交互式登录才能)。占位地址不算 */
678
+ get interactive(): boolean;
679
+ get clientMetadata(): OAuthClientMetadata;
680
+ /**
681
+ * OAuth 的 `state` 参数。
682
+ *
683
+ * SDK 把它当**生成器**调(每次授权一个新值),不是「读一个固定值」——
684
+ * opencode `src/mcp/oauth-provider.ts` 里踩过这个:写成只读缓存的话,
685
+ * 两次登录会拿到同一个 state,回调分不清是哪一次。
686
+ */
687
+ state(): string;
688
+ clientInformation(): OAuthClientInformationMixed | undefined;
689
+ saveClientInformation(info: OAuthClientInformationMixed): void;
690
+ tokens(): OAuthTokens | undefined;
691
+ /**
692
+ * 存 token。
693
+ *
694
+ * 顺手把 `expires_in`(相对秒数)换算成绝对时刻落盘 —— 相对值一重启就是错的。
695
+ * 同时清掉 `codeVerifier`:它只在「跳转了、还没换到 token」那段窗口有用,
696
+ * 换到了就是一份没有用处的秘密,留着只是多一处泄漏点。
697
+ */
698
+ saveTokens(tokens: OAuthTokens): void;
699
+ redirectToAuthorization(url: URL): Promise<void>;
700
+ saveCodeVerifier(codeVerifier: string): void;
701
+ codeVerifier(): string;
702
+ /**
703
+ * 服务端说凭据不好使了,SDK 调这里让我们删掉。
704
+ *
705
+ * 不实现的话用户要自己去删 `~/.epoch/mcp-auth.json` —— 而「client 注册
706
+ * 被服务端撤销」这种情况下,不删就会一直拿同一个失效 client_id 重试。
707
+ */
708
+ invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void;
709
+ saveDiscoveryState(state: OAuthDiscoveryState): void;
710
+ discoveryState(): OAuthDiscoveryState | undefined;
711
+ private entry;
712
+ }
713
+
714
+ /**
715
+ * MCP 的另两个原语 —— resources 与 prompts。
716
+ *
717
+ * 之前只做了 tools 一个。这里只放**读取**逻辑,怎么暴露给模型 / 用户分别在
718
+ * `resource-tools.ts` 和 registry 的 `listPrompts()`。
719
+ *
720
+ * 三个共同点,都是从 gemini `packages/core/src/tools/mcp-client.ts` 的
721
+ * `listResources` 抄来的做法:
722
+ *
723
+ * 1. **先看 capability**。server 没声明 `resources` 就别问了 —— 问了多半得到
724
+ * 一个 `-32601 Method not found`,白跑一次往返还污染错误日志。
725
+ * 2. **翻页**。`resources/list` 和 `prompts/list` 都有 `nextCursor`,
726
+ * 不翻页就是「只有前 N 个」,而 N 由 server 决定,用户无从察觉。
727
+ * 3. **method-not-found 当空**。有些 server 声明了 capability 却没实现,
728
+ * 这种情况下「没有 resources」比「MCP 初始化失败」更贴近事实。
729
+ *
730
+ * ## 两个原语的**去处不一样**,这是刻意的
731
+ *
732
+ * - **resources → 包成工具**(`resource-tools.ts`:每个 server 一对
733
+ * `mcp__<server>__list_resources` / `read_resource`)。不走「上下文注入」是因为
734
+ * 那条路只有两种走法,都不成立:全量塞(几个 server 就能把上下文吃光),
735
+ * 或者先猜模型需要哪些资源 —— 而「按需取」正是工具存在的意义。
736
+ * - **prompts → 不进工具表**,只有 registry 的 `listPrompts()` / `getPrompt()`,
737
+ * **模型看不见**。判据在 MCP 规范里:**prompts 是 user-controlled、tools 是
738
+ * model-controlled**。混进工具表等于让模型自己决定往自己的上下文里塞什么 ——
739
+ * 那不是「多一个工具」,是把一个由用户把关的入口改成由模型把关。
740
+ * gemini 也把它放在用户侧(slash command);我们的 TUI 入口还没做。
741
+ */
742
+
743
+ interface McpResourceInfo {
744
+ uri: string;
745
+ name?: string;
746
+ description?: string;
747
+ mimeType?: string;
748
+ }
749
+ interface McpPromptInfo {
750
+ name: string;
751
+ description?: string;
752
+ arguments?: Array<{
753
+ name: string;
754
+ description?: string;
755
+ required?: boolean;
756
+ }>;
757
+ }
758
+ declare function listResources(client: Client): Promise<McpResourceInfo[]>;
759
+ declare function listPrompts(client: Client): Promise<McpPromptInfo[]>;
760
+ /**
761
+ * 读一个 resource,压成纯文本。
762
+ *
763
+ * 二进制内容(`blob`)只留一行占位说明,不做 base64 透传 —— 那会把几 MB 的
764
+ * 图片塞进上下文,直接把方案 02 的成本闸门顶爆,而模型也读不懂那串字符。
765
+ * gemini 的 `read-mcp-resource.ts:145` 是同样的处理。
766
+ */
767
+ declare function readResourceText(client: Client, uri: string): Promise<string>;
768
+ /**
769
+ * 取一个 prompt 的消息内容,压成纯文本(给 CLI 展示 / 注入用)。
770
+ *
771
+ * 非文本那几支走 `convertMcpContent` 的同一套判别 —— 改造前这里是
772
+ * `JSON.stringify(content)`,和 tool result 那条路犯的是同一个错:
773
+ * prompt 里夹一张图,整张 base64 就进了展示文本和注入内容。
774
+ * 这里**不产生 artifact**:prompt 是给人看 / 拼进 system 的文本通道,
775
+ * 二进制在这条路上没有去处,只留一行占位。
776
+ */
777
+ declare function getPromptText(client: Client, name: string, args?: Record<string, string>): Promise<string>;
778
+
779
+ /**
780
+ * MCP 注册中心 — 多 server 管理 [Hermes mcp_tool.py]。
781
+ */
782
+
783
+ interface McpRegistryOptions {
784
+ /** 家目录(决定 schema 缓存落在哪)。不传走 infra 的真源解析 */
785
+ homeDir?: string;
786
+ /** 显式指定缓存文件;传 null 表示只用内存缓存(测试用) */
787
+ cacheFile?: string | null;
788
+ /** 显式指定凭据存储(测试用)。不传就按 homeDir 解析出真源路径 */
789
+ authStore?: McpAuthStore;
790
+ }
791
+ declare class McpRegistry {
792
+ private clients;
793
+ private cache;
794
+ private authStore;
795
+ /**
796
+ * 某个 server 的工具表变了(收到 `notifications/tools/list_changed`)。
797
+ *
798
+ * registry 这层只负责把缓存失效掉并转发这个信号 —— **真正的热更新做不到**:
799
+ * `AgentLoop` 的工具集是构造参数,一次性传进去的,没有「运行中换一套工具」
800
+ * 的入口。要做需要先给 loop 引入 `ToolProvider` 接口,那是另一个方案的事,
801
+ * 本方案明确不碰 loop.ts。当前的效果是:下一次 `getAllTools()` / `refresh()`
802
+ * 能拿到新工具表,正在跑的那一轮不受影响。
803
+ */
804
+ onToolsChanged: ((server: string) => void) | undefined;
805
+ constructor(options?: McpRegistryOptions);
806
+ /**
807
+ * 从配置文件加载所有 MCP servers。
808
+ *
809
+ * 返回值带 `issues`:以前是 `catch { return [] }`,mcp.json 写错一个字符
810
+ * 用户只会发现「MCP 没了」,看不到任何提示。调用方**必须**把 issues
811
+ * 写进 diagnostics(见 runtime/tools.ts)。
812
+ */
813
+ loadFromConfig(configPath?: string): McpConfigLoadResult;
814
+ /**
815
+ * 连接所有已配置的 servers。
816
+ *
817
+ * @param source 这一批是谁带进来的(方案 44 PR-2)。**由调用方说了算,不从
818
+ * `configs` 里读** —— 判据在 `McpClient.source` 上:`McpServerConfig` 是
819
+ * 用户手写文件解析出来的形状,来源要是它的一个字段,用户就能给自己那台
820
+ * 贴上宿主的标。缺省 `'user'`,于是老调用点(`epoch mcp` 那几条、用例)
821
+ * 一个字都不用改
822
+ *
823
+ * ⚠️ **可以调多次(两批 server 各一发),但名字必须在批与批之间唯一** ——
824
+ * `clients` 是一张按名字索引的表,重名的后一发会把前一发整个顶掉,而这里
825
+ * 拿不到诊断口说不出这件事。去重归调用方,`runtime/src/tools.ts` 的
826
+ * `registerMcpTools` 就是这么做的(用户那批先到、宿主重名的那台被跳过并报一条)。
827
+ */
828
+ connectAll(configs: McpServerConfig[], source?: McpServerSource): Promise<McpServerStatus[]>;
829
+ /**
830
+ * 获取所有 MCP 工具(扁平化)。
831
+ *
832
+ * schema 走缓存,第二次调用不再打网络;工具本身每次重建
833
+ * (execute 闭包要绑到当前的 client 实例上,重连后不能还指向旧连接)。
834
+ */
835
+ getAllTools(): Promise<EpochTool[]>;
836
+ /**
837
+ * 丢掉 schema 缓存后重新取一遍工具表。
838
+ *
839
+ * 给 `onToolsChanged` 的消费方用:收到通知 → `refresh()` → 拿到新工具表。
840
+ * 不传 name 就刷所有 server。
841
+ */
842
+ refresh(name?: string): Promise<EpochTool[]>;
843
+ /**
844
+ * 列出某个 server 的 prompts。
845
+ *
846
+ * **prompts 不包成工具**,与 resources 的处理刻意不同:MCP 规范把 prompts
847
+ * 定义成 user-controlled(用户主动挑一个模板来用),tools 才是 model-controlled。
848
+ * gemini 也是这么分的 —— 它把 prompts 注册成斜杠命令(`PromptRegistry`),
849
+ * 不给模型。所以这里只提供查询 API,暴露方式交给 CLI/TUI。
850
+ */
851
+ listPrompts(name: string): Promise<McpPromptInfo[]>;
852
+ /** 取一个 prompt 渲染后的文本 */
853
+ getPrompt(name: string, promptName: string, args?: Record<string, string>): Promise<string>;
854
+ /** 各 server 的登录态。纯读本地凭据文件,不打网络 */
855
+ getAuthStatus(): McpAuthStatus[];
856
+ /** 凭据存储。`epoch mcp login/logout` 要用同一份,否则缓存不一致 */
857
+ get auth(): McpAuthStore;
858
+ /** 重连指定 server。工具表可能已变,顺带失效它的 schema 缓存 */
859
+ reconnect(name: string): Promise<McpServerStatus>;
860
+ /**
861
+ * 显式失效 schema 缓存,不传 name 清全部。
862
+ *
863
+ * 给 `epoch mcp reconnect` 和将来的 `notifications/tools/list_changed` 用。
864
+ */
865
+ invalidateSchemaCache(name?: string): void;
866
+ /**
867
+ * 断开**一台**并把它从表里删掉。返回 false 表示本来就没有这一台。
868
+ *
869
+ * ## 为什么在 `disconnectAll()` 之外单开这一个(2026-08-18)
870
+ *
871
+ * 在这之前,「让一台 server 在这个进程里消失」**做不到** —— 只能把所有连接一起
872
+ * 断掉。而 `mcp.json` 的一次编辑真的会删掉一台(runtime 的 `applyMcpConfig`),
873
+ * 那时全断再全连是错的:宿主注入的和插件带来的那两批也会被连带断开,而它们
874
+ * 一个字节都不在这份文件里。
875
+ *
876
+ * ⚠️ **它连带失效 schema 缓存**:那台下次要是又被配回来,缓存里躺着的是上一份
877
+ * 工具表。同 `reconnect()` 里那一下,理由逐字相同。
878
+ *
879
+ * ⚠️ **工具表不在这一层清**。这里只管连接;把 `mcp:<名字>` 那一组工具从
880
+ * `ToolRegistry` 里摘掉是装配层的事(`ToolSync.replace`,判据在 runtime 的
881
+ * `ToolSync` 上:三条路必须排同一条队)。少了那一下的表现是**工具表里留着一批
882
+ * 指向已断连接的工具**,模型下一轮照着调,报一个它读不懂的错。
883
+ */
884
+ disconnect(name: string): Promise<boolean>;
885
+ /**
886
+ * 这台 server 在**这个进程里此刻按的是哪份配置**(2026-08-18)。
887
+ *
888
+ * 给「盘上那份和进程里这份差在哪」用(runtime 的 `applyMcpConfig` 拿它做 diff)。
889
+ * ⚠️ 真源必须是 client 手上那一份,**不能是调用方自己记的一份快照**:那份快照
890
+ * 会和这里分叉,而分叉的表现是「明明改了配置,应用之后说没动」。
891
+ */
892
+ configOf(name: string): McpServerConfig | null;
893
+ /** 断开所有连接 */
894
+ disconnectAll(): Promise<void>;
895
+ /** 获取状态 */
896
+ getStatus(name: string): McpServerStatus | null;
897
+ getAllStatus(): McpServerStatus[];
898
+ private requireClient;
899
+ }
900
+
901
+ /**
902
+ * 把 MCP resources 包成两个工具暴露给模型。
903
+ *
904
+ * **为什么是工具,不是上下文注入**(方案里点名要求写清这个选择):
905
+ *
906
+ * 读了 gemini 的做法 —— 它有一个内置工具
907
+ * `read_mcp_resource(uri)`(`packages/core/src/tools/read-mcp-resource.ts`),
908
+ * 外加一个在连接时把 `resources/list` 结果收进去的 registry。也就是说
909
+ * **gemini 也是包成工具**,不是把内容塞进 system prompt。两条理由我们同样成立:
910
+ *
911
+ * 1. resource 的内容量不可控。一个 server 挂几十份文档是常态,全量注入等于
912
+ * 每一轮对话都为「模型多半用不到的东西」付 token —— 方案 02 的成本闸门
913
+ * 会第一个报警。
914
+ * 2. 我们的 `AgentLoop` 工具集是构造参数,注入式上下文没有随会话更新的入口;
915
+ * 而工具是按需调用的,天然是懒加载。
916
+ *
917
+ * 与 gemini 的一个差异:它是**一个全局** `read_mcp_resource`,靠 registry 反查
918
+ * uri 属于哪个 server;我们是**每个 server 一对**带 `mcp__<server>__` 前缀的工具。
919
+ * 理由是我们的工具名前缀本来就是撞名处理的依据(见 tool-adapter.ts),
920
+ * 多一个不走这套规则的全局工具会让「哪个 server 提供了什么」变得不可解释。
921
+ */
922
+
923
+ /**
924
+ * 为一个 server 生成 resources 相关的工具。
925
+ *
926
+ * server 没声明 resources capability 时返回空数组 —— 给模型一个调了必然
927
+ * 报错的工具,只会诱导它反复尝试。
928
+ *
929
+ * @param getClient 取当前连接。**不能捕获 Client 实例**:重连后 client 会换一个,
930
+ * 闭包里握着旧的就会一直往断掉的连接上打。
931
+ */
932
+ declare function createResourceTools(serverName: string, getClient: () => Client | null): EpochTool[];
933
+
934
+ /**
935
+ * mcp.json 的校验与归一化。
936
+ *
937
+ * 存在的理由:以前 `loadFromConfig` 是 `catch { return [] }` —— 配置里少一个逗号、
938
+ * 把 `timeout` 拼成 `timout`,行为都是「静默地没有 MCP」,用户查不出所以然。
939
+ * 这里把每一处问题都变成一条带字段路径的 `ParseIssue`,交给调用方写进 diagnostics。
940
+ *
941
+ * 归一化只在这一个文件里做:registry 不再手工 `sc.connect_timeout as number`,
942
+ * 两套映射迟早会分叉(`streamable` / `headers` 就是这么漏掉的)。
943
+ */
944
+
945
+ interface ParsedConfig {
946
+ servers: McpServerConfig[];
947
+ issues: ParseIssue[];
948
+ }
949
+ /**
950
+ * 解析 mcp.json 的文本内容。
951
+ *
952
+ * 一个 server 配错不影响其他 server —— 逐个校验,坏的跳过并记 issue。
953
+ */
954
+ declare function parseMcpConfig(raw: string, label: string): ParsedConfig;
955
+
956
+ /**
957
+ * `McpToolSchema` → `EpochTool` 的适配。
958
+ *
959
+ * 单独一个文件是为了让「从缓存里重建工具」和「从 live 响应里重建工具」
960
+ * 走同一条代码路径 —— 两条路径分叉的话,缓存命中和不命中会得到不同的工具。
961
+ */
962
+
963
+ /** MCP 工具名前缀。撞名交给 ToolRegistry 处理(先注册的胜出) */
964
+ declare function prefixedName(serverName: string, toolName: string): string;
965
+ type McpCall = (toolName: string, args: Record<string, unknown>) => Promise<{
966
+ success: boolean;
967
+ output: string;
968
+ artifacts?: ToolArtifactInput[];
969
+ }>;
970
+ /**
971
+ * 把一批 MCP 工具 schema 转成 EpochTool。
972
+ *
973
+ * annotations 直接透传,**不伪造默认值** —— server 没声明就是 undefined。
974
+ * 按 MCP 规范 annotations 全是「提示」,编造一个 `readOnlyHint: false`
975
+ * 会让权限系统(方案 04)把「未声明」和「明确声明有副作用」混为一谈。
976
+ * 参考 gemini-cli packages/core/src/tools/mcp-client.ts:1365。
977
+ */
978
+ declare function toEpochTools(serverName: string, schemas: McpToolSchema[], call: McpCall): EpochTool[];
979
+
980
+ export { CALLBACK_TIMEOUT_MS, type CallbackServer, type McpAuthEntry, type McpAuthFile, type McpAuthState, type McpAuthStatus, McpAuthStore, McpClient, type McpConfigLoadResult, type McpLoginOptions, McpLoginRequiredError, type McpLoginResult, type McpOAuthConfig, McpOAuthProvider, type McpOAuthProviderOptions, type McpPromptInfo, McpRegistry, type McpRegistryOptions, type McpResourceInfo, McpSchemaCache, type McpServerConfig, type McpServerSource, type McpServerStatus, type McpToolAnnotations, type McpToolSchema, type McpTransport, REFRESH_SKEW_MS, configFingerprint, createResourceTools, getPromptText, isExpired, listPrompts, listResources, loginToMcpServer, logoutFromMcpServer, mcpAuthStatus, parseMcpConfig, prefixedName, readResourceText, startCallbackServer, toEpochTools };