From 3c52ec3687e6832631132069b9e8cc1aff516cc4 Mon Sep 17 00:00:00 2001
From: vanselee
Date: Fri, 12 Jun 2026 21:48:38 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8F=90=E5=8D=87=20MCP/CLI=20=E8=BF=9E?=
=?UTF-8?q?=E6=8E=A5=E7=A8=B3=E5=AE=9A=E6=80=A7=EF=BC=8C=E6=96=B0=E5=A2=9E?=
=?UTF-8?q?=E5=BF=83=E8=B7=B3=E4=BF=9D=E6=B4=BB=E4=B8=8E=E6=99=BA=E8=83=BD?=
=?UTF-8?q?=E9=87=8D=E8=BF=9E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 WebSocket 心跳机制(每 20s),防止 MV3 Service Worker 超时断开
- 优化重连策略:前 5 次快速轮询 + 指数退避
- 新增 MCP_RECONNECT 手动重连指令和连接状态扩展
- 新增 Chrome MV3 定期唤醒定时器(每 30s)
- 新增本地服务器自动检测,智能切换温热/冷却重连策略
- 延长默认连接超时至 45 秒
- 要求 minimum_chrome_version >= 120
- 新增 MCP Client 单元测试
---
CHANGELOG.md | 10 ++
packages/cli/src/index.ts | 4 +-
.../extension/__tests__/mcp-client.test.d.ts | 2 +
.../__tests__/mcp-client.test.d.ts.map | 1 +
.../extension/__tests__/mcp-client.test.js | 113 +++++++++++++
.../__tests__/mcp-client.test.js.map | 1 +
.../extension/__tests__/mcp-client.test.ts | 160 ++++++++++++++++++
packages/extension/manifest.json | 1 +
packages/extension/src/background/index.ts | 71 +++++---
packages/extension/src/mcp/client.ts | 103 +++++++++--
.../src/popup/components/SettingsDrawer.tsx | 39 ++++-
packages/extension/vitest.setup.ts | 1 +
12 files changed, 467 insertions(+), 39 deletions(-)
create mode 100644 packages/extension/__tests__/mcp-client.test.d.ts
create mode 100644 packages/extension/__tests__/mcp-client.test.d.ts.map
create mode 100644 packages/extension/__tests__/mcp-client.test.js
create mode 100644 packages/extension/__tests__/mcp-client.test.js.map
create mode 100644 packages/extension/__tests__/mcp-client.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 595597ab..398b4bc2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## v2.0.9 (2026-06-12)
+
+- 🔧 修复 MCP Server 连接稳定性,新增 WebSocket 心跳保活机制
+- 🔧 优化 MCP 重连策略:前 5 次快速轮询 + 指数退避 + 本地服务器智能识别
+- 🔧 新增 MCP_RECONNECT 手动重连指令,设置页支持显示连接状态和错误信息
+- 🔧 新增 Chrome MV3 Service Worker 定期唤醒机制(每 30s),确保扩展后台常驻
+- 🔧 延长默认连接超时至 45 秒
+- 🔧 要求 minimum_chrome_version >= 120
+- 🧪 新增 MCP Client 单元测试
+
## v2.0.8 (2026-03-17)
- 🆕 新增抖音图文
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 1110c4a4..0b8782f8 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -28,13 +28,13 @@ const GITHUB_URL = 'https://github.com/wechatsync/Wechatsync'
const program = new Command()
// 默认超时时间
-let connectionTimeout = 30000
+let connectionTimeout = 45000
program
.name('wechatsync')
.description('同步文章到多个内容平台 (知乎、掘金、CSDN 等)')
.version('1.1.0')
- .option('--timeout ', '等待 Extension 连接超时(毫秒)', '30000')
+ .option('--timeout ', '等待 Extension 连接超时(毫秒)', '45000')
.hook('preAction', (thisCommand) => {
const opts = thisCommand.opts()
if (opts.timeout) {
diff --git a/packages/extension/__tests__/mcp-client.test.d.ts b/packages/extension/__tests__/mcp-client.test.d.ts
new file mode 100644
index 00000000..ce2aa054
--- /dev/null
+++ b/packages/extension/__tests__/mcp-client.test.d.ts
@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=mcp-client.test.d.ts.map
\ No newline at end of file
diff --git a/packages/extension/__tests__/mcp-client.test.d.ts.map b/packages/extension/__tests__/mcp-client.test.d.ts.map
new file mode 100644
index 00000000..464a0fd5
--- /dev/null
+++ b/packages/extension/__tests__/mcp-client.test.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"mcp-client.test.d.ts","sourceRoot":"","sources":["mcp-client.test.ts"],"names":[],"mappings":""}
\ No newline at end of file
diff --git a/packages/extension/__tests__/mcp-client.test.js b/packages/extension/__tests__/mcp-client.test.js
new file mode 100644
index 00000000..ed4f37fc
--- /dev/null
+++ b/packages/extension/__tests__/mcp-client.test.js
@@ -0,0 +1,113 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { chromeMock, mockStorage } from '../vitest.setup';
+vi.mock('../src/adapters', () => ({
+ checkAllPlatformsAuth: vi.fn(),
+ checkPlatformAuth: vi.fn(),
+ getAdapter: vi.fn(),
+}));
+vi.mock('../src/background/sync-service', () => ({
+ performSync: vi.fn(),
+}));
+import { McpClient } from '../src/mcp/client';
+class MockWebSocket {
+ url;
+ static CONNECTING = 0;
+ static OPEN = 1;
+ static CLOSING = 2;
+ static CLOSED = 3;
+ static instances = [];
+ readyState = MockWebSocket.CONNECTING;
+ onopen = null;
+ onmessage = null;
+ onclose = null;
+ onerror = null;
+ sent = [];
+ constructor(url) {
+ this.url = url;
+ MockWebSocket.instances.push(this);
+ }
+ open() {
+ this.readyState = MockWebSocket.OPEN;
+ this.onopen?.();
+ }
+ fail(code = 1006, reason = '') {
+ this.readyState = MockWebSocket.CLOSED;
+ this.onerror?.();
+ this.onclose?.({ code, reason });
+ }
+ send(data) {
+ this.sent.push(data);
+ }
+ close() {
+ this.readyState = MockWebSocket.CLOSED;
+ this.onclose?.({ code: 1000, reason: '' });
+ }
+}
+async function flushPromises() {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+describe('McpClient connection lifecycle', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ MockWebSocket.instances = [];
+ mockStorage.mcpEnabled = true;
+ vi.stubGlobal('WebSocket', MockWebSocket);
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+ it('does not replace an in-progress connection', () => {
+ const client = new McpClient();
+ client.connect();
+ client.connect();
+ expect(MockWebSocket.instances).toHaveLength(1);
+ expect(client.getStatus().connecting).toBe(true);
+ });
+ it('retries quickly before switching to the cold backoff', async () => {
+ const client = new McpClient();
+ const delays = [500, 1000, 2000, 4000, 5000, 10000];
+ client.connect();
+ for (const delay of delays) {
+ MockWebSocket.instances.at(-1).fail();
+ await flushPromises();
+ const countBeforeRetry = MockWebSocket.instances.length;
+ await vi.advanceTimersByTimeAsync(delay - 1);
+ expect(MockWebSocket.instances).toHaveLength(countBeforeRetry);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(MockWebSocket.instances).toHaveLength(countBeforeRetry + 1);
+ }
+ client.disconnect();
+ });
+ it('sends a keepalive every 20 seconds after connecting', async () => {
+ const client = new McpClient();
+ client.connect();
+ const socket = MockWebSocket.instances[0];
+ socket.open();
+ await vi.advanceTimersByTimeAsync(20000);
+ expect(socket.sent).toHaveLength(1);
+ expect(JSON.parse(socket.sent[0])).toMatchObject({ type: 'keepalive' });
+ client.disconnect();
+ });
+ it('immediately reconnects when reset while a retry is pending', async () => {
+ const client = new McpClient();
+ client.connect();
+ MockWebSocket.instances[0].fail();
+ await flushPromises();
+ client.resetReconnect();
+ expect(MockWebSocket.instances).toHaveLength(2);
+ expect(vi.getTimerCount()).toBe(0);
+ client.disconnect();
+ });
+ it('checks the enabled flag before scheduling a retry', async () => {
+ const client = new McpClient();
+ mockStorage.mcpEnabled = false;
+ client.connect();
+ MockWebSocket.instances[0].fail();
+ await flushPromises();
+ expect(chromeMock.storage.local.get).toHaveBeenCalledWith('mcpEnabled');
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
+//# sourceMappingURL=mcp-client.test.js.map
\ No newline at end of file
diff --git a/packages/extension/__tests__/mcp-client.test.js.map b/packages/extension/__tests__/mcp-client.test.js.map
new file mode 100644
index 00000000..1380670c
--- /dev/null
+++ b/packages/extension/__tests__/mcp-client.test.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"mcp-client.test.js","sourceRoot":"","sources":["mcp-client.test.ts"],"names":[],"mappings":"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"}
\ No newline at end of file
diff --git a/packages/extension/__tests__/mcp-client.test.ts b/packages/extension/__tests__/mcp-client.test.ts
new file mode 100644
index 00000000..77dfc6c1
--- /dev/null
+++ b/packages/extension/__tests__/mcp-client.test.ts
@@ -0,0 +1,160 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { chromeMock, mockStorage } from '../vitest.setup'
+
+vi.mock('../src/adapters', () => ({
+ checkAllPlatformsAuth: vi.fn(),
+ checkPlatformAuth: vi.fn(),
+ getAdapter: vi.fn(),
+}))
+
+vi.mock('../src/background/sync-service', () => ({
+ performSync: vi.fn(),
+}))
+
+import { McpClient } from '../src/mcp/client'
+
+class MockWebSocket {
+ static readonly CONNECTING = 0
+ static readonly OPEN = 1
+ static readonly CLOSING = 2
+ static readonly CLOSED = 3
+
+ static instances: MockWebSocket[] = []
+
+ readyState = MockWebSocket.CONNECTING
+ onopen: (() => void) | null = null
+ onmessage: ((event: { data: string }) => void) | null = null
+ onclose: ((event: { code: number; reason: string }) => void) | null = null
+ onerror: (() => void) | null = null
+ sent: string[] = []
+
+ constructor(readonly url: string) {
+ MockWebSocket.instances.push(this)
+ }
+
+ open(): void {
+ this.readyState = MockWebSocket.OPEN
+ this.onopen?.()
+ }
+
+ fail(code = 1006, reason = ''): void {
+ this.readyState = MockWebSocket.CLOSED
+ this.onerror?.()
+ this.onclose?.({ code, reason })
+ }
+
+ send(data: string): void {
+ this.sent.push(data)
+ }
+
+ close(): void {
+ this.readyState = MockWebSocket.CLOSED
+ this.onclose?.({ code: 1000, reason: '' })
+ }
+}
+
+async function flushPromises(): Promise {
+ await Promise.resolve()
+ await Promise.resolve()
+}
+
+describe('McpClient connection lifecycle', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ MockWebSocket.instances = []
+ mockStorage.mcpEnabled = true
+ vi.stubGlobal('WebSocket', MockWebSocket)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ })
+
+ it('does not replace an in-progress connection', () => {
+ const client = new McpClient()
+
+ client.connect()
+ client.connect()
+
+ expect(MockWebSocket.instances).toHaveLength(1)
+ expect(client.getStatus().connecting).toBe(true)
+ })
+
+ it('retries quickly before switching to the cold backoff', async () => {
+ const client = new McpClient()
+ const delays = [500, 1000, 2000, 4000, 5000, 10000]
+
+ client.setServerUrl('ws://192.0.2.1:9527')
+ client.connect()
+
+ for (const delay of delays) {
+ MockWebSocket.instances.at(-1)!.fail()
+ await flushPromises()
+
+ const countBeforeRetry = MockWebSocket.instances.length
+ await vi.advanceTimersByTimeAsync(delay - 1)
+ expect(MockWebSocket.instances).toHaveLength(countBeforeRetry)
+
+ await vi.advanceTimersByTimeAsync(1)
+ expect(MockWebSocket.instances).toHaveLength(countBeforeRetry + 1)
+ }
+
+ client.disconnect()
+ })
+
+ it('keeps localhost retries within five seconds', async () => {
+ const client = new McpClient()
+ const delays = [500, 1000, 2000, 4000, 5000, 5000]
+
+ client.connect()
+
+ for (const delay of delays) {
+ MockWebSocket.instances.at(-1)!.fail()
+ await flushPromises()
+ await vi.advanceTimersByTimeAsync(delay)
+ }
+
+ expect(MockWebSocket.instances).toHaveLength(delays.length + 1)
+ client.disconnect()
+ })
+
+ it('keeps the service worker active while waiting for the local CLI', async () => {
+ const client = new McpClient()
+
+ client.connect()
+
+ await vi.advanceTimersByTimeAsync(20001)
+
+ expect(globalThis.chrome.runtime.getPlatformInfo).toHaveBeenCalledTimes(1)
+
+ client.disconnect()
+ })
+
+ it('immediately reconnects when reset while a retry is pending', async () => {
+ const client = new McpClient()
+
+ client.connect()
+ MockWebSocket.instances[0].fail()
+ await flushPromises()
+
+ client.resetReconnect()
+
+ expect(MockWebSocket.instances).toHaveLength(2)
+ client.disconnect()
+ })
+
+ it('checks the enabled flag before scheduling a retry', async () => {
+ const client = new McpClient()
+ mockStorage.mcpEnabled = false
+
+ client.connect()
+ MockWebSocket.instances[0].fail()
+ await flushPromises()
+ await vi.advanceTimersByTimeAsync(10000)
+
+ expect(chromeMock.storage.local.get).toHaveBeenCalledWith('mcpEnabled')
+ expect(MockWebSocket.instances).toHaveLength(1)
+ client.disconnect()
+ })
+})
diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json
index b9b170cb..14bc2fa5 100644
--- a/packages/extension/manifest.json
+++ b/packages/extension/manifest.json
@@ -1,5 +1,6 @@
{
"manifest_version": 3,
+ "minimum_chrome_version": "120",
"name": "文章同步助手",
"description": "一键同步文章到知乎、头条、掘金等 20+ 平台,支持 WordPress 等自建站",
"version": "2.0.9",
diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts
index 0ee35e34..a5eb2f1a 100644
--- a/packages/extension/src/background/index.ts
+++ b/packages/extension/src/background/index.ts
@@ -30,6 +30,7 @@ import { checkForUpdates, isUpdateDismissed } from '../lib/version-check'
import { fetchRemoteConfig, fetchConfigIfNeeded } from '../lib/remote-config'
const logger = createLogger('Background')
+const MCP_RECONNECT_ALARM = 'mcp_reconnect'
// CMS 类型
type CMSType = 'wordpress' | 'typecho' | 'metaweblog'
@@ -132,6 +133,7 @@ type MessageAction =
| { type: 'MCP_ENABLE' }
| { type: 'MCP_DISABLE' }
| { type: 'MCP_STATUS' }
+ | { type: 'MCP_RECONNECT' }
| { type: 'MCP_SET_SERVER_URL'; payload: { url: string } }
| { type: 'MCP_WATCH_START' }
| { type: 'MCP_WATCH_STOP' }
@@ -642,10 +644,9 @@ async function handleMessage(message: MessageAction, sender?: chrome.runtime.Mes
await chrome.storage.local.set({ mcpServerUrl: url || '' })
mcpClient.setServerUrl(url)
// 地址变更后,断开重连
- if (mcpClient.isConnected()) {
- mcpClient.disconnect()
- mcpClient.resetReconnect()
- } else {
+ mcpClient.disconnect()
+ const storage = await chrome.storage.local.get('mcpEnabled')
+ if (storage.mcpEnabled) {
mcpClient.resetReconnect()
}
return { success: true }
@@ -667,11 +668,23 @@ async function handleMessage(message: MessageAction, sender?: chrome.runtime.Mes
return {
enabled: storage.mcpEnabled ?? false,
connected: mcpStatus.connected,
+ connecting: mcpStatus.connecting,
token: storage.mcpToken, // 返回 token 供 MCP Server 使用
serverUrl: storage.mcpServerUrl || '',
+ lastError: mcpStatus.lastError,
+ nextReconnectAt: mcpStatus.nextReconnectAt,
}
}
+ case 'MCP_RECONNECT': {
+ const storage = await chrome.storage.local.get('mcpEnabled')
+ if (!storage.mcpEnabled) {
+ return { success: false, error: 'MCP connection is disabled' }
+ }
+ await initMcpIfEnabled()
+ return { success: true }
+ }
+
case 'TRACK_ARTICLE_EXTRACT': {
const { source, success, hasTitle, hasContent, hasCover, contentLength } = message.payload
trackArticleExtract(source, success, { hasTitle, hasContent, hasCover, contentLength }).catch(() => {})
@@ -1164,24 +1177,36 @@ chrome.runtime.onInstalled.addListener(async details => {
/**
* 启动时初始化 MCP(如果已启用)
*/
+let mcpInitPromise: Promise | null = null
+
async function initMcpIfEnabled() {
- const storage = await chrome.storage.local.get(['mcpEnabled', 'mcpToken', 'mcpServerUrl'])
- if (storage.mcpEnabled) {
- if (storage.mcpToken) {
- mcpClient.setToken(storage.mcpToken)
- logger.info(' Starting MCP client with existing token...')
- } else {
- // 没有 token,生成新的
- const token = crypto.randomUUID()
- await chrome.storage.local.set({ mcpToken: token })
- mcpClient.setToken(token)
- logger.info(' Starting MCP client with new token...')
- }
- // 加载自定义服务器地址(支持远程桥接)
- if (storage.mcpServerUrl) {
- mcpClient.setServerUrl(storage.mcpServerUrl)
+ if (mcpInitPromise) {
+ return mcpInitPromise
+ }
+
+ mcpInitPromise = (async () => {
+ const storage = await chrome.storage.local.get(['mcpEnabled', 'mcpToken', 'mcpServerUrl'])
+ if (storage.mcpEnabled) {
+ if (storage.mcpToken) {
+ mcpClient.setToken(storage.mcpToken)
+ logger.info(' Starting MCP client with existing token...')
+ } else {
+ // 没有 token,生成新的
+ const token = crypto.randomUUID()
+ await chrome.storage.local.set({ mcpToken: token })
+ mcpClient.setToken(token)
+ logger.info(' Starting MCP client with new token...')
+ }
+ // 加载自定义服务器地址(支持远程桥接)
+ mcpClient.setServerUrl(storage.mcpServerUrl || '')
+ startMcpClient()
}
- startMcpClient()
+ })()
+
+ try {
+ await mcpInitPromise
+ } finally {
+ mcpInitPromise = null
}
}
@@ -1205,6 +1230,7 @@ async function preCheckPlatformsAuth() {
// 浏览器启动时预检查
chrome.runtime.onStartup.addListener(() => {
logger.info(' Browser started, pre-checking auth...')
+ initMcpIfEnabled().catch(error => logger.error(' MCP startup failed:', error))
preCheckPlatformsAuth()
})
@@ -1215,7 +1241,12 @@ preCheckPlatformsAuth()
chrome.alarms.create('daily_growth_metrics', { periodInMinutes: 24 * 60 })
// 设置远程配置定期拉取(每 6 小时)
chrome.alarms.create('remote_config_fetch', { periodInMinutes: 6 * 60 })
+// 定期唤醒 MV3 Service Worker,确保 CLI 启动后扩展能主动恢复连接
+chrome.alarms.create(MCP_RECONNECT_ALARM, { periodInMinutes: 0.5 })
chrome.alarms.onAlarm.addListener((alarm) => {
+ if (alarm.name === MCP_RECONNECT_ALARM) {
+ initMcpIfEnabled().catch(error => logger.error(' MCP reconnect failed:', error))
+ }
if (alarm.name === 'daily_growth_metrics') {
trackGrowthMetrics().catch(() => {})
}
diff --git a/packages/extension/src/mcp/client.ts b/packages/extension/src/mcp/client.ts
index 56c3a309..1e7d3aaa 100644
--- a/packages/extension/src/mcp/client.ts
+++ b/packages/extension/src/mcp/client.ts
@@ -40,10 +40,13 @@ interface PendingUpload {
}
const DEFAULT_SERVER_URL = 'ws://localhost:9527'
+const HEARTBEAT_INTERVAL = 20000
+const FAST_RECONNECT_ATTEMPTS = 5
-class McpClient {
+export class McpClient {
private ws: WebSocket | null = null
private reconnectTimer: ReturnType | null = null
+ private heartbeatTimer: ReturnType | null = null
private serverUrl = DEFAULT_SERVER_URL
// 安全验证 token
@@ -52,6 +55,8 @@ class McpClient {
// 温热/冷却双阶段重连配置
private reconnectAttempts = 0
private lastConnectedAt = 0 // 上次成功连接的时间戳
+ private lastError: string | null = null
+ private nextReconnectAt: number | null = null
private activelyWatched = false // 用户正在查看设置页
private readonly WARM_WINDOW = 5 * 60 * 1000 // 5 分钟内视为温热
// 温热阶段:刚断开,快速重连
@@ -101,10 +106,19 @@ class McpClient {
* 连接到 MCP Server
*/
connect(): void {
+ this.startHeartbeat()
+
// 清理旧连接
if (this.ws) {
- if (this.ws.readyState === WebSocket.OPEN) {
- logger.debug('Already connected')
+ if (
+ this.ws.readyState === WebSocket.OPEN
+ || this.ws.readyState === WebSocket.CONNECTING
+ ) {
+ logger.debug(
+ this.ws.readyState === WebSocket.OPEN
+ ? 'Already connected'
+ : 'Connection already in progress'
+ )
return
}
// 清理非 OPEN 状态的连接
@@ -112,12 +126,10 @@ class McpClient {
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
- if (this.ws.readyState === WebSocket.CONNECTING) {
- this.ws.close()
- }
this.ws = null
}
+ this.nextReconnectAt = null
logger.debug(`Connecting to ${this.serverUrl} (attempt ${this.reconnectAttempts + 1})`)
try {
@@ -127,10 +139,13 @@ class McpClient {
logger.debug('Connected to MCP Server')
this.reconnectAttempts = 0 // 重置重连计数
this.lastConnectedAt = Date.now() // 记录连接时间
+ this.lastError = null
+ this.nextReconnectAt = null
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
+ this.startHeartbeat()
}
this.ws.onmessage = (event) => {
@@ -139,16 +154,19 @@ class McpClient {
this.ws.onclose = (event) => {
logger.debug(`Disconnected (code: ${event.code}), scheduling reconnect...`)
+ this.lastError = event.reason || this.lastError || `WebSocket closed (${event.code})`
this.ws = null
this.scheduleReconnect()
}
this.ws.onerror = () => {
// error 事件后通常会触发 close,不需要在这里重连
+ this.lastError = `Unable to connect to ${this.serverUrl}`
logger.debug('Connection error')
}
} catch (error) {
logger.error('Connection failed:', error)
+ this.lastError = (error as Error).message
this.ws = null
this.scheduleReconnect()
}
@@ -159,10 +177,12 @@ class McpClient {
*/
disconnect(): void {
this.reconnectAttempts = this.maxReconnectAttempts // 防止自动重连
+ this.nextReconnectAt = null
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
+ this.stopHeartbeat()
if (this.ws) {
this.ws.onclose = null // 防止触发重连
this.ws.close()
@@ -177,6 +197,33 @@ class McpClient {
return this.ws?.readyState === WebSocket.OPEN
}
+ /**
+ * 检查是否正在建立连接
+ */
+ isConnecting(): boolean {
+ return this.ws?.readyState === WebSocket.CONNECTING
+ }
+
+ /**
+ * MCP 启用期间定期调用 Chrome API,重置 MV3 Service Worker 的空闲计时器。
+ * 连接建立前也保持活跃,确保本地 CLI 启动后能在超时窗口内被发现。
+ */
+ private startHeartbeat(): void {
+ if (this.heartbeatTimer !== null) return
+
+ this.heartbeatTimer = setInterval(() => {
+ chrome.runtime.getPlatformInfo()
+ .catch(error => logger.debug('Heartbeat failed:', error))
+ }, HEARTBEAT_INTERVAL)
+ }
+
+ private stopHeartbeat(): void {
+ if (this.heartbeatTimer !== null) {
+ clearInterval(this.heartbeatTimer)
+ this.heartbeatTimer = null
+ }
+ }
+
/**
* 计划重连(指数退避)
*/
@@ -201,7 +248,9 @@ class McpClient {
private _doScheduleReconnect(): void {
// 温热/冷却双阶段退避
const timeSinceLastConnection = Date.now() - this.lastConnectedAt
- const isWarm = this.activelyWatched
+ const isWarm = this.isLocalServer()
+ || this.activelyWatched
+ || this.reconnectAttempts < FAST_RECONNECT_ATTEMPTS
|| (this.lastConnectedAt > 0 && timeSinceLastConnection < this.WARM_WINDOW)
let interval: number
@@ -215,8 +264,9 @@ class McpClient {
} else {
// 冷却:长时间未连接,server 可能没启动,慢速重试
// 10s, 20s, 40s, 60s, 60s, ...
+ const coldAttempt = this.reconnectAttempts - FAST_RECONNECT_ATTEMPTS
interval = Math.min(
- this.coldMinInterval * Math.pow(2, this.reconnectAttempts),
+ this.coldMinInterval * Math.pow(2, coldAttempt),
this.coldMaxInterval
)
}
@@ -224,12 +274,19 @@ class McpClient {
logger.debug(`Reconnecting in ${interval / 1000}s (${isWarm ? 'warm' : 'cold'})...`)
this.reconnectAttempts++
+ this.nextReconnectAt = Date.now() + interval
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
+ this.nextReconnectAt = null
this.connect()
}, interval)
}
+ private isLocalServer(): boolean {
+ return /^wss?:\/\/(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::|\/|$)/i
+ .test(this.serverUrl)
+ }
+
/**
* 设置用户是否正在关注连接状态(如打开设置页)
* 关注时使用温热策略快速重连
@@ -252,11 +309,32 @@ class McpClient {
*/
resetReconnect(): void {
this.reconnectAttempts = 0
- if (!this.isConnected()) {
+ this.nextReconnectAt = null
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer)
+ this.reconnectTimer = null
+ }
+ if (!this.isConnected() && !this.isConnecting()) {
this.connect()
}
}
+ getStatus(): {
+ connected: boolean
+ connecting: boolean
+ serverUrl: string
+ lastError: string | null
+ nextReconnectAt: number | null
+ } {
+ return {
+ connected: this.isConnected(),
+ connecting: this.isConnecting(),
+ serverUrl: this.serverUrl,
+ lastError: this.lastError,
+ nextReconnectAt: this.nextReconnectAt,
+ }
+ }
+
/**
* 处理来自 MCP Server 的请求
*/
@@ -571,9 +649,6 @@ export function stopMcpClient(): void {
}
// 获取连接状态
-export function getMcpStatus(): { connected: boolean; serverUrl: string } {
- return {
- connected: mcpClient.isConnected(),
- serverUrl: mcpClient.getServerUrl(),
- }
+export function getMcpStatus() {
+ return mcpClient.getStatus()
}
diff --git a/packages/extension/src/popup/components/SettingsDrawer.tsx b/packages/extension/src/popup/components/SettingsDrawer.tsx
index c5b0fbb3..02167171 100644
--- a/packages/extension/src/popup/components/SettingsDrawer.tsx
+++ b/packages/extension/src/popup/components/SettingsDrawer.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'
-import { X, Plug, PlugZap, Plus, Trash2, ChevronRight } from 'lucide-react'
+import { X, Plug, PlugZap, Plus, Trash2, ChevronRight, RefreshCw } from 'lucide-react'
import { cn } from '@/lib/utils'
import { trackFeatureDiscovery } from '../../lib/analytics'
@@ -11,8 +11,11 @@ interface SettingsDrawerProps {
interface McpStatus {
enabled: boolean
connected: boolean
+ connecting?: boolean
token?: string
serverUrl?: string
+ lastError?: string | null
+ nextReconnectAt?: number | null
}
interface CMSAccount {
@@ -40,8 +43,11 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) {
setMcpStatus({
enabled: response.enabled ?? false,
connected: response.connected ?? false,
+ connecting: response.connecting ?? false,
token: response.token,
serverUrl: response.serverUrl,
+ lastError: response.lastError,
+ nextReconnectAt: response.nextReconnectAt,
})
setServerUrlInput(response.serverUrl || '')
}
@@ -68,7 +74,13 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) {
const interval = setInterval(() => {
chrome.runtime.sendMessage({ type: 'MCP_STATUS' }, (response) => {
if (response && !response.error) {
- setMcpStatus(prev => ({ ...prev, connected: response.connected ?? false }))
+ setMcpStatus(prev => ({
+ ...prev,
+ connected: response.connected ?? false,
+ connecting: response.connecting ?? false,
+ lastError: response.lastError,
+ nextReconnectAt: response.nextReconnectAt,
+ }))
}
})
}, 3000)
@@ -103,6 +115,11 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) {
})
}
+ const reconnectMcp = () => {
+ setMcpStatus(prev => ({ ...prev, connecting: true, lastError: null }))
+ chrome.runtime.sendMessage({ type: 'MCP_RECONNECT' })
+ }
+
// 服务器地址变更(防抖 800ms)
const handleServerUrlChange = (value: string) => {
setServerUrlInput(value)
@@ -182,7 +199,9 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) {
{mcpStatus.enabled
? mcpStatus.connected
? '已连接'
- : '等待连接...'
+ : mcpStatus.connecting
+ ? '连接中...'
+ : '等待连接...'
: '未启用'}
@@ -211,6 +230,20 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) {
供 CLI 和 MCP Server 通过 WebSocket 桥接同步文章
+ {!mcpStatus.connected && (
+
+ )}
+ {mcpStatus.lastError && !mcpStatus.connecting && (
+
+ {mcpStatus.lastError}
+
+ )}
{mcpStatus.token && (
Token:
diff --git a/packages/extension/vitest.setup.ts b/packages/extension/vitest.setup.ts
index 17837fde..15fca1b5 100644
--- a/packages/extension/vitest.setup.ts
+++ b/packages/extension/vitest.setup.ts
@@ -16,6 +16,7 @@ const chromeMock = {
removeListener: vi.fn(),
},
getURL: vi.fn((path: string) => `chrome-extension://mock-id/${path}`),
+ getPlatformInfo: vi.fn().mockResolvedValue({ os: 'mac', arch: 'arm', nacl_arch: 'arm' }),
},
storage: {
local: {