Files
browser_mcp/app.md
T
egutierrez 254f089982 fix: matar los chromium que el MCP lanza para cerrar el leak de RAM
El pool nunca guardaba el PID del Chrome lanzado por browser_launch, así que
closeAll() y drop() cerraban con CdpClose(c, 0): solo soltaban el WebSocket y
dejaban el proceso chromium vivo y huérfano (~789 MiB RSS cada uno). Llamadas
repetidas a browser_launch acumulaban instancias sin límite hasta saturar la RAM
(apagón del 06/06/2026, ~35 chromium huérfanos).

Cambios:
- pool.go: el pool registra el PID lanzado por puerto (mapa `pids`) con
  setPID/getPID/clearPID/launchedCount. drop() y closeAll() matan el grupo de
  proceso completo (CdpClose con pid real) SOLO si el PID está registrado, es
  decir, si lo lanzó el MCP. Un Chrome externo sin PID registrado (el navegador
  diario del usuario en 9222) nunca se mata: pid=0 solo cierra el WebSocket.
  Nuevo releaseConn() suelta únicamente el WebSocket preservando el PID, para la
  reconexión interna (no debe matar el navegador).
- tools_session.go: handleLaunch registra el PID devuelto por ChromeLaunch
  (setPID); es idempotente por puerto (reusa el Chrome ya lanzado), pasa
  ReuseExisting=true para no duplicar un Chrome ya vivo en el puerto, y aplica
  un tope duro de 4 instancias (maxLaunchedChromes) devolviendo un error de tool
  al superarlo. browser_disconnect ahora mata el Chrome propio.
- main.go: handler SIGTERM/SIGINT que llama closeAll antes de salir (los defers
  no corren al recibir señal). El retry de withConn usa releaseConn en vez de
  drop para no matar el Chrome al reconectar.
- pool_test.go: tests lógicos sin Chrome (cap, idempotencia, ciclo de PID, drop).
- pool_e2e_test.go: tests con Chrome real (gate BMCP_E2E=1) — golden (3 launch →
  closeAll → 0 huérfanos), dedup mismo puerto, y salvaguarda propio-vs-externo.
- app.md: e2e_checks (build, unit, leak_no_orphans) + growth log + bump a 0.5.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:06:14 +02:00

264 lines
14 KiB
Markdown

