@epoch-agent/plugin-mcp 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -126,6 +126,12 @@ snake_case 和 camelCase 两种写法都收。**未知字段会进启动诊断**
126
126
  - `tools/list` annotations + title 透传到 `EpochTool.annotations`(权限系统消费)
127
127
  - schema 缓存:内存优先,磁盘(0600)作降级;按连接配置指纹失效
128
128
  - 看门狗式重连:断连**不阻塞**工具调用,重连在后台按 1s/2s/4s 退避
129
+ - **取消得掉**:`ToolContext.signal` 一路递到 SDK 的 `RequestOptions.signal`,
130
+ 用户按停时 server 当场收到 `notifications/cancelled`(见下面那一节)
131
+ - **`timeout` 对每一条请求都算数**:`tools/call` / `tools/list` / `resources/*` /
132
+ `prompts/*` 用的是同一个 `McpClient.requestOptions()`。
133
+ ⚠️ `resources/list` 这种翻页的是**逐页**给 —— SDK 的超时按请求算,只给第一页的话
134
+ 第二页起又落回它那个 60 秒默认值
129
135
  - keepalive 心跳、idle 超时断开、最大生命周期
130
136
  - **OAuth**(`oauth/`):`epoch mcp login` / `logout` 的后端,本机回调 server +
131
137
  token 刷新(带 skew)+ 0600 存储。认证过不去时置 `needsLogin`——重连解决不了
@@ -149,6 +155,28 @@ method-not-found **当空**处理——有些 server 声明了 capability 却没
149
155
  resources 包成工具而不是注入上下文:一个 server 挂几十份文档是常态,全量注入等于
150
156
  每一轮都为「模型多半用不到的东西」付 token;而工具是按需调用的,天然懒加载。
151
157
 
158
+ ## ⚠️ 写 MCP server 的人请处理 `notifications/cancelled`
159
+
160
+ 一次 `tools/call` 有两种结束不掉的方式,**两种我们都会发那条通知**:
161
+
162
+ - 用户按停(`ToolContext.signal` → SDK 的 `RequestOptions.signal`)
163
+ - `timeout` 到了(上面那张表里的 `timeout` 秒)
164
+
165
+ 通知的形状是 JSON-RPC 规范里那一条:
166
+
167
+ ```jsonc
168
+ { "method": "notifications/cancelled", "params": { "requestId": 2, "reason": "…" } }
169
+ ```
170
+
171
+ **不认它的 server 会继续把活干完**,而客户端这侧已经没人在等了。实测过一趟真跑的
172
+ 代价:4 次 `invoke` → 后端 4 条「收流结束」一条不少,两份跑完的完整报告被扔掉,
173
+ 外加一次白烧的后端算力。用 SDK 写的 server 自动就有这个行为;**手写 JSON-RPC 的
174
+ 要自己接**(按 `requestId` 记一张在飞表,收到通知就中断那次活,且**取消之后
175
+ result 和 error 都不发**)。
176
+
177
+ ⚠️ 这件事**不因为 `timeout` 配得多长而消失**:只要还有超时,「客户端放弃了而
178
+ server 还在跑」这个窗口就一直在。
179
+
152
180
  ## 还没做
153
181
 
154
182
  `notifications/tools/list_changed` 收得到,但**不是热更新**:`AgentLoop` 的工具集是
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { ParseIssue } from '@epoch-agent/infra';
2
- import { ToolAnnotations, ToolArtifactInput, EpochTool } from '@epoch-agent/protocol';
2
+ import { ToolAnnotations, ToolContext, ToolArtifactInput, EpochTool } from '@epoch-agent/protocol';
3
3
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4
4
  import { OAuthClientInformationFull, OAuthTokens, OAuthClientMetadata, OAuthClientInformationMixed } from '@modelcontextprotocol/sdk/shared/auth.js';
