Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions apps/desktop/src/main/lib/browser-mcp-bridge/cdp-filter-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { randomBytes } from "node:crypto";
import { chmodSync, 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";

Expand All @@ -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;
Expand All @@ -35,8 +39,73 @@ interface TokenEntry {

const tokensBySession = new Map<string, string>();
const entriesByToken = new Map<string, TokenEntry>();
let hydrated = false;

interface PersistedTokenFile {
version: 1;
entries: Array<TokenEntry & { token: string }>;
}

/**
* 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<PersistedTokenFile>;
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,
});
Comment thread
MocA-Love marked this conversation as resolved.
// writeFileSync's `mode` only applies to new files. If the file
// already existed with broader permissions (backup/restore, etc.)
// we still need to tighten it so long-lived /cdp/<token>
// credentials never leak to other local users.
try {
chmodSync(TOKEN_STORE_PATH, 0o600);
} catch {
/* best-effort */
}
} 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);
Expand All @@ -50,12 +119,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();
Expand Down
79 changes: 69 additions & 10 deletions apps/desktop/src/main/lib/browser-mcp-bridge/server.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<number | null> {
return new Promise<number | null>((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<number> {
// 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<
Expand Down Expand Up @@ -209,15 +276,7 @@ export async function startBrowserMcpBridge(): Promise<BridgeHandle> {
});
});

await new Promise<void>((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), {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export function SessionConnectModal({

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[820px] sm:!max-w-[820px] p-0 gap-0 overflow-hidden">
<DialogContent className="!max-w-[min(1640px,95vw)] sm:!max-w-[min(1640px,95vw)] p-0 gap-0 overflow-hidden">
<DialogHeader className="px-5 py-4 border-b">
<DialogTitle className="text-sm">
Connect browser automation
Expand All @@ -172,7 +172,7 @@ export function SessionConnectModal({
</DialogDescription>
</DialogHeader>

<div className="grid grid-cols-[minmax(320px,1fr)_minmax(280px,0.9fr)] min-h-[380px] max-h-[560px]">
<div className="grid grid-cols-[minmax(320px,1fr)_minmax(280px,0.9fr)] min-h-[min(570px,70vh)] max-h-[min(840px,85vh)]">
<div className="overflow-y-auto p-4 border-r">
<div className="flex items-center gap-3 rounded-lg bg-muted/40 px-3 py-2.5 mb-3">
<div className="flex size-7 items-center justify-center rounded-md bg-brand/15 text-brand text-sm font-bold">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ 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 the same `--cdp-url` flag that the CLI
// accepts. Port + token are stable across Superset restarts (see
// server.ts / cdp-filter-proxy.ts), so this registration only has to
// be done once per install.
const browserUseCmd = `claude mcp add browser-use -s user -- uvx --from "browser-use[cli]" browser-use --mcp --cdp-url ${data.wsEndpoint}`;

return (
<div className="rounded-xl border p-3 bg-card/60 flex flex-col gap-3">
Expand Down Expand Up @@ -149,7 +154,7 @@ function CommandBlock({
<div className="mt-2">
<div className="text-[11px] font-medium">{title}</div>
<div className="mt-1 flex items-center gap-2">
<pre className="flex-1 rounded-md border bg-black/40 p-2 text-[11px] leading-relaxed whitespace-pre-wrap break-words">
<pre className="flex-1 min-w-0 max-w-full rounded-md border bg-black/40 p-2 text-[11px] leading-relaxed whitespace-pre-wrap break-all">
{cmd}
</pre>
<Button size="sm" variant="outline" onClick={onCopy}>
Expand Down
Loading