From 3d5ab70ced03d714b6c08955c89a33ae9f4452d6 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Tue, 21 Apr 2026 05:57:27 +0900 Subject: [PATCH 1/2] feat(desktop): stable CDP endpoint + browser-use MCP snippet + wider dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ユーザーが外部ブラウザ MCP を 1 回登録したら Superset を再起動しても 同じ URL が有効なようにする。併せて Connect モーダルの情報量が増えて はみ出していた snippet と狭いダイアログも直す。 ### Stable CDP endpoint (再登録不要に) - browser-mcp-bridge/server.ts: リスン時に `browser-mcp.json` に 書いた前回のポートを最優先、なければ preferred port 47834、最後に kernel-assigned port へフォールバック。次回起動で同じ port を 復元する。 - cdp-filter-proxy.ts: `mintCdpToken` / `resolveTokenToPane` が `~/.superset/browser-mcp-tokens.json` に hydrate/persist する ようになった。同じ sessionId (= terminal pane) を使い続ける限り token も不変。Superset 再起動後も URL が同じ。 ### browser-use を MCP 形式に修正 - CdpEndpointCard の snippet: `claude mcp add browser-use -s user -e BROWSER_USE_CDP_URL= -- uvx --from \"browser-use[cli]\" browser-use --mcp` - chrome-devtools-mcp の snippet はそのまま (既に正しい形) ### Dialog サイズ + overflow - SessionConnectModal: !max-w 820→1640 (横 2x)、max-h 560→840 + min-h 380→570 (縦 1.5x) - CdpEndpointCard の `
` を `min-w-0 max-w-full break-all`
  に変えて長い URL snippet がダイアログ幅を超えて漏れないように
---
 .../browser-mcp-bridge/cdp-filter-proxy.ts    | 62 +++++++++++++++
 .../src/main/lib/browser-mcp-bridge/server.ts | 79 ++++++++++++++++---
 .../SessionConnectModal.tsx                   |  4 +-
 .../CdpEndpointCard/CdpEndpointCard.tsx       |  8 +-
 4 files changed, 139 insertions(+), 14 deletions(-)

diff --git a/apps/desktop/src/main/lib/browser-mcp-bridge/cdp-filter-proxy.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/cdp-filter-proxy.ts
index 487d357edab..d00d0e949ce 100644
--- a/apps/desktop/src/main/lib/browser-mcp-bridge/cdp-filter-proxy.ts
+++ b/apps/desktop/src/main/lib/browser-mcp-bridge/cdp-filter-proxy.ts
@@ -1,8 +1,11 @@
 import { randomBytes } from "node:crypto";
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
 import type { IncomingMessage, ServerResponse } from "node:http";
+import { dirname, join } from "node:path";
 import type { Duplex } from "node:stream";
 import { WebSocket, type WebSocketServer } from "ws";
 import { bindingStore } from "../../../lib/trpc/routers/browser-automation/index";
+import { SUPERSET_HOME_DIR } from "../app-environment";
 import { browserManager } from "../browser/browser-manager";
 import { resolveCdpPort } from "./cdp-port";
 
@@ -26,6 +29,7 @@ import { resolveCdpPort } from "./cdp-port";
  */
 
 const TOKEN_BYTES = 24;
+const TOKEN_STORE_PATH = join(SUPERSET_HOME_DIR, "browser-mcp-tokens.json");
 
 interface TokenEntry {
 	sessionId: string;
@@ -35,8 +39,64 @@ interface TokenEntry {
 
 const tokensBySession = new Map();
 const entriesByToken = new Map();
+let hydrated = false;
+
+interface PersistedTokenFile {
+	version: 1;
+	entries: Array;
+}
+
+/**
+ * Load previously-minted tokens from disk. Tokens survive app
+ * restarts so the URL a user registered once into their external
+ * browser MCP (chrome-devtools-mcp / browser-use) stays valid.
+ */
+function hydrate(): void {
+	if (hydrated) return;
+	hydrated = true;
+	try {
+		const raw = readFileSync(TOKEN_STORE_PATH, "utf8");
+		const parsed = JSON.parse(raw) as Partial;
+		if (parsed?.version !== 1 || !Array.isArray(parsed.entries)) return;
+		for (const e of parsed.entries) {
+			if (
+				typeof e.token === "string" &&
+				e.token.length >= TOKEN_BYTES * 2 &&
+				typeof e.sessionId === "string"
+			) {
+				tokensBySession.set(e.sessionId, e.token);
+				entriesByToken.set(e.token, {
+					sessionId: e.sessionId,
+					createdAt: e.createdAt ?? Date.now(),
+					lastUsedAt: e.lastUsedAt ?? Date.now(),
+				});
+			}
+		}
+	} catch {
+		/* no prior state, start fresh */
+	}
+}
+
+function persist(): void {
+	try {
+		const payload: PersistedTokenFile = {
+			version: 1,
+			entries: Array.from(entriesByToken.entries()).map(([token, entry]) => ({
+				token,
+				...entry,
+			})),
+		};
+		mkdirSync(dirname(TOKEN_STORE_PATH), { recursive: true });
+		writeFileSync(TOKEN_STORE_PATH, JSON.stringify(payload, null, 2), {
+			mode: 0o600,
+		});
+	} catch (error) {
+		console.warn("[cdp-filter-proxy] failed to persist tokens:", error);
+	}
+}
 
 export function mintCdpToken(sessionId: string): string {
+	hydrate();
 	const existing = tokensBySession.get(sessionId);
 	if (existing) {
 		const entry = entriesByToken.get(existing);
@@ -50,12 +110,14 @@ export function mintCdpToken(sessionId: string): string {
 		createdAt: Date.now(),
 		lastUsedAt: Date.now(),
 	});
+	persist();
 	return token;
 }
 
 function resolveTokenToPane(
 	token: string,
 ): { sessionId: string; paneId: string; targetId: string } | null {
+	hydrate();
 	const entry = entriesByToken.get(token);
 	if (!entry) return null;
 	entry.lastUsedAt = Date.now();
diff --git a/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts
index f16eab3cbd7..623fecf31fe 100644
--- a/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts
+++ b/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts
@@ -1,5 +1,5 @@
 import { randomBytes } from "node:crypto";
-import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
+import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
 import {
 	createServer,
 	type IncomingMessage,
@@ -37,6 +37,73 @@ import { getBoundPaneForSession, resolvePpidToSession } from "./pane-resolver";
 
 const RUNTIME_INFO_PATH = join(SUPERSET_HOME_DIR, "browser-mcp.json");
 
+/**
+ * Preferred loopback port for the bridge. Chosen in the IANA
+ * dynamic-port range where browser dev tools are unlikely to collide
+ * (9000-series is taken by Chrome remote debugging, 3000/5173 by dev
+ * servers, 8080 by everything, etc.). Persisted to browser-mcp.json so
+ * the same port is reused on restart — which lets the CDP URL that an
+ * external MCP was registered with stay valid across Superset launches.
+ */
+const PREFERRED_BRIDGE_PORT = 47834;
+
+async function tryListen(server: Server, port: number): Promise {
+	return new Promise((resolve) => {
+		const onError = (err: NodeJS.ErrnoException): void => {
+			server.off("error", onError);
+			if (err.code === "EADDRINUSE") resolve(null);
+			else resolve(null);
+		};
+		server.once("error", onError);
+		server.listen(port, "127.0.0.1", () => {
+			server.off("error", onError);
+			const address = server.address();
+			if (!address || typeof address === "string") {
+				resolve(null);
+				return;
+			}
+			resolve(address.port);
+		});
+	});
+}
+
+function readPersistedPort(): number | null {
+	try {
+		const raw = readFileSync(RUNTIME_INFO_PATH, "utf8");
+		const parsed = JSON.parse(raw) as { port?: number };
+		if (
+			typeof parsed.port === "number" &&
+			Number.isInteger(parsed.port) &&
+			parsed.port > 0 &&
+			parsed.port < 65_536
+		) {
+			return parsed.port;
+		}
+	} catch {
+		/* no prior state */
+	}
+	return null;
+}
+
+async function listenPreferringStablePort(server: Server): Promise {
+	// 1. Try the port used last run (so external MCP registrations stay
+	//    valid across restarts).
+	const previous = readPersistedPort();
+	const candidates = [previous, PREFERRED_BRIDGE_PORT].filter(
+		(p): p is number => typeof p === "number",
+	);
+	for (const candidate of candidates) {
+		const bound = await tryListen(server, candidate);
+		if (bound) return bound;
+	}
+	// 2. Fall back to a kernel-assigned port. The user will have to
+	//    re-register external MCPs with the new URL, but the app still
+	//    comes up cleanly instead of hanging on a conflict.
+	const bound = await tryListen(server, 0);
+	if (bound) return bound;
+	throw new Error("browser-mcp-bridge: could not bind any loopback port");
+}
+
 async function resolvePaneFromRequest(
 	req: IncomingMessage,
 ): Promise<
@@ -209,15 +276,7 @@ export async function startBrowserMcpBridge(): Promise {
 		});
 	});
 
-	await new Promise((resolve, reject) => {
-		server.once("error", reject);
-		server.listen(0, "127.0.0.1", resolve);
-	});
-	const address = server.address();
-	if (!address || typeof address === "string") {
-		throw new Error("browser-mcp-bridge: failed to bind port");
-	}
-	const port = address.port;
+	const port = await listenPreferringStablePort(server);
 
 	mkdirSync(dirname(RUNTIME_INFO_PATH), { recursive: true });
 	writeFileSync(RUNTIME_INFO_PATH, JSON.stringify({ port, secret }, null, 2), {
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx
index f16d338ac62..4bc108b6bba 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx
@@ -162,7 +162,7 @@ export function SessionConnectModal({
 
 	return (
 		
-			
+			
 				
 					
 						Connect browser automation
@@ -172,7 +172,7 @@ export function SessionConnectModal({
 					
 				
 
-				
+
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/CdpEndpointCard/CdpEndpointCard.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/CdpEndpointCard/CdpEndpointCard.tsx index a2bf7f82656..c9694fae258 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/CdpEndpointCard/CdpEndpointCard.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/CdpEndpointCard/CdpEndpointCard.tsx @@ -58,7 +58,11 @@ export function CdpEndpointCard({ sessionId }: CdpEndpointCardProps) { } const chromeDevtoolsCmd = `claude mcp add chrome-devtools-mcp -s user -- npx -y chrome-devtools-mcp --browser-url ${data.httpBase}`; - const browserUseCmd = `browser-use --cdp-url ${data.wsEndpoint}`; + // browser-use ships its own MCP mode via `uvx --from "browser-use[cli]"`. + // CDP endpoint is passed via BROWSER_USE_CDP_URL; set once per install — + // port + token are stable across Superset restarts (see server.ts / + // cdp-filter-proxy.ts). + const browserUseCmd = `claude mcp add browser-use -s user -e BROWSER_USE_CDP_URL=${data.wsEndpoint} -- uvx --from "browser-use[cli]" browser-use --mcp`; return (
@@ -149,7 +153,7 @@ function CommandBlock({
{title}
-
+				
 					{cmd}