5
+ import { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
5
6
  import { OAuthClientProvider, OAuthDiscoveryState } from '@modelcontextprotocol/sdk/client/auth.js';
6
7
 
7
8
  /**
@@ -390,6 +391,122 @@ declare class McpAuthStore {
390
391
  */
391
392
  declare function isExpired(entry: McpAuthEntry | undefined, now?: number): boolean;
392
393
 
394
+ /**
395
+ * MCP 的另两个原语 —— resources 与 prompts。
396
+ *
397
+ * 之前只做了 tools 一个。这里只放**读取**逻辑,怎么暴露给模型 / 用户分别在
398
+ * `resource-tools.ts` 和 registry 的 `listPrompts()`。
399
+ *
400
+ * 三个共同点,都是从 gemini `packages/core/src/tools/mcp-client.ts` 的
401
+ * `listResources` 抄来的做法:
402
+ *
403
+ * 1. **先看 capability**。server 没声明 `resources` 就别问了 —— 问了多半得到
404
+ * 一个 `-32601 Method not found`,白跑一次往返还污染错误日志。
405
+ * 2. **翻页**。`resources/list` 和 `prompts/list` 都有 `nextCursor`,
406
+ * 不翻页就是「只有前 N 个」,而 N 由 server 决定,用户无从察觉。
407
+ * 3. **method-not-found 当空**。有些 server 声明了 capability 却没实现,
408
+ * 这种情况下「没有 resources」比「MCP 初始化失败」更贴近事实。
409
+ *
410
+ * ## 两个原语的**去处不一样**,这是刻意的
411
+ *
412
+ * - **resources → 包成工具**(`resource-tools.ts`:每个 server 一对
413
+ * `mcp__<server>__list_resources` / `read_resource`)。不走「上下文注入」是因为
414
+ * 那条路只有两种走法,都不成立:全量塞(几个 server 就能把上下文吃光),
415
+ * 或者先猜模型需要哪些资源 —— 而「按需取」正是工具存在的意义。
416
+ * - **prompts → 不进工具表**,只有 registry 的 `listPrompts()` / `getPrompt()`,
417
+ * **模型看不见**。判据在 MCP 规范里:**prompts 是 user-controlled、tools 是
418
+ * model-controlled**。混进工具表等于让模型自己决定往自己的上下文里塞什么 ——
419
+ * 那不是「多一个工具」,是把一个由用户把关的入口改成由模型把关。
420
+ * gemini 也把它放在用户侧(slash command);我们的 TUI 入口还没做。
421
+ */
422
+
423
+ /**
424
+ * ⚠️ **这四个函数的 `options` 一个都不能省**,`McpClient.requestOptions()` 是它唯一的
425
+ * 正当来源(2026-09-05)。
426
+ *
427
+ * 这个文件拿的是**裸 `Client`**,够不着 `McpServerConfig.timeout` —— 所以在补上这一格
428
+ * 之前,`resources/*` 和 `prompts/*` 全都吃 SDK 的 `DEFAULT_REQUEST_TIMEOUT_MSEC = 60000`,
429
+ * 而且没有取消入口。那正是 `callTool` 上刚修掉的那个 bug 的**同一份**,只是没人往
430
+ * 这条路上看:用户在 `mcp.json` 里写的 `timeout: 600` 对 `mcp__x__read_resource` 一直
431
+ * 是不生效的,按停也停不掉它。
432
+ *
433
+ * 收 `RequestOptions` 而不是收一个 `McpClient`:这一层是**纯读取**,不持有连接状态
434
+ * (文件头第一段),让它认识那个状态机等于把两件事又焊回去。
435
+ */
436
+ type McpRequestOptions = RequestOptions;
437
+ interface McpResourceInfo {
438
+ uri: string;
439
+ name?: string;
440
+ description?: string;
441
+ mimeType?: string;
442
+ }
443
+ interface McpPromptInfo {
444
+ name: string;
445
+ description?: string;
446
+ arguments?: Array<{
447
+ name: string;
448
+ description?: string;
449
+ required?: boolean;
450
+ }>;
451
+ }
452
+ declare function listResources(client: Client, options?: McpRequestOptions): Promise<McpResourceInfo[]>;
453
+ declare function listPrompts(client: Client, options?: McpRequestOptions): Promise<McpPromptInfo[]>;
454
+ /**
455
+ * 读一个 resource,压成纯文本。
456
+ *
457
+ * 二进制内容(`blob`)只留一行占位说明,不做 base64 透传 —— 那会把几 MB 的
458
+ * 图片塞进上下文,直接把方案 02 的成本闸门顶爆,而模型也读不懂那串字符。
459
+ * gemini 的 `read-mcp-resource.ts:145` 是同样的处理。
460
+ */
461
+ declare function readResourceText(client: Client, uri: string, options?: McpRequestOptions): Promise<string>;
462
+ /**
463
+ * 取一个 prompt 的消息内容,压成纯文本(给 CLI 展示 / 注入用)。
464
+ *
465
+ * 非文本那几支走 `convertMcpContent` 的同一套判别 —— 改造前这里是
466
+ * `JSON.stringify(content)`,和 tool result 那条路犯的是同一个错:
467
+ * prompt 里夹一张图,整张 base64 就进了展示文本和注入内容。
468
+ * 这里**不产生 artifact**:prompt 是给人看 / 拼进 system 的文本通道,
469
+ * 二进制在这条路上没有去处,只留一行占位。
470
+ */
471
+ declare function getPromptText(client: Client, name: string, args?: Record<string, string>, options?: McpRequestOptions): Promise<string>;
472
+
473
+ /**
474
+ * `McpToolSchema` → `EpochTool` 的适配。
475
+ *
476
+ * 单独一个文件是为了让「从缓存里重建工具」和「从 live 响应里重建工具」
477
+ * 走同一条代码路径 —— 两条路径分叉的话,缓存命中和不命中会得到不同的工具。
478
+ */
479
+
480
+ /** MCP 工具名前缀。撞名交给 ToolRegistry 处理(先注册的胜出) */
481
+ declare function prefixedName(serverName: string, toolName: string): string;
482
+ /**
483
+ * 取消信号 —— `ToolContext.signal` 那一格,原样透传。
484
+ *
485
+ * 契约上它只保证有 `aborted` 一格(`protocol/src/tool.ts`),**不是** `AbortSignal`。
486
+ * 真要把它交给 SDK 得先过一道判别,判据全文在 `client.ts` 的 `sdkSignal()`。
487
+ */
488
+ type CancelSignal = NonNullable<ToolContext['signal']>;
489
+ type McpCall = (toolName: string, args: Record<string, unknown>,
490
+ /**
491
+ * 这一次调用的取消信号。**必须一路递到 `client.callTool` 的 `RequestOptions`** ——
492
+ * 断在任何一跳的表现都一样:用户按了停,UI 立刻回到可输入状态,而那次 MCP 调用
493
+ * 连同它背后的后端任务还在跑。判据在 {@link toEpochTool} 的 `execute` 上。
494
+ */
495
+ signal?: CancelSignal) => Promise<{
496
+ success: boolean;
497
+ output: string;
498
+ artifacts?: ToolArtifactInput[];
499
+ }>;
500
+ /**
501
+ * 把一批 MCP 工具 schema 转成 EpochTool。
502
+ *
503
+ * annotations 直接透传,**不伪造默认值** —— server 没声明就是 undefined。
504
+ * 按 MCP 规范 annotations 全是「提示」,编造一个 `readOnlyHint: false`
505
+ * 会让权限系统(方案 04)把「未声明」和「明确声明有副作用」混为一谈。
506
+ * 参考 gemini-cli packages/core/src/tools/mcp-client.ts:1365。
507
+ */
508
+ declare function toEpochTools(serverName: string, schemas: McpToolSchema[], call: McpCall): EpochTool[];
509
+
393
510
  /**
394
511
  * MCP 客户端 —— 连接状态机:握手、重连、保活 / 生存期 / 空闲计时器、工具调用。
395
512
  *
@@ -464,8 +581,34 @@ declare class McpClient {
464
581
  * **断线不在这里等重连**:以前这里 `await this.reconnect()`,而 reconnect 是
465
582
  * 1s + 2s + 4s 的 backoff 循环 —— 工具执行路径上最多阻塞 7 秒,用户看到的是
466
583
  * agent 卡住。现在改成排一次后台重连、立刻返回一条可重试的错误。
584
+ *
585
+ * ## ⚠️ `timeout` 必须走 SDK 的第三个参数,外面那层 `withTimeout` 不算数
586
+ *
587
+ * 这里原来只传了 `callTool` 的第一个参数,于是吃 SDK 的
588
+ * `DEFAULT_REQUEST_TIMEOUT_MSEC = 60000`(`shared/protocol.js`:
589
+ * `const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC`)——
590
+ * 外层那个 `withTimeout(config.timeout)` 永远轮不到,是**死代码**。
591
+ * 表现是 `McpServerConfig.timeout` 配成 600 也照样在 60.0 秒断,
592
+ * 报一句 `MCP error -32001: Request timed out`,而 server 那侧还在把活干完。
593
+ * 实测过一次 93 秒的正常 SSE 调用被这么判成失败、模型原样重试、
594
+ * 两份跑完的报告被扔掉。
595
+ *
596
+ * 同一个文件里 `connect()` **是给了的**(见 {@link connectInner} 里那段注释),
597
+ * 所以这不是「不知道有这个参数」,是漏在了 `callTool` / `listTools` 这两处。
598
+ *
599
+ * ## ⚠️ `signal` 和 `timeout` 是同一个 `RequestOptions` 的两格,缺哪一格都有后果
600
+ *
601
+ * 少了 `timeout`:上面那一段。少了 `signal`:**用户按停停不掉** —— 能结束一次
602
+ * 在飞调用的只剩 SDK 那个超时,UI 立刻回到可输入状态而后端还在跑。
603
+ * 而且两者相互放大:上限从 60 秒抬到 300 秒之后,「按了停但停不掉」的窗口
604
+ * 也跟着从 60 秒变成 300 秒。所以这两格是一起补的。
467
605
  */
468
- callTool(name: string, args: Record<string, unknown>): Promise<McpToolCallResult>;
606
+ callTool(name: string, args: Record<string, unknown>,
607
+ /**
608
+ * 这一次调用的取消信号,来自 `ToolContext.signal`(`toEpochTool` 那一跳递下来)。
609
+ * 不给就是没有取消入口 —— 与补这一格之前逐字相同。
610
+ */
611
+ signal?: CancelSignal): Promise<McpToolCallResult>;
469
612
  disconnect(): Promise<void>;
470
613
  /**
471
614
  * 建连。**握手成功之前不写 this.client**。
@@ -519,6 +662,17 @@ declare class McpClient {
519
662
  */
520
663
  private scheduleReconnect;
521
664
  private attemptReconnect;
665
+ /**
666
+ * 用户按停之后回给模型的那句话。
667
+ *
668
+ * ⚠️ 措辞上必须说清「是**用户**取消的」,不能只说「调用失败了」:模型对这两种的
669
+ * 处置正好相反 —— 失败它会重试,而重试一次用户刚刚亲手停掉的调用是这条路上
670
+ * 最贵的错。第 1 条那个 bug 的现场里模型就是这么原样重试的。
671
+ *
672
+ * 不带 `lastError`(`unavailableMessage()` 带):那一格是「这台 server 怎么了」,
673
+ * 而这里 server 什么事都没有。
674
+ */
675
+ private cancelledMessage;
522
676
  private unavailableMessage;
523
677
  private startKeepalive;
524
678
  private pingOnce;
@@ -534,6 +688,41 @@ declare class McpClient {
534
688
  private resetIdle;
535
689
  private armDisconnect;
536
690
  private clearTimers;
691
+ /**
692
+ * 一次请求等多久(毫秒)—— `tools/call` 和 `tools/list` 共用这一个答案。
693
+ *
694
+ * 单独一个方法是为了**只有一处**在做「秒 → 毫秒」和「缺省值兜底」这两件事:
695
+ * 两处各算一遍的下场就是修完 `callTool` 之后 `listTools` 还在漏
696
+ * (那正是这个 bug 原来的形状)。
697
+ *
698
+ * ⚠️ `DEFAULTS.timeout = 300` 这个缺省值在这次改动之前是**没有意义的**
699
+ * (真实值恒为 SDK 的 60)。修完之后它才第一次生效 —— 所以这一改会**改变
700
+ * 现有行为**:今天所有跑得久的 MCP 工具都在 60 秒被切,之后变成 300 秒。
701
+ * 这是修不是回归,但它进了 changeset。
702
+ */
703
+ private requestTimeoutMs;
704
+ /**
705
+ * 一次请求交给 SDK 的 `RequestOptions` —— **timeout + signal 那两格的唯一来源**。
706
+ *
707
+ * ## 为什么是公开的
708
+ *
709
+ * `resources/*` 和 `prompts/*` 走的是 [primitives.ts](./primitives.ts),那一层拿的是
710
+ * **裸 `Client`**、够不着 `this.config`。在补上这个方法之前它们因此**两格都没有**:
711
+ * 用户在 `mcp.json` 里写的 `timeout: 600` 对 `mcp__x__read_resource` 一直不生效
712
+ * (吃 SDK 的 60 秒),按停也停不掉它 —— 和 `callTool` 上刚修掉的是同一个 bug,
713
+ * 只是没人往那条路上看。
714
+ *
715
+ * 让它们**问这个方法**而不是各自去拼一份:两处各拼一份的下场,就是这个 bug 第一次
716
+ * 出现的方式(`connect()` 拼对了、`callTool` 漏了,两处谁也不知道对方存在)。
717
+ *
718
+ * ## ⚠️ 进度那三格不在这里
719
+ *
720
+ * `onprogress` / `resetTimeoutOnProgress` / `maxTotalTimeout` 只有 {@link callTool}
721
+ * 加,刻意的:那三格是给 SSE / 长任务类**工具**的,而 `resources/read` 和
722
+ * `prompts/get` 是一问一答,给它们塞一个 `progressToken` 是纯粹多出来的协议面
723
+ * (每次请求的 `params._meta` 多一格),换不回任何东西。
724
+ */
725
+ requestOptions(signal?: CancelSignal): McpRequestOptions;
537
726
  private withTimeout;
538
727
  }
539
728
 
@@ -711,71 +900,6 @@ declare class McpOAuthProvider implements OAuthClientProvider {
711
900
  private entry;
712
901
  }
713
902
 
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
903
  /**
780
904
  * MCP 注册中心 — 多 server 管理 [Hermes mcp_tool.py]。
781
905
  */
@@ -926,10 +1050,19 @@ declare class McpRegistry {
926
1050
  * server 没声明 resources capability 时返回空数组 —— 给模型一个调了必然
927
1051
  * 报错的工具,只会诱导它反复尝试。
928
1052
  *
929
- * @param getClient 取当前连接。**不能捕获 Client 实例**:重连后 client 会换一个,
930
- * 闭包里握着旧的就会一直往断掉的连接上打。
1053
+ * @param owner 这台 server {@link McpClient}。
1054
+ *
1055
+ * ⚠️ **收 `McpClient` 而不是 `() => Client | null`**(2026-09-05)。原来那个取值器
1056
+ * 守的是一条真判据 —— 「**不能捕获 `Client` 实例**:重连后 client 会换一个,闭包里
1057
+ * 握着旧的就会一直往断掉的连接上打」—— 而 `McpClient` 把那条守得**更**牢:
1058
+ * 它本身跨重连是同一个对象,`client.raw` 每次现读。
1059
+ *
1060
+ * 换过来是因为光有连接不够:这两个工具还要 `timeout` 和 `signal`,而那两格的真源是
1061
+ * `McpServerConfig`,只有 `McpClient` 够得着(判据全文在 `McpClient.requestOptions()`)。
1062
+ * 拿裸 `Client` 的那个版本因此**两格都没有** —— 用户写的 `timeout: 600` 对
1063
+ * `read_resource` 不生效(吃 SDK 的 60 秒),按停也停不掉它。
931
1064
  */
932
- declare function createResourceTools(serverName: string, getClient: () => Client | null): EpochTool[];
1065
+ declare function createResourceTools(serverName: string, owner: McpClient): EpochTool[];
933
1066
 
934
1067
  /**
935
1068
  * mcp.json 的校验与归一化。
@@ -953,28 +1086,4 @@ interface ParsedConfig {
953
1086
  */
954
1087
  declare function parseMcpConfig(raw: string, label: string): ParsedConfig;
955
1088
 
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 };
1089
+ 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 McpRequestOptions, 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 };
package/dist/index.js CHANGED
@@ -81,7 +81,10 @@ function isDiskShape(value) {
81
81
  }
