From 6ed106f84ed1119b7998ab5d67b579514647217e Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:02:20 +0900 Subject: [PATCH 01/11] feat(desktop): end-to-end browser-pane MCP with PID-based auto-binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone `@superset/superset-browser-mcp` package plus the Electron app-side HTTP bridge so a Claude / Codex session can drive any browser pane the user binds to it — set up once, no per-pane command. ### New package `packages/superset-browser-mcp` - stdio MCP server (`@modelcontextprotocol/sdk`) - Reads ~/.superset/browser-mcp.json for the app's loopback port + per-app secret - Tools: get_connected_pane / navigate / screenshot / evaluate_js / get_console_logs - Every request includes `x-superset-mcp-ppid`, the MCP's own process.ppid — the Superset app uses that to auto-resolve which LLM session is calling ### App-side bridge `apps/desktop/src/main/lib/browser-mcp-bridge` - HTTP listener on 127.0.0.1:, auth via shared secret - Writes ~/.superset/browser-mcp.json on startup - resolvePpidToSession(): - todo-agent: registered PID from supervisor-engine (new pid-registry module), see supervisor-engine onChild wiring - terminal pane: walks each PTY's process tree for the PPID - ensureDebuggerAttached(paneId): Electron webContents.debugger via CDP 1.3, Page/Runtime/Log enabled, buffers console output ### UI / router wiring - browserAutomation.getMcpStatus now returns `serverCommand` resolving to `bun run /packages/superset-browser-mcp/src/bin.ts` in dev (falls back to `bunx @superset/superset-browser-mcp` for published installs). The Connect modal's setup snippet is generated from that exact argv so copy-pasting produces a working `claude mcp add`. - stores/browser-automation: getSnippetForSession takes the server argv; TOML-quotes / shell-quotes it properly. --- .../trpc/routers/browser-automation/index.ts | 33 +- apps/desktop/src/main/index.ts | 8 + .../lib/browser-mcp-bridge/pane-resolver.ts | 89 ++++++ .../lib/browser-mcp-bridge/pid-registry.ts | 31 ++ .../src/main/lib/browser-mcp-bridge/server.ts | 296 ++++++++++++++++++ .../src/main/todo-daemon/supervisor-engine.ts | 19 ++ .../SessionConnectModal.tsx | 12 +- .../src/renderer/stores/browser-automation.ts | 36 ++- bun.lock | 18 ++ packages/superset-browser-mcp/package.json | 27 ++ packages/superset-browser-mcp/src/bin.ts | 32 ++ packages/superset-browser-mcp/src/index.ts | 2 + .../superset-browser-mcp/src/tools/index.ts | 172 ++++++++++ .../src/transport/bridge-client.ts | 97 ++++++ packages/superset-browser-mcp/tsconfig.json | 11 + 15 files changed, 870 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts create mode 100644 apps/desktop/src/main/lib/browser-mcp-bridge/pid-registry.ts create mode 100644 apps/desktop/src/main/lib/browser-mcp-bridge/server.ts create mode 100644 packages/superset-browser-mcp/package.json create mode 100644 packages/superset-browser-mcp/src/bin.ts create mode 100644 packages/superset-browser-mcp/src/index.ts create mode 100644 packages/superset-browser-mcp/src/tools/index.ts create mode 100644 packages/superset-browser-mcp/src/transport/bridge-client.ts create mode 100644 packages/superset-browser-mcp/tsconfig.json diff --git a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts index 864eebc95dc..a21633e0ef8 100644 --- a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts +++ b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "node:events"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, resolve as resolvePath } from "node:path"; import { browserAutomationBindings, projects, @@ -11,6 +11,7 @@ import { } from "@superset/local-db"; import { observable } from "@trpc/server/observable"; import { and, eq, ne } from "drizzle-orm"; +import { app } from "electron"; import { localDb } from "main/lib/local-db"; import { getProcessName, getProcessTree } from "main/lib/terminal/port-scanner"; import { getTerminalHostClient } from "main/lib/terminal-host/client"; @@ -351,6 +352,33 @@ async function detectTerminalAgentSessions(): Promise { return out; } +/** + * Resolve the `superset-browser-mcp` bin that a Claude / Codex session + * should spawn. In dev we return `bun run /packages/superset-browser-mcp/src/bin.ts` + * so the snippet shown in the Connect modal is copy-pasteable without + * requiring a global install. In packaged production builds the source + * tree is not available; we fall back to the bare name so a future + * published npm package still produces a usable snippet. + */ +function resolveSupersetBrowserMcpCommand(): { + command: string; + args: string[]; + available: boolean; +} { + if (!app.isPackaged) { + const repoRoot = resolvePath(app.getAppPath(), "../.."); + const binPath = join(repoRoot, "packages/superset-browser-mcp/src/bin.ts"); + if (existsSync(binPath)) { + return { command: "bun", args: ["run", binPath], available: true }; + } + } + return { + command: "bunx", + args: ["@superset/superset-browser-mcp"], + available: false, + }; +} + export const createBrowserAutomationRouter = () => { return router({ getMcpStatus: publicProcedure.query(() => { @@ -380,6 +408,7 @@ export const createBrowserAutomationRouter = () => { codexReady, claudeConfigPath: CLAUDE_USER_JSON_PATH, codexConfigPath: CODEX_CONFIG_PATH, + serverCommand: resolveSupersetBrowserMcpCommand(), }; }), diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 38a1a209176..c7562e4514e 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -620,6 +620,14 @@ if (!gotTheLock) { initializeBrowserIdentityManager(); initializeBrowserWebviewCompat(); browserSitePermissionManager.initialize(); + try { + const { startBrowserMcpBridge } = await import( + "./lib/browser-mcp-bridge/server" + ); + await startBrowserMcpBridge(); + } catch (error) { + console.warn("[main] browser-mcp-bridge startup skipped", error); + } // One-shot sweep of 30-day-old pasted attachments so userData // doesn't grow forever from screenshots dropped into TODOs. try { diff --git a/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts new file mode 100644 index 00000000000..0a853c7dc96 --- /dev/null +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts @@ -0,0 +1,89 @@ +import { getProcessName, getProcessTree } from "main/lib/terminal/port-scanner"; +import { getTerminalHostClient } from "main/lib/terminal-host/client"; +import { bindingStore } from "../../../lib/trpc/routers/browser-automation/index"; +import { findTodoAgentSessionByPid } from "./pid-registry"; + +/** + * PID-based automatic mapping from an MCP process's PPID (the Claude / + * Codex CLI that spawned the MCP) to a Superset session and therefore a + * bound browser pane. + * + * Resolution order: + * 1. A todo-agent worker PID registered with this exact PID. + * 2. A terminal pane whose PTY process tree contains this PID. + * + * The first match wins. The mapping is cached briefly so we do not re-walk + * every terminal's /proc tree on every MCP tool call. + */ +export interface ResolvedSession { + sessionId: string; + kind: "todo-agent" | "terminal"; + paneId?: string; +} + +const CACHE_TTL_MS = 5_000; + +interface CacheEntry { + resolved: ResolvedSession | null; + at: number; +} + +const cache = new Map(); + +async function resolveFromTerminalPanes( + ppid: number, +): Promise { + let sessions: Awaited< + ReturnType["listSessions"]> + >["sessions"]; + try { + const client = getTerminalHostClient(); + const res = await client.listSessions(); + sessions = res.sessions; + } catch { + return null; + } + for (const s of sessions) { + if (!s.isAlive || typeof s.pid !== "number") continue; + const tree = await getProcessTree(s.pid); + if (tree.includes(ppid)) { + // Validate the PPID actually looks like an agent so we don't + // snap random terminal children into sessions. + const name = await getProcessName(ppid).catch(() => ""); + if (name === "claude" || name === "codex" || name.includes("node")) { + return { + sessionId: `terminal:${s.paneId}`, + kind: "terminal", + paneId: s.paneId, + }; + } + } + } + return null; +} + +function resolveFromTodoAgent(ppid: number): ResolvedSession | null { + const sessionId = findTodoAgentSessionByPid(ppid); + if (sessionId) { + return { sessionId, kind: "todo-agent" }; + } + return null; +} + +export async function resolvePpidToSession( + ppid: number, +): Promise { + const cached = cache.get(ppid); + if (cached && Date.now() - cached.at < CACHE_TTL_MS) { + return cached.resolved; + } + const todo = resolveFromTodoAgent(ppid); + const resolved = todo ?? (await resolveFromTerminalPanes(ppid)); + cache.set(ppid, { resolved, at: Date.now() }); + return resolved; +} + +export function getBoundPaneForSession(sessionId: string): string | null { + const binding = bindingStore.getBySessionId(sessionId); + return binding?.paneId ?? null; +} diff --git a/apps/desktop/src/main/lib/browser-mcp-bridge/pid-registry.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/pid-registry.ts new file mode 100644 index 00000000000..bd95628263c --- /dev/null +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/pid-registry.ts @@ -0,0 +1,31 @@ +/** + * In-memory registry of { pid: string -> todoSessionId }. The todo-agent + * supervisor reports each Claude worker it spawns here so the browser-mcp + * bridge can resolve `process.ppid` -> running session in one lookup, + * without having to walk every terminal's PTY tree for todo-agent cases. + * + * Entries are cleared on process exit. + */ +const byPid = new Map(); + +export function registerTodoAgentWorker(sessionId: string, pid: number): void { + byPid.set(pid, sessionId); +} + +export function unregisterTodoAgentWorker(pid: number): void { + byPid.delete(pid); +} + +export function findTodoAgentSessionByPid(pid: number): string | null { + return byPid.get(pid) ?? null; +} + +export function listTodoAgentWorkers(): Array<{ + pid: number; + sessionId: string; +}> { + return Array.from(byPid.entries()).map(([pid, sessionId]) => ({ + pid, + sessionId, + })); +} diff --git a/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts new file mode 100644 index 00000000000..70d5d500b93 --- /dev/null +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts @@ -0,0 +1,296 @@ +import { randomBytes } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { app } from "electron"; +import { browserManager } from "../browser/browser-manager"; +import { getBoundPaneForSession, resolvePpidToSession } from "./pane-resolver"; + +/** + * HTTP bridge between the `packages/superset-browser-mcp` MCP server and + * this Electron app. The MCP discovers the app via a runtime info file at + * ~/.superset/browser-mcp.json written here. + * + * Requests carry the MCP process's PPID in `x-superset-mcp-ppid`. We use + * that to resolve the LLM session and then the bound paneId on every call, + * so the user-visible flow is "set up MCP once, then bind panes in the + * UI — the MCP follows whatever pane is currently bound". + */ + +const RUNTIME_DIR = join(homedir(), ".superset"); +const RUNTIME_INFO_PATH = join(RUNTIME_DIR, "browser-mcp.json"); +const CONSOLE_BUFFER_LIMIT = 500; + +interface ConsoleEntry { + level: string; + message: string; + at: number; +} + +const consoleByPane = new Map(); +const attachedPanes = new Set(); + +async function ensureDebuggerAttached( + paneId: string, +): Promise { + const wc = browserManager.getWebContents(paneId); + if (!wc) throw new Error(`pane ${paneId} is not registered`); + if (!wc.debugger.isAttached()) { + try { + wc.debugger.attach("1.3"); + } catch (error) { + throw new Error( + `Failed to attach CDP to pane ${paneId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + await wc.debugger.sendCommand("Page.enable"); + await wc.debugger.sendCommand("Runtime.enable"); + await wc.debugger.sendCommand("Log.enable").catch(() => {}); + wc.debugger.on("message", (_event, method, params) => { + if ( + method === "Runtime.consoleAPICalled" || + method === "Log.entryAdded" + ) { + const level = + (params as { type?: string; entry?: { level?: string } }).type ?? + (params as { entry?: { level?: string } }).entry?.level ?? + "log"; + const args = + (params as { args?: Array<{ value?: unknown }> }).args ?? []; + const text = + (params as { entry?: { text?: string } }).entry?.text ?? + args + .map((a) => + a.value === undefined ? "(unserializable)" : String(a.value), + ) + .join(" "); + const buf = consoleByPane.get(paneId) ?? []; + buf.push({ level, message: text, at: Date.now() }); + if (buf.length > CONSOLE_BUFFER_LIMIT) buf.shift(); + consoleByPane.set(paneId, buf); + } + }); + wc.debugger.on("detach", () => { + attachedPanes.delete(paneId); + }); + attachedPanes.add(paneId); + } + return wc; +} + +async function resolvePaneFromRequest( + req: IncomingMessage, +): Promise< + { paneId: string; sessionId: string } | { error: string; status: number } +> { + const ppidHeader = req.headers["x-superset-mcp-ppid"]; + const ppid = + typeof ppidHeader === "string" ? Number.parseInt(ppidHeader, 10) : NaN; + if (!Number.isFinite(ppid) || ppid <= 0) { + return { error: "missing x-superset-mcp-ppid header", status: 400 }; + } + const resolved = await resolvePpidToSession(ppid); + if (!resolved) { + return { + error: + "Could not map this MCP to a Superset LLM session. Make sure Claude / Codex is running inside a Superset terminal pane or as a TODO-Agent worker.", + status: 404, + }; + } + const paneId = getBoundPaneForSession(resolved.sessionId); + if (!paneId) { + return { + error: `No browser pane is bound to session ${resolved.sessionId}. Open the Connect dialog in the Superset UI to pick one.`, + status: 409, + }; + } + return { paneId, sessionId: resolved.sessionId }; +} + +async function readJson(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString("utf8"); + return raw ? (JSON.parse(raw) as T) : ({} as T); +} + +function send(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(body)); +} + +interface BridgeHandle { + port: number; + secret: string; + stop: () => Promise; +} + +let current: BridgeHandle | null = null; + +export function getBrowserMcpBridge(): BridgeHandle | null { + return current; +} + +export async function startBrowserMcpBridge(): Promise { + if (current) return current; + const secret = randomBytes(24).toString("hex"); + + const server: Server = createServer(async (req, res) => { + try { + // Require loopback + shared secret. + const remote = req.socket.remoteAddress ?? ""; + if ( + remote !== "127.0.0.1" && + remote !== "::1" && + remote !== "::ffff:127.0.0.1" + ) { + return send(res, 403, { error: "loopback only" }); + } + const auth = req.headers.authorization ?? ""; + if (auth !== `Bearer ${secret}`) { + return send(res, 401, { error: "bad token" }); + } + + const url = new URL(req.url ?? "/", "http://localhost"); + + if (req.method === "POST" && url.pathname === "/mcp/register") { + return send(res, 200, { ok: true }); + } + + if (req.method === "GET" && url.pathname === "/mcp/binding") { + const resolved = await resolvePaneFromRequest(req); + if ("error" in resolved) { + return send(res, 200, { + bound: false, + paneId: null, + sessionId: null, + url: null, + title: null, + reason: resolved.error, + }); + } + const wc = browserManager.getWebContents(resolved.paneId); + return send(res, 200, { + bound: true, + paneId: resolved.paneId, + sessionId: resolved.sessionId, + url: wc?.getURL() ?? null, + title: wc?.getTitle() ?? null, + }); + } + + if (req.method === "POST" && url.pathname === "/mcp/navigate") { + const resolved = await resolvePaneFromRequest(req); + if ("error" in resolved) + return send(res, resolved.status, { error: resolved.error }); + const body = await readJson<{ url?: string }>(req); + if (!body.url) return send(res, 400, { error: "url required" }); + const wc = await ensureDebuggerAttached(resolved.paneId); + await wc.debugger.sendCommand("Page.navigate", { url: body.url }); + return send(res, 200, { paneId: resolved.paneId, url: body.url }); + } + + if (req.method === "POST" && url.pathname === "/mcp/screenshot") { + const resolved = await resolvePaneFromRequest(req); + if ("error" in resolved) + return send(res, resolved.status, { error: resolved.error }); + const wc = await ensureDebuggerAttached(resolved.paneId); + const out = (await wc.debugger.sendCommand("Page.captureScreenshot", { + format: "png", + captureBeyondViewport: false, + })) as { data: string }; + return send(res, 200, { + paneId: resolved.paneId, + base64: out.data, + mimeType: "image/png", + }); + } + + if (req.method === "POST" && url.pathname === "/mcp/evaluate") { + const resolved = await resolvePaneFromRequest(req); + if ("error" in resolved) + return send(res, resolved.status, { error: resolved.error }); + const body = await readJson<{ code?: string }>(req); + if (typeof body.code !== "string") { + return send(res, 400, { error: "code required" }); + } + const wc = await ensureDebuggerAttached(resolved.paneId); + const out = (await wc.debugger.sendCommand("Runtime.evaluate", { + expression: body.code, + awaitPromise: true, + returnByValue: true, + })) as { + result?: { value?: unknown }; + exceptionDetails?: { + text?: string; + exception?: { description?: string }; + }; + }; + return send(res, 200, { + paneId: resolved.paneId, + value: out.result?.value ?? null, + exceptionDetails: out.exceptionDetails + ? (out.exceptionDetails.exception?.description ?? + out.exceptionDetails.text ?? + "unknown exception") + : undefined, + }); + } + + if (req.method === "GET" && url.pathname === "/mcp/console-logs") { + const resolved = await resolvePaneFromRequest(req); + if ("error" in resolved) + return send(res, resolved.status, { error: resolved.error }); + // Make sure logging is being captured for this pane. + await ensureDebuggerAttached(resolved.paneId); + const entries = consoleByPane.get(resolved.paneId) ?? []; + consoleByPane.set(resolved.paneId, []); + return send(res, 200, { paneId: resolved.paneId, entries }); + } + + return send(res, 404, { error: "not found" }); + } catch (error) { + console.error("[browser-mcp-bridge]", error); + return send(res, 500, { + error: error instanceof Error ? error.message : String(error), + }); + } + }); + + 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; + + mkdirSync(dirname(RUNTIME_INFO_PATH), { recursive: true }); + writeFileSync(RUNTIME_INFO_PATH, JSON.stringify({ port, secret }, null, 2), { + mode: 0o600, + }); + + app.on("will-quit", () => { + server.close(); + }); + + current = { + port, + secret, + stop: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; + console.log(`[browser-mcp-bridge] listening on 127.0.0.1:${port}`); + return current; +} diff --git a/apps/desktop/src/main/todo-daemon/supervisor-engine.ts b/apps/desktop/src/main/todo-daemon/supervisor-engine.ts index 95dc10cf8d5..f1a33821738 100644 --- a/apps/desktop/src/main/todo-daemon/supervisor-engine.ts +++ b/apps/desktop/src/main/todo-daemon/supervisor-engine.ts @@ -428,6 +428,25 @@ export class TodoSupervisorEngine { remoteControlEnabled, onChild: (child) => { run.currentChild = child; + const childPid = child.pid; + if (typeof childPid === "number" && childPid > 0) { + // Publish the Claude worker PID so the browser-mcp + // bridge can map MCP processes (spawned under this + // Claude) back to this TODO-Agent session without + // walking PTY trees. + void import("../lib/browser-mcp-bridge/pid-registry").then( + ({ registerTodoAgentWorker }) => { + registerTodoAgentWorker(currentSession.id, childPid); + }, + ); + child.once("exit", () => { + void import("../lib/browser-mcp-bridge/pid-registry").then( + ({ unregisterTodoAgentWorker }) => { + unregisterTodoAgentWorker(childPid); + }, + ); + }); + } }, }); run.currentChild = null; 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 d721db9e870..61d7527cc67 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 @@ -16,6 +16,7 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { type AutomationSession, getSnippetForSession, + type ServerCommand, useBrowserAutomationStore, } from "renderer/stores/browser-automation"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -143,10 +144,14 @@ export function SessionConnectModal({ } }; + const serverCommand = mcpStatus?.serverCommand as ServerCommand | undefined; + const handleCopySnippet = async () => { if (!session) return; try { - await navigator.clipboard.writeText(getSnippetForSession(session)); + await navigator.clipboard.writeText( + getSnippetForSession(session, serverCommand), + ); toast.success("Configuration snippet copied"); } catch { toast.error("Failed to copy snippet"); @@ -244,6 +249,7 @@ export function SessionConnectModal({ ? (mcpStatus?.codexConfigPath ?? null) : (mcpStatus?.claudeConfigPath ?? null) } + serverCommand={serverCommand} onCopy={handleCopySnippet} /> ) @@ -448,13 +454,15 @@ function DetailItem({ function SetupPanel({ session, mcpConfigPath, + serverCommand, onCopy, }: { session: AutomationSession; mcpConfigPath: string | null; + serverCommand?: ServerCommand; onCopy: () => void; }) { - const snippet = getSnippetForSession(session); + const snippet = getSnippetForSession(session, serverCommand); return (
diff --git a/apps/desktop/src/renderer/stores/browser-automation.ts b/apps/desktop/src/renderer/stores/browser-automation.ts index f62229cb6af..ee3b42c11e6 100644 --- a/apps/desktop/src/renderer/stores/browser-automation.ts +++ b/apps/desktop/src/renderer/stores/browser-automation.ts @@ -64,18 +64,36 @@ export const useBrowserAutomationStore = create( }), ); -export function getSnippetForSession(session: AutomationSession): string { +export interface ServerCommand { + command: string; + args: string[]; + available: boolean; +} + +function tomlString(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function shellQuote(value: string): string { + return /^[\w@./:+-]+$/.test(value) + ? value + : `'${value.replace(/'/g, "'\\''")}'`; +} + +export function getSnippetForSession( + session: AutomationSession, + server?: ServerCommand, +): string { + const cmd = server?.command ?? "bunx"; + const args = server?.args ?? ["@superset/superset-browser-mcp"]; if (session.provider === "Codex") { - // TOML is section-based, so appending this block to an existing - // config.toml is safe. The bin comes from packages/desktop-mcp. + const argsToml = args.map(tomlString).join(", "); return `[mcp_servers.superset-browser] -command = "desktop-mcp" -args = []`; +command = ${tomlString(cmd)} +args = [${argsToml}]`; } - // For Claude Code we recommend `claude mcp add` so the user's - // ~/.claude.json is updated in-place instead of manually merging a - // standalone JSON document (which would produce invalid JSON). - return `claude mcp add superset-browser -s local -- desktop-mcp`; + const parts = [cmd, ...args].map(shellQuote).join(" "); + return `claude mcp add superset-browser -s user -- ${parts}`; } function formatRelativeTime(ts: number | null | undefined): string { diff --git a/bun.lock b/bun.lock index 61b4aeca4a7..6334825b2e4 100644 --- a/bun.lock +++ b/bun.lock @@ -901,6 +901,22 @@ "typescript": "^5.9.3", }, }, + "packages/superset-browser-mcp": { + "name": "@superset/superset-browser-mcp", + "version": "0.1.0", + "bin": { + "superset-browser-mcp": "./src/bin.ts", + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "zod": "^4.3.5", + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "@types/node": "^24.9.1", + "typescript": "^5.9.3", + }, + }, "packages/trpc": { "name": "@superset/trpc", "version": "0.1.0", @@ -2654,6 +2670,8 @@ "@superset/shared": ["@superset/shared@workspace:packages/shared"], + "@superset/superset-browser-mcp": ["@superset/superset-browser-mcp@workspace:packages/superset-browser-mcp"], + "@superset/trpc": ["@superset/trpc@workspace:packages/trpc"], "@superset/typescript": ["@superset/typescript@workspace:tooling/typescript"], diff --git a/packages/superset-browser-mcp/package.json b/packages/superset-browser-mcp/package.json new file mode 100644 index 00000000000..e52afce5302 --- /dev/null +++ b/packages/superset-browser-mcp/package.json @@ -0,0 +1,27 @@ +{ + "name": "@superset/superset-browser-mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "superset-browser-mcp": "./src/bin.ts" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit --emitDeclarationOnly false" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "zod": "^4.3.5" + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "@types/node": "^24.9.1", + "typescript": "^5.9.3" + } +} diff --git a/packages/superset-browser-mcp/src/bin.ts b/packages/superset-browser-mcp/src/bin.ts new file mode 100644 index 00000000000..e2450e1e4cd --- /dev/null +++ b/packages/superset-browser-mcp/src/bin.ts @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { registerTools } from "./tools/index.js"; +import { BridgeClient } from "./transport/bridge-client.js"; + +const server = new McpServer( + { name: "superset-browser", version: "0.1.0" }, + { capabilities: { tools: {} } }, +); + +// process.ppid is the PID of whatever spawned us — typically the Claude Code +// or Codex CLI. The Superset app uses that to figure out which running LLM +// session this MCP is serving. +const ppid = + typeof process.ppid === "number" && process.ppid > 0 + ? process.ppid + : process.pid; + +const client = new BridgeClient(ppid); + +// Announce ourselves to Superset so it can bind PPID -> MCP. Failure is not +// fatal — tool calls will surface BridgeUnavailableError if the app never +// comes online. +client.request("POST", "/mcp/register", { ppid }).catch(() => { + /* ignore — the first tool call will surface a clearer error */ +}); + +registerTools(server, client); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/packages/superset-browser-mcp/src/index.ts b/packages/superset-browser-mcp/src/index.ts new file mode 100644 index 00000000000..3e63ee3cd28 --- /dev/null +++ b/packages/superset-browser-mcp/src/index.ts @@ -0,0 +1,2 @@ +export { registerTools } from "./tools/index.js"; +export { BridgeClient } from "./transport/bridge-client.js"; diff --git a/packages/superset-browser-mcp/src/tools/index.ts b/packages/superset-browser-mcp/src/tools/index.ts new file mode 100644 index 00000000000..69d85d6537e --- /dev/null +++ b/packages/superset-browser-mcp/src/tools/index.ts @@ -0,0 +1,172 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { BridgeClient } from "../transport/bridge-client.js"; + +interface NavigateResponse { + paneId: string; + url: string; +} + +interface ScreenshotResponse { + paneId: string; + base64: string; + mimeType: string; +} + +interface EvaluateResponse { + paneId: string; + value: unknown; + exceptionDetails?: string; +} + +interface ConsoleLogsResponse { + paneId: string; + entries: Array<{ level: string; message: string; at: number }>; +} + +interface BindingResponse { + bound: boolean; + paneId: string | null; + sessionId: string | null; + url: string | null; + title: string | null; +} + +export function registerTools(server: McpServer, client: BridgeClient): void { + server.registerTool( + "get_connected_pane", + { + title: "Get connected browser pane", + description: + "Return the currently bound browser pane for this Claude session. Reports whether a pane is bound, its URL and title.", + inputSchema: {}, + }, + async () => { + const data = await client.request("GET", "/mcp/binding"); + return { + content: [ + { + type: "text", + text: data.bound + ? `Bound to pane ${data.paneId} (${data.url ?? "blank"}): ${data.title ?? ""}` + : "No browser pane is bound to this Claude session. Open the Connect dialog in the Superset UI to pick one.", + }, + ], + }; + }, + ); + + server.registerTool( + "navigate", + { + title: "Navigate the bound browser pane", + description: + "Navigate the browser pane that the user has bound to this Claude session to the given URL. The binding is managed in the Superset UI.", + inputSchema: { + url: z.string().describe("Absolute URL (must include scheme)"), + }, + }, + async ({ url }) => { + const data = await client.request( + "POST", + "/mcp/navigate", + { url }, + ); + return { + content: [ + { + type: "text", + text: `Navigated pane ${data.paneId} to ${data.url}`, + }, + ], + }; + }, + ); + + server.registerTool( + "screenshot", + { + title: "Screenshot the bound browser pane", + description: + "Capture the currently visible viewport of the bound browser pane as a PNG.", + inputSchema: {}, + }, + async () => { + const data = await client.request( + "POST", + "/mcp/screenshot", + {}, + ); + return { + content: [ + { + type: "image", + data: data.base64, + mimeType: data.mimeType, + }, + ], + }; + }, + ); + + server.registerTool( + "evaluate_js", + { + title: "Run JavaScript in the bound browser pane", + description: + "Execute a JavaScript expression in the bound browser pane and return the serialized result. The expression runs in the page, not in Node.", + inputSchema: { + code: z.string().describe("JavaScript expression to evaluate"), + }, + }, + async ({ code }) => { + const data = await client.request( + "POST", + "/mcp/evaluate", + { code }, + ); + if (data.exceptionDetails) { + return { + isError: true, + content: [ + { type: "text", text: `Exception: ${data.exceptionDetails}` }, + ], + }; + } + return { + content: [{ type: "text", text: JSON.stringify(data.value, null, 2) }], + }; + }, + ); + + server.registerTool( + "get_console_logs", + { + title: "Get buffered console logs from the bound browser pane", + description: + "Return recent console.log / warn / error output the bound pane has emitted since the last call.", + inputSchema: {}, + }, + async () => { + const data = await client.request( + "GET", + "/mcp/console-logs", + ); + if (data.entries.length === 0) { + return { + content: [{ type: "text", text: "(no console output buffered)" }], + }; + } + return { + content: [ + { + type: "text", + text: data.entries + .map((e) => `[${e.level}] ${e.message}`) + .join("\n"), + }, + ], + }; + }, + ); +} diff --git a/packages/superset-browser-mcp/src/transport/bridge-client.ts b/packages/superset-browser-mcp/src/transport/bridge-client.ts new file mode 100644 index 00000000000..98256718236 --- /dev/null +++ b/packages/superset-browser-mcp/src/transport/bridge-client.ts @@ -0,0 +1,97 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** + * Runtime info written by the Superset app on startup to + * ~/.superset/browser-mcp.json: + * { port: number, secret: string } + * This MCP server reads that file to discover where to talk to the app. + */ +const RUNTIME_INFO_PATH = join(homedir(), ".superset", "browser-mcp.json"); + +interface RuntimeInfo { + port: number; + secret: string; +} + +function readRuntimeInfo(): RuntimeInfo { + const contents = readFileSync(RUNTIME_INFO_PATH, "utf8"); + const parsed = JSON.parse(contents) as RuntimeInfo; + if (typeof parsed.port !== "number" || typeof parsed.secret !== "string") { + throw new Error(`Invalid ${RUNTIME_INFO_PATH}: expected { port, secret }`); + } + return parsed; +} + +export class BridgeUnavailableError extends Error { + constructor(cause: unknown) { + super( + `Superset app is not reachable. Make sure Superset is running, then restart this MCP. (cause: ${ + cause instanceof Error ? cause.message : String(cause) + })`, + ); + this.name = "BridgeUnavailableError"; + } +} + +export class BridgeClient { + private info: RuntimeInfo | null = null; + private readonly ppid: number; + + constructor(ppid: number) { + this.ppid = ppid; + } + + private load(): RuntimeInfo { + if (!this.info) this.info = readRuntimeInfo(); + return this.info; + } + + reset(): void { + this.info = null; + } + + async request( + method: "GET" | "POST", + path: string, + body?: unknown, + ): Promise { + const info = this.load(); + const url = `http://127.0.0.1:${info.port}${path}`; + let response: Response; + try { + response = await fetch(url, { + method, + headers: { + "content-type": "application/json", + authorization: `Bearer ${info.secret}`, + "x-superset-mcp-ppid": String(this.ppid), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (error) { + // Stale port / app restarted: retry once after re-reading the file. + this.reset(); + try { + const fresh = this.load(); + response = await fetch(`http://127.0.0.1:${fresh.port}${path}`, { + method, + headers: { + "content-type": "application/json", + authorization: `Bearer ${fresh.secret}`, + "x-superset-mcp-ppid": String(this.ppid), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (retryError) { + throw new BridgeUnavailableError(retryError ?? error); + } + } + if (!response.ok) { + const text = await response.text().catch(() => response.statusText); + throw new Error(`Superset bridge ${response.status}: ${text}`); + } + return (await response.json()) as T; + } +} diff --git a/packages/superset-browser-mcp/tsconfig.json b/packages/superset-browser-mcp/tsconfig.json new file mode 100644 index 00000000000..525620cf0a6 --- /dev/null +++ b/packages/superset-browser-mcp/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@superset/typescript/internal-package.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules"] +} From 5b600fcd39d0562f06ffe646081cd50b629e5446 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:16:32 +0900 Subject: [PATCH 02/11] fix(desktop): scope browser-mcp runtime file per workspace, block stub snippet - Bridge runtime file moves from ~/.superset/browser-mcp.json to $SUPERSET_HOME_DIR/browser-mcp.json. Propagate SUPERSET_HOME_DIR into terminal-session env so claude/codex spawned inside a Superset terminal read the correct workspace-scoped file. Multiple Superset instances with different SUPERSET_WORKSPACE_NAME values no longer overwrite each other's port/secret. (codex P2) - SetupPanel: if serverCommand.available is false (packaged build before @superset/superset-browser-mcp ships on npm) show an explanatory placeholder instead of a bunx command that cannot start the server. (codex P1) --- .../src/main/lib/browser-mcp-bridge/server.ts | 9 ++++---- .../src/main/lib/terminal/env-terminal.ts | 5 +++++ .../SessionConnectModal.tsx | 21 +++++++++++++++++++ .../src/transport/bridge-client.ts | 17 +++++++++------ 4 files changed, 42 insertions(+), 10 deletions(-) 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 70d5d500b93..1ab3f29dd52 100644 --- a/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts @@ -6,16 +6,18 @@ import { type Server, type ServerResponse, } from "node:http"; -import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { app } from "electron"; +import { SUPERSET_HOME_DIR } from "../app-environment"; import { browserManager } from "../browser/browser-manager"; import { getBoundPaneForSession, resolvePpidToSession } from "./pane-resolver"; /** * HTTP bridge between the `packages/superset-browser-mcp` MCP server and * this Electron app. The MCP discovers the app via a runtime info file at - * ~/.superset/browser-mcp.json written here. + * `${SUPERSET_HOME_DIR}/browser-mcp.json` (workspace-scoped) — this lets + * multiple Superset instances with different `SUPERSET_WORKSPACE_NAME` + * values coexist without overwriting each other's port/secret. * * Requests carry the MCP process's PPID in `x-superset-mcp-ppid`. We use * that to resolve the LLM session and then the bound paneId on every call, @@ -23,8 +25,7 @@ import { getBoundPaneForSession, resolvePpidToSession } from "./pane-resolver"; * UI — the MCP follows whatever pane is currently bound". */ -const RUNTIME_DIR = join(homedir(), ".superset"); -const RUNTIME_INFO_PATH = join(RUNTIME_DIR, "browser-mcp.json"); +const RUNTIME_INFO_PATH = join(SUPERSET_HOME_DIR, "browser-mcp.json"); const CONSOLE_BUFFER_LIMIT = 500; interface ConsoleEntry { diff --git a/apps/desktop/src/main/lib/terminal/env-terminal.ts b/apps/desktop/src/main/lib/terminal/env-terminal.ts index 982882b2a71..e7641277879 100644 --- a/apps/desktop/src/main/lib/terminal/env-terminal.ts +++ b/apps/desktop/src/main/lib/terminal/env-terminal.ts @@ -108,6 +108,11 @@ export function buildTerminalEnv(params: { COLORTERM: "truecolor", COLORFGBG: colorFgBg, LANG: locale, + // Browser-MCP bridge discovery: propagate the resolved Superset home + // dir so MCP servers spawned by claude/codex in this terminal read + // the correct workspace-scoped browser-mcp.json. + SUPERSET_HOME_DIR: + process.env.SUPERSET_HOME_DIR ?? shellEnv.SUPERSET_HOME_DIR ?? "", SUPERSET_PANE_ID: paneId, SUPERSET_TAB_ID: tabId, SUPERSET_WORKSPACE_ID: workspaceId, 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 61d7527cc67..1fa4eb85412 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 @@ -463,6 +463,27 @@ function SetupPanel({ onCopy: () => void; }) { const snippet = getSnippetForSession(session, serverCommand); + // `available: false` means the Superset install cannot start the MCP + // locally yet (e.g. packaged build before the @superset/superset-browser-mcp + // package ships on npm). Surface that state instead of handing the user + // a command that would fail. + if (serverCommand && !serverCommand.available) { + return ( +
+
+
+ Browser MCP is not yet available in this build +
+
+ The @superset/superset-browser-mcp package needs to + ship before this release can register the MCP for you. Until then + run the browser automation from a dev build, or check back once the + next desktop release lands. +
+
+
+ ); + } return (
diff --git a/packages/superset-browser-mcp/src/transport/bridge-client.ts b/packages/superset-browser-mcp/src/transport/bridge-client.ts index 98256718236..86039976610 100644 --- a/packages/superset-browser-mcp/src/transport/bridge-client.ts +++ b/packages/superset-browser-mcp/src/transport/bridge-client.ts @@ -4,11 +4,16 @@ import { join } from "node:path"; /** * Runtime info written by the Superset app on startup to - * ~/.superset/browser-mcp.json: - * { port: number, secret: string } - * This MCP server reads that file to discover where to talk to the app. + * `$SUPERSET_HOME_DIR/browser-mcp.json` (workspace-scoped so multiple + * Superset instances do not collide). Defaults to `~/.superset` when the + * env var is not set. This MCP server reads that file to discover where + * to talk to the app. */ -const RUNTIME_INFO_PATH = join(homedir(), ".superset", "browser-mcp.json"); +function runtimeInfoPath(): string { + const home = process.env.SUPERSET_HOME_DIR?.trim(); + const base = home && home.length > 0 ? home : join(homedir(), ".superset"); + return join(base, "browser-mcp.json"); +} interface RuntimeInfo { port: number; @@ -16,10 +21,10 @@ interface RuntimeInfo { } function readRuntimeInfo(): RuntimeInfo { - const contents = readFileSync(RUNTIME_INFO_PATH, "utf8"); + const contents = readFileSync(runtimeInfoPath(), "utf8"); const parsed = JSON.parse(contents) as RuntimeInfo; if (typeof parsed.port !== "number" || typeof parsed.secret !== "string") { - throw new Error(`Invalid ${RUNTIME_INFO_PATH}: expected { port, secret }`); + throw new Error(`Invalid ${runtimeInfoPath()}: expected { port, secret }`); } return parsed; } From f84dfa612458b6d5ec508881b7cb94a6eb9c83e0 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:27:56 +0900 Subject: [PATCH 03/11] fix(desktop): do not cache null PPID resolutions resolvePpidToSession used to cache 'null' for the TTL, so a transient listSessions failure or a brief race between MCP spawn and todo-agent PID registration would lock the MCP out for 5s with 'No browser pane bound'. Only cache positive resolutions. (codex P2) --- .../src/main/lib/browser-mcp-bridge/pane-resolver.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts index 0a853c7dc96..d4a9d973e46 100644 --- a/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts @@ -79,7 +79,11 @@ export async function resolvePpidToSession( } const todo = resolveFromTodoAgent(ppid); const resolved = todo ?? (await resolveFromTerminalPanes(ppid)); - cache.set(ppid, { resolved, at: Date.now() }); + // Only cache hits. A null result can be caused by a transient + // listSessions failure or a brief race before the todo-agent worker + // has registered its PID; caching that would lock the MCP out for + // the TTL and surface "No browser pane bound" until it expired. + if (resolved) cache.set(ppid, { resolved, at: Date.now() }); return resolved; } From 43d3b732a93cdde024f955dd7fe96a65eadcf104 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:38:29 +0900 Subject: [PATCH 04/11] fix(mcp): wrap runtime info load in BridgeUnavailable handling BridgeClient.request called this.load() outside the try block, so when browser-mcp.json was missing (MCP starting before Superset writes it) or malformed (wrong SUPERSET_HOME_DIR) the method threw raw ENOENT / JSON parse errors instead of BridgeUnavailableError. Move the load inside the retry scope so both the initial load and the post-reset retry surface the friendly error. (codex P2) --- .../src/transport/bridge-client.ts | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/superset-browser-mcp/src/transport/bridge-client.ts b/packages/superset-browser-mcp/src/transport/bridge-client.ts index 86039976610..84eea7e2321 100644 --- a/packages/superset-browser-mcp/src/transport/bridge-client.ts +++ b/packages/superset-browser-mcp/src/transport/bridge-client.ts @@ -62,11 +62,8 @@ export class BridgeClient { path: string, body?: unknown, ): Promise { - const info = this.load(); - const url = `http://127.0.0.1:${info.port}${path}`; - let response: Response; - try { - response = await fetch(url, { + const perform = async (info: RuntimeInfo): Promise => + fetch(`http://127.0.0.1:${info.port}${path}`, { method, headers: { "content-type": "application/json", @@ -75,20 +72,21 @@ export class BridgeClient { }, body: body === undefined ? undefined : JSON.stringify(body), }); + + let response: Response; + try { + const info = this.load(); + response = await perform(info); } catch (error) { - // Stale port / app restarted: retry once after re-reading the file. + // ENOENT / JSON parse / connection refused: Superset may have + // been started after this MCP, restarted on a new port, or the + // SUPERSET_HOME_DIR env was wrong. Drop the cached file and try + // once more; if that still fails, surface the friendly + // BridgeUnavailableError instead of a raw fs/fetch exception. this.reset(); try { const fresh = this.load(); - response = await fetch(`http://127.0.0.1:${fresh.port}${path}`, { - method, - headers: { - "content-type": "application/json", - authorization: `Bearer ${fresh.secret}`, - "x-superset-mcp-ppid": String(this.ppid), - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); + response = await perform(fresh); } catch (retryError) { throw new BridgeUnavailableError(retryError ?? error); } From bc1a7291c5fd39c36e4dafe5345bbe9c649527c4 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:52:03 +0900 Subject: [PATCH 05/11] fix(desktop/mcp): security + robustness from codex/coderabbit review - supervisor-engine: drop the pid-registry wiring. The todo-daemon runs in a separate process from main, so an in-process map never reaches the bridge. TODO-agent PID matching will come through daemon-bridge IPC in a follow-up. (codex P1, CodeRabbit Major) - pane-resolver: try/catch per pane so a single PTY race failing its process-tree lookup doesn't take down the whole MCP resolution. Positive results still cache; negatives do not. (CodeRabbit Major) - browser-mcp-bridge/server: - chmodSync(0o600) after write so an existing runtime file can't stay world-readable. (CodeRabbit Major) - validateNavigateUrl: allowlist http(s) only, reject file:, js:, about:, etc. (CodeRabbit Major) - CDP debugger message/detach listeners are now off()'d on detach so re-attaching the same pane doesn't double-fire console events. (CodeRabbit Minor) - superset-browser-mcp bridge client: - 15s fetch timeout via AbortController; a hung port no longer stalls MCP tool calls forever. (CodeRabbit Major) - Strict runtime-info validation: port is 1..65535 integer, secret is non-empty string. (CodeRabbit Minor) - tools/index: wording uses "LLM session" instead of "Claude session" so Codex sessions read naturally. (CodeRabbit Minor) --- .../lib/browser-mcp-bridge/pane-resolver.ts | 46 ++++++------- .../lib/browser-mcp-bridge/pid-registry.ts | 31 --------- .../src/main/lib/browser-mcp-bridge/server.ts | 64 ++++++++++++++++--- .../src/main/todo-daemon/supervisor-engine.ts | 28 +++----- .../superset-browser-mcp/src/tools/index.ts | 6 +- .../src/transport/bridge-client.ts | 51 +++++++++++---- 6 files changed, 123 insertions(+), 103 deletions(-) delete mode 100644 apps/desktop/src/main/lib/browser-mcp-bridge/pid-registry.ts diff --git a/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts index d4a9d973e46..4d4c7edf0a0 100644 --- a/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts @@ -1,19 +1,21 @@ import { getProcessName, getProcessTree } from "main/lib/terminal/port-scanner"; import { getTerminalHostClient } from "main/lib/terminal-host/client"; import { bindingStore } from "../../../lib/trpc/routers/browser-automation/index"; -import { findTodoAgentSessionByPid } from "./pid-registry"; /** * PID-based automatic mapping from an MCP process's PPID (the Claude / - * Codex CLI that spawned the MCP) to a Superset session and therefore a - * bound browser pane. + * Codex CLI that spawned the MCP) to a Superset session and therefore + * a bound browser pane. * - * Resolution order: - * 1. A todo-agent worker PID registered with this exact PID. - * 2. A terminal pane whose PTY process tree contains this PID. + * Resolution today walks every live terminal pane's PTY process tree + * for the PPID. TODO-Agent worker resolution will be added in a + * follow-up that pipes the worker PID through the daemon-bridge IPC + * (the daemon is a separate process, so an in-process registry cannot + * reach this main-process code). * - * The first match wins. The mapping is cached briefly so we do not re-walk - * every terminal's /proc tree on every MCP tool call. + * Positive resolutions are cached briefly so we do not re-walk process + * trees on every tool call. Negative resolutions are NOT cached — a + * miss can be a transient listSessions failure or a brief race. */ export interface ResolvedSession { sessionId: string; @@ -24,7 +26,7 @@ export interface ResolvedSession { const CACHE_TTL_MS = 5_000; interface CacheEntry { - resolved: ResolvedSession | null; + resolved: ResolvedSession; at: number; } @@ -45,10 +47,11 @@ async function resolveFromTerminalPanes( } for (const s of sessions) { if (!s.isAlive || typeof s.pid !== "number") continue; - const tree = await getProcessTree(s.pid); - if (tree.includes(ppid)) { - // Validate the PPID actually looks like an agent so we don't - // snap random terminal children into sessions. + // A single pane's process tree / name lookup can race with + // exit; swallow the per-pane failure and try the next one. + try { + const tree = await getProcessTree(s.pid); + if (!tree.includes(ppid)) continue; const name = await getProcessName(ppid).catch(() => ""); if (name === "claude" || name === "codex" || name.includes("node")) { return { @@ -57,19 +60,13 @@ async function resolveFromTerminalPanes( paneId: s.paneId, }; } + } catch { + // Keep scanning — other panes may still match. } } return null; } -function resolveFromTodoAgent(ppid: number): ResolvedSession | null { - const sessionId = findTodoAgentSessionByPid(ppid); - if (sessionId) { - return { sessionId, kind: "todo-agent" }; - } - return null; -} - export async function resolvePpidToSession( ppid: number, ): Promise { @@ -77,12 +74,7 @@ export async function resolvePpidToSession( if (cached && Date.now() - cached.at < CACHE_TTL_MS) { return cached.resolved; } - const todo = resolveFromTodoAgent(ppid); - const resolved = todo ?? (await resolveFromTerminalPanes(ppid)); - // Only cache hits. A null result can be caused by a transient - // listSessions failure or a brief race before the todo-agent worker - // has registered its PID; caching that would lock the MCP out for - // the TTL and surface "No browser pane bound" until it expired. + const resolved = await resolveFromTerminalPanes(ppid); if (resolved) cache.set(ppid, { resolved, at: Date.now() }); return resolved; } diff --git a/apps/desktop/src/main/lib/browser-mcp-bridge/pid-registry.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/pid-registry.ts deleted file mode 100644 index bd95628263c..00000000000 --- a/apps/desktop/src/main/lib/browser-mcp-bridge/pid-registry.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * In-memory registry of { pid: string -> todoSessionId }. The todo-agent - * supervisor reports each Claude worker it spawns here so the browser-mcp - * bridge can resolve `process.ppid` -> running session in one lookup, - * without having to walk every terminal's PTY tree for todo-agent cases. - * - * Entries are cleared on process exit. - */ -const byPid = new Map(); - -export function registerTodoAgentWorker(sessionId: string, pid: number): void { - byPid.set(pid, sessionId); -} - -export function unregisterTodoAgentWorker(pid: number): void { - byPid.delete(pid); -} - -export function findTodoAgentSessionByPid(pid: number): string | null { - return byPid.get(pid) ?? null; -} - -export function listTodoAgentWorkers(): Array<{ - pid: number; - sessionId: string; -}> { - return Array.from(byPid.entries()).map(([pid, sessionId]) => ({ - pid, - sessionId, - })); -} 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 1ab3f29dd52..e7482c9e301 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 { mkdirSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; import { createServer, type IncomingMessage, @@ -53,7 +53,13 @@ async function ensureDebuggerAttached( await wc.debugger.sendCommand("Page.enable"); await wc.debugger.sendCommand("Runtime.enable"); await wc.debugger.sendCommand("Log.enable").catch(() => {}); - wc.debugger.on("message", (_event, method, params) => { + // Capture the listener refs so we can detach them on `detach`, + // otherwise re-attaching the same pane double-fires console events. + const onMessage = ( + _event: Electron.Event, + method: string, + params: unknown, + ) => { if ( method === "Runtime.consoleAPICalled" || method === "Log.entryAdded" @@ -76,15 +82,41 @@ async function ensureDebuggerAttached( if (buf.length > CONSOLE_BUFFER_LIMIT) buf.shift(); consoleByPane.set(paneId, buf); } - }); - wc.debugger.on("detach", () => { + }; + const onDetach = () => { attachedPanes.delete(paneId); - }); + wc.debugger.off("message", onMessage); + wc.debugger.off("detach", onDetach); + }; + wc.debugger.on("message", onMessage); + wc.debugger.on("detach", onDetach); attachedPanes.add(paneId); } return wc; } +// Allow only network-facing schemes in navigate — blocks file:, javascript:, +// about:, chrome: etc that could leak local content or escalate via tool use. +const ALLOWED_NAVIGATE_PROTOCOLS = new Set(["http:", "https:"]); + +function validateNavigateUrl(raw: unknown): URL | { error: string } { + if (typeof raw !== "string" || raw.length === 0) { + return { error: "url required" }; + } + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return { error: "url must be an absolute URL" }; + } + if (!ALLOWED_NAVIGATE_PROTOCOLS.has(parsed.protocol)) { + return { + error: `protocol ${parsed.protocol} is not allowed; use http(s)`, + }; + } + return parsed; +} + async function resolvePaneFromRequest( req: IncomingMessage, ): Promise< @@ -191,11 +223,17 @@ export async function startBrowserMcpBridge(): Promise { const resolved = await resolvePaneFromRequest(req); if ("error" in resolved) return send(res, resolved.status, { error: resolved.error }); - const body = await readJson<{ url?: string }>(req); - if (!body.url) return send(res, 400, { error: "url required" }); + const body = await readJson<{ url?: unknown }>(req); + const target = validateNavigateUrl(body.url); + if ("error" in target) return send(res, 400, { error: target.error }); const wc = await ensureDebuggerAttached(resolved.paneId); - await wc.debugger.sendCommand("Page.navigate", { url: body.url }); - return send(res, 200, { paneId: resolved.paneId, url: body.url }); + await wc.debugger.sendCommand("Page.navigate", { + url: target.toString(), + }); + return send(res, 200, { + paneId: resolved.paneId, + url: target.toString(), + }); } if (req.method === "POST" && url.pathname === "/mcp/screenshot") { @@ -279,6 +317,14 @@ export async function startBrowserMcpBridge(): Promise { writeFileSync(RUNTIME_INFO_PATH, JSON.stringify({ port, secret }, null, 2), { mode: 0o600, }); + // writeFileSync's mode only applies to new files — an existing + // runtime file from a previous run could still be world-readable. + // Force 0600 on every start so the shared secret stays locked down. + try { + chmodSync(RUNTIME_INFO_PATH, 0o600); + } catch { + /* best-effort */ + } app.on("will-quit", () => { server.close(); diff --git a/apps/desktop/src/main/todo-daemon/supervisor-engine.ts b/apps/desktop/src/main/todo-daemon/supervisor-engine.ts index f1a33821738..2f0af433b28 100644 --- a/apps/desktop/src/main/todo-daemon/supervisor-engine.ts +++ b/apps/desktop/src/main/todo-daemon/supervisor-engine.ts @@ -428,25 +428,15 @@ export class TodoSupervisorEngine { remoteControlEnabled, onChild: (child) => { run.currentChild = child; - const childPid = child.pid; - if (typeof childPid === "number" && childPid > 0) { - // Publish the Claude worker PID so the browser-mcp - // bridge can map MCP processes (spawned under this - // Claude) back to this TODO-Agent session without - // walking PTY trees. - void import("../lib/browser-mcp-bridge/pid-registry").then( - ({ registerTodoAgentWorker }) => { - registerTodoAgentWorker(currentSession.id, childPid); - }, - ); - child.once("exit", () => { - void import("../lib/browser-mcp-bridge/pid-registry").then( - ({ unregisterTodoAgentWorker }) => { - unregisterTodoAgentWorker(childPid); - }, - ); - }); - } + // NOTE: browser-mcp bridge PID-based mapping for + // TODO-Agent workers is not wired here — the daemon + // runs in a separate process from main, so + // pid-registry writes would not be visible to the + // bridge. TODO-Agent MCP resolution will be added in + // a follow-up that pipes the PID through the + // daemon-bridge IPC. Terminal-pane claude/codex + // sessions continue to resolve automatically via + // the PTY process tree. }, }); run.currentChild = null; diff --git a/packages/superset-browser-mcp/src/tools/index.ts b/packages/superset-browser-mcp/src/tools/index.ts index 69d85d6537e..34ca2d24380 100644 --- a/packages/superset-browser-mcp/src/tools/index.ts +++ b/packages/superset-browser-mcp/src/tools/index.ts @@ -38,7 +38,7 @@ export function registerTools(server: McpServer, client: BridgeClient): void { { title: "Get connected browser pane", description: - "Return the currently bound browser pane for this Claude session. Reports whether a pane is bound, its URL and title.", + "Return the currently bound browser pane for this LLM session. Reports whether a pane is bound, its URL and title.", inputSchema: {}, }, async () => { @@ -49,7 +49,7 @@ export function registerTools(server: McpServer, client: BridgeClient): void { type: "text", text: data.bound ? `Bound to pane ${data.paneId} (${data.url ?? "blank"}): ${data.title ?? ""}` - : "No browser pane is bound to this Claude session. Open the Connect dialog in the Superset UI to pick one.", + : "No browser pane is bound to this LLM session. Open the Connect dialog in the Superset UI to pick one.", }, ], }; @@ -61,7 +61,7 @@ export function registerTools(server: McpServer, client: BridgeClient): void { { title: "Navigate the bound browser pane", description: - "Navigate the browser pane that the user has bound to this Claude session to the given URL. The binding is managed in the Superset UI.", + "Navigate the browser pane that the user has bound to this LLM session to the given URL. The binding is managed in the Superset UI.", inputSchema: { url: z.string().describe("Absolute URL (must include scheme)"), }, diff --git a/packages/superset-browser-mcp/src/transport/bridge-client.ts b/packages/superset-browser-mcp/src/transport/bridge-client.ts index 84eea7e2321..81b905a1ee7 100644 --- a/packages/superset-browser-mcp/src/transport/bridge-client.ts +++ b/packages/superset-browser-mcp/src/transport/bridge-client.ts @@ -20,13 +20,24 @@ interface RuntimeInfo { secret: string; } +const REQUEST_TIMEOUT_MS = 15_000; + function readRuntimeInfo(): RuntimeInfo { const contents = readFileSync(runtimeInfoPath(), "utf8"); - const parsed = JSON.parse(contents) as RuntimeInfo; - if (typeof parsed.port !== "number" || typeof parsed.secret !== "string") { - throw new Error(`Invalid ${runtimeInfoPath()}: expected { port, secret }`); + const parsed = JSON.parse(contents) as Partial; + const { port, secret } = parsed; + if ( + !Number.isInteger(port) || + (port as number) < 1 || + (port as number) > 65_535 || + typeof secret !== "string" || + secret.length === 0 + ) { + throw new Error( + `Invalid ${runtimeInfoPath()}: expected { port: 1..65535, secret: non-empty string }`, + ); } - return parsed; + return { port: port as number, secret }; } export class BridgeUnavailableError extends Error { @@ -62,16 +73,28 @@ export class BridgeClient { path: string, body?: unknown, ): Promise { - const perform = async (info: RuntimeInfo): Promise => - fetch(`http://127.0.0.1:${info.port}${path}`, { - method, - headers: { - "content-type": "application/json", - authorization: `Bearer ${info.secret}`, - "x-superset-mcp-ppid": String(this.ppid), - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); + const perform = async (info: RuntimeInfo): Promise => { + // If the cached port was reused by an unrelated process that + // hangs instead of replying, the MCP tool call would stall + // forever. Apply a deadline so the retry/reset path can take + // over. + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + return await fetch(`http://127.0.0.1:${info.port}${path}`, { + method, + headers: { + "content-type": "application/json", + authorization: `Bearer ${info.secret}`, + "x-superset-mcp-ppid": String(this.ppid), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + }; let response: Response; try { From 9efe4cf34064af6a1f41376c9781ef2fe644edac Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:03:14 +0900 Subject: [PATCH 06/11] fix(desktop): hide todo-agent sessions from Connect until bridge supports them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit browser-mcp pane-resolver cannot map MCP PIDs to TODO-Agent workers (they run in the separate todo-daemon process). The UI was still offering TODO-Agent sessions in the Connect list, and setBinding accepted them — both led to silently-broken bindings. - useBrowserAutomationData: drop the todoAgent.listAll query and the todo-agent row mapping. Only terminal-derived sessions are offered. - setBinding: reject sessionKind="todo-agent" with an explanatory error. Default sessionKind flipped to "terminal". (codex P1) --- .../trpc/routers/browser-automation/index.ts | 21 ++++++-- .../hooks/useBrowserAutomationData.ts | 51 +++---------------- 2 files changed, 25 insertions(+), 47 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts index a21633e0ef8..b442893232e 100644 --- a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts +++ b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts @@ -489,12 +489,25 @@ export const createBrowserAutomationRouter = () => { z.object({ paneId: z.string(), sessionId: z.string(), - sessionKind: z.enum(["todo-agent", "terminal"]).default("todo-agent"), + sessionKind: z.enum(["todo-agent", "terminal"]).default("terminal"), }), ) - .mutation(({ input }) => - bindingStore.set(input.paneId, input.sessionId, input.sessionKind), - ), + .mutation(({ input }) => { + // TODO-Agent workers live in the todo-daemon process; the + // browser-mcp bridge in main can't resolve their PIDs yet. + // Reject the binding instead of letting users create one + // whose MCP tool calls would always error. + if (input.sessionKind === "todo-agent") { + throw new Error( + "TODO-Agent browser automation bindings are not supported yet. Run claude / codex in a Superset terminal pane instead.", + ); + } + return bindingStore.set( + input.paneId, + input.sessionId, + input.sessionKind, + ); + }), removeBinding: publicProcedure .input(z.object({ paneId: z.string() })) diff --git a/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts b/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts index 8966b4bf0d4..65f26dcc26b 100644 --- a/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts +++ b/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts @@ -29,12 +29,6 @@ export function useBrowserAutomationData({ } = {}) { const panes = useTabsStore((s) => s.panes); - const { data: todoSessions = [], refetch: refetchSessions } = - electronTrpc.todoAgent.listAll.useQuery(undefined, { - enabled, - refetchOnWindowFocus: enabled, - refetchInterval: enabled ? 15000 : false, - }); const { data: terminalAgents = [] } = electronTrpc.browserAutomation.listTerminalAgentSessions.useQuery( undefined, @@ -60,15 +54,12 @@ export function useBrowserAutomationData({ // consumer. const sessions: AutomationSession[] = useMemo(() => { - // Only sessions that have a live worker (or are actively scheduled to - // wake up) should be connectable. Queued/paused/aborted/done/failed/ - // escalated sessions either never started or are terminal. - const liveStatuses = new Set([ - "running", - "preparing", - "verifying", - "waiting", - ]); + // TODO-Agent sessions are intentionally hidden here. The browser-mcp + // bridge resolves MCP → session by walking terminal PTY trees, and + // the TODO-Agent daemon runs in a separate process so its worker + // PIDs are not visible to the bridge. Showing TODO-Agent rows would + // let users build bindings that always fail at tool-call time. + // Re-enable once the daemon-bridge IPC pipe lands. const claudeReadyForWorkspace = (workspaceId: string | null): McpStatus => { if (!mcpStatus) return "unknown"; if (mcpStatus.claudeHomeReady) return "ready"; @@ -76,30 +67,7 @@ export function useBrowserAutomationData({ return "ready"; return "missing"; }; - const todo: AutomationSession[] = todoSessions - .filter((s) => liveStatuses.has(s.status)) - .map((s) => { - // Todo-agent rows always represent Claude Code workers (see - // todo-daemon/claude-code-runner.ts). - const provider = "Claude" as const; - const mcp: McpStatus = claudeReadyForWorkspace(s.workspaceId); - const displayName = s.title || `Session ${s.id.slice(0, 6)}`; - const branchOrContext = - s.workspaceBranch ?? - s.workspaceName ?? - (s.projectName ? s.projectName : "workspace"); - return { - id: s.id, - displayName, - provider, - kind: "TODO-Agent", - branchOrContextLabel: branchOrContext, - lastActiveAt: formatRelativeTime(s.updatedAt ?? s.createdAt), - mcpStatus: mcp, - }; - }); - - const terminal: AutomationSession[] = terminalAgents.map((t) => { + return terminalAgents.map((t): AutomationSession => { const pane = panes[t.paneId]; const mcp: McpStatus = t.provider === "Codex" @@ -121,9 +89,7 @@ export function useBrowserAutomationData({ mcpStatus: mcp, }; }); - - return [...todo, ...terminal]; - }, [todoSessions, terminalAgents, mcpStatus, panes]); + }, [terminalAgents, mcpStatus, panes]); const bindingsByPane = useMemo(() => { const map: Record = {}; @@ -135,6 +101,5 @@ export function useBrowserAutomationData({ sessions, bindingsByPane, mcpStatus, - refetchSessions, }; } From cd71dad6b12636284c1b5580b12a6ba0181182e9 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:14:40 +0900 Subject: [PATCH 07/11] fix(mcp): reload runtime info on 401/403 once per request A Superset restart can keep the same loopback port but rotate the shared secret, in which case BridgeClient kept sending the stale Bearer token and got 401 forever. On auth failure, drop the cached runtime info and retry with the freshly-read file once. (codex P2) --- .../src/transport/bridge-client.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/superset-browser-mcp/src/transport/bridge-client.ts b/packages/superset-browser-mcp/src/transport/bridge-client.ts index 81b905a1ee7..30f3221159a 100644 --- a/packages/superset-browser-mcp/src/transport/bridge-client.ts +++ b/packages/superset-browser-mcp/src/transport/bridge-client.ts @@ -97,6 +97,7 @@ export class BridgeClient { }; let response: Response; + let reloaded = false; try { const info = this.load(); response = await perform(info); @@ -107,6 +108,7 @@ export class BridgeClient { // once more; if that still fails, surface the friendly // BridgeUnavailableError instead of a raw fs/fetch exception. this.reset(); + reloaded = true; try { const fresh = this.load(); response = await perform(fresh); @@ -114,6 +116,20 @@ export class BridgeClient { throw new BridgeUnavailableError(retryError ?? error); } } + // A Superset restart may keep the same port but rotate the secret. + // Treat auth failures as a stale-runtime-info signal and reload + // once before surfacing the error. Skip if we already reloaded + // above (e.g. Superset is genuinely returning 401 for a valid + // fresh secret). + if (!reloaded && (response.status === 401 || response.status === 403)) { + this.reset(); + try { + const fresh = this.load(); + response = await perform(fresh); + } catch { + // fall through to the generic error path below + } + } if (!response.ok) { const text = await response.text().catch(() => response.statusText); throw new Error(`Superset bridge ${response.status}: ${text}`); From 87d0d640bf3e46d15234e276f037d444da3d1e70 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:25:37 +0900 Subject: [PATCH 08/11] fix(desktop): sweep persisted todo-agent bindings on liveness read Older builds allowed todo-agent bindings to be saved. Now that the Connect flow hides those sessions, a stored todo-agent binding would show 'Connected' on the toolbar even though the MCP bridge cannot reach it. Drop todo-agent rows from the bindings table at the top of listBindingLiveness, and always report live=false for any non-terminal binding. (codex P2) --- .../trpc/routers/browser-automation/index.ts | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts index b442893232e..7ed8befc530 100644 --- a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts +++ b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts @@ -15,7 +15,6 @@ import { app } from "electron"; import { localDb } from "main/lib/local-db"; import { getProcessName, getProcessTree } from "main/lib/terminal/port-scanner"; import { getTerminalHostClient } from "main/lib/terminal-host/client"; -import { getTodoSessionStore } from "main/todo-agent/session-store"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -427,6 +426,17 @@ export const createBrowserAutomationRouter = () => { * against the live status whitelist. */ listBindingLiveness: publicProcedure.query(async () => { + // Sweep out any persisted todo-agent bindings — those were + // allowed by an earlier build but the MCP bridge cannot resolve + // them yet. Leaving them would show up as "Connected" on the + // ConnectButton even though no session is reachable. After the + // sweep, re-read. + const stored = bindingStore.list(); + for (const b of stored) { + if (b.sessionKind === "todo-agent") { + bindingStore.remove(b.paneId); + } + } const bindings = bindingStore.list(); if (bindings.length === 0) return [] as Array<{ @@ -438,23 +448,9 @@ export const createBrowserAutomationRouter = () => { const hasTerminalBinding = bindings.some( (b) => b.sessionKind === "terminal", ); - const hasTodoBinding = bindings.some((b) => b.sessionKind !== "terminal"); - const liveTodoIds = hasTodoBinding - ? new Set( - getTodoSessionStore() - .listAll() - .filter((s) => - ["running", "preparing", "verifying", "waiting"].includes( - s.status, - ), - ) - .map((s) => s.id), - ) - : new Set(); // Only probe the terminal daemon when at least one binding actually // points at a terminal — otherwise every Connect button's 15s poll - // would wake the terminal-host and walk every PTY's process tree - // just to confirm TODO-Agent liveness we already have in memory. + // would wake the terminal-host and walk every PTY's process tree. const liveTerminalIds = hasTerminalBinding ? new Set( (await detectTerminalAgentSessions()).map( @@ -466,7 +462,7 @@ export const createBrowserAutomationRouter = () => { const live = b.sessionKind === "terminal" ? liveTerminalIds.has(b.sessionId) - : liveTodoIds.has(b.sessionId); + : false; return { paneId: b.paneId, sessionId: b.sessionId, From bf9b9ed790fa43c02073d088f8315401ed44dc9f Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:43:06 +0900 Subject: [PATCH 09/11] feat(desktop): bundle superset-browser-mcp binary + one-click installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes Superset-browser MCP installable from the app UI with a single button press instead of copy-pasting a snippet, and makes the bundled binary the canonical install so readiness detection only trusts entries pointing at it. ### Bundle - packages/superset-browser-mcp/package.json: `bun build --compile` produces a single executable under `dist/superset-browser-mcp`. - apps/desktop/package.json: `prebuild` compiles the MCP before electron-builder runs. - apps/desktop/electron-builder.ts: extraResources ships the binary into `/Contents/Resources/superset-browser-mcp/`. - resolveSupersetBrowserMcpCommand returns that absolute path in packaged builds, `bun run /src/bin.ts` in dev. ### Installer (tRPC) - browserAutomation.getMcpInstallState: probes claude / codex CLIs, reads their current superset-browser registration, compares command + args against the bundled bin to decide cliFound / installed / matchesExpected. - browserAutomation.installMcp({targets}): idempotent `claude mcp remove + add` / `codex mcp remove + add` against each requested runtime. Remove-first makes reinstalling correct stale paths without blowing up when the entry is already there. ### UI - New McpInstallPanel replaces the old snippet view. Checkboxes for Claude Code / Codex disabled when the CLI is missing, per-runtime status line (✓ installed / ⚠ stale command / not installed / CLI not found), Install button runs the mutation, toast with restart instructions on success. ### Readiness now command-aware (codex P1) - isEnabledMcpEntry accepts an `expected` command and requires the registered command + args to match. Legacy desktop-mcp entries are no longer reported as ready, so the UI reliably surfaces the install flow instead of enabling Connect against a broken registration. - detectClaudeMcp / detectCodexMcp take the same expected argument and getMcpStatus passes its own resolved serverCommand through. --- apps/desktop/electron-builder.ts | 9 + apps/desktop/package.json | 3 +- .../trpc/routers/browser-automation/index.ts | 152 ++++++++++--- .../lib/browser-mcp-bridge/mcp-installer.ts | 205 ++++++++++++++++++ .../SessionConnectModal.tsx | 94 +------- .../McpInstallPanel/McpInstallPanel.tsx | 186 ++++++++++++++++ .../components/McpInstallPanel/index.ts | 1 + packages/superset-browser-mcp/package.json | 3 +- 8 files changed, 531 insertions(+), 122 deletions(-) create mode 100644 apps/desktop/src/main/lib/browser-mcp-bridge/mcp-installer.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/McpInstallPanel/McpInstallPanel.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/McpInstallPanel/index.ts diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index d190cbf2b0f..bcab6a2f253 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -69,6 +69,15 @@ const config: Configuration = { to: "resources/host-migrations", filter: ["**/*"], }, + // Standalone `superset-browser-mcp` binary produced by + // `bun build --compile`. Shipped with the app so users register it + // into Claude Code / Codex via one command with an absolute path + // and never need npm or a separate install step. + { + from: "../../packages/superset-browser-mcp/dist", + to: "resources/superset-browser-mcp", + filter: ["superset-browser-mcp", "superset-browser-mcp.exe"], + }, ], files: [ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7516b0d2636..9fab2a530bc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,7 +22,8 @@ "compile:app": "cross-env NODE_OPTIONS=--max-old-space-size=12288 electron-vite build", "copy:native-modules": "bun run scripts/copy-native-modules.ts", "validate:native-runtime": "bun run scripts/validate-native-runtime.ts", - "prebuild": "bun run clean:dev && bun run generate:icons && bun run compile:app && bun run copy:native-modules && bun run validate:native-runtime", + "build:browser-mcp": "bun --cwd ../../packages/superset-browser-mcp run build:bin", + "prebuild": "bun run clean:dev && bun run generate:icons && bun run compile:app && bun run copy:native-modules && bun run validate:native-runtime && bun run build:browser-mcp", "build": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --publish never", "prepackage": "bun run copy:native-modules && bun run validate:native-runtime", "package": "electron-builder --config electron-builder.ts", diff --git a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts index 7ed8befc530..ebbd90d5668 100644 --- a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts +++ b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts @@ -139,16 +139,34 @@ export const bindingStore = new BindingStore(); const SERVER_NAME = "superset-browser"; -function isEnabledMcpEntry(value: unknown): boolean { +function isEnabledMcpEntry( + value: unknown, + expected?: { command: string; args: string[] }, +): boolean { if (value == null || typeof value !== "object") return false; const entry = value as Record; if (entry.disabled === true) return false; - // An entry needs at minimum a command/url/args hint to be usable. - return ( + const hasShape = typeof entry.command === "string" || typeof entry.url === "string" || - Array.isArray(entry.args) - ); + Array.isArray(entry.args); + if (!hasShape) return false; + // When we know the canonical command the app wants to install (the + // bundled binary path), require the registered entry to match. That + // way a legacy `desktop-mcp` / `superset-browser-mcp` registration + // isn't reported as ready and the UI prompts the user to re-install + // against the current bundled binary. Absence of expected means the + // shape check alone is enough (for callers that do not care yet). + if (!expected) return true; + if (entry.command !== expected.command) return false; + const rawArgs = Array.isArray(entry.args) + ? (entry.args as unknown[]).map(String) + : []; + if (rawArgs.length !== expected.args.length) return false; + for (let i = 0; i < rawArgs.length; i++) { + if (rawArgs[i] !== expected.args[i]) return false; + } + return true; } /** @@ -177,19 +195,24 @@ function mcpServersInObject(obj: unknown): Record | null { */ function detectClaudeMcpInFile( filePath: string, - opts?: { workspacePaths?: readonly string[] }, + opts?: { + workspacePaths?: readonly string[]; + expected?: { command: string; args: string[] }; + }, ): boolean { try { const contents = readFileSync(filePath, "utf8"); const parsed = JSON.parse(contents) as unknown; const topLevel = mcpServersInObject(parsed); - if (topLevel && isEnabledMcpEntry(topLevel[SERVER_NAME])) return true; + if (topLevel && isEnabledMcpEntry(topLevel[SERVER_NAME], opts?.expected)) + return true; const projects = (parsed as Record | null)?.projects; if (projects && typeof projects === "object" && opts?.workspacePaths) { for (const wsPath of opts.workspacePaths) { const project = (projects as Record)[wsPath]; const entries = mcpServersInObject(project); - if (entries && isEnabledMcpEntry(entries[SERVER_NAME])) return true; + if (entries && isEnabledMcpEntry(entries[SERVER_NAME], opts?.expected)) + return true; } } return false; @@ -200,7 +223,10 @@ function detectClaudeMcpInFile( function detectClaudeMcp( paths: readonly string[], - opts?: { workspacePaths?: readonly string[] }, + opts?: { + workspacePaths?: readonly string[]; + expected?: { command: string; args: string[] }; + }, ): boolean { return paths.some((p) => detectClaudeMcpInFile(p, opts)); } @@ -254,17 +280,13 @@ function collectWorkspacePathsByWorkspaceId(): Record< * at least one usable field (`command`, `url`, `args`) and is not marked * `disabled = true`. Comment lines (starting with `#`) are ignored. */ -function detectCodexMcp(filePath: string): boolean { +function detectCodexMcp( + filePath: string, + expected?: { command: string; args: string[] }, +): boolean { try { const contents = readFileSync(filePath, "utf8"); - // TOML accepts several equivalent header forms for the same table: - // [mcp_servers.superset-browser] - // [mcp_servers."superset-browser"] - // [mcp_servers.'superset-browser'] - // ["mcp_servers".superset-browser] (rarely used) - // The regex below matches the common shapes; it is not a full TOML - // parser but is strict enough that typos and unrelated keys don't - // match. + // TOML accepts several equivalent header forms for the same table. const q = `["']`; const name = SERVER_NAME.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); const sectionRe = new RegExp( @@ -277,7 +299,23 @@ function detectCodexMcp(filePath: string): boolean { .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith("#")); if (body.some((line) => /^disabled\s*=\s*true\b/.test(line))) return false; - return body.some((line) => /^(command|url|args)\s*=/.test(line)); + const hasShape = body.some((line) => /^(command|url|args)\s*=/.test(line)); + if (!hasShape) return false; + if (!expected) return true; + const commandMatch = body + .find((line) => /^command\s*=/.test(line)) + ?.match(/"([^"]*)"/); + const argsMatches = + body + .find((line) => /^args\s*=/.test(line)) + ?.match(/"([^"]*)"/g) + ?.map((s) => s.replace(/"/g, "")) ?? []; + if ((commandMatch?.[1] ?? "") !== expected.command) return false; + if (argsMatches.length !== expected.args.length) return false; + for (let i = 0; i < argsMatches.length; i++) { + if (argsMatches[i] !== expected.args[i]) return false; + } + return true; } catch { return false; } @@ -364,16 +402,35 @@ function resolveSupersetBrowserMcpCommand(): { args: string[]; available: boolean; } { - if (!app.isPackaged) { - const repoRoot = resolvePath(app.getAppPath(), "../.."); - const binPath = join(repoRoot, "packages/superset-browser-mcp/src/bin.ts"); + if (app.isPackaged) { + // Standalone binary shipped alongside the app (see electron-builder + // extraResources). Single executable, no runtime deps. + const binName = + process.platform === "win32" + ? "superset-browser-mcp.exe" + : "superset-browser-mcp"; + const binPath = join( + process.resourcesPath, + "superset-browser-mcp", + binName, + ); if (existsSync(binPath)) { - return { command: "bun", args: ["run", binPath], available: true }; + return { command: binPath, args: [], available: true }; } + return { + command: binPath, + args: [], + available: false, + }; + } + const repoRoot = resolvePath(app.getAppPath(), "../.."); + const binPath = join(repoRoot, "packages/superset-browser-mcp/src/bin.ts"); + if (existsSync(binPath)) { + return { command: "bun", args: ["run", binPath], available: true }; } return { - command: "bunx", - args: ["@superset/superset-browser-mcp"], + command: "bun", + args: ["run", binPath], available: false, }; } @@ -390,24 +447,35 @@ export const createBrowserAutomationRouter = () => { // * `~/.claude.json` under `projects[]` // (local scope, where `claude mcp add` lands by default) // * `/.mcp.json` (project scope) - const claudeHomeReady = detectClaudeMcp(CLAUDE_CONFIG_PATHS); + // Only accept entries that point at *this* install's bundled + // binary. An older desktop-mcp / legacy superset-browser-mcp + // registration from a prior build would otherwise be reported + // as ready and the UI would enable Connect against a command + // that does not exist. + const expected = resolveSupersetBrowserMcpCommand(); + const claudeHomeReady = detectClaudeMcp(CLAUDE_CONFIG_PATHS, { + expected, + }); const wsInfo = collectWorkspacePathsByWorkspaceId(); const claudeReadyByWorkspaceId: Record = {}; for (const [workspaceId, info] of Object.entries(wsInfo)) { const localScope = detectClaudeMcpInFile(CLAUDE_USER_JSON_PATH, { workspacePaths: [info.base], + expected, + }); + const projectScope = detectClaudeMcpInFile(info.mcpJsonPath, { + expected, }); - const projectScope = detectClaudeMcpInFile(info.mcpJsonPath); claudeReadyByWorkspaceId[workspaceId] = localScope || projectScope; } - const codexReady = detectCodexMcp(CODEX_CONFIG_PATH); + const codexReady = detectCodexMcp(CODEX_CONFIG_PATH, expected); return { claudeHomeReady, claudeReadyByWorkspaceId, codexReady, claudeConfigPath: CLAUDE_USER_JSON_PATH, codexConfigPath: CODEX_CONFIG_PATH, - serverCommand: resolveSupersetBrowserMcpCommand(), + serverCommand: expected, }; }), @@ -511,6 +579,32 @@ export const createBrowserAutomationRouter = () => { removed: bindingStore.remove(input.paneId), })), + getMcpInstallState: publicProcedure.query(async () => { + const { getInstallState } = await import( + "main/lib/browser-mcp-bridge/mcp-installer" + ); + return getInstallState(resolveSupersetBrowserMcpCommand()); + }), + + installMcp: publicProcedure + .input( + z.object({ + targets: z.array(z.enum(["claude", "codex"])).min(1), + }), + ) + .mutation(async ({ input }) => { + const server = resolveSupersetBrowserMcpCommand(); + if (!server.available) { + throw new Error( + "The bundled superset-browser-mcp binary is not available in this build.", + ); + } + const { installMcp } = await import( + "main/lib/browser-mcp-bridge/mcp-installer" + ); + return installMcp(input.targets, server); + }), + onBindingsChanged: publicProcedure.subscription(() => { return observable((emit) => { emit.next(bindingStore.list()); diff --git a/apps/desktop/src/main/lib/browser-mcp-bridge/mcp-installer.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/mcp-installer.ts new file mode 100644 index 00000000000..660343e22e2 --- /dev/null +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/mcp-installer.ts @@ -0,0 +1,205 @@ +import { execFile as execFileCb } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFile = promisify(execFileCb); + +const SERVER_NAME = "superset-browser"; + +export type McpTarget = "claude" | "codex"; + +export interface InstallTargetState { + /** CLI binary found on PATH. */ + cliFound: boolean; + /** + * `superset-browser` is already registered. `matchesExpected` is true + * when the registered command + args match what the Superset app would + * install today — if false, re-installing the entry will correct a + * stale legacy registration (e.g. the old `desktop-mcp` bin name). + */ + installed: boolean; + matchesExpected: boolean; + /** Raw command string currently registered, for display only. */ + currentCommand: string | null; +} + +export interface InstallState { + claude: InstallTargetState; + codex: InstallTargetState; +} + +interface ExpectedCommand { + command: string; + args: string[]; +} + +async function which(binary: string): Promise { + try { + const { stdout } = await execFile( + process.platform === "win32" ? "where" : "which", + [binary], + ); + return stdout.trim().length > 0; + } catch { + return false; + } +} + +function commandsEqual( + a: { command: string; args: string[] }, + b: ExpectedCommand, +): boolean { + if (a.command !== b.command) return false; + if (a.args.length !== b.args.length) return false; + for (let i = 0; i < a.args.length; i++) { + if (a.args[i] !== b.args[i]) return false; + } + return true; +} + +async function probeClaude( + expected: ExpectedCommand, +): Promise { + const cliFound = await which("claude"); + if (!cliFound) { + return { + cliFound: false, + installed: false, + matchesExpected: false, + currentCommand: null, + }; + } + try { + const { stdout } = await execFile("claude", ["mcp", "get", SERVER_NAME]); + const lines = stdout.split("\n"); + const commandLine = lines.find((l) => /^\s*command:/i.test(l)); + const argsLine = lines.find((l) => /^\s*args:/i.test(l)); + const command = commandLine?.split(":").slice(1).join(":").trim() ?? ""; + const argsRaw = argsLine?.split(":").slice(1).join(":").trim() ?? ""; + const args = argsRaw.length > 0 ? argsRaw.split(/\s+/) : []; + return { + cliFound: true, + installed: true, + matchesExpected: commandsEqual({ command, args }, expected), + currentCommand: [command, ...args].filter(Boolean).join(" "), + }; + } catch { + return { + cliFound: true, + installed: false, + matchesExpected: false, + currentCommand: null, + }; + } +} + +function probeCodex(expected: ExpectedCommand): InstallTargetState { + const cliFound = true; // Probed separately when install is requested. + const configPath = join(homedir(), ".codex", "config.toml"); + let contents: string; + try { + contents = readFileSync(configPath, "utf8"); + } catch { + return { + cliFound, + installed: false, + matchesExpected: false, + currentCommand: null, + }; + } + const nameRe = new RegExp( + String.raw`(^|\n)\[\s*mcp_servers\.(?:${SERVER_NAME}|["']${SERVER_NAME}["'])\s*\]\s*\n([\s\S]*?)(?=\n\[|$)`, + ); + const match = contents.match(nameRe); + if (!match) { + return { + cliFound, + installed: false, + matchesExpected: false, + currentCommand: null, + }; + } + const body = match[2] + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith("#")); + const commandLine = body.find((l) => /^command\s*=/.test(l)); + const argsLine = body.find((l) => /^args\s*=/.test(l)); + const commandMatch = commandLine?.match(/^command\s*=\s*"([^"]*)"/); + const command = commandMatch?.[1] ?? ""; + const argsMatches = + argsLine?.match(/"([^"]*)"/g)?.map((s) => s.replace(/"/g, "")) ?? []; + return { + cliFound, + installed: true, + matchesExpected: commandsEqual({ command, args: argsMatches }, expected), + currentCommand: [command, ...argsMatches].filter(Boolean).join(" "), + }; +} + +export async function getInstallState( + expected: ExpectedCommand, +): Promise { + const [claude, codexCliFound] = await Promise.all([ + probeClaude(expected), + which("codex"), + ]); + const codexBase = probeCodex(expected); + return { + claude, + codex: { ...codexBase, cliFound: codexCliFound }, + }; +} + +async function installForClaude(expected: ExpectedCommand): Promise { + // `claude mcp add` fails if the name already exists; remove first so + // the call is idempotent and also corrects stale command paths. + await execFile("claude", ["mcp", "remove", SERVER_NAME]).catch(() => {}); + await execFile("claude", [ + "mcp", + "add", + SERVER_NAME, + "-s", + "user", + "--", + expected.command, + ...expected.args, + ]); +} + +async function installForCodex(expected: ExpectedCommand): Promise { + await execFile("codex", ["mcp", "remove", SERVER_NAME]).catch(() => {}); + await execFile("codex", [ + "mcp", + "add", + SERVER_NAME, + "--", + expected.command, + ...expected.args, + ]); +} + +export async function installMcp( + targets: readonly McpTarget[], + expected: ExpectedCommand, +): Promise> { + const results: Record = { + claude: { ok: false, error: null }, + codex: { ok: false, error: null }, + }; + for (const target of targets) { + try { + if (target === "claude") await installForClaude(expected); + else await installForCodex(expected); + results[target] = { ok: true, error: null }; + } catch (error) { + results[target] = { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + return results; +} 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 1fa4eb85412..90c6e507ad6 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 @@ -10,7 +10,7 @@ import { import { toast } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; import { useEffect, useMemo } from "react"; -import { LuCopy, LuList } from "react-icons/lu"; +import { LuList } from "react-icons/lu"; import { useBrowserAutomationData } from "renderer/hooks/useBrowserAutomationData"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { @@ -20,6 +20,7 @@ import { useBrowserAutomationStore, } from "renderer/stores/browser-automation"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { McpInstallPanel } from "./components/McpInstallPanel"; interface SessionConnectModalProps { open: boolean; @@ -452,101 +453,12 @@ function DetailItem({ } function SetupPanel({ - session, - mcpConfigPath, serverCommand, - onCopy, }: { session: AutomationSession; mcpConfigPath: string | null; serverCommand?: ServerCommand; onCopy: () => void; }) { - const snippet = getSnippetForSession(session, serverCommand); - // `available: false` means the Superset install cannot start the MCP - // locally yet (e.g. packaged build before the @superset/superset-browser-mcp - // package ships on npm). Surface that state instead of handing the user - // a command that would fail. - if (serverCommand && !serverCommand.available) { - return ( -
-
-
- Browser MCP is not yet available in this build -
-
- The @superset/superset-browser-mcp package needs to - ship before this release can register the MCP for you. Until then - run the browser automation from a dev build, or check back once the - next desktop release lands. -
-
-
- ); - } - return ( -
-
-
- This session needs browser MCP setup -
-
- The connect action will not fail silently. Add the{" "} - superset-browser MCP - server to {session.provider}, then reload this session. -
-
    - {session.provider === "Claude" ? ( - <> -
  1. - Run the command below in a terminal that has the{" "} - claude CLI installed. It will register{" "} - superset-browser in{" "} - ~/.claude.json{" "} - without hand-editing JSON. -
  2. -
  3. - Restart {session.displayName} (or run /mcp in the - session) so the new entry is picked up. -
  4. - - ) : ( - <> -
  5. - Open{" "} - {mcpConfigPath ? ( - {mcpConfigPath} - ) : ( - "your Codex config file" - )} - . -
  6. -
  7. - Append the [mcp_servers.superset-browser] section - below. TOML section-append is safe against existing content. -
  8. -
  9. - Restart {session.displayName} so the new entry is picked up. -
  10. - - )} -
-
-					{snippet}
-				
-
- -
-
- MCP readiness is detected by inspecting the config file for the string{" "} - superset-browser. If you prefer a managed location, the - desktop app also ships the server at packages/desktop-mcp - . -
-
-
- ); + return ; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/McpInstallPanel/McpInstallPanel.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/McpInstallPanel/McpInstallPanel.tsx new file mode 100644 index 00000000000..4e1338df6dd --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/McpInstallPanel/McpInstallPanel.tsx @@ -0,0 +1,186 @@ +import { Button } from "@superset/ui/button"; +import { Checkbox } from "@superset/ui/checkbox"; +import { toast } from "@superset/ui/sonner"; +import { useState } from "react"; +import { LuInfo } from "react-icons/lu"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import type { ServerCommand } from "renderer/stores/browser-automation"; + +interface McpInstallPanelProps { + serverCommand?: ServerCommand; +} + +/** + * One-click installer for the bundled `superset-browser-mcp` into Claude + * Code and/or Codex. The canonical command comes from the app itself + * (getMcpStatus.serverCommand) so we never hand the user a stub command + * that would fail to start. Re-installing corrects stale registrations + * whose command paths no longer match the current bundled binary. + */ +export function McpInstallPanel({ serverCommand }: McpInstallPanelProps) { + const utils = electronTrpc.useUtils(); + const { data: state, isLoading } = + electronTrpc.browserAutomation.getMcpInstallState.useQuery(undefined, { + refetchOnWindowFocus: true, + refetchInterval: 30_000, + }); + const installMutation = + electronTrpc.browserAutomation.installMcp.useMutation(); + + const canInstallClaude = state?.claude.cliFound ?? false; + const canInstallCodex = state?.codex.cliFound ?? false; + + const [claudeChecked, setClaudeChecked] = useState(true); + const [codexChecked, setCodexChecked] = useState(false); + + if (serverCommand && !serverCommand.available) { + return ( +
+
+ Browser MCP binary is not available in this build +
+
+ The bundled superset-browser-mcp executable is missing + from this install (expected at{" "} + {serverCommand.command} + ). Use a dev build or wait for the next desktop release. +
+
+ ); + } + + const targets = [ + claudeChecked && canInstallClaude ? ("claude" as const) : null, + codexChecked && canInstallCodex ? ("codex" as const) : null, + ].filter((t): t is "claude" | "codex" => t !== null); + + const handleInstall = async () => { + try { + const result = await installMutation.mutateAsync({ targets }); + const okTargets = Object.entries(result) + .filter(([_, v]) => v.ok) + .map(([k]) => k); + const failedTargets = Object.entries(result) + .filter(([_, v]) => v.ok === false && v.error) + .map(([k, v]) => `${k}: ${v.error ?? "unknown"}`); + if (okTargets.length > 0) { + toast.success( + `Registered superset-browser in ${okTargets.join(" + ")}. Restart the agent (or run /mcp in Claude) to pick it up.`, + ); + } + if (failedTargets.length > 0) { + toast.error(`Install failed for: ${failedTargets.join("; ")}`); + } + await utils.browserAutomation.getMcpInstallState.invalidate(); + await utils.browserAutomation.getMcpStatus.invalidate(); + } catch (error) { + toast.error( + `Install failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; + + return ( +
+
+
+ Install Superset Browser MCP +
+
+ Pick which LLM runtime(s) should be able to drive the browser pane. + Installing is a one-shot operation; after this you just bind panes + from the Connect dialog. Already-installed runtimes are kept + idempotent — re-installing corrects stale paths. +
+ +
+ + +
+ +
+ +
+ + {serverCommand && ( +
+ + Will register the command{" "} + + {[serverCommand.command, ...serverCommand.args].join(" ")} + + . +
+ )} +
+
+ ); +} + +function TargetRow({ + label, + subLabel, + checked, + disabled, + onChange, +}: { + label: string; + subLabel: string; + checked: boolean; + disabled: boolean; + onChange: (v: boolean) => void; +}) { + return ( +
+ onChange(v === true)} + className="mt-0.5" + aria-label={label} + /> + + {label} + {subLabel} + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/McpInstallPanel/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/McpInstallPanel/index.ts new file mode 100644 index 00000000000..9178d4e5f60 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/McpInstallPanel/index.ts @@ -0,0 +1 @@ +export { McpInstallPanel } from "./McpInstallPanel"; diff --git a/packages/superset-browser-mcp/package.json b/packages/superset-browser-mcp/package.json index e52afce5302..af2777eb4f1 100644 --- a/packages/superset-browser-mcp/package.json +++ b/packages/superset-browser-mcp/package.json @@ -13,7 +13,8 @@ } }, "scripts": { - "typecheck": "tsc --noEmit --emitDeclarationOnly false" + "typecheck": "tsc --noEmit --emitDeclarationOnly false", + "build:bin": "bun build --compile --minify --sourcemap --outfile dist/superset-browser-mcp src/bin.ts" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", From 23e8a8129fb9835726f8e5d56c90f87a03332539 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:55:01 +0900 Subject: [PATCH 10/11] fix(desktop/mcp): packaged binary path + TOML escape handling + body cap - resolveSupersetBrowserMcpCommand: electron-builder's extraResources 'to: "resources/superset-browser-mcp"' ends up at /Contents/Resources/resources/... , not at process.resourcesPath/superset-browser-mcp/... . Include the 'resources/' segment so packaged builds actually find the bundled bin. (codex P1) - Codex TOML probes now unescape basic-string values (\\, \n, \uXXXX, etc.) and accept single-quoted literal strings, so Windows paths like C:\\Users\\... match correctly instead of being reported as missing. The same helpers are shared between the readiness detector and the install-state probe. (codex P1) - browser-mcp-bridge server: readJson caps request bodies at 8MB and returns 413 Payload Too Large instead of buffering arbitrary data into main-process memory. (CodeRabbit Minor) --- .../trpc/routers/browser-automation/index.ts | 79 ++++++++++++++++--- .../lib/browser-mcp-bridge/mcp-installer.ts | 54 +++++++++++-- .../src/main/lib/browser-mcp-bridge/server.ts | 22 +++++- 3 files changed, 135 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts index ebbd90d5668..707267cb348 100644 --- a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts +++ b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts @@ -280,6 +280,60 @@ function collectWorkspacePathsByWorkspaceId(): Record< * at least one usable field (`command`, `url`, `args`) and is not marked * `disabled = true`. Comment lines (starting with `#`) are ignored. */ +function unescapeTomlBasicString(raw: string): string { + // Minimal TOML basic-string unescape: handles the standard sequences + // users actually write for Windows paths (backslashes) and shell + // invocations. Not a full TOML parser but enough for command / args + // values that come out of `codex mcp add`. + return raw.replace( + /\\(["\\bfnrt]|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8})/g, + (_, esc) => { + switch (esc) { + case "\\": + return "\\"; + case '"': + return '"'; + case "b": + return "\b"; + case "f": + return "\f"; + case "n": + return "\n"; + case "r": + return "\r"; + case "t": + return "\t"; + default: { + const hex = esc.slice(1); + const code = Number.parseInt(hex, 16); + return Number.isFinite(code) ? String.fromCodePoint(code) : ""; + } + } + }, + ); +} + +function extractTomlStrings(line: string | undefined): string[] { + if (!line) return []; + const out: string[] = []; + // Match basic strings "…" (with escapes) and literal strings '…' + // (no escape processing). Both are valid TOML. + const re = /"((?:\\.|[^"\\])*)"|'([^']*)'/g; + for (let m = re.exec(line); m !== null; m = re.exec(line)) { + if (m[1] !== undefined) out.push(unescapeTomlBasicString(m[1])); + else if (m[2] !== undefined) out.push(m[2]); + } + return out; +} + +function parseFirstTomlString(line: string | undefined): string { + return extractTomlStrings(line)[0] ?? ""; +} + +function parseAllTomlStrings(line: string | undefined): string[] { + return extractTomlStrings(line); +} + function detectCodexMcp( filePath: string, expected?: { command: string; args: string[] }, @@ -302,18 +356,14 @@ function detectCodexMcp( const hasShape = body.some((line) => /^(command|url|args)\s*=/.test(line)); if (!hasShape) return false; if (!expected) return true; - const commandMatch = body - .find((line) => /^command\s*=/.test(line)) - ?.match(/"([^"]*)"/); - const argsMatches = - body - .find((line) => /^args\s*=/.test(line)) - ?.match(/"([^"]*)"/g) - ?.map((s) => s.replace(/"/g, "")) ?? []; - if ((commandMatch?.[1] ?? "") !== expected.command) return false; - if (argsMatches.length !== expected.args.length) return false; - for (let i = 0; i < argsMatches.length; i++) { - if (argsMatches[i] !== expected.args[i]) return false; + const commandLine = body.find((line) => /^command\s*=/.test(line)); + const argsLine = body.find((line) => /^args\s*=/.test(line)); + const command = parseFirstTomlString(commandLine); + const args = parseAllTomlStrings(argsLine); + if (command !== expected.command) return false; + if (args.length !== expected.args.length) return false; + for (let i = 0; i < args.length; i++) { + if (args[i] !== expected.args[i]) return false; } return true; } catch { @@ -404,13 +454,16 @@ function resolveSupersetBrowserMcpCommand(): { } { if (app.isPackaged) { // Standalone binary shipped alongside the app (see electron-builder - // extraResources). Single executable, no runtime deps. + // extraResources `to: "resources/superset-browser-mcp"`). On macOS + // process.resourcesPath is /Contents/Resources, so the final + // layout is /Contents/Resources/resources/superset-browser-mcp/. const binName = process.platform === "win32" ? "superset-browser-mcp.exe" : "superset-browser-mcp"; const binPath = join( process.resourcesPath, + "resources", "superset-browser-mcp", binName, ); diff --git a/apps/desktop/src/main/lib/browser-mcp-bridge/mcp-installer.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/mcp-installer.ts index 660343e22e2..957bae0c0b1 100644 --- a/apps/desktop/src/main/lib/browser-mcp-bridge/mcp-installer.ts +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/mcp-installer.ts @@ -4,6 +4,50 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; +function unescapeTomlBasicString(raw: string): string { + return raw.replace( + /\\(["\\bfnrt]|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8})/g, + (_, esc) => { + switch (esc) { + case "\\": + return "\\"; + case '"': + return '"'; + case "b": + return "\b"; + case "f": + return "\f"; + case "n": + return "\n"; + case "r": + return "\r"; + case "t": + return "\t"; + default: { + const hex = esc.slice(1); + const code = Number.parseInt(hex, 16); + return Number.isFinite(code) ? String.fromCodePoint(code) : ""; + } + } + }, + ); +} + +function extractTomlStrings(line: string | undefined): string[] { + if (!line) return []; + const out: string[] = []; + const re = /"((?:\\.|[^"\\])*)"|'([^']*)'/g; + for (let m = re.exec(line); m !== null; m = re.exec(line)) { + if (m[1] !== undefined) out.push(unescapeTomlBasicString(m[1])); + else if (m[2] !== undefined) out.push(m[2]); + } + return out; +} + +function parseFirstTomlString(line: string | undefined): string { + return extractTomlStrings(line)[0] ?? ""; +} + const execFile = promisify(execFileCb); const SERVER_NAME = "superset-browser"; @@ -127,15 +171,13 @@ function probeCodex(expected: ExpectedCommand): InstallTargetState { .filter((l) => l.length > 0 && !l.startsWith("#")); const commandLine = body.find((l) => /^command\s*=/.test(l)); const argsLine = body.find((l) => /^args\s*=/.test(l)); - const commandMatch = commandLine?.match(/^command\s*=\s*"([^"]*)"/); - const command = commandMatch?.[1] ?? ""; - const argsMatches = - argsLine?.match(/"([^"]*)"/g)?.map((s) => s.replace(/"/g, "")) ?? []; + const command = parseFirstTomlString(commandLine); + const args = extractTomlStrings(argsLine); return { cliFound, installed: true, - matchesExpected: commandsEqual({ command, args: argsMatches }, expected), - currentCommand: [command, ...argsMatches].filter(Boolean).join(" "), + matchesExpected: commandsEqual({ command, args }, expected), + currentCommand: [command, ...args].filter(Boolean).join(" "), }; } 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 e7482c9e301..5582c6c282b 100644 --- a/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts @@ -146,9 +146,26 @@ async function resolvePaneFromRequest( return { paneId, sessionId: resolved.sessionId }; } +const MAX_JSON_BODY_BYTES = 8 * 1024 * 1024; + +class PayloadTooLargeError extends Error { + readonly status = 413; + constructor() { + super(`request body exceeds ${MAX_JSON_BODY_BYTES} bytes`); + } +} + async function readJson(req: IncomingMessage): Promise { const chunks: Buffer[] = []; - for await (const chunk of req) chunks.push(chunk as Buffer); + let total = 0; + for await (const chunk of req) { + const buf = chunk as Buffer; + total += buf.length; + if (total > MAX_JSON_BODY_BYTES) { + throw new PayloadTooLargeError(); + } + chunks.push(buf); + } const raw = Buffer.concat(chunks).toString("utf8"); return raw ? (JSON.parse(raw) as T) : ({} as T); } @@ -296,6 +313,9 @@ export async function startBrowserMcpBridge(): Promise { return send(res, 404, { error: "not found" }); } catch (error) { + if (error instanceof PayloadTooLargeError) { + return send(res, 413, { error: error.message }); + } console.error("[browser-mcp-bridge]", error); return send(res, 500, { error: error instanceof Error ? error.message : String(error), From 9537cf37e3891bac181a0aaaddf5ee6a465e8164 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Tue, 21 Apr 2026 02:04:55 +0900 Subject: [PATCH 11/11] fix(desktop/mcp): wire bin build into package + use login-shell PATH - package.json: build:browser-mcp now runs from prepackage too, not only prebuild. Release flows (`bun run package` / the GitHub Actions desktop build) skip prebuild, so the bundled MCP binary was missing from packaged apps. (codex P1) - mcp-installer: route `claude` / `codex` probes and installs through getProcessEnvWithShellPath so macOS GUI launches that lack PATH entries for Homebrew / nvm / asdf etc. still find the CLIs that a terminal session would. One-click install now matches the CLI resolution the terminal pane already sees. (codex P2) --- apps/desktop/package.json | 2 +- .../lib/browser-mcp-bridge/mcp-installer.ts | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9fab2a530bc..25c6d104c66 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -25,7 +25,7 @@ "build:browser-mcp": "bun --cwd ../../packages/superset-browser-mcp run build:bin", "prebuild": "bun run clean:dev && bun run generate:icons && bun run compile:app && bun run copy:native-modules && bun run validate:native-runtime && bun run build:browser-mcp", "build": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --publish never", - "prepackage": "bun run copy:native-modules && bun run validate:native-runtime", + "prepackage": "bun run copy:native-modules && bun run validate:native-runtime && bun run build:browser-mcp", "package": "electron-builder --config electron-builder.ts", "install:deps": "electron-builder install-app-deps", "release": "electron-builder --publish always", diff --git a/apps/desktop/src/main/lib/browser-mcp-bridge/mcp-installer.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/mcp-installer.ts index 957bae0c0b1..7393d44d62b 100644 --- a/apps/desktop/src/main/lib/browser-mcp-bridge/mcp-installer.ts +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/mcp-installer.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; +import { getProcessEnvWithShellPath } from "lib/trpc/routers/workspaces/utils/shell-env"; function unescapeTomlBasicString(raw: string): string { return raw.replace( @@ -48,7 +49,22 @@ function parseFirstTomlString(line: string | undefined): string { return extractTomlStrings(line)[0] ?? ""; } -const execFile = promisify(execFileCb); +const execFileRaw = promisify(execFileCb); + +/** + * Run a CLI (`claude` / `codex`) with the login-shell PATH merged in so + * macOS GUI launches (Dock / Finder) can still find tools installed + * under $HOME/.local/bin, homebrew, nvm, etc. that a non-shell Electron + * launch misses. + */ +async function execFile( + command: string, + args: readonly string[], +): Promise<{ stdout: string; stderr: string }> { + return execFileRaw(command, [...args], { + env: await getProcessEnvWithShellPath(), + }); +} const SERVER_NAME = "superset-browser";