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 5582c6c282b..431dd6eeeca 100644 --- a/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts @@ -19,102 +19,24 @@ import { getBoundPaneForSession, resolvePpidToSession } from "./pane-resolver"; * 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". + * Scope of this bridge is intentionally small: the MCP only needs to + * resolve its PPID → Superset LLM session → bound paneId → metadata + * about that pane. Actual browser automation (click / navigate / DOM + * inspection / screenshot) is delegated to external browser MCPs via + * the per-pane filtered CDP endpoint (see ./plan.md in the repo root). + * This file should stay small; if you are about to add tool-like + * endpoints here, you're fighting the plan. */ 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:"]); +const MAX_JSON_BODY_BYTES = 8 * 1024 * 1024; -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)`, - }; +class PayloadTooLargeError extends Error { + readonly status = 413; + constructor() { + super(`request body exceeds ${MAX_JSON_BODY_BYTES} bytes`); } - return parsed; } async function resolvePaneFromRequest( @@ -132,7 +54,7 @@ async function resolvePaneFromRequest( 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.", + "Could not map this MCP to a Superset LLM session. Make sure Claude / Codex is running inside a Superset terminal pane.", status: 404, }; } @@ -146,29 +68,20 @@ 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 { +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(); - } + 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); } +// Kept for follow-up endpoints that accept bodies; silences unused hint. +void _readJson; function send(res: ServerResponse, status: number, body: unknown): void { res.statusCode = status; @@ -236,81 +149,6 @@ export async function startBrowserMcpBridge(): Promise { }); } - 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) { diff --git a/packages/superset-browser-mcp/src/tools/index.ts b/packages/superset-browser-mcp/src/tools/index.ts index 34ca2d24380..ee906062f00 100644 --- a/packages/superset-browser-mcp/src/tools/index.ts +++ b/packages/superset-browser-mcp/src/tools/index.ts @@ -1,29 +1,6 @@ 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; @@ -32,13 +9,21 @@ interface BindingResponse { title: string | null; } +/** + * This MCP is intentionally kept minimal. The plan (see ./plan.md in + * the repo root) is to expose the bound pane as a filtered CDP endpoint + * so users can drive it with mature external browser MCPs + * (chrome-devtools-mcp, browser-use, playwright-mcp). The tools here + * are only the metadata shim LLMs need to verify the binding; the CDP + * endpoint itself ships in follow-up PRs. + */ 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.", + "Return the currently bound browser pane for this LLM session (URL / title). Use this to confirm the UI-side binding before asking a browser-automation MCP (chrome-devtools-mcp / browser-use / etc.) to drive the pane.", inputSchema: {}, }, async () => { @@ -55,118 +40,4 @@ export function registerTools(server: McpServer, client: BridgeClient): void { }; }, ); - - 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/plan.md b/plan.md new file mode 100644 index 00000000000..e4e4c100efb --- /dev/null +++ b/plan.md @@ -0,0 +1,159 @@ +# Browser Pane × LLM Binding Roadmap + +> このドキュメントは、Superset Desktop の「複数 pane × 複数 LLM」ブラウザ自動化 +> 機能を **CDP エンドポイント公開型 (Phase B)** へ進化させるための実装計画。 +> 途中で作業コンテキストが失われても再開できるよう、最終形と途中 PR の境界を +> ここに固定する。 + +## 最終的に作りたい体験 + +1. Superset Desktop を起動すると、複数のプロジェクト / ワークスペースを横断して + 好きなプロダクトのフロントエンドを **browser pane** として開ける。 +2. UI の **Connect** ボタンで pane と LLM session (Claude / Codex など) を + **1 対 1** でアタッチし、好きなタイミングで別の LLM に繋ぎ変えられる。 +3. LLM は自分側で好きな browser 自動化 MCP を使う: + - `chrome-devtools-mcp` (Google) + - `browser-use` (Python/Playwright) + - `playwright-mcp` など + これらは **成熟した外部プロジェクト**。自前で再実装しない。 +4. Superset は **バインディングルーター** として動く: + - 各 pane を独立した CDP (Chrome DevTools Protocol) エンドポイントに見せる + - session token 付き URL で外部 MCP が接続 + - 別 pane はそもそも見えない (フィルタプロキシ) +5. pane ↔ session の紐付けを UI から差し替えると、外部 MCP が使っている CDP + endpoint の backing pane がホットスワップされ、LLM はそのまま別 pane を操作 + できるようになる。 + +## 現状 (PR #354 マージ済み) + +- `packages/superset-browser-mcp` (独立パッケージ) を同梱 +- 起動時に `~/.superset/browser-mcp.json` (workspace スコープ) に + port / secret を書き出す HTTP bridge +- PID ベースの自動セッションマッピング (terminal pane 限定) +- UI `McpInstallPanel`: Claude / Codex 選択してワンクリック install +- バインディングは local-db に永続化 +- Phase A 相当の薄い tools: `get_connected_pane` / `navigate` / `screenshot` + / `evaluate_js` / `get_console_logs` (= 自前 CDP 実装で十分薄い) + +### 現状の限界 + +- navigate/screenshot/... を全部自作しているため、tools の表現力が外部 MCP に + 追いつかない。 +- pane 単位の CDP エンドポイントは露出していない。 + +## Phase B PR ロードマップ + +### PR1: CDP エンドポイント公開(pane → Chromium targetId 解決) + +- Superset 起動時に `--remote-debugging-port=0` (ランダム port) を有効化 +- Chromium の `/json/list` を取得して pane ごとの targetId を特定する仕組み + - 実装案: pane 生成時に `window.__supersetPaneId = ""` を注入、 + `/json/list` の各 page target に `Runtime.evaluate` でマッチング +- 新 tRPC / MCP tool `get_cdp_endpoint()`: + - 入力: session PPID (既存のヘッダ) + - 出力: + ```json + { + "webSocketDebuggerUrl": "ws://127.0.0.1:/devtools/page/", + "targetId": "", + "paneId": "", + "url": "https://...", + "title": "..." + } + ``` +- **この PR では生の CDP URL を返すだけ**。フィルタリングは PR2 で。 + 外部 MCP が接続すると他 pane も見えるが、動作検証のマイルストーンとして価値あり。 +- 既存の webContents.debugger.attach 経路は navigate/screenshot のままにして共存。 + +### PR2: CDP WebSocket フィルタプロキシ(pane 単位の分離) + +- Superset main に CDP proxy を追加: + - `http://127.0.0.1:/cdp//json` + → bound pane 1 つだけを返す (他 pane / devtools / workspace shell は隠す) + - `ws://127.0.0.1:/cdp//devtools/page/` + → Chromium の CDP へ透過プロキシ。`Target.*` コマンドだけフィルタ: + - `Target.setDiscoverTargets` / `Target.getTargets` は bound pane のみ返す + - `Target.attachToTarget` は bound pane 以外拒否 + - `Target.targetCreated` / `Target.targetDestroyed` イベントも pane フィルタ + - 認証: session-token + loopback 限定 +- `get_cdp_endpoint()` が返す URL を **フィルタ版** に差し替え +- セキュリティテスト: 別 pane を誤って見せないユニットテスト +- バインディングを別 pane にホットスワップした時、WS セッションを + `Target.detachFromTarget` で繋ぎ直す or 切断してクライアントに再接続させる + 挙動を実装 + +### PR3: UI に外部 MCP 接続ガイド + +- Connect モーダルに「外部ブラウザ MCP を使う場合」セクションを追加 +- ワンクリックでコピーできる例: + ``` + # chrome-devtools-mcp + claude mcp add chrome-devtools-mcp -s user -- \ + npx -y chrome-devtools-mcp --browser-url + + # browser-use (pyproject 経由) + browser-use --cdp-url + ``` +- `` は `get_cdp_endpoint` 相当の値を main process で生成 +- Connect 後の pane 情報画面 (ReadyPanel) にも「この pane の CDP endpoint」 + リンクを常設し、別の LLM クライアントから直接叩けるように + +### PR4: 古い自作 tools の整理 + +- PR1〜3 で代替が整い次第、以下の MCP tools を deprecated にするか削除: + - `navigate` / `screenshot` / `evaluate_js` / `get_console_logs` + - 対応する HTTP bridge エンドポイントも +- 残すのは: + - `get_cdp_endpoint` (メイン出口) + - `get_connected_pane` (メタ情報用 sanity check) +- `webContents.debugger.attach` 経路は不要になるので削除 +- README / PR 本文で「外部 MCP を使ってください」案内 +- リリースノートで破壊的変更を明示 (旧 tools を叩いてくるカスタム自動化は動かなく + なる) + +## 設計上の注意 + +### Chromium target ID と Electron webContents のマッピング + +- `/json/list` には Electron の `webContents.id` が直接出ない。 +- 確実なマッチング: 各 pane の load 完了時に + `webContents.executeJavaScript("window.__supersetPaneId = ''")` + を注入しておき、`/json/list` の各 target に対して `Runtime.evaluate` で拾う。 +- pane の navigation で JS コンテキストが消えると `__supersetPaneId` も消える + → `did-navigate` フックで毎回注入する必要あり。 + +### Electron の debugger と外部 CDP の同居 + +- Chromium M100+ は同一 target への複数 CDP セッションを flatten mode で許容 + するが、Electron `webContents.debugger.attach()` と外部 `chrome-devtools-mcp` + が同時 attach できるかは要検証。 +- 干渉するなら Electron debugger 側を detach して外部に譲る仕組みが要る。 +- PR1〜2 の間にサンプルで 1 度試す。 + +### セキュリティ + +- loopback 限定は bridge 側ですでにやっている +- session token は 32 バイト乱数で十分 +- Target コマンドフィルタにバグがあると他 pane へ波及する → Phase B は + 自動テスト必須 + +### マルチ Superset インスタンス + +- 既に `${SUPERSET_HOME_DIR}/browser-mcp.json` で workspace スコープ化済み +- CDP フィルタプロキシの port も同じ runtime file に追加 (`cdpPort` フィールド) + → 外部 MCP は token 付き URL だけ見れば済む + +## やらないこと(Phase C 以降、保留) + +- superset-browser-mcp 内部で chrome-devtools-mcp を subprocess として起動し + tools を forward する「MCP-to-MCP ブリッジ」 + - Phase B で 90% の価値は出るため優先度低い +- TODO-Agent worker 経由のブラウザ自動化 + - Claude worker PID が daemon プロセス側にあり、bridge からは resolve できない + - Phase B が安定してから daemon-bridge IPC 経由で対応 + +## 参考実装 / 外部 MCP + +- https://github.com/ChromeDevTools/chrome-devtools-mcp +- https://github.com/browser-use/browser-use +- https://github.com/microsoft/playwright-mcp