@miphamai/cli 0.52.0 → 0.54.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.
@@ -4,6 +4,7 @@ import type { FeishuConfig, FeishuTextMessage } from './types.js'
4
4
  import type { SessionManager } from '../session-manager'
5
5
  import type { SessionWorker } from '../session-worker'
6
6
  import type { RateLimiter } from '../rate-limiter'
7
+ import { handleChannelMessage } from '../channel-message.js'
7
8
 
8
9
  export interface FeishuAdapterDeps {
9
10
  sm: SessionManager
@@ -24,33 +25,21 @@ export function createFeishuAdapter(config: FeishuConfig, deps: FeishuAdapterDep
24
25
  const allowed = new Set(config.allowedOpenIds)
25
26
 
26
27
  const onMessage = async (msg: FeishuTextMessage) => {
27
- try {
28
- if (!allowed.has(msg.openId)) return
29
- if (!deps.rateLimiter.check(`feishu:${msg.openId}`).allowed) return
30
-
31
- const session = deps.sm.getOrCreateByExternalUser(
32
- 'feishu',
33
- msg.openId,
34
- deps.cwd,
35
- deps.provider,
36
- deps.model,
37
- )
38
- const worker = deps.getOrCreateWorker(session.id)
39
- if (!worker) {
40
- await api.sendText(msg.openId, '(会话初始化失败,请稍后重试)')
41
- return
42
- }
43
- await worker.processPrompt(msg.text)
44
- const result = worker.getLastAssistantContent()
45
- await api.sendText(msg.openId, result ? result.slice(0, 4000) : '(无回复)')
46
- } catch (err) {
47
- console.error('[feishu] message handling failed:', err)
48
- try {
49
- await api.sendText(msg.openId, '(处理失败,请稍后重试)')
50
- } catch {
51
- /* 忽略回送失败,确保不 rethrow → Feishu 不重试 */
52
- }
53
- }
28
+ await handleChannelMessage({
29
+ channel: 'feishu',
30
+ externalId: msg.openId,
31
+ text: msg.text,
32
+ allowed,
33
+ rateLimiter: deps.rateLimiter,
34
+ sm: deps.sm,
35
+ getOrCreateWorker: deps.getOrCreateWorker,
36
+ cwd: deps.cwd,
37
+ provider: deps.provider,
38
+ model: deps.model,
39
+ sendText: (id, t) => api.sendText(id, t),
40
+ maxLen: 4000,
41
+ logPrefix: '[feishu]',
42
+ })
54
43
  }
55
44
 
56
45
  const dispatcher = createFeishuEventDispatcher(config, onMessage)
@@ -27,6 +27,10 @@ import { parseFeishuEnv } from './feishu/env.js'
27
27
  import type { FeishuConfig } from './feishu/types.js'
28
28
  import { parseTelegramEnv } from './telegram/env.js'
29
29
  import type { TelegramConfig } from './telegram/types.js'
30
+ import { parseWecomEnv } from './wecom/env.js'
31
+ import type { WecomConfig } from './wecom/types.js'
32
+ import { parseDingtalkEnv } from './dingtalk/env.js'
33
+ import type { DingtalkConfig } from './dingtalk/types.js'
30
34
  import { loadConfig } from '../config/loader'
31
35
 
32
36
  const HOME = homedir()
@@ -194,6 +198,34 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
194
198
  }
195
199
  }
196
200
 
201
+ // 企业微信 remote-control adapter(env 未配置时跳过)
202
+ const wecomConfig = parseWecomEnv()
203
+ let wecom: { config: WecomConfig; cwd: string; provider: string; model: string } | undefined
204
+ if (wecomConfig) {
205
+ const cfg = loadConfig()
206
+ const provider = cfg.providers.find((p) => p.status !== 'upcoming') ?? cfg.providers[0]
207
+ wecom = {
208
+ config: wecomConfig,
209
+ cwd: process.env.WECOM_CWD || process.cwd(),
210
+ provider: provider?.id ?? 'anthropic',
211
+ model: provider?.models?.[0]?.id ?? 'claude-sonnet-5',
212
+ }
213
+ }
214
+
215
+ // 钉钉 remote-control adapter(Stream 长连接,env 未配置时跳过)
216
+ const dingtalkConfig = parseDingtalkEnv()
217
+ let dingtalk: { config: DingtalkConfig; cwd: string; provider: string; model: string } | undefined
218
+ if (dingtalkConfig) {
219
+ const cfg = loadConfig()
220
+ const provider = cfg.providers.find((p) => p.status !== 'upcoming') ?? cfg.providers[0]
221
+ dingtalk = {
222
+ config: dingtalkConfig,
223
+ cwd: process.env.DINGTALK_CWD || process.cwd(),
224
+ provider: provider?.id ?? 'anthropic',
225
+ model: provider?.models?.[0]?.id ?? 'claude-sonnet-5',
226
+ }
227
+ }
228
+
197
229
  // Start HTTP server (Bun.serve starts listening immediately)
