From 68a5ed7f0ab26bf718bd2c1a5d422ca157c31418 Mon Sep 17 00:00:00 2001 From: liuzaisen Date: Tue, 29 Sep 2026 10:13:13 +0800 Subject: [PATCH 1/2] feat(pairing): surface MCP client version mismatches --- ...2026-08-18-multi-session-pairing-design.md | 16 +++ .../src/background/coordinator-pool.ts | 7 +- packages/extension/src/background/index.ts | 1 + .../extension/src/content/pairing-relay.ts | 43 ++++++-- .../pages/coordinator-settings-page.tsx | 104 +++++++++++------- .../tests/background/coordinator-pool.test.ts | 5 +- .../tests/content/pairing-relay.test.ts | 31 +++++- .../pages/coordinator-settings-page.test.tsx | 73 +++++++++++- packages/mcp-server/src/ensure-hub.ts | 4 +- packages/mcp-server/src/mcp-server.ts | 3 +- packages/mcp-server/src/pair-page.ts | 11 +- packages/mcp-server/src/version.ts | 3 + packages/mcp-server/tests/ensure-hub.test.ts | 5 +- packages/mcp-server/tests/pair-page.test.ts | 11 +- packages/shared/src/pairing/index.ts | 1 + packages/shared/src/pairing/types.ts | 2 + packages/shared/src/pairing/version.ts | 70 ++++++++++++ packages/shared/tests/pairing/version.test.ts | 39 +++++++ 18 files changed, 370 insertions(+), 59 deletions(-) create mode 100644 packages/mcp-server/src/version.ts create mode 100644 packages/shared/src/pairing/version.ts create mode 100644 packages/shared/tests/pairing/version.test.ts diff --git a/docs/superpowers/specs/2026-08-18-multi-session-pairing-design.md b/docs/superpowers/specs/2026-08-18-multi-session-pairing-design.md index 7a48658..48b0f55 100644 --- a/docs/superpowers/specs/2026-08-18-multi-session-pairing-design.md +++ b/docs/superpowers/specs/2026-08-18-multi-session-pairing-design.md @@ -219,6 +219,22 @@ closure. Ownership is **advisory**. Nothing is blocked. An agent that sees a tab is taken can open its own, and two agents may share a tab on purpose. +### MCP release version visibility + +The pairing payload carries an optional `mcpVersion` sourced from the published +MCP package. The extension keeps it on the corresponding connection-pool entry +and shows it in the MCP settings session list. The field remains optional under +pairing payload `v: 1`, so older MCP clients still connect and are displayed as +"MCP 版本未知". + +On each pairing request, including an already-trusted install, the extension +compares `mcpVersion` with `chrome.runtime.getManifest().version`. A mismatch is +advisory: the pair page remains open with a recommendation to update the older +side, and the settings page keeps the warning visible beside the affected +session. Matching versions preserve the existing auto-close flow. Version +comparison never changes trust, websocket connectivity, or the separate +`protocol_version` compatibility gate. + ### Tab freshness A new C→S `TABS_UPDATE` fires on tab changes and ownership changes, reusing the diff --git a/packages/extension/src/background/coordinator-pool.ts b/packages/extension/src/background/coordinator-pool.ts index 272ea5a..a1dd830 100644 --- a/packages/extension/src/background/coordinator-pool.ts +++ b/packages/extension/src/background/coordinator-pool.ts @@ -13,6 +13,7 @@ import { CoordinatorClient, type CoordinatorClientOptions } from "./coordinator- export type PoolEntryStatus = "connected" | "connecting" | "disconnected" | "dormant"; export type PoolEntry = { + source: "mcp" | "manual"; endpoint: string; installId: string; sessionId: string; @@ -21,6 +22,7 @@ export type PoolEntry = { port: number; status: PoolEntryStatus; failures: number; + mcpVersion?: string; }; type Managed = PoolEntry & { client: CoordinatorClient }; @@ -51,17 +53,20 @@ export class CoordinatorPool { async addFromPairing(payload: PairPayload): Promise { const existing = this.entries.get(payload.sessionId); if (existing) { + existing.mcpVersion = payload.mcpVersion; existing.client.wakeUp(); this.changed(); return; } await this.add({ + source: "mcp", endpoint: endpointFor(payload.port), installId: payload.installId, sessionId: payload.sessionId, label: payload.label, pid: payload.pid, - port: payload.port + port: payload.port, + mcpVersion: payload.mcpVersion }); } diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index 44ce701..0229329 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -256,6 +256,7 @@ export async function startCoordinatorClient(): Promise { const p = ensurePool(); if (p.list().some((e) => e.sessionId === "legacy")) return; await p.add({ + source: "manual", endpoint: config.ws_url, installId: "legacy", sessionId: "legacy", diff --git a/packages/extension/src/content/pairing-relay.ts b/packages/extension/src/content/pairing-relay.ts index 7a8a75a..00af601 100644 --- a/packages/extension/src/content/pairing-relay.ts +++ b/packages/extension/src/content/pairing-relay.ts @@ -2,6 +2,7 @@ import { PAIR_PAGE_SOURCE, PAIR_READY_SOURCE, PAIR_RESULT_SOURCE, + comparePairingVersions, type PairPayload, type PairingDecision } from "@atwebpilot/shared/pairing"; @@ -43,6 +44,11 @@ export function installPairingRelay(): () => void { } async function handle(payload: PairPayload): Promise { + const extensionVersion = chrome.runtime.getManifest().version; + const { warning: versionWarning } = comparePairingVersions( + payload.mcpVersion, + extensionVersion + ); const reply = (await chrome.runtime.sendMessage({ type: "pairing.request", payload @@ -54,21 +60,33 @@ async function handle(payload: PairPayload): Promise { } if (reply.decision === "trusted") { - post({ ok: true, trusted: true }); + post({ ok: true, trusted: true, extensionVersion, versionWarning }); return; } - const approved = await askUser(payload); + const approved = await askUser(payload, extensionVersion, versionWarning); await chrome.runtime.sendMessage({ type: "pairing.decision", sessionId: payload.sessionId, approved, payload }); - post({ ok: approved, trusted: false, reason: approved ? undefined : "denied" }); + post({ + ok: approved, + trusted: false, + reason: approved ? undefined : "denied", + extensionVersion, + versionWarning + }); } -function post(result: { ok: boolean; trusted?: boolean; reason?: "denied" | "connection_error" }): void { +function post(result: { + ok: boolean; + trusted?: boolean; + reason?: "denied" | "connection_error"; + extensionVersion?: string; + versionWarning?: string | null; +}): void { window.postMessage({ source: PAIR_RESULT_SOURCE, ...result }, "*"); } @@ -80,6 +98,9 @@ function parsePayload(raw: unknown): PairPayload | null { const strings = ["installId", "secret", "sessionId", "label"] as const; for (const k of strings) if (typeof p[k] !== "string" || !p[k]) return null; if (typeof p.pid !== "number" || typeof p.port !== "number") return null; + if (p.mcpVersion !== undefined && (typeof p.mcpVersion !== "string" || !p.mcpVersion)) { + return null; + } return { v: 1, installId: p.installId as string, @@ -87,7 +108,8 @@ function parsePayload(raw: unknown): PairPayload | null { sessionId: p.sessionId as string, label: p.label as string, pid: p.pid, - port: p.port + port: p.port, + ...(p.mcpVersion ? { mcpVersion: p.mcpVersion as string } : {}) }; } @@ -95,7 +117,11 @@ function parsePayload(raw: unknown): PairPayload | null { * Rendered into a closed shadow root so the page cannot restyle it into * something misleading or read its contents. */ -export function askUser(payload: PairPayload): Promise { +export function askUser( + payload: PairPayload, + extensionVersion = chrome.runtime.getManifest().version, + versionWarning = comparePairingVersions(payload.mcpVersion, extensionVersion).warning +): Promise { return new Promise((resolve) => { const host = document.createElement("div"); host.setAttribute("data-atwebpilot-pairing", ""); @@ -111,6 +137,7 @@ export function askUser(payload: PairPayload): Promise { max-width:26rem; box-shadow:0 10px 40px rgba(0,0,0,.3); } h2 { font-size:15px; margin:0 0 8px; } .meta { color:#666; font-size:13px; margin:0 0 14px; word-break:break-all; } + .warning { color:#b45309; font-size:13px; margin:0 0 14px; } .row { display:flex; gap:8px; justify-content:flex-end; } button { font:inherit; padding:6px 14px; border-radius:6px; cursor:pointer; border:1px solid #ccc; background:#f6f6f6; } @@ -121,8 +148,10 @@ export function askUser(payload: PairPayload): Promise {

