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":"AAAA,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAA;AACxE,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAEzD,EAAE,CAAC,IAAI,CAAC,iBAAiB,EAAE,GAAG,EAAE,CAAC,CAAC;IAChC,qBAAqB,EAAE,EAAE,CAAC,EAAE,EAAE;IAC9B,iBAAiB,EAAE,EAAE,CAAC,EAAE,EAAE;IAC1B,UAAU,EAAE,EAAE,CAAC,EAAE,EAAE;CACpB,CAAC,CAAC,CAAA;AAEH,EAAE,CAAC,IAAI,CAAC,gCAAgC,EAAE,GAAG,EAAE,CAAC,CAAC;IAC/C,WAAW,EAAE,EAAE,CAAC,EAAE,EAAE;CACrB,CAAC,CAAC,CAAA;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAE7C,MAAM,aAAa;IAeI;IAdrB,MAAM,CAAU,UAAU,GAAG,CAAC,CAAA;IAC9B,MAAM,CAAU,IAAI,GAAG,CAAC,CAAA;IACxB,MAAM,CAAU,OAAO,GAAG,CAAC,CAAA;IAC3B,MAAM,CAAU,MAAM,GAAG,CAAC,CAAA;IAE1B,MAAM,CAAC,SAAS,GAAoB,EAAE,CAAA;IAEtC,UAAU,GAAG,aAAa,CAAC,UAAU,CAAA;IACrC,MAAM,GAAwB,IAAI,CAAA;IAClC,SAAS,GAA+C,IAAI,CAAA;IAC5D,OAAO,GAA+D,IAAI,CAAA;IAC1E,OAAO,GAAwB,IAAI,CAAA;IACnC,IAAI,GAAa,EAAE,CAAA;IAEnB,YAAqB,GAAW;QAAX,QAAG,GAAH,GAAG,CAAQ;QAC9B,aAAa,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACpC,CAAC;IAED,IAAI;QACF,IAAI,CAAC,UAAU,GAAG,aAAa,CAAC,IAAI,CAAA;QACpC,IAAI,CAAC,MAAM,EAAE,EAAE,CAAA;IACjB,CAAC;IAED,IAAI,CAAC,IAAI,GAAG,IAAI,EAAE,MAAM,GAAG,EAAE;QAC3B,IAAI,CAAC,UAAU,GAAG,aAAa,CAAC,MAAM,CAAA;QACtC,IAAI,CAAC,OAAO,EAAE,EAAE,CAAA;QAChB,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAA;IAClC,CAAC;IAED,IAAI,CAAC,IAAY;QACf,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACtB,CAAC;IAED,KAAK;QACH,IAAI,CAAC,UAAU,GAAG,aAAa,CAAC,MAAM,CAAA;QACtC,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CAAA;IAC5C,CAAC;;AAGH,KAAK,UAAU,aAAa;IAC1B,MAAM,OAAO,CAAC,OAAO,EAAE,CAAA;IACvB,MAAM,OAAO,CAAC,OAAO,EAAE,CAAA;AACzB,CAAC;AAED,QAAQ,CAAC,gCAAgC,EAAE,GAAG,EAAE;IAC9C,UAAU,CAAC,GAAG,EAAE;QACd,EAAE,CAAC,aAAa,EAAE,CAAA;QAClB,aAAa,CAAC,SAAS,GAAG,EAAE,CAAA;QAC5B,WAAW,CAAC,UAAU,GAAG,IAAI,CAAA;QAC7B,EAAE,CAAC,UAAU,CAAC,WAAW,EAAE,aAAa,CAAC,CAAA;IAC3C,CAAC,CAAC,CAAA;IAEF,SAAS,CAAC,GAAG,EAAE;QACb,EAAE,CAAC,aAAa,EAAE,CAAA;QAClB,EAAE,CAAC,gBAAgB,EAAE,CAAA;IACvB,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,4CAA4C,EAAE,GAAG,EAAE;QACpD,MAAM,MAAM,GAAG,IAAI,SAAS,EAAE,CAAA;QAE9B,MAAM,CAAC,OAAO,EAAE,CAAA;QAChB,MAAM,CAAC,OAAO,EAAE,CAAA;QAEhB,MAAM,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QAC/C,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAClD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,sDAAsD,EAAE,KAAK,IAAI,EAAE;QACpE,MAAM,MAAM,GAAG,IAAI,SAAS,EAAE,CAAA;QAC9B,MAAM,MAAM,GAAG,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,CAAA;QAEnD,MAAM,CAAC,OAAO,EAAE,CAAA;QAEhB,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;YAC3B,aAAa,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC,CAAC,CAAE,CAAC,IAAI,EAAE,CAAA;YACtC,MAAM,aAAa,EAAE,CAAA;YAErB,MAAM,gBAAgB,GAAG,aAAa,CAAC,SAAS,CAAC,MAAM,CAAA;YACvD,MAAM,EAAE,CAAC,wBAAwB,CAAC,KAAK,GAAG,CAAC,CAAC,CAAA;YAC5C,MAAM,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,YAAY,CAAC,gBAAgB,CAAC,CAAA;YAE9D,MAAM,EAAE,CAAC,wBAAwB,CAAC,CAAC,CAAC,CAAA;YACpC,MAAM,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,YAAY,CAAC,gBAAgB,GAAG,CAAC,CAAC,CAAA;QACpE,CAAC;QAED,MAAM,CAAC,UAAU,EAAE,CAAA;IACrB,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,qDAAqD,EAAE,KAAK,IAAI,EAAE;QACnE,MAAM,MAAM,GAAG,IAAI,SAAS,EAAE,CAAA;QAE9B,MAAM,CAAC,OAAO,EAAE,CAAA;QAChB,MAAM,MAAM,GAAG,aAAa,CAAC,SAAS,CAAC,CAAC,CAAC,CAAA;QACzC,MAAM,CAAC,IAAI,EAAE,CAAA;QAEb,MAAM,EAAE,CAAC,wBAAwB,CAAC,KAAK,CAAC,CAAA;QAExC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QACnC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,CAAA;QAEvE,MAAM,CAAC,UAAU,EAAE,CAAA;IACrB,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,4DAA4D,EAAE,KAAK,IAAI,EAAE;QAC1E,MAAM,MAAM,GAAG,IAAI,SAAS,EAAE,CAAA;QAE9B,MAAM,CAAC,OAAO,EAAE,CAAA;QAChB,aAAa,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QACjC,MAAM,aAAa,EAAE,CAAA;QAErB,MAAM,CAAC,cAAc,EAAE,CAAA;QAEvB,MAAM,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QAC/C,MAAM,CAAC,EAAE,CAAC,aAAa,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QAClC,MAAM,CAAC,UAAU,EAAE,CAAA;IACrB,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,mDAAmD,EAAE,KAAK,IAAI,EAAE;QACjE,MAAM,MAAM,GAAG,IAAI,SAAS,EAAE,CAAA;QAC9B,WAAW,CAAC,UAAU,GAAG,KAAK,CAAA;QAE9B,MAAM,CAAC,OAAO,EAAE,CAAA;QAChB,aAAa,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QACjC,MAAM,aAAa,EAAE,CAAA;QAErB,MAAM,CAAC,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,oBAAoB,CAAC,YAAY,CAAC,CAAA;QACvE,MAAM,CAAC,EAAE,CAAC,aAAa,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACpC,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"} \ 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: {