198
230
  const server = createServer({
199
231
  db,
@@ -210,6 +242,8 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
210
242
  rateLimiter,
211
243
  feishu,
212
244
  telegram,
245
+ wecom,
246
+ dingtalk,
213
247
  })
214
248
  activeServer = server
215
249
 
@@ -29,6 +29,12 @@ import type { FeishuConfig } from './feishu/types.js'
29
29
  import { createTelegramAdapter } from './telegram/adapter.js'
30
30
  import { createTelegramApi } from './telegram/api.js'
31
31
  import type { TelegramConfig } from './telegram/types.js'
32
+ import { createWecomAdapter } from './wecom/adapter.js'
33
+ import { createWecomApi } from './wecom/api.js'
34
+ import type { WecomConfig } from './wecom/types.js'
35
+ import { createDingtalkAdapter } from './dingtalk/adapter.js'
36
+ import { createDingtalkApi } from './dingtalk/api.js'
37
+ import type { DingtalkConfig } from './dingtalk/types.js'
32
38
  import { startHeartbeat } from './heartbeat'
33
39
 
34
40
  interface ServerConfig {
@@ -46,6 +52,8 @@ interface ServerConfig {
46
52
  rateLimiter: RateLimiter
47
53
  feishu?: { config: FeishuConfig; cwd: string; provider: string; model: string }
48
54
  telegram?: { config: TelegramConfig; cwd: string; provider: string; model: string }
55
+ wecom?: { config: WecomConfig; cwd: string; provider: string; model: string }
56
+ dingtalk?: { config: DingtalkConfig; cwd: string; provider: string; model: string }
49
57
  }
50
58
 
51
59
  interface WsData {
@@ -124,6 +132,8 @@ export function createServer(config: ServerConfig): Server<WsData> {
124
132
  rateLimiter,
125
133
  feishu,
126
134
  telegram,
135
+ wecom,
136
+ dingtalk,
127
137
  } = config
128
138
 
129
139
  const wsClients = new Map<string, Set<ServerWebSocket<WsData>>>()
@@ -274,6 +284,32 @@ export function createServer(config: ServerConfig): Server<WsData> {
274
284
  : undefined
275
285
  telegramAdapter?.start()
276
286
 
287
+ // ── 企业微信 remote-control adapter(长连接,无需 webhook 路由)──
288
+ const wecomAdapter = wecom
289
+ ? createWecomAdapter(wecom.config, createWecomApi(wecom.config), {
290
+ sm,
291
+ getOrCreateWorker,
292
+ rateLimiter,
293
+ cwd: wecom.cwd,
294
+ provider: wecom.provider,
295
+ model: wecom.model,
296
+ })
297
+ : undefined
298
+ wecomAdapter?.start()
299
+
300
+ // ── 钉钉 remote-control adapter(Stream 长连接,无需 webhook 路由)──
301
+ const dingtalkAdapter = dingtalk
302
+ ? createDingtalkAdapter(dingtalk.config, createDingtalkApi(dingtalk.config), {
303
+ sm,
304
+ getOrCreateWorker,
305
+ rateLimiter,
306
+ cwd: dingtalk.cwd,
307
+ provider: dingtalk.provider,
308
+ model: dingtalk.model,
309
+ })
310
+ : undefined
311
+ dingtalkAdapter?.start()
312
+
277
313
  // ── 心跳式通知:定时扫 pending(goal/schedule),只通知、不自主行动 ──
278
314
  const heartbeatSource = {
279
315
  listGoals: () => sm.listSessions().flatMap((s) => goalManager.getGoals(s.id)),
@@ -3,6 +3,7 @@ import type { TelegramConfig, TelegramMessage } from './types.js'
3
3
  import type { SessionManager } from '../session-manager'
4
4
  import type { SessionWorker } from '../session-worker'
5
5
  import type { RateLimiter } from '../rate-limiter'
6
+ import { handleChannelMessage } from '../channel-message.js'
6
7
  import { startTelegramPoller } from './poller.js'
7
8
 
8
9
  export interface TelegramAdapterDeps {
@@ -29,33 +30,21 @@ export function createTelegramAdapter(
29
30
  let stopPoller: (() => void) | null = null
30
31
 
31
32
  async function handleMessage(msg: TelegramMessage): Promise<void> {
32
- try {
33
- if (!allowed.has(msg.chatId)) return
34
- if (!deps.rateLimiter.check(`telegram:${msg.chatId}`).allowed) return
35
-
36
- const session = deps.sm.getOrCreateByExternalUser(
37
- 'telegram',
38
- msg.chatId,
39
- deps.cwd,
40
- deps.provider,
41
- deps.model,
42
- )
43
- const worker = deps.getOrCreateWorker(session.id)
44
- if (!worker) {
45
- await api.sendText(msg.chatId, '(会话初始化失败,请稍后重试)')
46
- return
47
- }
48
- await worker.processPrompt(msg.text)
49
- const result = worker.getLastAssistantContent()
50
- await api.sendText(msg.chatId, result ? result.slice(0, 4096) : '(无回复)')
51
- } catch (err) {
52
- console.error('[telegram] message handling failed:', err)
53
- try {
54
- await api.sendText(msg.chatId, '(处理失败,请稍后重试)')
55
- } catch {
56
- /* 忽略回送失败,不 rethrow */
57
- }
58
- }
33
+ await handleChannelMessage({
34
+ channel: 'telegram',
35
+ externalId: msg.chatId,
36
+ text: msg.text,
37
+ allowed,
38
+ rateLimiter: deps.rateLimiter,
39
+ sm: deps.sm,
40
+ getOrCreateWorker: deps.getOrCreateWorker,
41
+ cwd: deps.cwd,
42
+ provider: deps.provider,
43
+ model: deps.model,
44
+ sendText: (id, t) => api.sendText(id, t),
45
+ maxLen: 4096,
46
+ logPrefix: '[telegram]',
47
+ })
59
48
  }
60
49
 
61
50
  return {
@@ -1,5 +1,6 @@
1
1
  import type { TelegramApi } from './api.js'
2
2
  import type { TelegramMessage } from './types.js'
3
+ import { nextBackoff } from '../backoff.js'
3
4
 
4
5
  /** 从 update 提取文本消息;非文本/缺 message → null。chat.id 统一字符串化比对(id < 2^53;超范围 bigint 后续再引入)。 */
5
6
  export function extractTextMessage(update: unknown): TelegramMessage | null {
@@ -16,11 +17,6 @@ export function nextOffset(updates: Array<{ update_id?: number }>, prevOffset: n
16
17
  return Math.max(...updates.map((u) => u.update_id ?? prevOffset)) + 1
17
18
  }
18
19
 
19
- /** 指数退避,封顶 30s。 */
20
- export function nextBackoff(currentMs: number): number {
21
- return Math.min(currentMs * 2, 30_000)
22
- }
23
-
24
20
  /** 长轮询循环(unref 不阻退出)。返回 stop 函数。循环行为由 Task 7 集成测试覆盖。 */
25
21
  export function startTelegramPoller(
26
22
  api: TelegramApi,
@@ -0,0 +1,61 @@
1
+ import type { WecomApi } from './api.js'
2
+ import type { WecomConfig, WecomMessage } from './types.js'
3
+ import type { SessionManager } from '../session-manager'
4
+ import type { SessionWorker } from '../session-worker'
5
+ import type { RateLimiter } from '../rate-limiter'
6
+ import { startWecomWs } from './ws-client.js'
7
+ import { handleChannelMessage } from '../channel-message.js'
8
+
9
+ export interface WecomAdapterDeps {
10
+ sm: SessionManager
11
+ getOrCreateWorker: (sessionId: string) => SessionWorker | null
12
+ rateLimiter: RateLimiter
13
+ cwd: string
14
+ provider: string
15
+ model: string
16
+ }
17
+
18
+ export interface WecomAdapter {
19
+ start(): () => void
20
+ handleMessage(msg: WecomMessage): Promise<void>
21
+ isAllowed(userId: string): boolean
22
+ }
23
+
24
+ export function createWecomAdapter(
25
+ config: WecomConfig,
26
+ api: WecomApi,
27
+ deps: WecomAdapterDeps,
28
+ ): WecomAdapter {
29
+ const allowed = new Set(config.allowedUserIds)
30
+ let stopWs: (() => void) | null = null
31
+
32
+ async function handleMessage(msg: WecomMessage): Promise<void> {
33
+ await handleChannelMessage({
34
+ channel: 'wecom',
35
+ externalId: msg.userId,
36
+ text: msg.text,
37
+ allowed,
38
+ rateLimiter: deps.rateLimiter,
39
+ sm: deps.sm,
40
+ getOrCreateWorker: deps.getOrCreateWorker,
41
+ cwd: deps.cwd,
42
+ provider: deps.provider,
43
+ model: deps.model,
44
+ sendText: async (userId, text) => {
45
+ api.respond(userId, text)
46
+ },
47
+ maxLen: 2048,
48
+ logPrefix: '[wecom]',
49
+ })
50
+ }
51
+
52
+ return {
53
+ handleMessage,
54
+ isAllowed: (userId) => allowed.has(userId),
55
+ start() {
56
+ if (stopWs) return stopWs
57
+ stopWs = startWecomWs(api, handleMessage)
58
+ return stopWs
59
+ },
60
+ }
61
+ }
@@ -0,0 +1,58 @@
1
+ import type { WecomConfig, WecomMessage } from './types.js'
2
+
3
+ export interface WecomApi {
4
+ open(): WebSocket
5
+ subscribe(ws: WebSocket): void
6
+ ping(ws: WebSocket): void
7
+ attach(ws: WebSocket | null): void
8
+ respond(userId: string, text: string): void
9
+ parseMessage(frame: unknown): WecomMessage | null
10
+ isDisconnected(frame: unknown): boolean
11
+ }
12
+
13
+ const WS_ENDPOINT = 'wss://openws.work.weixin.qq.com'
14
+
15
+ /** 协议帧 codec;内部持有 activeWs(由 ws-client attach)。零依赖(globalThis.WebSocket)。 */
16
+ export function createWecomApi(config: WecomConfig): WecomApi {
17
+ let activeWs: WebSocket | null = null
18
+ return {
19
+ open() {
20
+ return new WebSocket(WS_ENDPOINT)
21
+ },
22
+ subscribe(ws) {
23
+ ws.send(
24
+ JSON.stringify({
25
+ cmd: 'aibot_subscribe',
26
+ body: { bot_id: config.botId, bot_secret: config.botSecret },
27
+ }),
28
+ )
29
+ },
30
+ ping(ws) {
31
+ ws.send(JSON.stringify({ cmd: 'ping' }))
32
+ },
33
+ attach(ws) {
34
+ activeWs = ws
35
+ },
36
+ respond(userId, text) {
37
+ if (activeWs) {
38
+ activeWs.send(
39
+ JSON.stringify({ cmd: 'aibot_respond_msg', body: { userid: userId, content: text } }),
40
+ )
41
+ }
42
+ },
43
+ parseMessage(frame) {
44
+ if (!frame || typeof frame !== 'object') return null
45
+ const f = frame as {
46
+ cmd?: string
47
+ body?: { userid?: string; chatid?: string; msg_id?: string; content?: string }
48
+ }
49
+ if (f.cmd !== 'aibot_msg_callback' || !f.body) return null
50
+ const { userid, chatid, msg_id, content } = f.body
51
+ if (!userid || !content) return null
52
+ return { userId: userid, chatId: chatid ?? '', msgId: msg_id ?? '', text: content }
53
+ },
54
+ isDisconnected(frame) {
55
+ return (frame as { cmd?: string })?.cmd === 'disconnected_event'
56
+ },
57
+ }
58
+ }
@@ -0,0 +1,16 @@
1
+ import type { WecomConfig } from './types.js'
2
+
3
+ /** fail-closed:缺 botId 或 botSecret → null(daemon 不启用企微)。 */
4
+ export function parseWecomEnv(): WecomConfig | null {
5
+ const botId = process.env.WECOM_BOT_ID
6
+ const botSecret = process.env.WECOM_BOT_SECRET
7
+ if (!botId || !botSecret) return null
8
+ return {
9
+ botId,
10
+ botSecret,
11
+ allowedUserIds: (process.env.WECOM_ALLOWED_USER_IDS || '')
12
+ .split(',')
13
+ .map((s) => s.trim())
14
+ .filter(Boolean),
15
+ }
16
+ }
@@ -0,0 +1,12 @@
1
+ export interface WecomConfig {
2
+ botId: string
3
+ botSecret: string
4
+ allowedUserIds: string[] // 白名单(企微内部 userid)
5
+ }
6
+
7
+ export interface WecomMessage {
8
+ userId: string // 发消息用户(userid)
9
+ chatId: string // 会话 id(chatid)
10
+ msgId: string // 消息 id(req_id 关联回包)
11
+ text: string // 文本内容
12
+ }
@@ -0,0 +1,73 @@
1
+ import type { WecomApi } from './api.js'
2
+ import type { WecomMessage } from './types.js'
3
+ import { nextBackoff } from '../backoff.js'
4
+
5
+ /** WebSocket 长连接生命周期:建连→subscribe→心跳→消息回调→断开重连。返回 stop。 */
6
+ export function startWecomWs(
7
+ api: WecomApi,
8
+ onMessage: (msg: WecomMessage) => Promise<void>,
9
+ opts?: { heartbeatMs?: number },
10
+ ): () => void {
11
+ const heartbeatMs = opts?.heartbeatMs ?? 30_000
12
+ let stopped = false
13
+ let disconnected = false // disconnected_event 触发时置 true,主动 close 后不重连
14
+ let ws: WebSocket
15
+ let backoffMs = 1000
16
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null
17
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
18
+
19
+ function clearTimers() {
20
+ if (heartbeatTimer) clearInterval(heartbeatTimer)
21
+ if (reconnectTimer) clearTimeout(reconnectTimer)
22
+ heartbeatTimer = null
23
+ reconnectTimer = null
24
+ }
25
+
26
+ function connect() {
27
+ if (stopped) return
28
+ ws = api.open()
29
+ ws.onopen = () => {
30
+ backoffMs = 1000
31
+ api.attach(ws)
32
+ api.subscribe(ws)
33
+ heartbeatTimer = setInterval(() => api.ping(ws), heartbeatMs)
34
+ ;(heartbeatTimer as unknown as { unref?: () => void }).unref?.()
35
+ }
36
+ ws.onmessage = (ev) => {
37
+ let frame: unknown
38
+ try {
39
+ frame = JSON.parse(ev.data as string)
40
+ } catch {
41
+ return
42
+ }
43
+ if (api.isDisconnected(frame)) {
44
+ disconnected = true
45
+ ws.close()
46
+ return
47
+ }
48
+ const msg = api.parseMessage(frame)
49
+ if (msg) void onMessage(msg).catch(() => {})
50
+ }
51
+ ws.onclose = () => {
52
+ api.attach(null)
53
+ if (heartbeatTimer) clearInterval(heartbeatTimer)
54
+ heartbeatTimer = null
55
+ if (stopped || disconnected) return
56
+ reconnectTimer = setTimeout(connect, backoffMs)
57
+ backoffMs = nextBackoff(backoffMs)
58
+ ;(reconnectTimer as unknown as { unref?: () => void }).unref?.()
59
+ }
60
+ }
61
+
62
+ connect()
63
+ return () => {
64
+ stopped = true
65
+ disconnected = true
66
+ clearTimers()
67
+ try {
68
+ ws.close()
69
+ } catch {
70
+ /* 连接尚未建立时 close 可能抛错,忽略 */
71
+ }
72
+ }
73
+ }
@@ -215,6 +215,9 @@
215
215
  "entered": "Entered plan mode.",
216
216
  "content": "── Plan Mode ──\n\nEntering plan mode — read-only analysis and design.\nUse EnterPlanMode to start, ExitPlanMode to submit for approval."
217
217
  },
218
+ "save": {
219
+ "content": "── Save to Wiki ──\n\nAnalyzing the conversation and filing the most valuable insight into your Obsidian wiki…"
220
+ },
218
221
  "diff": {
219
222
  "clean": "No uncommitted changes (working tree clean).",
220
223
  "title": "── Git Diff ──",
@@ -560,6 +563,7 @@
560
563
  "cd": {
561
564
  "usage": "Usage: /cd <path>\n\nChange the session working directory.\nExample: /cd ~/projects/my-app\n\nCurrent: {cwd}",
562
565
  "not_found": "❌ Directory not found: {path}",
566
+ "suggestions": "Did you mean:",
563
567
  "failed": "❌ Failed to change directory: {error}",
564
568
  "title": "── Directory Changed ──",
565
569
  "content": "New CWD: {path}\n\nThe AI will now work relative to this directory.\nNote: This changes the filesystem root for tools like Read, Write, Bash."
@@ -591,7 +595,10 @@
591
595
  "git_section": "── Git ──",
592
596
  "no_git": " (not a git repository or git not available)",
593
597
  "skills_section": "── Skills ──",
594
- "skills_unavailable": " (skills info unavailable)"
598
+ "skills_unavailable": " (skills info unavailable)",
599
+ "audit_section": "── CLAUDE.md Audit ──",
600
+ "audit_clean": " ✓ no code-derivable sections found",
601
+ "audit_found": "Add the sections above to the `prompt-exclude` frontmatter to stop sending them to the model."
595
602
  },
596
603
  "plugins": {
597
604
  "unavailable": "PluginManager not available in this session.",
@@ -215,6 +215,9 @@
215
215
  "entered": "已进入计划模式。",
216
216
  "content": "── 计划模式 ──\n\n进入计划模式 — 只读分析与设计。\n使用 EnterPlanMode 开始,ExitPlanMode 提交审批。"
217
217
  },
218
+ "save": {
219
+ "content": "── 保存到 Wiki ──\n\n正在分析对话,并把最有价值的洞察归档到你的 Obsidian wiki…"
220
+ },
218
221
  "diff": {
219
222
  "clean": "没有未提交的更改(工作区干净)。",
220
223
  "title": "── Git Diff ──",
@@ -560,6 +563,7 @@
560
563
  "cd": {
561
564
  "usage": "用法: /cd <path>\n\n更改会话工作目录。\n示例: /cd ~/projects/my-app\n\n当前: {cwd}",
562
565
  "not_found": "❌ 目录未找到: {path}",
566
+ "suggestions": "你是想找:",
563
567
  "failed": "❌ 更改目录失败: {error}",
564
568
  "title": "── 目录已更改 ──",
565
569
  "content": "新目录: {path}\n\nAI 将相对于此目录工作。\n注意: 这会更改 Read、Write、Bash 等工具的文件系统根路径。"
@@ -591,7 +595,10 @@
591
595
  "git_section": "── Git ──",
592
596
  "no_git": " (不是 git 仓库或 git 不可用)",
593
597
  "skills_section": "── 技能 ──",
594
- "skills_unavailable": " (技能信息不可用)"
598
+ "skills_unavailable": " (技能信息不可用)",
599
+ "audit_section": "── CLAUDE.md 审计 ──",
600
+ "audit_clean": " ✓ 未发现可从代码推断的冗余章节",
601
+ "audit_found": "将上述章节加入 `prompt-exclude` frontmatter,可停止发送给模型、节省 token。"
595
602
  },
596
603
  "plugins": {
597
604
  "unavailable": "当前会话中 PluginManager 不可用。",
package/src/mcp/client.ts CHANGED
@@ -161,8 +161,8 @@ export class McpClient {
161
161
  if (existing?.status === 'connected') return
162
162
 
163
163
  const transport: StdioTransport | HttpTransport = config.url
164
- ? new HttpTransport()
165
- : new StdioTransport()
164
+ ? new HttpTransport(undefined, config.request_timeout_ms)
165
+ : new StdioTransport(config.request_timeout_ms)
166
166
  const protocol = new McpProtocol(transport)
167
167
 
168
168
  const connection: ActiveConnection = {
@@ -1,10 +1,9 @@
1
1
  import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification, JsonRpcError } from './types'
2
2
  import type { Transport, NotificationHandler } from './transport'
3
+ import { DEFAULT_REQUEST_TIMEOUT_MS, requestTimeoutError } from './transport'
3
4
 
4
5
  type FetchFn = (input: string, init?: RequestInit) => Promise<Response>
5
6
 
6
- const REQUEST_TIMEOUT_MS = 60_000
7
-
8
7
  /**
9
8
  * Merge multiple SSE `data:` events into a single JSON-RPC result.
10
9
  *
@@ -74,7 +73,10 @@ export class HttpTransport implements Transport {
74
73
  private closed = false
75
74
  private notificationHandlers: NotificationHandler[] = []
76
75
 
77
- constructor(private fetchImpl: FetchFn = fetch as unknown as FetchFn) {}
76
+ constructor(
77
+ private fetchImpl: FetchFn = fetch as unknown as FetchFn,
78
+ private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
79
+ ) {}
78
80
 
79
81
  async start(
80
82
  url: string,
@@ -107,7 +109,7 @@ export class HttpTransport implements Transport {
107
109
  }
108
110
 
109
111
  const controller = new AbortController()
110
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
112
+ const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs)
111
113
 
112
114
  try {
113
115
  const response = await this.fetchImpl(this.url, {
@@ -142,6 +144,11 @@ export class HttpTransport implements Transport {
142
144
  throw new Error(`MCP error ${json.error.code}: ${json.error.message}`)
143
145
  }
144
146
  return json.result
147
+ } catch (err) {
148
+ if (controller.signal.aborted) {
149
+ throw requestTimeoutError(method, this.requestTimeoutMs)
150
+ }
151
+ throw err
145
152
  } finally {
146
153
  clearTimeout(timer)
147
154
  }
package/src/mcp/oauth.ts CHANGED
@@ -4,6 +4,7 @@ import { createServer, Server } from 'node:http'
4
4
  import type { IncomingMessage, ServerResponse } from 'node:http'
5
5
  import type { McpServerConfig } from '../shared/types'
6
6
  import { TokenStore } from './token-store'
7
+ import { fetchWithRetry } from '../providers/fetch-utils'
7
8
  import { createT } from '../i18n-core/t'
8
9
  import enUS from '../i18n-core/locales/en-US.json'
9
10
  import zhCN from '../i18n-core/locales/zh-CN.json'
@@ -148,15 +149,22 @@ export class OAuthClient {
148
149
  throw new Error(`No refresh token available for "${serverName}"`)
149
150
  }
150
151
  const auth = config.auth!
151
- const response = await fetch(auth.tokenUrl, {
152
- method: 'POST',
153
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
154
- body: new URLSearchParams({
155
- grant_type: 'refresh_token',
156
- refresh_token: saved.refreshToken,
157
- client_id: auth.clientId,
158
- }).toString(),
159
- })
152
+ // A single failed refresh is often a transient network/server error, not a
153
+ // real revocation — retry (5xx/429/network) before forcing PKCE. 4xx (e.g.
154
+ // invalid_grant) is a genuine revocation and falls through to PKCE directly.
155
+ const response = await fetchWithRetry(
156
+ auth.tokenUrl,
157
+ {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
160
+ body: new URLSearchParams({
161
+ grant_type: 'refresh_token',
162
+ refresh_token: saved.refreshToken,
163
+ client_id: auth.clientId,
164
+ }).toString(),
165
+ },
166
+ { maxRetries: 1, baseDelay: 300 },
167
+ )
160
168
  if (!response.ok) {
161
169
  this.store.delete(serverName)
162
170
  const fresh = await this.executePkceFlow(config)
@@ -19,7 +19,12 @@ export interface Transport {
19
19
  isConnected(): boolean
20
20
  }
21
21
 
22
- const REQUEST_TIMEOUT_MS = 60_000
22
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 60_000
23
+
24
+ /** Shared timeout error for both stdio and HTTP transports. */
25
+ export function requestTimeoutError(method: string, ms: number): Error {
26
+ return new Error(`MCP request timeout: ${method} (${ms}ms)`)
27
+ }
23
28
 
24
29
  // ── Environment variable security: block sensitive vars from MCP subprocess ──
25
30
  //
@@ -69,6 +74,7 @@ function buildProcEnv(extra?: Record<string, string>): Record<string, string> {
69
74
  * Uses Node.js child_process for portability across Bun and Node runtimes.
70
75
  */
71
76
  export class StdioTransport implements Transport {
77
+ constructor(private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {}
72
78
  private proc: ChildProcess | null = null
73
79
  private msgId = 0
74
80
  private pending = new Map<
@@ -169,8 +175,8 @@ export class StdioTransport implements Transport {
169
175
  return new Promise((resolve, reject) => {
170
176
  const timer = setTimeout(() => {
171
177
  this.pending.delete(id)
172
- reject(new Error(`MCP request timeout: ${method} (${REQUEST_TIMEOUT_MS}ms)`))
173
- }, REQUEST_TIMEOUT_MS)
178
+ reject(requestTimeoutError(method, this.requestTimeoutMs))
179
+ }, this.requestTimeoutMs)
174
180
 
175
181
  this.pending.set(id, { resolve, reject, timer })
176
182