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..25c6d104c66 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,9 +22,10 @@ "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", + "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/lib/trpc/routers/browser-automation/index.ts b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts index 864eebc95dc..707267cb348 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,10 +11,10 @@ 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"; -import { getTodoSessionStore } from "main/todo-agent/session-store"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -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,67 @@ 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 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[] }, +): 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 +353,19 @@ 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 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 { return false; } @@ -351,6 +439,55 @@ 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) { + // Standalone binary shipped alongside the app (see electron-builder + // 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, + ); + if (existsSync(binPath)) { + 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: "bun", + args: ["run", binPath], + available: false, + }; +} + export const createBrowserAutomationRouter = () => { return router({ getMcpStatus: publicProcedure.query(() => { @@ -363,23 +500,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: expected, }; }), @@ -398,6 +547,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<{ @@ -409,23 +569,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( @@ -437,7 +583,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, @@ -460,12 +606,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() })) @@ -473,6 +632,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/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/mcp-installer.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/mcp-installer.ts new file mode 100644 index 00000000000..7393d44d62b --- /dev/null +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/mcp-installer.ts @@ -0,0 +1,263 @@ +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"; +import { getProcessEnvWithShellPath } from "lib/trpc/routers/workspaces/utils/shell-env"; + +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 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"; + +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 command = parseFirstTomlString(commandLine); + const args = extractTomlStrings(argsLine); + return { + cliFound, + installed: true, + matchesExpected: commandsEqual({ command, args }, expected), + currentCommand: [command, ...args].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/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..4d4c7edf0a0 --- /dev/null +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts @@ -0,0 +1,85 @@ +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"; + +/** + * 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 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). + * + * 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; + kind: "todo-agent" | "terminal"; + paneId?: string; +} + +const CACHE_TTL_MS = 5_000; + +interface CacheEntry { + resolved: ResolvedSession; + 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; + // 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 { + sessionId: `terminal:${s.paneId}`, + kind: "terminal", + paneId: s.paneId, + }; + } + } catch { + // Keep scanning — other panes may still match. + } + } + 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 resolved = await resolveFromTerminalPanes(ppid); + if (resolved) 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/server.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts new file mode 100644 index 00000000000..5582c6c282b --- /dev/null +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts @@ -0,0 +1,363 @@ +import { randomBytes } from "node:crypto"; +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +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_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, + * 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_INFO_PATH = join(SUPERSET_HOME_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(() => {}); + // 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" + ) { + 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); + } + }; + 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< + { 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 }; +} + +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[] = []; + 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); +} + +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?: 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: target.toString(), + }); + return send(res, 200, { + paneId: resolved.paneId, + url: target.toString(), + }); + } + + 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) { + 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), + }); + } + }); + + 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, + }); + // 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(); + }); + + 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/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/main/todo-daemon/supervisor-engine.ts b/apps/desktop/src/main/todo-daemon/supervisor-engine.ts index 95dc10cf8d5..2f0af433b28 100644 --- a/apps/desktop/src/main/todo-daemon/supervisor-engine.ts +++ b/apps/desktop/src/main/todo-daemon/supervisor-engine.ts @@ -428,6 +428,15 @@ export class TodoSupervisorEngine { remoteControlEnabled, onChild: (child) => { run.currentChild = child; + // 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/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, }; } 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..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,15 +10,17 @@ 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 { type AutomationSession, getSnippetForSession, + type ServerCommand, useBrowserAutomationStore, } from "renderer/stores/browser-automation"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { McpInstallPanel } from "./components/McpInstallPanel"; interface SessionConnectModalProps { open: boolean; @@ -143,10 +145,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 +250,7 @@ export function SessionConnectModal({ ? (mcpStatus?.codexConfigPath ?? null) : (mcpStatus?.claudeConfigPath ?? null) } + serverCommand={serverCommand} onCopy={handleCopySnippet} /> ) @@ -446,78 +453,12 @@ function DetailItem({ } function SetupPanel({ - session, - mcpConfigPath, - onCopy, + serverCommand, }: { session: AutomationSession; mcpConfigPath: string | null; + serverCommand?: ServerCommand; onCopy: () => void; }) { - const snippet = getSnippetForSession(session); - 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/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..af2777eb4f1 --- /dev/null +++ b/packages/superset-browser-mcp/package.json @@ -0,0 +1,28 @@ +{ + "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", + "build:bin": "bun build --compile --minify --sourcemap --outfile dist/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" + } +} 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..34ca2d24380 --- /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 LLM 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 LLM 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 LLM 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..30f3221159a --- /dev/null +++ b/packages/superset-browser-mcp/src/transport/bridge-client.ts @@ -0,0 +1,139 @@ +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_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. + */ +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; + secret: string; +} + +const REQUEST_TIMEOUT_MS = 15_000; + +function readRuntimeInfo(): RuntimeInfo { + const contents = readFileSync(runtimeInfoPath(), "utf8"); + 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 { port: port as number, secret }; +} + +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 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; + let reloaded = false; + try { + const info = this.load(); + response = await perform(info); + } catch (error) { + // 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(); + reloaded = true; + try { + const fresh = this.load(); + response = await perform(fresh); + } catch (retryError) { + 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}`); + } + 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"] +}