82
82
  // src/client.ts
83
83
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
84
- import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
84
+ import {
85
+ CallToolResultSchema,
86
+ ToolListChangedNotificationSchema
87
+ } from "@modelcontextprotocol/sdk/types.js";
85
88
  // src/content.ts
86
89
  var DEFAULT_MEDIA_TYPE = "application/octet-stream";
87
90
  function str(v) {
@@ -491,6 +494,12 @@ var DEFAULTS = {
491
494
  // src/client.ts
492
495
  var MAX_RECONNECT = 3;
493
496
  var RECONNECT_BACKOFF = [1e3, 2e3, 4e3];
497
+ var OUTER_TIMEOUT_SLACK_MS = 5e3;
498
+ var KEEPALIVE_TIMEOUT_MS = 1e4;
499
+ function sdkSignal(signal) {
500
+ const maybe = signal;
501
+ return typeof maybe?.addEventListener === "function" ? signal : void 0;
502
+ }
494
503
  var STDERR_TAIL_LINES = 20;
495
504
  var McpClient = class {
496
505
  config;
@@ -568,9 +577,10 @@ var McpClient = class {
568
577
  if (!this.client) return null;
569
578
  this.resetIdle();
570
579
  try {
580
+ const timeoutMs = this.requestTimeoutMs();
571
581
  const result = await this.withTimeout(
572
- this.client.listTools(),
573
- (this.config.timeout ?? DEFAULTS.timeout) * 1e3
582
+ this.client.listTools(void 0, { timeout: timeoutMs }),
583
+ timeoutMs + OUTER_TIMEOUT_SLACK_MS
574
584
  );
575
585
  return (result.tools ?? []).map((t10) => {
576
586
  const tool = t10;
@@ -587,16 +597,26 @@ var McpClient = class {
587
597
  return null;
588
598
  }
589
599
  }
590
- async callTool(name, args) {
600
+ async callTool(name, args, signal) {
591
601
  if (!this.client) {
592
602
  this.scheduleReconnect();
593
603
  return { success: false, output: this.unavailableMessage() };
594
604
  }
605
+ if (signal?.aborted) {
606
+ return { success: false, output: this.cancelledMessage(name) };
607
+ }
595
608
  this.resetIdle();
596
609
  try {
610
+ const timeoutMs = this.requestTimeoutMs();
597
611
  const result = await this.withTimeout(
598
- this.client.callTool({ name, arguments: args }),
599
- (this.config.timeout ?? DEFAULTS.timeout) * 1e3
612
+ this.client.callTool({ name, arguments: args }, CallToolResultSchema, {
613
+ ...this.requestOptions(signal),
614
+ onprogress: () => {
615
+ },
616
+ resetTimeoutOnProgress: true,
617
+ maxTotalTimeout: timeoutMs
618
+ }),
619
+ timeoutMs + OUTER_TIMEOUT_SLACK_MS
600
620
  );
601
621
  const { text, artifacts } = convertMcpContent(result.content);
602
622
  return {
@@ -605,6 +625,9 @@ var McpClient = class {
605
625
  ...artifacts.length > 0 ? { artifacts } : {}
606
626
  };
607
627
  } catch (err) {
628
+ if (signal?.aborted) {
629
+ return { success: false, output: this.cancelledMessage(name) };
630
+ }
608
631
  const authHint = describeAuthFailure(err, this.config.name);
609
632
  if (authHint) {
610
633
  this.needsLogin = true;
@@ -713,6 +736,9 @@ var McpClient = class {
713
736
  this.startLifetime();
714
737
  }
715
738
  }
739
+ cancelledMessage(toolName) {
740
+ return `\u5DE5\u5177 ${toolName} \u7684\u8C03\u7528\u5DF2\u88AB\u7528\u6237\u53D6\u6D88\uFF08\u4E0D\u662F\u5931\u8D25\uFF09\u3002\u4E0D\u8981\u91CD\u8BD5\uFF0C\u7B49\u7528\u6237\u7684\u4E0B\u4E00\u53E5\u8BDD\u3002`;
741
+ }
716
742
  unavailableMessage() {
717
743
  if (this.needsLogin) {
718
744
  return this.lastError ?? `MCP server ${this.config.name} \u9700\u8981 OAuth \u767B\u5F55`;
@@ -733,7 +759,10 @@ var McpClient = class {
733
759
  async pingOnce() {
734
760
  if (!this.client) return;
735
761
  try {
736
- await this.withTimeout(this.client.listTools(), 1e4);
762
+ await this.withTimeout(
763
+ this.client.listTools(void 0, { timeout: KEEPALIVE_TIMEOUT_MS }),
764
+ KEEPALIVE_TIMEOUT_MS
765
+ );
737
766
  } catch {
738
767
  this.lastError = "keepalive \u5931\u8D25";
739
768
  this.client = null;
@@ -779,6 +808,16 @@ var McpClient = class {
779
808
  this.lifetimeTimer = null;
780
809
  this.reconnectTimer = null;
781
810
  }
811
+ requestTimeoutMs() {
812
+ return (this.config.timeout ?? DEFAULTS.timeout) * 1e3;
813
+ }
814
+ requestOptions(signal) {
815
+ const forSdk = sdkSignal(signal);
816
+ return {
817
+ timeout: this.requestTimeoutMs(),
818
+ ...forSdk ? { signal: forSdk } : {}
819
+ };
820
+ }
782
821
  async withTimeout(promise, ms) {
783
822
  let timer;
784
823
  const timeout = new Promise((_, reject) => {
@@ -1031,22 +1070,22 @@ async function openInBrowser(url) {
1031
1070
  // src/primitives.ts
1032
1071
  var METHOD_NOT_FOUND = -32601;
1033
1072
  var MAX_PAGES = 50;
1034
- async function listResources(client) {
1073
+ async function listResources(client, options) {
1035
1074
  if (!client.getServerCapabilities()?.resources) return [];
1036
1075
  return paginate(async (cursor) => {
1037
- const res = await client.listResources(cursor ? { cursor } : {});
1076
+ const res = await client.listResources(cursor ? { cursor } : {}, options);
1038
1077
  return { items: res.resources ?? [], nextCursor: res.nextCursor };
1039
1078
  });
1040
1079
  }
1041
- async function listPrompts(client) {
1080
+ async function listPrompts(client, options) {
1042
1081
  if (!client.getServerCapabilities()?.prompts) return [];
1043
1082
  return paginate(async (cursor) => {
1044
- const res = await client.listPrompts(cursor ? { cursor } : {});
1083
+ const res = await client.listPrompts(cursor ? { cursor } : {}, options);
1045
1084
  return { items: res.prompts ?? [], nextCursor: res.nextCursor };
1046
1085
  });
1047
1086
  }
1048
- async function readResourceText(client, uri) {
1049
- const result = await client.readResource({ uri });
1087
+ async function readResourceText(client, uri, options) {
1088
+ const result = await client.readResource({ uri }, options);
1050
1089
  const parts = [];
1051
1090
  for (const content of result.contents ?? []) {
1052
1091
  if ("text" in content && typeof content.text === "string") {
@@ -1058,8 +1097,8 @@ async function readResourceText(client, uri) {
1058
1097
  }
1059
1098
  return parts.join("\n");
1060
1099
  }
1061
- async function getPromptText(client, name, args = {}) {
1062
- const result = await client.getPrompt({ name, arguments: args });
1100
+ async function getPromptText(client, name, args = {}, options) {
1101
+ const result = await client.getPrompt({ name, arguments: args }, options);
1063
1102
  return (result.messages ?? []).map((msg) => {
1064
1103
  const { text, artifacts } = convertMcpContent([msg.content]);
1065
1104
  const placeholders = artifacts.map(
@@ -1111,9 +1150,9 @@ function toEpochTool(serverName, schema, call) {
1111
1150
  properties: {}
1112
1151
  },
1113
1152
  annotations: schema.annotations ? stripTitle(schema.annotations) : void 0,
1114
- execute: async (args) => {
1153
+ execute: async (args, ctx) => {
1115
1154
  const startedAt = performance.now();
1116
- const result = await call(schema.name, args);
1155
+ const result = await call(schema.name, args, ctx.signal);
1117
1156
  return {
1118
1157
  success: result.success,
1119
1158
  output: result.output,
@@ -1132,19 +1171,18 @@ function stripTitle(annotations) {
1132
1171
  }
1133
1172
  // src/resource-tools.ts
1134
1173
  var MAX_LISTED = 200;
1135
- function createResourceTools(serverName, getClient) {
1136
- const client = getClient();
1137
- if (!client?.getServerCapabilities()?.resources) return [];
1174
+ function createResourceTools(serverName, owner) {
1175
+ if (!owner.raw?.getServerCapabilities()?.resources) return [];
1138
1176
  return [
1139
1177
  {
1140
1178
  name: prefixedName(serverName, "list_resources"),
1141
1179
  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`,
1142
1180
  parameters: { type: "object", properties: {} },
1143
1181
  annotations: { readOnlyHint: true, openWorldHint: true },
1144
- execute: async () => {
1182
+ execute: async (_args, ctx) => {
1145
1183
  return run(async () => {
1146
- const current = requireClient(getClient, serverName);
1147
- const resources = await listResources(current);
1184
+ const current = requireClient(owner, serverName);
1185
+ const resources = await listResources(current, owner.requestOptions(ctx.signal));
1148
1186
  if (resources.length === 0) return `${serverName} \u6CA1\u6709\u63D0\u4F9B\u4EFB\u4F55\u8D44\u6E90`;
1149
1187
  const shown = resources.slice(0, MAX_LISTED);
1150
1188
  const lines = shown.map(
@@ -1169,20 +1207,20 @@ function createResourceTools(serverName, getClient) {
1169
1207
  required: ["uri"]
1170
1208
  },
1171
1209
  annotations: { readOnlyHint: true, openWorldHint: true },
1172
- execute: async (args) => {
1210
+ execute: async (args, ctx) => {
1173
1211
  return run(async () => {
1174
1212
  const uri = args["uri"];
1175
1213
  if (typeof uri !== "string" || uri.length === 0) throw new Error("\u7F3A\u5C11 uri \u53C2\u6570");
1176
- const current = requireClient(getClient, serverName);
1177
- const text = await readResourceText(current, uri);
1214
+ const current = requireClient(owner, serverName);
1215
+ const text = await readResourceText(current, uri, owner.requestOptions(ctx.signal));
1178
1216
  return text.length > 0 ? text : `\u8D44\u6E90 ${uri} \u6CA1\u6709\u53EF\u8BFB\u7684\u6587\u672C\u5185\u5BB9`;
1179
1217
  });
1180
1218
  }
1181
1219
  }
1182
1220
  ];
1183
1221
  }
1184
- function requireClient(getClient, serverName) {
1185
- const client = getClient();
1222
+ function requireClient(owner, serverName) {
1223
+ const client = owner.raw;
1186
1224
  if (!client) throw new Error(`MCP server ${serverName} \u5F53\u524D\u672A\u8FDE\u63A5`);
1187
1225
  return client;
1188
1226
  }
@@ -1385,8 +1423,10 @@ var McpRegistry = class {
1385
1423
  const allTools = [];
1386
1424
  for (const [name, client] of this.clients) {
1387
1425
  const schemas = await client.getToolSchemas();
1388
- allTools.push(...toEpochTools(name, schemas, (tool, args) => client.callTool(tool, args)));
1389
- allTools.push(...createResourceTools(name, () => client.raw));
1426
+ allTools.push(
1427
+ ...toEpochTools(name, schemas, (tool, args, signal) => client.callTool(tool, args, signal))
1428
+ );
1429
+ allTools.push(...createResourceTools(name, client));
1390
1430
  }
1391
1431
  return allTools;
1392
1432
  }
@@ -1396,12 +1436,12 @@ var McpRegistry = class {
1396
1436
  }
1397
1437
  async listPrompts(name) {
1398
1438
  const client = this.requireClient(name);
1399
- return client.raw ? listPrompts(client.raw) : [];
1439
+ return client.raw ? listPrompts(client.raw, client.requestOptions()) : [];
1400
1440
  }
1401
1441
  async getPrompt(name, promptName, args = {}) {
1402
1442
  const client = this.requireClient(name);
1403
1443
  if (!client.raw) throw new Error(`MCP server "${name}" \u5F53\u524D\u672A\u8FDE\u63A5`);
1404
- return getPromptText(client.raw, promptName, args);
1444
+ return getPromptText(client.raw, promptName, args, client.requestOptions());
1405
1445
  }
1406
1446
  getAuthStatus() {
1407
1447
  return [...this.clients.values()].map((c) => mcpAuthStatus(c.serverConfig, this.authStore));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epoch-agent/plugin-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "epoch-agent MCP 客户端插件",
6
6
  "repository": {
@@ -23,8 +23,8 @@
23
23
  "dependencies": {
24
24
  "@modelcontextprotocol/sdk": "^1.30.0",
25
25
  "zod": "^4.4.3",
26
- "@epoch-agent/infra": "0.4.0",
27
- "@epoch-agent/protocol": "0.4.0"
26
+ "@epoch-agent/protocol": "0.5.0",
27
+ "@epoch-agent/infra": "0.5.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "^26.1.2",