---
name: browser_mcp
lang: go
domain: infra
version: 0.5.0
description: "Servidor MCP que expone control total del navegador via CDP (40 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión, selección determinista de pestaña, lectura compacta texto/AX + bucle percibir→actuar por #ref con auto-observe, incluyendo find-ref-by-text) 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]
e2e_checks:
- id: build
cmd: "cd projects/web_scraping/apps/browser_mcp && go build -o browser_mcp ."
timeout_s: 120
- id: unit
cmd: "cd projects/web_scraping/apps/browser_mcp && go test -count=1 ./..."
timeout_s: 120
- id: leak_no_orphans
cmd: "cd projects/web_scraping/apps/browser_mcp && go test -c -o /tmp/bmcp_e2e.test . && systemd-run --user --quiet --collect --unit=bmcp_e2e_ci --wait -p Type=oneshot --setenv=BMCP_E2E=1 -p StandardOutput=journal /tmp/bmcp_e2e.test -test.run TestE2E -test.v"
timeout_s: 180
severity: warning
uses_functions:
- chrome_launch_go_browser
- cdp_connect_go_browser
- cdp_close_go_browser
- cdp_navigate_go_browser
- cdp_list_tabs_go_browser
- cdp_new_tab_go_browser
- cdp_close_tab_go_browser
- cdp_activate_tab_go_browser
- cdp_nav_back_go_browser
- cdp_nav_forward_go_browser
- cdp_wait_load_go_browser
- cdp_wait_idle_go_browser
- cdp_get_html_go_browser
- cdp_evaluate_go_browser
- cdp_screenshot_go_browser
- cdp_click_go_browser
- cdp_click_human_go_browser
- cdp_click_text_go_browser
- cdp_type_text_go_browser
- cdp_find_by_text_go_browser
- cdp_find_ref_by_text_go_browser
- cdp_wait_element_go_browser
- cdp_press_key_go_browser
- cdp_scroll_go_browser
- cdp_handle_dialog_go_browser
- cdp_set_cookie_go_browser
- cdp_get_cookies_go_browser
- cdp_delete_cookies_go_browser
- cdp_clear_cookies_go_browser
- cdp_list_frames_go_browser
- cdp_eval_in_frame_go_browser
- cdp_get_frame_html_go_browser
- cdp_save_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
- cdp_click_ref_go_browser
- cdp_type_ref_go_browser
- cdp_hover_ref_go_browser
- cdp_click_xy_human_go_browser
uses_types: []
framework: ""
entry_point: "main.go"
dir_path: "projects/web_scraping/apps/browser_mcp"
repo_url: ""
---
# browser_mcp
Servidor MCP (Model Context Protocol) en Go que expone el control de navegador via CDP
del registry `fn_registry` como tools MCP. Cualquier cliente MCP (Claude Code, otros
agentes) puede manejar un Chrome/Chromium vivo: navegar, leer el DOM, hacer clicks,
gestionar cookies, evaluar JavaScript, operar iframes y persistir/restaurar sesiones.
Clona el patrón de `apps/registry_mcp/` (librería `github.com/mark3labs/mcp-go` v0.52.0,
`server.NewMCPServer` + `server.ServeStdio`, tools con `mcp.NewTool` + handlers tipados
via `mcp.NewTypedToolHandler`, transporte stdio por defecto + HTTP opcional con `--http`,
slog a stderr porque stdout pertenece al JSON-RPC).
## Arquitectura: pool de conexiones CDP
A diferencia de `registry_mcp` (que abre la DB una vez), `browser_mcp` mantiene un
**pool de conexiones CDP vivas** indexado por puerto (`pool.go`). Razón:
`browser.CdpConnect(port)` hace un handshake WebSocket contra una tab "page" de Chrome
(~50-200ms) y esa conexión ES una sesión viva (soporta `Page.*`, `Runtime.*`, `Input.*`).
El agente llama muchas tools seguidas (navigate → wait → click → eval); reconectar en
cada tool pagaría el handshake repetidamente y perdería estado entre tools (los event
handlers persistentes, como el de `handle_dialog`, viven mientras la conexión esté viva).
Por eso reusamos la conexión por puerto.
- `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.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.closeAll()` se ejecuta con `defer` en `main()`.
- `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),
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 9333). Las tools de tabs
(`tab_list`, `tab_new`, `tab_close`, `tab_activate`, `tab_select`) usan el endpoint HTTP `/json`
de CDP directamente (host `localhost`), no el pool, porque no requieren una sesión WebSocket viva.
## 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 (39)
### Sesión (`tools_session.go`)
- `browser_launch` (MUTA) — lanza Chrome con CDP. args: port, headless, user_data_dir, url.
- `browser_connect` — abre/poolea la conexión CDP del puerto. args: port.
- `browser_disconnect` — cierra y descarta la conexión del puerto (no mata Chrome). args: port.
### Navegación + tabs (`tools_nav.go`)
- `tab_navigate` (MUTA) — `Page.navigate`. args: port, url.
- `tab_list` — lista targets via `GET /json`. args: port.
- `tab_new` (MUTA) — abre tab via `PUT /json/new`. args: port, url.
- `tab_close` (MUTA) — cierra tab por ID. 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_forward` (MUTA) — adelante en el historial. args: port.
- `page_wait_load` — espera el evento load. args: port, timeout_ms (default 10000).
- `page_wait_idle` — espera red idle. args: port, timeout_ms (default 15000).
### Lectura (`tools_read.go`)
- `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_screenshot` — captura a archivo. args: port, path, full_page.
### DOM (`tools_dom.go`)
- `dom_click` (MUTA) — click por selector. args: port, selector.
- `dom_click_human` (MUTA) — click con movimiento humano. args: port, selector.
- `dom_click_text` (MUTA) — click sobre el primer elemento con ese texto. args: port, text.
- `dom_type` (MUTA) — escribe texto en el elemento enfocado. args: port, text.
- `dom_find_by_text` — devuelve un selector CSS único para un texto visible. args: port, text.
- `dom_wait_element` — espera a que aparezca un selector. args: port, selector, timeout_ms (default 10000).
- `dom_click_ref` (MUTA) — click humanizado por `#ref` (backendDOMNodeId del outline de `page_perceive`) + auto-observe. args: port, ref.
- `dom_type_ref` (MUTA) — enfoca el `#ref` y escribe texto + auto-observe. args: port, ref, text.
- `dom_hover_ref` (MUTA) — hover humanizado por `#ref` + auto-observe. args: port, ref.
#### Bucle percibir→actuar (por `#ref`)
`page_perceive` devuelve un outline accionable donde cada elemento lleva un `#ref`
estable (su `backendDOMNodeId`). Las tools `dom_click_ref` / `dom_type_ref` /
`dom_hover_ref` actúan directamente sobre ese `#ref` — no necesitas resolver un
selector CSS. Tras la acción esperan un settle breve (400ms) y **devuelven el
outline actualizado** (auto-observe), cerrando el bucle percibir→actuar:
```
page_perceive → outline con #ref de cada elemento
dom_click_ref → click humanizado + outline nuevo tras la acción
dom_type_ref → escribe + outline nuevo
```
Las tools `*_ref` usan humanización por defecto (Bézier+jitter). Una política de
sesión `fast`/`instant` para scraping masivo está pendiente (ver TODO en el código).
### Input (`tools_input.go`) — todas MUTA
- `press_key` — presiona una tecla nombrada (Enter/Tab/Escape/ArrowDown/...). args: port, key.
- `scroll` — scroll por (delta_x, delta_y). args: port, delta_x (default 0), delta_y (default 300).
- `handle_dialog` — arma un auto-handler de diálogos JS (vive en la conexión del pool). args: port, accept (default true), prompt_text.
### Cookies (`tools_cookies.go`)
- `cookie_get` — todas las cookies como JSON. args: port.
- `cookie_set` (MUTA) — set cookie. args: port, name, value, domain, path, http_only.
- `cookie_delete` (MUTA) — borra cookies por nombre. args: port, name, domain.
- `cookie_clear` (MUTA) — borra todas las cookies. args: port.
### Iframes (`tools_frames.go`)
- `frame_list` — lista frames con sus IDs. args: port.
- `frame_eval` (MUTA) — evalúa JS dentro de un frame. args: port, frame_id, expression.
- `frame_get_html` — HTML de un frame (truncado a 200000). args: port, frame_id.
### Estado de sesión (`tools_storage.go`)
- `storage_save` — guarda cookies + localStorage a JSON. args: port, path.
- `storage_load` (MUTA) — carga cookies + localStorage desde JSON. args: port, path.
## Cómo lanzarlo
Transporte stdio (default, para clientes MCP):
```bash
cd projects/web_scraping/apps/browser_mcp
go build -o browser_mcp .
./browser_mcp
```
Transporte HTTP (Streamable HTTP):
```bash
./browser_mcp --http :7740 # bind 127.0.0.1:7740
./browser_mcp --http :7740 --bind 0.0.0.0 # requiere REGISTRY_API_TOKEN (bearer auth)
```
### Flag `--read-only`
Con `--read-only`, el servidor NO registra las tools mutantes (marcadas MUTA arriba):
solo expone las 17 tools de lectura/control (`browser_connect`, `browser_disconnect`, `tab_list`,
`tab_activate`, `tab_select`, `page_wait_load`, `page_wait_idle`, `page_get_html`, `page_get_text`,
`page_perceive`, `page_screenshot`, `dom_find_by_text`, `dom_wait_element`, `cookie_get`,
`frame_list`, `frame_get_html`, `storage_save`). Útil para sesiones de inspección sin riesgo de
modificar el estado del navegador.
## Omitido en v1
Funciones del dominio `browser` que NO se exponen como tools en esta versión, con su razón:
- **`cdp_har_record_go_browser`** — graba el tráfico de red (HAR). Requiere un callback de
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
de un diseño con tool de start + tool de stop.
- **`cdp_get_ax_tree`** — ya expuesto desde v0.2.0 via la tool `page_perceive`, que invoca
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
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
como `fn run` aparte.
## Capability growth log
- v0.5.0 (2026-06-06) — Fix del leak de RAM (chromium huérfanos, apagón 06/06/2026). El pool
ahora registra el PID del Chrome que lanzó por puerto (`pids` map + setPID/getPID/clearPID/
launchedCount). `browser_disconnect` (drop) y el shutdown (closeAll) matan el grupo de proceso
completo SOLO si el PID está registrado (lo lanzó el MCP) — un Chrome externo (navegador diario
en 9222) nunca se mata, solo se cierra el WebSocket. `browser_launch` es idempotente por puerto,
reusa un Chrome ya vivo (`ChromeLaunch.ReuseExisting`, pid 0 = no relanza) y aplica un tope duro
de 4 instancias. Handler SIGTERM/SIGINT en main.go llama closeAll (los defers no corren con
señal). `withConn` retry usa `releaseConn` (suelta solo el WS) en vez de drop. Tests: pool_test.go
(lógicos) + pool_e2e_test.go (Chrome real, gate BMCP_E2E=1). e2e_checks añadidos.
- v0.3.0 (2026-06-06) — Cierre del bucle percibir→actuar. Nuevas tools `dom_click_ref`,
`dom_type_ref`, `dom_hover_ref`: actúan sobre el `#ref` (backendDOMNodeId estable) del
outline de `page_perceive` con humanización por defecto (Bézier+jitter) y auto-observe
(devuelven el outline actualizado tras la acción). Refactor: la generación del outline
se extrajo a `deps.perceiveOutline`/`perceiveOutlineTab`, reusado por `page_perceive` y
por las tools `*_ref`. 36 → 39 tools.
- 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.