feat: P0 LLM-readiness — Chrome aislado (9333), tab_select determinista, page_get_text, page_perceive

This commit is contained in:
agent
2026-06-06 11:15:12 +02:00
parent 6ecaf9a969
commit 9af2e75246
7 changed files with 272 additions and 36 deletions
+26 -13
View File
@@ -5,9 +5,23 @@ MCP server (Go) that exposes the registry's CDP browser-control functions
Chrome DevTools Protocol: navigate, read the DOM, click, manage cookies, evaluate Chrome DevTools Protocol: navigate, read the DOM, click, manage cookies, evaluate
JavaScript, operate iframes, and persist/restore session state. JavaScript, operate iframes, and persist/restore session state.
33 tools total, grouped by domain. See `app.md` for the full per-tool reference and the 36 tools total, grouped by domain. See `app.md` for the full per-tool reference and the
"Omitido en v1" section. "Omitido en v1" section.
## Security: isolated Chrome by default (port 9333)
**By default the MCP operates on its OWN isolated Chrome, NOT the user's daily browser.**
In this ecosystem the user's daily chromium has CDP enabled globally on port **9222** (via
`/etc/chromium.d/cdp`). If the MCP defaulted there, the agent could drive the user's own
tabs (banking, email). To prevent that:
- The default CDP port is **9333** (the MCP's dedicated Chrome), not 9222.
- `browser_launch` without `user_data_dir` uses a dedicated isolated profile
(`<tmp>/browser_mcp_userdata`) on port 9333.
- **Port 9222 = the daily browser.** Pass `port: 9222` explicitly, with care, only when you
deliberately want to attach to it.
## Build ## Build
```bash ```bash
@@ -54,22 +68,21 @@ For an inspection-only session that cannot mutate browser state, pass `"args": [
## Example session ## Example session
Assuming a Chrome already running with `--remote-debugging-port=9222` (or call The default port is **9333** (the MCP's isolated Chrome). A typical LLM-readiness agent
`browser_launch` first), a typical agent flow: flow — launch isolated Chrome, pick the right tab, perceive the page, act, read result:
``` ```
browser_launch { "port": 9222, "url": "https://example.com" } # -> "launched pid=... port=9222" browser_launch { "url": "https://example.com" } # -> "launched pid=... port=9333 user_data_dir=<tmp>/browser_mcp_userdata"
browser_connect { "port": 9222 } # -> "connected port=9222" tab_list { } # -> JSON list of targets (id, type, url, title)
tab_navigate { "port": 9222, "url": "https://example.com" } tab_select { "match": "example.com" } # -> "selected target matching: example.com" (deterministic, by id or URL substring)
page_wait_load { "port": 9222, "timeout_ms": 10000 } page_perceive { } # -> indented accessibility outline (roles, names, #ref) — the LLM "sees" the page compactly
page_get_html { "port": 9222 } # -> serialized HTML (truncated 200k) dom_click { "selector": "a" } # act on what you perceived
dom_find_by_text { "port": 9222, "text": "More information" } # -> "a" / "#id" selector page_get_text { "selector": "body", "max_bytes": 20000 } # -> visible innerText, compact (does NOT blow up the context like page_get_html)
dom_click { "port": 9222, "selector": "a" } browser_disconnect{ }
page_eval_js { "port": 9222, "expression": "document.title" } # -> page title
page_screenshot { "port": 9222, "path": "/tmp/example.png", "full_page": true }
browser_disconnect{ "port": 9222 }
``` ```
To attach to the daily browser instead, pass `port: 9222` explicitly in each call (with care).
Cookies, iframes (`frame_list` -> `frame_eval`/`frame_get_html`), keyboard/scroll Cookies, iframes (`frame_list` -> `frame_eval`/`frame_get_html`), keyboard/scroll
(`press_key`, `scroll`), JS dialogs (`handle_dialog`), and session persistence (`press_key`, `scroll`), JS dialogs (`handle_dialog`), and session persistence
(`storage_save` / `storage_load`) follow the same per-port pattern. (`storage_save` / `storage_load`) follow the same per-port pattern.
+51 -13
View File
@@ -2,8 +2,8 @@
name: browser_mcp name: browser_mcp
lang: go lang: go
domain: infra domain: infra
version: 0.1.0 version: 0.2.0
description: "Servidor MCP que expone control total del navegador via CDP (33 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión) reusando funciones del dominio browser del registry con un pool de conexiones CDP vivas." description: "Servidor MCP que expone control total del navegador via CDP (36 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión, selección determinista de pestaña + lectura compacta texto/AX) reusando funciones del dominio browser del registry con un pool de conexiones CDP vivas. Por defecto opera sobre un Chrome aislado (puerto 9333) separado del navegador diario."
tags: [mcp, browser, cdp, automation, scraping] tags: [mcp, browser, cdp, automation, scraping]
uses_functions: uses_functions:
- chrome_launch_go_browser - chrome_launch_go_browser
@@ -39,6 +39,9 @@ uses_functions:
- cdp_get_frame_html_go_browser - cdp_get_frame_html_go_browser
- cdp_save_storage_state_go_browser - cdp_save_storage_state_go_browser
- cdp_load_storage_state_go_browser - cdp_load_storage_state_go_browser
- cdp_get_text_go_browser
- cdp_connect_target_go_browser
- cdp_perceive_outline_py_pipelines
uses_types: [] uses_types: []
framework: "" framework: ""
entry_point: "main.go" entry_point: "main.go"
@@ -71,17 +74,33 @@ Por eso reusamos la conexión por puerto.
- `connPool.get(port)` devuelve la conexión cacheada o abre una nueva. - `connPool.get(port)` devuelve la conexión cacheada o abre una nueva.
- `connPool.drop(port)` cancela el handler de diálogo (si lo hay) y cierra la conexión. - `connPool.drop(port)` cancela el handler de diálogo (si lo hay) y cierra la conexión.
- `connPool.connectTarget(port, match)` descarta la conexión actual y reconecta a un target
determinista (por id o substring de URL). Es lo que usa `tab_select` para fijar la pestaña.
- `connPool.setCancel(port, cancel)` registra el cancel del auto-handler de `handle_dialog`. - `connPool.setCancel(port, cancel)` registra el cancel del auto-handler de `handle_dialog`.
- `connPool.closeAll()` se ejecuta con `defer` en `main()`. - `connPool.closeAll()` se ejecuta con `defer` en `main()`.
- `deps.withConn(port, fn)` ejecuta `fn` con la conexión del pool y, si el error indica - `deps.withConn(port, fn)` ejecuta `fn` con la conexión del pool y, si el error indica
conexión muerta (`isConnErr`: connection close, broken pipe, use of closed, ws read, EOF), conexión muerta (`isConnErr`: connection close, broken pipe, use of closed, ws read, EOF),
descarta la conexión y reintenta UNA vez (Chrome pudo cerrar la tab entre tools). descarta la conexión y reintenta UNA vez (Chrome pudo cerrar la tab entre tools).
Toda tool con argumento `port` usa `portOr(a.Port)` (default 9222). Las tools de tabs Toda tool con argumento `port` usa `portOr(a.Port)` (default 9333). Las tools de tabs
(`tab_list`, `tab_new`, `tab_close`, `tab_activate`) usan el endpoint HTTP `/json` de CDP (`tab_list`, `tab_new`, `tab_close`, `tab_activate`, `tab_select`) usan el endpoint HTTP `/json`
directamente (host `localhost`), no el pool, porque no requieren una sesión WebSocket viva. de CDP directamente (host `localhost`), no el pool, porque no requieren una sesión WebSocket viva.
## Tools (33) ## Seguridad: Chrome aislado por defecto (puerto 9333)
**El default del MCP es operar sobre su PROPIO Chrome aislado, no sobre el navegador diario.**
En este ecosistema el chromium diario del usuario tiene CDP habilitado globalmente en el
puerto **9222** (via `/etc/chromium.d/cdp`). Si el MCP usara 9222 por defecto, el agente
podría manipular pestañas ajenas del usuario (banca, correo). Para evitarlo:
- `portOr` devuelve **9333** por defecto (no 9222) — el Chrome dedicado del MCP.
- `browser_launch` sin `user_data_dir` usa un perfil DEDICADO y aislado:
`<tmp>/browser_mcp_userdata` (se crea si hace falta) en el puerto 9333.
- Para adjuntarte deliberadamente al navegador diario, pasa `port: 9222` explícito en cada
tool. Hazlo solo con cuidado.
## Tools (36)
### Sesión (`tools_session.go`) ### Sesión (`tools_session.go`)
- `browser_launch` (MUTA) — lanza Chrome con CDP. args: port, headless, user_data_dir, url. - `browser_launch` (MUTA) — lanza Chrome con CDP. args: port, headless, user_data_dir, url.
@@ -94,6 +113,9 @@ directamente (host `localhost`), no el pool, porque no requieren una sesión Web
- `tab_new` (MUTA) — abre tab via `PUT /json/new`. args: port, url. - `tab_new` (MUTA) — abre tab via `PUT /json/new`. args: port, url.
- `tab_close` (MUTA) — cierra tab por ID. args: port, tab_id. - `tab_close` (MUTA) — cierra tab por ID. args: port, tab_id.
- `tab_activate` — pone tab en foreground. args: port, tab_id. - `tab_activate` — pone tab en foreground. args: port, tab_id.
- `tab_select` — fija la pestaña sobre la que operan las siguientes tools, eligiéndola por id
o por substring de su URL (determinista). Usar tras `tab_list` para no operar sobre la
pestaña equivocada. args: port, match.
- `nav_back` (MUTA) — atrás en el historial. args: port. - `nav_back` (MUTA) — atrás en el historial. args: port.
- `nav_forward` (MUTA) — adelante en el historial. args: port. - `nav_forward` (MUTA) — adelante en el historial. args: port.
- `page_wait_load` — espera el evento load. args: port, timeout_ms (default 10000). - `page_wait_load` — espera el evento load. args: port, timeout_ms (default 10000).
@@ -101,6 +123,14 @@ directamente (host `localhost`), no el pool, porque no requieren una sesión Web
### Lectura (`tools_read.go`) ### Lectura (`tools_read.go`)
- `page_get_html` — HTML serializado (truncado a 200000 chars). args: port. - `page_get_html` — HTML serializado (truncado a 200000 chars). args: port.
- `page_get_text` — texto visible (innerText) de la página o de un elemento (selector CSS),
truncado a `max_bytes`. Preferir sobre `page_get_html` cuando solo necesitas leer contenido
— no revienta el contexto. args: port, selector (opcional), max_bytes (default 20000).
- `page_perceive` — outline indentado y accionable del árbol de accesibilidad (roles, nombres,
`#ref`): la forma compacta de que el agente "perciba" la página sin reventar el contexto.
Implementado por subprocess (`fn run cdp_perceive_outline`). Si `tab_id` se omite, usa la
primera pestaña page. args: port, tab_id (opcional), max_chars (default 20000).
**Gotcha:** requiere el binario `fn` y el venv de Python del registry disponibles en runtime.
- `page_eval_js` (MUTA) — `Runtime.evaluate`. args: port, expression. - `page_eval_js` (MUTA) — `Runtime.evaluate`. args: port, expression.
- `page_screenshot` — captura a archivo. args: port, path, full_page. - `page_screenshot` — captura a archivo. args: port, path, full_page.
@@ -152,11 +182,11 @@ Transporte HTTP (Streamable HTTP):
### Flag `--read-only` ### Flag `--read-only`
Con `--read-only`, el servidor NO registra las tools mutantes (marcadas MUTA arriba): Con `--read-only`, el servidor NO registra las tools mutantes (marcadas MUTA arriba):
solo expone las 14 tools de lectura (`browser_connect`, `browser_disconnect`, `tab_list`, solo expone las 17 tools de lectura/control (`browser_connect`, `browser_disconnect`, `tab_list`,
`tab_activate`, `page_wait_load`, `page_wait_idle`, `page_get_html`, `page_screenshot`, `tab_activate`, `tab_select`, `page_wait_load`, `page_wait_idle`, `page_get_html`, `page_get_text`,
`dom_find_by_text`, `dom_wait_element`, `cookie_get`, `frame_list`, `frame_get_html`, `page_perceive`, `page_screenshot`, `dom_find_by_text`, `dom_wait_element`, `cookie_get`,
`storage_save`). Útil para sesiones de inspección sin riesgo de modificar el estado del `frame_list`, `frame_get_html`, `storage_save`). Útil para sesiones de inspección sin riesgo de
navegador. modificar el estado del navegador.
## Omitido en v1 ## Omitido en v1
@@ -166,9 +196,17 @@ Funciones del dominio `browser` que NO se exponen como tools en esta versión, c
larga duración (registrar handlers + un punto de "stop" que devuelve los datos larga duración (registrar handlers + un punto de "stop" que devuelve los datos
acumulados); no encaja en el modelo request/response de una tool MCP simple. Pendiente acumulados); no encaja en el modelo request/response de una tool MCP simple. Pendiente
de un diseño con tool de start + tool de stop. de un diseño con tool de start + tool de stop.
- **`cdp_get_ax_tree`** — el árbol de accesibilidad se obtiene hoy via un pipeline Python; - **`cdp_get_ax_tree`** — ya expuesto desde v0.2.0 via la tool `page_perceive`, que invoca
futuro a exponer via `fn run` en vez de duplicar la lógica aquí. el pipeline `cdp_perceive_outline` por subprocess (`fn run`) en vez de duplicar la lógica aquí.
- **Funciones de perfiles Chrome (Bash: create/delete/appearance/reset)** — requieren que - **Funciones de perfiles Chrome (Bash: create/delete/appearance/reset)** — requieren que
Chrome esté CERRADO para modificar el `Local State` / `Preferences` del perfil; son Chrome esté CERRADO para modificar el `Local State` / `Preferences` del perfil; son
incompatibles con un MCP cuyo propósito es controlar un Chrome vivo. Quedan disponibles incompatibles con un MCP cuyo propósito es controlar un Chrome vivo. Quedan disponibles
como `fn run` aparte. como `fn run` aparte.
## Capability growth log
- v0.2.0 (2026-06-06) — P0 LLM-readiness. Seguridad: Chrome aislado por defecto (puerto 9333
+ perfil dedicado `<tmp>/browser_mcp_userdata`), separado del navegador diario en 9222.
Nuevas tools: `tab_select` (selección determinista de pestaña por id/URL), `page_get_text`
(lectura compacta de innerText), `page_perceive` (outline AX via `fn run cdp_perceive_outline`).
33 → 36 tools.
+34 -2
View File
@@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
@@ -85,10 +86,16 @@ func registerTools(s *server.MCPServer, d *deps) {
registerStorageTools(s, d) registerStorageTools(s, d)
} }
// portOr returns the CDP port, defaulting to 9222 when zero. // portOr returns the CDP port, defaulting to 9333 when zero.
//
// SECURITY (P0.3): the default is 9333 — the MCP's OWN isolated Chrome — NOT
// 9222. Port 9222 is the user's daily chromium (CDP enabled globally via
// /etc/chromium.d/cdp). Defaulting there would let the agent drive the user's
// banking/email tabs. The MCP operates on its dedicated browser by default;
// pass port=9222 explicitly only to deliberately attach to the daily browser.
func portOr(p int) int { func portOr(p int) int {
if p == 0 { if p == 0 {
return 9222 return 9333
} }
return p return p
} }
@@ -172,3 +179,28 @@ func truncate(s string, n int) string {
} }
return s[:n] + "\n... [truncated]" return s[:n] + "\n... [truncated]"
} }
// resolveRoot finds the fn_registry root so we can locate the `fn` binary and
// the Python venv at runtime. Mirrors registry_mcp's resolveRoot: honors
// FN_REGISTRY_ROOT, otherwise walks up from cwd looking for registry.db.
func resolveRoot() (string, error) {
if env := os.Getenv("FN_REGISTRY_ROOT"); env != "" {
return filepath.Abs(env)
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
dir := cwd
for {
if _, err := os.Stat(filepath.Join(dir, "registry.db")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", fmt.Errorf("registry.db not found upward from %s", cwd)
}
+15
View File
@@ -47,6 +47,21 @@ func (p *connPool) drop(port int) {
} }
} }
// connectTarget descarta la conexión actual del puerto y reconecta a un target
// determinista (por id o substring de URL). Asegura que el agente opera sobre una
// pestaña conocida y no sobre "la primera al azar".
func (p *connPool) connectTarget(port int, match string) (*browser.CDPConn, error) {
p.drop(port)
c, err := browser.CdpConnectTarget("localhost", port, match)
if err != nil {
return nil, err
}
p.mu.Lock()
p.conns[port] = c
p.mu.Unlock()
return c, nil
}
func (p *connPool) setCancel(port int, cancel func()) { func (p *connPool) setCancel(port int, cancel func()) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
+23
View File
@@ -16,6 +16,7 @@ func registerNavTools(s *server.MCPServer, d *deps) {
// Tab tools use HTTP /json directly (no pool) — list/activate are read-only. // Tab tools use HTTP /json directly (no pool) — list/activate are read-only.
s.AddTool(tabListTool(), mcp.NewTypedToolHandler(d.handleTabList)) s.AddTool(tabListTool(), mcp.NewTypedToolHandler(d.handleTabList))
s.AddTool(tabActivateTool(), mcp.NewTypedToolHandler(d.handleTabActivate)) s.AddTool(tabActivateTool(), mcp.NewTypedToolHandler(d.handleTabActivate))
s.AddTool(tabSelectTool(), mcp.NewTypedToolHandler(d.handleTabSelect))
s.AddTool(pageWaitLoadTool(), mcp.NewTypedToolHandler(d.handlePageWaitLoad)) s.AddTool(pageWaitLoadTool(), mcp.NewTypedToolHandler(d.handlePageWaitLoad))
s.AddTool(pageWaitIdleTool(), mcp.NewTypedToolHandler(d.handlePageWaitIdle)) s.AddTool(pageWaitIdleTool(), mcp.NewTypedToolHandler(d.handlePageWaitIdle))
@@ -152,6 +153,28 @@ func (d *deps) handleTabActivate(_ context.Context, _ mcp.CallToolRequest, a tab
return mcp.NewToolResultText("activated tab " + a.TabID), nil return mcp.NewToolResultText("activated tab " + a.TabID), nil
} }
// ---- tab_select ----
type tabSelectArgs struct {
Port int `json:"port"`
Match string `json:"match"`
}
func tabSelectTool() mcp.Tool {
return mcp.NewTool("tab_select",
mcp.WithDescription("Fija la pestaña sobre la que operan las siguientes tools, eligiéndola por id o por substring de su URL (determinista). Úsala tras tab_list para no operar sobre la pestaña equivocada."),
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
mcp.WithString("match", mcp.Description("Target id exacto o substring de la URL de la pestaña. Vacío = primera page.")),
)
}
func (d *deps) handleTabSelect(_ context.Context, _ mcp.CallToolRequest, a tabSelectArgs) (*mcp.CallToolResult, error) {
if _, err := d.pool.connectTarget(portOr(a.Port), a.Match); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText("selected target matching: " + a.Match), nil
}
// ---- nav_back (MUTA) ---- // ---- nav_back (MUTA) ----
type navBackArgs struct { type navBackArgs struct {
+107 -1
View File
@@ -2,6 +2,10 @@ package main
import ( import (
"context" "context"
"fmt"
"os/exec"
"path/filepath"
"strings"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
@@ -11,9 +15,12 @@ import (
const htmlMax = 200_000 const htmlMax = 200_000
// registerReadTools wires page_get_html, page_eval_js (MUTA), page_screenshot. // registerReadTools wires page_get_html, page_get_text, page_perceive,
// page_eval_js (MUTA), page_screenshot.
func registerReadTools(s *server.MCPServer, d *deps) { func registerReadTools(s *server.MCPServer, d *deps) {
s.AddTool(pageGetHTMLTool(), mcp.NewTypedToolHandler(d.handlePageGetHTML)) s.AddTool(pageGetHTMLTool(), mcp.NewTypedToolHandler(d.handlePageGetHTML))
s.AddTool(pageGetTextTool(), mcp.NewTypedToolHandler(d.handlePageGetText))
s.AddTool(pagePerceiveTool(), mcp.NewTypedToolHandler(d.handlePagePerceive))
s.AddTool(pageScreenshotTool(), mcp.NewTypedToolHandler(d.handlePageScreenshot)) s.AddTool(pageScreenshotTool(), mcp.NewTypedToolHandler(d.handlePageScreenshot))
if !d.readOnly { if !d.readOnly {
@@ -21,6 +28,105 @@ func registerReadTools(s *server.MCPServer, d *deps) {
} }
} }
// ---- page_get_text ----
type pageGetTextArgs struct {
Port int `json:"port"`
Selector string `json:"selector"`
MaxBytes int `json:"max_bytes"`
}
func pageGetTextTool() mcp.Tool {
return mcp.NewTool("page_get_text",
mcp.WithDescription("Devuelve el texto visible (innerText) de la página o de un elemento (selector CSS), truncado a max_bytes. Preferir sobre page_get_html cuando solo necesitas leer contenido — no revienta el contexto."),
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
mcp.WithString("selector", mcp.Description("Selector CSS opcional. Vacío = body (toda la página).")),
mcp.WithNumber("max_bytes", mcp.Description("Máximo de bytes a devolver. Default 20000. 0 = sin límite.")),
)
}
func (d *deps) handlePageGetText(_ context.Context, _ mcp.CallToolRequest, a pageGetTextArgs) (*mcp.CallToolResult, error) {
maxBytes := a.MaxBytes
if maxBytes == 0 {
maxBytes = 20000
}
var text string
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
var e error
text, e = browser.CdpGetText(c, a.Selector, maxBytes)
return e
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(text), nil
}
// ---- page_perceive ----
type pagePerceiveArgs struct {
Port int `json:"port"`
TabID string `json:"tab_id"`
MaxChars int `json:"max_chars"`
}
func pagePerceiveTool() mcp.Tool {
return mcp.NewTool("page_perceive",
mcp.WithDescription("Devuelve un outline indentado y accionable del árbol de accesibilidad (roles, nombres, #ref) — la forma compacta de que el agente 'perciba' la página sin reventar el contexto. Si tab_id se omite, usa la primera pestaña page. Gotcha: requiere el binario `fn` y el venv de Python del registry disponibles en runtime."),
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
mcp.WithString("tab_id", mcp.Description("Target id de la pestaña. Vacío = primera pestaña page.")),
mcp.WithNumber("max_chars", mcp.Description("Máximo de chars del outline. Default 20000.")),
)
}
func (d *deps) handlePagePerceive(_ context.Context, _ mcp.CallToolRequest, a pagePerceiveArgs) (*mcp.CallToolResult, error) {
port := portOr(a.Port)
maxChars := a.MaxChars
if maxChars == 0 {
maxChars = 20000
}
root, err := resolveRoot()
if err != nil {
return mcp.NewToolResultError("resolve registry root: " + err.Error()), nil
}
tabID := a.TabID
if tabID == "" {
tabs, err := browser.CdpListTabs("localhost", port)
if err != nil {
return mcp.NewToolResultError("list tabs: " + err.Error()), nil
}
for _, t := range tabs {
if t.Type == "page" {
tabID = t.ID
break
}
}
if tabID == "" {
return mcp.NewToolResultError("no 'page' tab found on port " + fmt.Sprint(port)), nil
}
}
cmd := exec.Command(filepath.Join(root, "fn"), "run", "cdp_perceive_outline",
"--debug-port", fmt.Sprint(port),
"--tab-id", tabID,
"--max-chars", fmt.Sprint(maxChars),
)
cmd.Dir = root
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return mcp.NewToolResultError("cdp_perceive_outline failed: " + msg), nil
}
return mcp.NewToolResultText(truncate(stdout.String(), htmlMax)), nil
}
// ---- page_get_html ---- // ---- page_get_html ----
type pageGetHTMLArgs struct { type pageGetHTMLArgs struct {
+16 -7
View File
@@ -3,6 +3,8 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"path/filepath"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
@@ -30,19 +32,26 @@ type launchArgs struct {
func launchTool() mcp.Tool { func launchTool() mcp.Tool {
return mcp.NewTool("browser_launch", return mcp.NewTool("browser_launch",
mcp.WithDescription("Launch a Chrome/Chromium instance with CDP remote debugging enabled. Returns the launched PID. Waits up to 15s for the CDP port to be ready."), mcp.WithDescription("Launch a Chrome/Chromium instance with CDP remote debugging enabled. By default launches a Chrome ISOLATED from the user's daily browser: port 9333 (default) and a dedicated user_data_dir under the temp dir. This keeps the agent off the daily chromium on 9222 (banking, email). Returns the launched PID. Waits up to 15s for the CDP port to be ready."),
mcp.WithNumber("port", mcp.Description("CDP remote debugging port. Default 9222.")), mcp.WithNumber("port", mcp.Description("CDP remote debugging port. Default 9333 (the MCP's isolated Chrome). Pass 9222 only to attach to the daily browser.")),
mcp.WithBoolean("headless", mcp.Description("Run headless (--headless=new). Default false.")), mcp.WithBoolean("headless", mcp.Description("Run headless (--headless=new). Default false.")),
mcp.WithString("user_data_dir", mcp.Description("Chrome profile directory. Empty = /tmp/chrome-cdp-profile.")), mcp.WithString("user_data_dir", mcp.Description("Chrome profile directory. Empty = a dedicated isolated dir (<tmp>/browser_mcp_userdata), kept separate from the daily browser profile.")),
mcp.WithString("url", mcp.Description("Optional initial URL to open on launch.")), mcp.WithString("url", mcp.Description("Optional initial URL to open on launch.")),
) )
} }
func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchArgs) (*mcp.CallToolResult, error) { func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchArgs) (*mcp.CallToolResult, error) {
// SECURITY (P0.3): default to an isolated user-data-dir so the MCP never
// reuses the user's daily browser profile. Created on demand.
userDataDir := a.UserDataDir
if userDataDir == "" {
userDataDir = filepath.Join(os.TempDir(), "browser_mcp_userdata")
_ = os.MkdirAll(userDataDir, 0o755)
}
opts := browser.ChromeLaunchOpts{ opts := browser.ChromeLaunchOpts{
Port: portOr(a.Port), Port: portOr(a.Port),
Headless: a.Headless, Headless: a.Headless,
UserDataDir: a.UserDataDir, UserDataDir: userDataDir,
} }
if a.URL != "" { if a.URL != "" {
opts.ExtraArgs = append(opts.ExtraArgs, a.URL) opts.ExtraArgs = append(opts.ExtraArgs, a.URL)
@@ -51,7 +60,7 @@ func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchAr
if err != nil { if err != nil {
return mcp.NewToolResultError(err.Error()), nil return mcp.NewToolResultError(err.Error()), nil
} }
return mcp.NewToolResultText(fmt.Sprintf("launched pid=%d port=%d", pid, opts.Port)), nil return mcp.NewToolResultText(fmt.Sprintf("launched pid=%d port=%d user_data_dir=%s", pid, opts.Port, userDataDir)), nil
} }
// ---- browser_connect ---- // ---- browser_connect ----
@@ -63,7 +72,7 @@ type connectArgs struct {
func connectTool() mcp.Tool { func connectTool() mcp.Tool {
return mcp.NewTool("browser_connect", return mcp.NewTool("browser_connect",
mcp.WithDescription("Open (and pool) a CDP WebSocket connection to a running Chrome's first 'page' tab on the given port. Subsequent tools reuse this live session."), mcp.WithDescription("Open (and pool) a CDP WebSocket connection to a running Chrome's first 'page' tab on the given port. Subsequent tools reuse this live session."),
mcp.WithNumber("port", mcp.Description("CDP port. Default 9222.")), mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
) )
} }
@@ -84,7 +93,7 @@ type disconnectArgs struct {
func disconnectTool() mcp.Tool { func disconnectTool() mcp.Tool {
return mcp.NewTool("browser_disconnect", return mcp.NewTool("browser_disconnect",
mcp.WithDescription("Close and drop the pooled CDP connection for the given port (cancels any armed dialog handler). Does NOT kill Chrome."), mcp.WithDescription("Close and drop the pooled CDP connection for the given port (cancels any armed dialog handler). Does NOT kill Chrome."),
mcp.WithNumber("port", mcp.Description("CDP port. Default 9222.")), mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
) )
} }