允许该会话控制此浏览器?

会话目录:${escapeHtml(payload.label)}
- pid ${payload.pid} · 端口 ${payload.port} + pid ${payload.pid} · 端口 ${payload.port}
+ ${payload.mcpVersion ? `MCP v${escapeHtml(payload.mcpVersion)}` : "MCP 版本未知"} · 扩展 v${escapeHtml(extensionVersion)}

+ ${versionWarning ? `

${escapeHtml(versionWarning)}

` : ""}

允许后,本机上的 AtWebPilot MCP 会话都可以直接连接,无需再次确认。可在扩展设置里撤销。

diff --git a/packages/extension/src/sidepanel/pages/coordinator-settings-page.tsx b/packages/extension/src/sidepanel/pages/coordinator-settings-page.tsx index 612fc55..53420aa 100644 --- a/packages/extension/src/sidepanel/pages/coordinator-settings-page.tsx +++ b/packages/extension/src/sidepanel/pages/coordinator-settings-page.tsx @@ -17,7 +17,7 @@ import { setCdpRecorderEnabled } from "@/background/recorder/cdp-permission"; import { listTrusted, revokeTrust } from "@/background/pairing-host"; -import type { TrustRecord } from "@atwebpilot/shared/pairing"; +import { comparePairingVersions, type TrustRecord } from "@atwebpilot/shared/pairing"; import type { PoolEntry } from "@/background/coordinator-pool"; const DEFAULT_WS_URL = "ws://localhost:8787/worker"; @@ -161,6 +161,12 @@ export function CoordinatorSettingsPage() { if (!loaded) return
载入中…
; const liveStatus = formatConnectionStatus(enabled, wsUrl, connectionStatus, now); + const extensionVersion = chrome.runtime.getManifest().version; + const versionWarnings = sessions.flatMap((session) => { + if (session.source !== "mcp") return []; + const warning = comparePairingVersions(session.mcpVersion, extensionVersion).warning; + return warning ? [{ sessionId: session.sessionId, label: session.label, warning }] : []; + }); return (
@@ -321,45 +327,63 @@ export function CoordinatorSettingsPage() { 还没有会话接入。在 Claude Code 里让 AI 操作网页时,会自动打开一个配对页请你确认。

) : ( -
    - {sessions.map((s) => ( -
  • - - {s.label} - {s.pid > 0 ? ` · pid ${s.pid}` : ""} - {s.port > 0 ? ` · :${s.port}` : ""} - - - {s.status} - - {s.status === "dormant" ? ( - - ) : ( - - )} -
  • - ))} -
+ <> + {versionWarnings.length > 0 && ( +
+ {versionWarnings.map(({ sessionId, label, warning }) => ( +

+ {label}:{warning} +

+ ))} +
+ )} +
    + {sessions.map((s) => ( +
  • + + + {s.label} + {s.pid > 0 ? ` · pid ${s.pid}` : ""} + {s.port > 0 ? ` · :${s.port}` : ""} + + {s.source === "mcp" && ( + + {s.mcpVersion ? `MCP v${s.mcpVersion}` : "MCP 版本未知"} + + )} + + + {s.status} + + {s.status === "dormant" ? ( + + ) : ( + + )} +
  • + ))} +
+ )} diff --git a/packages/extension/tests/background/coordinator-pool.test.ts b/packages/extension/tests/background/coordinator-pool.test.ts index aed4884..c49dfc3 100644 --- a/packages/extension/tests/background/coordinator-pool.test.ts +++ b/packages/extension/tests/background/coordinator-pool.test.ts @@ -43,6 +43,7 @@ const payload = (over: Partial = {}): PairPayload => ({ label: "~/code/atwebpilot2", pid: 1, port: 51234, + mcpVersion: "0.0.75", ...over }); @@ -100,7 +101,8 @@ describe("CoordinatorPool", () => { label: "~/code/atwebpilot2", pid: 1, port: 51234, - installId: "inst_abc" + installId: "inst_abc", + mcpVersion: "0.0.75" }); }); @@ -177,6 +179,7 @@ describe("CoordinatorPool", () => { it("the legacy single-URL config becomes exactly one entry", async () => { const pool = makePool(); await pool.add({ + source: "manual", endpoint: "ws://127.0.0.1:8787/worker", installId: "legacy", sessionId: "legacy", diff --git a/packages/extension/tests/content/pairing-relay.test.ts b/packages/extension/tests/content/pairing-relay.test.ts index 96cf4db..52174df 100644 --- a/packages/extension/tests/content/pairing-relay.test.ts +++ b/packages/extension/tests/content/pairing-relay.test.ts @@ -15,7 +15,8 @@ const payload = { sessionId: "sess_1", label: "~/code/atwebpilot2", pid: 1234, - port: 51234 + port: 51234, + mcpVersion: "0.0.74" }; let sent: unknown[]; @@ -37,6 +38,7 @@ beforeEach(() => { reply = { decision: "trusted" }; globalThis.chrome = { runtime: { + getManifest: () => ({ version: "0.0.75" }), sendMessage: vi.fn(async (m: unknown) => { sent.push(m); return reply; @@ -112,7 +114,32 @@ describe("pairing relay", () => { pairMessage(); await settle(); expect(document.querySelector("[data-atwebpilot-pairing]")).toBeNull(); - expect(results()[0]).toMatchObject({ ok: true, trusted: true }); + expect(results()[0]).toMatchObject({ + ok: true, + trusted: true, + extensionVersion: "0.0.75", + versionWarning: "MCP v0.0.74 低于扩展 v0.0.75,请更新 MCP" + }); + }); + + it("accepts a legacy payload and reports that its MCP version is unknown", async () => { + const { mcpVersion: _mcpVersion, ...legacy } = payload; + pairMessage(legacy); + await settle(); + expect(results()[0]).toMatchObject({ + ok: true, + versionWarning: "MCP 版本未知,客户端可能过旧,请更新 MCP" + }); + }); + + it("does not warn when MCP and extension versions match", async () => { + pairMessage({ ...payload, mcpVersion: "0.0.75" }); + await settle(); + expect(results()[0]).toMatchObject({ + ok: true, + extensionVersion: "0.0.75", + versionWarning: null + }); }); it("renders an overlay when the worker asks", async () => { diff --git a/packages/extension/tests/sidepanel/pages/coordinator-settings-page.test.tsx b/packages/extension/tests/sidepanel/pages/coordinator-settings-page.test.tsx index f1afbfe..a58081e 100644 --- a/packages/extension/tests/sidepanel/pages/coordinator-settings-page.test.tsx +++ b/packages/extension/tests/sidepanel/pages/coordinator-settings-page.test.tsx @@ -45,7 +45,7 @@ function fakeChromeStorage(initial: Record = {}) { const sendMessage = vi.fn(async (_message?: unknown): Promise => ({ sessions: [] })); return { // Plan 33: the page polls the worker for connected sessions. - runtime: { sendMessage }, + runtime: { sendMessage, getManifest: () => ({ version: "0.0.75" }) }, permissions: { contains: vi.fn(async () => false) }, storage: { local, @@ -209,6 +209,77 @@ describe("CoordinatorSettingsPage", () => { expect(container.textContent).toContain("已取消 3 个标签页的 MCP 分组,标签页保持打开"); }); + it("shows each MCP version and warns about stale or legacy sessions", async () => { + const chromeMock = fakeChromeStorage(); + chromeMock.runtime.sendMessage.mockResolvedValue({ + sessions: [ + { + source: "mcp", + endpoint: "ws://127.0.0.1:51234/worker", + installId: "inst", + sessionId: "current", + label: "~/code/current", + pid: 10, + port: 51234, + status: "connected", + failures: 0, + mcpVersion: "0.0.75" + }, + { + source: "mcp", + endpoint: "ws://127.0.0.1:51235/worker", + installId: "inst", + sessionId: "stale", + label: "~/code/stale", + pid: 11, + port: 51235, + status: "connected", + failures: 0, + mcpVersion: "0.0.74" + }, + { + source: "mcp", + endpoint: "ws://127.0.0.1:51236/worker", + installId: "inst", + sessionId: "legacy", + label: "~/code/legacy", + pid: 12, + port: 51236, + status: "dormant", + failures: 10 + }, + { + source: "manual", + endpoint: "ws://localhost:8787/worker", + installId: "legacy", + sessionId: "manual", + label: "手动配置", + pid: 0, + port: 0, + status: "connected", + failures: 0 + } + ] + }); + vi.stubGlobal("chrome", chromeMock); + + await act(async () => { + root.render(); + }); + await flushAsync(); + + expect(container.textContent).toContain("MCP v0.0.75"); + expect(container.textContent).toContain("MCP v0.0.74"); + expect(container.textContent).toContain("MCP 版本未知"); + expect(container.textContent).toContain( + "~/code/stale:MCP v0.0.74 低于扩展 v0.0.75,请更新 MCP" + ); + expect(container.textContent).toContain( + "~/code/legacy:MCP 版本未知,客户端可能过旧,请更新 MCP" + ); + expect(container.textContent).not.toContain("手动配置:MCP 版本未知"); + }); + it("does not show a stale connected runtime status as live", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-07T12:00:00Z")); diff --git a/packages/mcp-server/src/ensure-hub.ts b/packages/mcp-server/src/ensure-hub.ts index 4cf3131..e0f1098 100644 --- a/packages/mcp-server/src/ensure-hub.ts +++ b/packages/mcp-server/src/ensure-hub.ts @@ -10,6 +10,7 @@ import { type ProcessInfo } from "./identity"; import type { Deps, HubBundle } from "./handlers"; +import { MCP_VERSION } from "./version"; export type EnsureDeps = { clock: Clock; @@ -112,7 +113,8 @@ export function createHubEnsurer(d: EnsureDeps): Deps & { bound(): boolean } { sessionId: info.sessionId, label: info.label, pid: info.pid, - port + port, + mcpVersion: MCP_VERSION }); try { diff --git a/packages/mcp-server/src/mcp-server.ts b/packages/mcp-server/src/mcp-server.ts index c6ae7cc..343220a 100644 --- a/packages/mcp-server/src/mcp-server.ts +++ b/packages/mcp-server/src/mcp-server.ts @@ -9,6 +9,7 @@ import { type Deps } from "./handlers"; import { readSkillBundle, SKILL_TOOL } from "./skill-bundle"; +import { MCP_VERSION } from "./version"; export type ToolListEntry = { name: string; description: string; inputSchema: JsonSchema }; @@ -158,7 +159,7 @@ async function revealBrowserSurface( export function createMcpServer(deps: Deps, state: ToolState = DEFAULT_STATE): Server { const server = new Server( - { name: "atwebpilot-mcp", version: "0.1.0" }, + { name: "atwebpilot-mcp", version: MCP_VERSION }, { capabilities: { tools: { listChanged: true }, logging: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: buildToolList(deps, state) })); diff --git a/packages/mcp-server/src/pair-page.ts b/packages/mcp-server/src/pair-page.ts index fea1450..a94a669 100644 --- a/packages/mcp-server/src/pair-page.ts +++ b/packages/mcp-server/src/pair-page.ts @@ -16,7 +16,7 @@ export function renderPairPage(payload: PairPayload): string {

AtWebPilot 配对

正在联系扩展…

-

会话:${escapeHtml(payload.label)} · pid ${payload.pid} · 端口 ${payload.port}

+

会话:${escapeHtml(payload.label)} · pid ${payload.pid} · 端口 ${payload.port} · ${payload.mcpVersion ? `MCP v${escapeHtml(payload.mcpVersion)}` : "MCP 版本未知"}

没有反应?请确认这个浏览器已安装并启用 AtWebPilot 扩展,然后刷新本页。