Compare commits

...

9 Commits

Author SHA1 Message Date
egutierrez 54fe1b7f17 Merge quick/visual-iframe-tools: perceive nativo + iframe data + click XY + screenshot imagen (v0.6.0) 2026-06-06 17:38:42 +02:00
egutierrez fed245a738 feat(browser_mcp): perceive nativo Go, datos de iframe, click XY y screenshot como imagen (v0.6.0)
Capacidades nuevas y cambios (40 -> 42 tools):

- page_perceive ahora se genera de forma NATIVA en Go sobre la conexion CDP
  viva del pool (cdp_get_ax_outline_go_browser). Elimina el subprocess
  `fn run cdp_perceive_outline` (Python), el venv y la dependencia del binario
  `fn` en runtime (se borra resolveRoot/exec.Command). Respeta tab_select.
- page_perceive acepta frame_id para percibir DENTRO de un iframe. El campo
  tab_id queda obsoleto (se ignora; usar tab_select) pero se conserva por
  compatibilidad.
- frame_get_text (nueva, lectura): innerText de un iframe via
  cdp_get_text_in_frame_go_browser. Activa tambien bajo --read-only.
- dom_click_xy (nueva, MUTA): click humanizado por coordenadas absolutas via
  cdp_click_xy_human_go_browser, con mode human/fast/instant y auto-observe.
  Fallback para actuar sobre lo que el LLM ve en page_screenshot.
- page_screenshot devuelve la imagen como image content
  (cdp_screenshot_bytes_go_browser + mcp.NewToolResultImage) para que el LLM
  vea los pixeles; path pasa a ser opcional (si se da, ademas guarda a disco).
- Auto-observe de las tools *_ref sube su truncado de 4000 a 8000 chars.
- Fix de seguridad documental: todas las descripciones del parametro port que
  decian "Default 9222" (navegador diario del usuario) corregidas a
  "Default 9333" (Chrome aislado del MCP). El codigo ya usaba 9333; la doc era
  falsa y podia inducir al modelo a tocar pestanas de banca/correo.

uses_functions del app.md: +cdp_get_ax_outline, +cdp_get_text_in_frame,
+cdp_screenshot_bytes; -cdp_perceive_outline_py_pipelines.

Verificacion: go build OK, go test OK (4 unit pass, 3 e2e skip gated BMCP_E2E=1),
go vet OK, gofmt limpio, sin "Default 9222" en el codigo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:35:33 +02:00
egutierrez 9c170b9c43 Merge issue/fix-chromium-ram-leak: cerrar leak de RAM de chromium huérfanos del MCP 2026-06-06 17:06:19 +02:00
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
egutierrez 9b437f1e5e merge: fix drift cdp_close en pool (quick/fix-close-drift) 2026-06-06 15:41:01 +02:00
Egutierrez 71fdae9e35 fix: pool usa CdpClose(c,0) en vez de CdpDisconnect (evita drift falso en uses_functions)
El wrapper CdpDisconnect comparte entry de registry con CdpClose; el auditor
uses_functions no lo reconoce como mismo símbolo y marcaba cdp_close como
declared-but-unused. CdpClose(c,0) expresa lo mismo sin drift.
2026-06-06 15:41:01 +02:00
egutierrez 9e6d9f7886 merge: adaptación a fixes del registry browser (handle_dialog + find_ref_by_text) 2026-06-06 15:33:24 +02:00
Egutierrez 71bc7ab8d8 feat: tool dom_find_ref_by_text (click-by-text por #ref) + mode en click_ref/hover_ref
dom_find_ref_by_text usa la nueva CdpFindRefByText del registry: encuentra por
texto y devuelve el #ref (backendDOMNodeId) listo para dom_click_ref, sin
selector CSS frágil; reporta count para ambigüedad.

Incluye WIP pre-existente ya estable: dom_click_ref/dom_hover_ref exponen
'mode' (human/fast/instant) vía MouseProfileForMode. Compila + 9 e2e verdes.
2026-06-06 15:33:18 +02:00
Egutierrez 4307fb2e58 feat: adapta CdpHandleDialog (nueva firma + DialogLog) y reporta diálogos en disconnect
CdpHandleDialog ahora devuelve (cancel, *DialogLog, error). El pool guarda el
DialogLog por puerto y browser_disconnect reporta cuántos diálogos se
auto-respondieron y el último (tipo + mensaje). drop/closeAll usan CdpDisconnect
(alias legible de CdpClose(c,0)).
2026-06-06 15:33:02 +02:00
13 changed files with 787 additions and 176 deletions
+83 -23
View File
@@ -2,9 +2,20 @@
name: browser_mcp
lang: go
domain: infra
version: 0.3.0
description: "Servidor MCP que expone control total del navegador via CDP (39 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) 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."
version: 0.6.0
description: "Servidor MCP que expone control total del navegador via CDP (42 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión, selección determinista de pestaña, lectura compacta texto/AX nativa + bucle percibir→actuar por #ref con auto-observe, percepción y lectura de texto dentro de iframes, click por coordenadas, y screenshot devuelto como image content que el LLM ve) 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
@@ -26,6 +37,7 @@ uses_functions:
- 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
@@ -40,8 +52,10 @@ uses_functions:
- cdp_save_storage_state_go_browser
- cdp_load_storage_state_go_browser
- cdp_get_text_go_browser
- cdp_get_text_in_frame_go_browser
- cdp_connect_target_go_browser
- cdp_perceive_outline_py_pipelines
- cdp_get_ax_outline_go_browser
- cdp_screenshot_bytes_go_browser
- cdp_click_ref_go_browser
- cdp_type_ref_go_browser
- cdp_hover_ref_go_browser
@@ -104,7 +118,7 @@ podría manipular pestañas ajenas del usuario (banca, correo). Para evitarlo:
- Para adjuntarte deliberadamente al navegador diario, pasa `port: 9222` explícito en cada
tool. Hazlo solo con cuidado.
## Tools (39)
## Tools (42)
### Sesión (`tools_session.go`)
- `browser_launch` (MUTA) — lanza Chrome con CDP. args: port, headless, user_data_dir, url.
@@ -132,11 +146,17 @@ podría manipular pestañas ajenas del usuario (banca, correo). Para evitarlo:
— 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.
**Nativo en Go** sobre la conexión CDP viva del pool (`cdp_get_ax_outline_go_browser`) — ya
no lanza subprocess `fn run` ni levanta el venv de Python. Para elegir la pestaña usa `tab_select`
ANTES (la conexión del pool ya está fijada a esa pestaña); el campo `tab_id` queda obsoleto y se
ignora (se conserva por compatibilidad). Si se pasa `frame_id`, percibe DENTRO de ese iframe
(obtén el id con `frame_list`). args: port, tab_id (obsoleto), frame_id (opcional), max_chars (default 20000).
- `page_eval_js` (MUTA) — `Runtime.evaluate`. args: port, expression.
- `page_screenshot` — captura a archivo. args: port, path, full_page.
- `page_screenshot` — captura la página y la **devuelve como image content** para que el LLM vea los
píxeles (vía `cdp_screenshot_bytes_go_browser`, sin tocar disco). Si se pasa `path`, además guarda la
imagen en ese archivo; el image content se devuelve siempre. Útil cuando el outline de `page_perceive`
no basta (canvas, mapas, layouts visuales): mira la captura y actúa con `dom_click_xy`. args: port,
path (opcional), full_page.
### DOM (`tools_dom.go`)
- `dom_click` (MUTA) — click por selector. args: port, selector.
@@ -145,17 +165,23 @@ podría manipular pestañas ajenas del usuario (banca, correo). Para evitarlo:
- `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_click_ref` (MUTA) — click humanizado por `#ref` (backendDOMNodeId del outline de `page_perceive`) + auto-observe. args: port, ref, mode.
- `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.
- `dom_hover_ref` (MUTA) — hover humanizado por `#ref` + auto-observe. args: port, ref, mode.
- `dom_click_xy` (MUTA) — fallback de click por coordenadas absolutas (x, y en CSS pixels del viewport) con
movimiento humanizado por defecto. Pensado para usarse sobre lo que el agente VE en `page_screenshot`
cuando el outline no basta (canvas, mapas, layouts visuales); prefiere `dom_click_ref` cuando el elemento
aparece en el outline. Devuelve el outline actualizado (auto-observe). args: port, x, y, mode.
#### 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` devuelve un outline accionable (generado de forma nativa en Go
sobre la conexión CDP viva) 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, truncado a 8000 chars), cerrando el bucle
percibir→actuar:
```
page_perceive → outline con #ref de cada elemento
@@ -163,8 +189,14 @@ 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).
Cuando el elemento no aparece en el outline (canvas, mapas, layouts puramente
visuales), el fallback es **mirar** con `page_screenshot` (que devuelve la imagen
al LLM) y **actuar** por coordenadas con `dom_click_xy`, que también devuelve el
outline tras el click.
Las tools `*_ref` y `dom_click_xy` aceptan `mode` (`human` por defecto con
Bézier+jitter anti-bot, `fast` para scraping masivo, `instant` sin movimiento de
ratón). La humanización es el default en todas para no facilitar la detección.
### Input (`tools_input.go`) — todas MUTA
- `press_key` — presiona una tecla nombrada (Enter/Tab/Escape/ArrowDown/...). args: port, key.
@@ -181,6 +213,9 @@ sesión `fast`/`instant` para scraping masivo está pendiente (ver TODO en el c
- `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.
- `frame_get_text` — texto visible (innerText) de un iframe, truncado a `max_bytes`. Para leer
contenido atrapado dentro de un iframe — `page_get_text` solo cubre el documento de nivel superior.
args: port, frame_id, max_bytes (default 20000).
### Estado de sesión (`tools_storage.go`)
- `storage_save` — guarda cookies + localStorage a JSON. args: port, path.
@@ -206,11 +241,11 @@ Transporte HTTP (Streamable HTTP):
### 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`,
solo expone las 19 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.
`page_perceive`, `page_screenshot`, `dom_find_by_text`, `dom_find_ref_by_text`, `dom_wait_element`,
`cookie_get`, `frame_list`, `frame_get_html`, `frame_get_text`, `storage_save`). Útil para sesiones
de inspección sin riesgo de modificar el estado del navegador.
## Omitido en v1
@@ -220,8 +255,10 @@ 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
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í.
- **`cdp_get_ax_tree`** — expuesto via la tool `page_perceive`. Desde v0.6.0 el outline se genera
de forma **nativa en Go** (`cdp_get_ax_outline_go_browser`) sobre la conexión CDP viva del pool;
ya no se invoca el pipeline Python `cdp_perceive_outline` por subprocess (`fn run`). El acceso al
árbol AX en bruto sigue sin exponerse: la tool devuelve directamente el outline accionable.
- **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
@@ -229,6 +266,29 @@ Funciones del dominio `browser` que NO se exponen como tools en esta versión, c
## Capability growth log
- v0.6.0 (2026-06-06) — Percepción visual y de iframes + perceive nativo. (1) `page_perceive` se
generó hasta ahora por subprocess `fn run cdp_perceive_outline` (Python); ahora es **nativo en Go**
sobre la conexión CDP viva del pool (`cdp_get_ax_outline_go_browser`) — mata el subprocess, el venv
y la dependencia del binario `fn` en runtime (se eliminó `resolveRoot`/`exec.Command`). (2) Acceso a
datos dentro de iframes: nueva tool `frame_get_text` (innerText de un iframe, `cdp_get_text_in_frame_go_browser`)
y nuevo parámetro `frame_id` en `page_perceive` para percibir DENTRO de un iframe. (3) Click por
coordenadas absolutas: nueva tool `dom_click_xy` (`cdp_click_xy_human_go_browser`), humanizada por
defecto, pensada para actuar sobre lo que el LLM ve en una captura. (4) `page_screenshot` ahora
**devuelve la imagen como image content** (vía `cdp_screenshot_bytes_go_browser` + `mcp.NewToolResultImage`)
para que el LLM vea los píxeles; `path` pasa a ser opcional (si se da, además guarda a disco). (5) El
auto-observe de las tools `*_ref` subió su truncado de 4000 a 8000 chars (outlines grandes se cortaban).
(6) Fix de seguridad documental: todas las descripciones del parámetro `port` que decían "Default 9222"
(el navegador diario del usuario) corregidas a "Default 9333" (Chrome aislado del MCP); el código ya
usaba 9333, la doc era falsa y podía inducir al modelo a tocar pestañas de banca/correo. 40 → 42 tools.
- 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
+21 -28
View File
@@ -6,15 +6,16 @@ import (
"log/slog"
"net/http"
"os"
"path/filepath"
"os/signal"
"strings"
"syscall"
"github.com/mark3labs/mcp-go/server"
"fn-registry/functions/browser"
)
const version = "0.3.0"
const version = "0.6.0"
type config struct {
httpAddr string
@@ -42,8 +43,22 @@ func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})))
pool := newConnPool()
// Cierre por EOF de stdio (ServeStdio retorna) o salida normal de serveHTTP.
defer pool.closeAll()
// Cierre por señal: SIGTERM/SIGINT NO ejecutan defers, así que matamos los
// Chrome propios explícitamente antes de salir. Sin esto, al matar el MCP los
// chromium lanzados quedaban vivos y huérfanos (~789 MiB RSS cada uno) — el
// leak que provocó el apagón por saturación de RAM (06/06/2026).
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
slog.Info("signal received, killing launched chromes", "signal", sig.String())
pool.closeAll()
os.Exit(0)
}()
d := &deps{pool: pool, readOnly: cfg.readOnly}
srv := server.NewMCPServer(
@@ -109,7 +124,10 @@ func (d *deps) withConn(port int, fn func(c *browser.CDPConn) error) error {
}
err = fn(c)
if err != nil && isConnErr(err) {
d.pool.drop(port)
// La conexión murió (Chrome pudo cerrar la tab). Soltamos SOLO el
// WebSocket y reconectamos al mismo Chrome — releaseConn, no drop: drop
// mataría el proceso y dejaría sin nada a qué reconectar.
d.pool.releaseConn(port)
c2, err2 := d.pool.get(port)
if err2 != nil {
return err2
@@ -179,28 +197,3 @@ func truncate(s string, n int) string {
}
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)
}
+117 -13
View File
@@ -10,14 +10,28 @@ import (
// connPool reusa conexiones CDP entre invocaciones de tools. Clave = puerto CDP.
// Una conexión = una sesión viva a una tab "page". Mantenerla evita pagar el
// handshake WebSocket en cada tool y preserva estado (event handlers, contexto).
//
// El pool también registra el PID del Chrome que el MCP LANZÓ por puerto
// (mapa `pids`). Sin ese PID, cerrar la conexión solo suelta el WebSocket y deja
// el proceso chromium huérfano (~789 MiB RSS cada uno) — ese era el leak de RAM.
// Con el PID registrado, `drop`/`closeAll` matan el grupo de proceso completo.
// Un puerto SIN pid registrado (p.ej. el navegador diario del usuario en 9222,
// que el MCP no lanzó) nunca se mata: solo se suelta el WebSocket.
type connPool struct {
mu sync.Mutex
conns map[int]*browser.CDPConn
cancels map[int]func() // cancels de handlers persistentes (handle_dialog)
mu sync.Mutex
conns map[int]*browser.CDPConn
pids map[int]int // puerto -> PID del Chrome lanzado por el MCP (solo los SUYOS)
cancels map[int]func() // cancels de handlers persistentes (handle_dialog)
dialogLogs map[int]*browser.DialogLog // log de diálogos auto-respondidos por puerto
}
func newConnPool() *connPool {
return &connPool{conns: map[int]*browser.CDPConn{}, cancels: map[int]func(){}}
return &connPool{
conns: map[int]*browser.CDPConn{},
pids: map[int]int{},
cancels: map[int]func(){},
dialogLogs: map[int]*browser.DialogLog{},
}
}
func (p *connPool) get(port int) (*browser.CDPConn, error) {
@@ -34,6 +48,62 @@ func (p *connPool) get(port int) (*browser.CDPConn, error) {
return c, nil
}
// setPID registra el PID del Chrome que el MCP lanzó en este puerto. A partir de
// aquí drop/closeAll podrán matar ese proceso (es nuestro).
func (p *connPool) setPID(port, pid int) {
p.mu.Lock()
defer p.mu.Unlock()
p.pids[port] = pid
}
// getPID devuelve el PID registrado para el puerto (y si existe). pid<=0 o
// ausente significa que el MCP no lanzó ningún Chrome propio en ese puerto.
func (p *connPool) getPID(port int) (int, bool) {
p.mu.Lock()
defer p.mu.Unlock()
pid, ok := p.pids[port]
return pid, ok
}
// clearPID olvida el PID de un puerto sin matar nada (p.ej. el proceso ya murió).
func (p *connPool) clearPID(port int) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.pids, port)
}
// launchedCount devuelve cuántos Chrome propios tiene vivos el MCP (uno por
// puerto registrado). Alimenta el tope de instancias en handleLaunch.
func (p *connPool) launchedCount() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.pids)
}
// releaseConn cierra SOLO el WebSocket pooled del puerto (pid=0, no mata Chrome)
// y lo borra del mapa, PRESERVANDO el PID registrado. Cancela el handler de
// diálogo de esa sesión (está atado a la conexión que se suelta). Lo usan el
// retry de withConn y connectTarget: necesitan reconectar al MISMO Chrome, no
// matarlo.
func (p *connPool) releaseConn(port int) {
p.mu.Lock()
defer p.mu.Unlock()
if cancel, ok := p.cancels[port]; ok && cancel != nil {
cancel()
delete(p.cancels, port)
}
delete(p.dialogLogs, port)
if c, ok := p.conns[port]; ok && c != nil {
// pid=0: solo soltar el WebSocket. El Chrome sigue vivo para reconectar.
_ = browser.CdpClose(c, 0)
delete(p.conns, port)
}
}
// drop cierra la sesión del puerto Y mata el Chrome SI lo lanzó el MCP (pid
// registrado). Para un Chrome externo (sin pid registrado, p.ej. el navegador
// diario en 9222) pasa pid=0 a CdpClose: solo cierra el WebSocket, NUNCA mata el
// navegador del usuario. Limpia todas las entradas del puerto.
func (p *connPool) drop(port int) {
p.mu.Lock()
defer p.mu.Unlock()
@@ -41,17 +111,24 @@ func (p *connPool) drop(port int) {
cancel()
delete(p.cancels, port)
}
if c, ok := p.conns[port]; ok && c != nil {
_ = browser.CdpClose(c, 0)
delete(p.conns, port)
}
delete(p.dialogLogs, port)
pid := p.pids[port] // 0 si el MCP no lanzó este Chrome
c := p.conns[port]
// CdpClose mata el grupo de proceso completo SOLO si pid>0 (Setpgid=true en
// ChromeLaunch). Con c!=nil cierra además el WebSocket; con pid<=0 no toca el
// proceso.
_ = browser.CdpClose(c, pid)
delete(p.conns, port)
delete(p.pids, port)
}
// 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".
// pestaña conocida y no sobre "la primera al azar". Usa releaseConn (NO drop):
// cambiar de pestaña no debe matar el Chrome, es el mismo navegador.
func (p *connPool) connectTarget(port int, match string) (*browser.CDPConn, error) {
p.drop(port)
p.releaseConn(port)
c, err := browser.CdpConnectTarget("localhost", port, match)
if err != nil {
return nil, err
@@ -62,15 +139,33 @@ func (p *connPool) connectTarget(port int, match string) (*browser.CDPConn, erro
return c, nil
}
func (p *connPool) setCancel(port int, cancel func()) {
// setDialog guarda el cancel y el DialogLog del auto-handler de diálogos del
// puerto. Si ya había uno armado, lo cancela primero.
func (p *connPool) setDialog(port int, cancel func(), dlog *browser.DialogLog) {
p.mu.Lock()
defer p.mu.Unlock()
if old := p.cancels[port]; old != nil {
old()
}
p.cancels[port] = cancel
p.dialogLogs[port] = dlog
}
// dialogSnapshot devuelve el estado del log de diálogos del puerto (0,"","" si
// no hay handler armado).
func (p *connPool) dialogSnapshot(port int) (int, string, string) {
p.mu.Lock()
defer p.mu.Unlock()
if dl := p.dialogLogs[port]; dl != nil {
return dl.Snapshot()
}
return 0, "", ""
}
// closeAll cierra todas las conexiones y mata TODOS los Chrome que el MCP lanzó
// (pid registrado). Se llama con defer en main() (cierre por EOF de stdio) y
// desde el handler de señales (SIGTERM/SIGINT). Idempotente: vacía los mapas, así
// que una segunda llamada no hace nada. Un Chrome externo (sin pid) no se mata.
func (p *connPool) closeAll() {
p.mu.Lock()
defer p.mu.Unlock()
@@ -78,12 +173,21 @@ func (p *connPool) closeAll() {
if cancel := p.cancels[port]; cancel != nil {
cancel()
}
if c != nil {
_ = browser.CdpClose(c, 0)
_ = browser.CdpClose(c, p.pids[port]) // mata nuestro Chrome; pid=0 para externos
delete(p.pids, port) // marcado como ya cerrado
}
// Matar también los Chrome propios cuya conexión ya fue soltada (releaseConn
// preserva el pid pero borra la conn): pid registrado sin conn viva.
for port, pid := range p.pids {
if pid > 0 {
_ = browser.CdpClose(nil, pid)
}
_ = port
}
p.conns = map[int]*browser.CDPConn{}
p.pids = map[int]int{}
p.cancels = map[int]func(){}
p.dialogLogs = map[int]*browser.DialogLog{}
}
// isConnErr reconoce errores de conexión CDP muerta para reintentar UNA vez.
+215
View File
@@ -0,0 +1,215 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"fn-registry/functions/browser"
)
// Estos tests lanzan y matan Chrome REAL. Gate BMCP_E2E=1 y deben correr
// AISLADOS en un servicio transitorio systemd-run --user: matar chromium desde
// el árbol de procesos del Bash tool dispara exit-144. Ver
// .claude/rules y la memoria harness-exit-144-chromium.
func requireE2E(t *testing.T) {
t.Helper()
if os.Getenv("BMCP_E2E") != "1" {
t.Skip("skip: requiere BMCP_E2E=1 + Chrome real, correr bajo systemd-run --user")
}
}
// chromePIDsByUDD cuenta los procesos chromium (browser + zygotes + renderers)
// que comparten un user-data-dir concreto, leyendo /proc/<pid>/cmdline. Usar el
// UDD como aguja cuenta el ÁRBOL completo (los hijos heredan --user-data-dir),
// y aísla el conteo del navegador diario en 9222 (UDD distinto).
func chromePIDsByUDD(udd string) []int {
var pids []int
needle := "--user-data-dir=" + udd
matches, _ := filepath.Glob("/proc/[0-9]*/cmdline")
for _, m := range matches {
b, err := os.ReadFile(m)
if err != nil {
continue
}
cmd := strings.ReplaceAll(string(b), "\x00", " ")
if strings.Contains(cmd, needle) {
parts := strings.Split(m, "/")
if len(parts) >= 3 {
if pid, err := strconv.Atoi(parts[2]); err == nil {
pids = append(pids, pid)
}
}
}
}
return pids
}
// rssKB suma el VmRSS (KiB) de un conjunto de PIDs.
func rssKB(pids []int) int64 {
var total int64
for _, pid := range pids {
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(line, "VmRSS:") {
f := strings.Fields(line)
if len(f) >= 2 {
if v, err := strconv.ParseInt(f[1], 10, 64); err == nil {
total += v
}
}
}
}
}
return total
}
// TestE2EPoolKillsLaunchedChromes — GOLDEN PATH del fix del leak.
// Lanza 3 Chrome headless en puertos aislados, los registra en el pool, mide su
// RSS, llama closeAll() (lo que hace el shutdown del MCP) y verifica CERO
// huérfanos. Reporta el RSS liberado.
func TestE2EPoolKillsLaunchedChromes(t *testing.T) {
requireE2E(t)
base := filepath.Join(os.TempDir(), "bmcp_e2e_golden")
_ = os.RemoveAll(base)
defer os.RemoveAll(base)
ports := []int{9401, 9402, 9403}
udds := map[int]string{}
pool := newConnPool()
defer pool.closeAll() // red de seguridad si el test aborta a mitad
for _, p := range ports {
udd := filepath.Join(base, strconv.Itoa(p))
if err := os.MkdirAll(udd, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", udd, err)
}
udds[p] = udd
pid, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{
Port: p,
Headless: true,
UserDataDir: udd,
ReuseExisting: true,
})
if err != nil {
t.Fatalf("ChromeLaunch port=%d: %v", p, err)
}
if pid == 0 {
t.Fatalf("port=%d ya estaba ocupado (ReuseExisting devolvió 0); usa otro puerto", p)
}
pool.setPID(p, pid)
t.Logf("lanzado Chrome pid=%d port=%d", pid, p)
}
// Verificar que los 3 árboles están vivos + medir RSS.
var alive int
var rssBefore int64
for _, p := range ports {
pids := chromePIDsByUDD(udds[p])
alive += len(pids)
rssBefore += rssKB(pids)
}
if alive < len(ports) {
t.Fatalf("esperaba >=%d procesos chrome vivos, vivos=%d", len(ports), alive)
}
t.Logf("ANTES: %d procesos chrome vivos, RSS total ~%d MiB", alive, rssBefore/1024)
// El kill: closeAll mata cada grupo de proceso registrado.
pool.closeAll()
time.Sleep(2 * time.Second) // dar tiempo al SIGKILL del grupo
var after int
for _, p := range ports {
after += len(chromePIDsByUDD(udds[p]))
}
if after != 0 {
t.Fatalf("LEAK: %d procesos chrome siguen vivos tras closeAll (esperaba 0)", after)
}
t.Logf("DESPUES: 0 huérfanos. RSS liberado ~%d MiB (%d → 0)", rssBefore/1024, rssBefore/1024)
}
// TestE2EDedupSamePort — EDGE: dos ChromeLaunch(ReuseExisting) al mismo puerto
// no duplican el proceso; el segundo devuelve pid 0.
func TestE2EDedupSamePort(t *testing.T) {
requireE2E(t)
base := filepath.Join(os.TempDir(), "bmcp_e2e_dedup")
_ = os.RemoveAll(base)
defer os.RemoveAll(base)
udd := filepath.Join(base, "9404")
if err := os.MkdirAll(udd, 0o755); err != nil {
t.Fatal(err)
}
pid1, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9404, Headless: true, UserDataDir: udd, ReuseExisting: true})
if err != nil {
t.Fatalf("primer launch: %v", err)
}
if pid1 == 0 {
t.Fatal("primer launch devolvió 0 (puerto ya ocupado)")
}
defer browser.CdpClose(nil, pid1) // cleanup: mata el grupo
pid2, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9404, Headless: true, UserDataDir: udd, ReuseExisting: true})
if err != nil {
t.Fatalf("segundo launch: %v", err)
}
if pid2 != 0 {
// matar el duplicado antes de fallar para no dejar huérfanos
_ = browser.CdpClose(nil, pid2)
t.Fatalf("segundo launch lanzó un DUPLICADO pid=%d (esperaba 0 = reuso)", pid2)
}
if n := len(chromePIDsByUDD(udd)); n == 0 {
t.Fatalf("el primer Chrome debería seguir vivo")
}
t.Logf("dedup OK: pid1=%d vivo, segundo launch reusó (pid 0)", pid1)
}
// TestE2EDropKillsOwnNotExternal — EDGE + SEGURIDAD: drop mata el Chrome que el
// MCP lanzó (pid registrado), pero NO mata un Chrome que el MCP no lanzó (pid no
// registrado en el pool) — la salvaguarda que protege el navegador diario.
func TestE2EDropKillsOwnNotExternal(t *testing.T) {
requireE2E(t)
base := filepath.Join(os.TempDir(), "bmcp_e2e_drop")
_ = os.RemoveAll(base)
defer os.RemoveAll(base)
// (a) Chrome PROPIO en 9405: registrado → drop debe matarlo.
uddOwn := filepath.Join(base, "9405")
_ = os.MkdirAll(uddOwn, 0o755)
ownPID, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9405, Headless: true, UserDataDir: uddOwn, ReuseExisting: true})
if err != nil || ownPID == 0 {
t.Fatalf("launch propio 9405: pid=%d err=%v", ownPID, err)
}
pool := newConnPool()
pool.setPID(9405, ownPID)
// (b) Chrome EXTERNO en 9406: NO registrado en el pool → drop NO debe matarlo.
uddExt := filepath.Join(base, "9406")
_ = os.MkdirAll(uddExt, 0o755)
extPID, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9406, Headless: true, UserDataDir: uddExt, ReuseExisting: true})
if err != nil || extPID == 0 {
t.Fatalf("launch externo 9406: pid=%d err=%v", extPID, err)
}
defer browser.CdpClose(nil, extPID) // lo mata el test, no el pool
// drop sobre ambos puertos.
pool.drop(9405) // pid registrado → mata
pool.drop(9406) // pid NO registrado → solo cierra WS, NO mata
time.Sleep(2 * time.Second)
if n := len(chromePIDsByUDD(uddOwn)); n != 0 {
t.Fatalf("drop NO mató el Chrome propio 9405: %d vivos", n)
}
if n := len(chromePIDsByUDD(uddExt)); n == 0 {
t.Fatalf("drop MATÓ un Chrome externo 9406 (debía respetarlo)")
}
t.Logf("OK: propio 9405 muerto, externo 9406 respetado (salvaguarda navegador diario)")
}
+104
View File
@@ -0,0 +1,104 @@
package main
import (
"context"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
// resultText concatena el texto de un CallToolResult para asserts.
func resultText(r *mcp.CallToolResult) string {
var sb strings.Builder
for _, c := range r.Content {
if tc, ok := c.(mcp.TextContent); ok {
sb.WriteString(tc.Text)
}
}
return sb.String()
}
// TestPoolPIDLifecycle verifica set/get/clear/count del registro de PIDs sin
// tocar Chrome real.
func TestPoolPIDLifecycle(t *testing.T) {
p := newConnPool()
if n := p.launchedCount(); n != 0 {
t.Fatalf("launchedCount inicial = %d, want 0", n)
}
p.setPID(9333, 4242)
if pid, ok := p.getPID(9333); !ok || pid != 4242 {
t.Fatalf("getPID(9333) = (%d,%v), want (4242,true)", pid, ok)
}
if n := p.launchedCount(); n != 1 {
t.Fatalf("launchedCount tras setPID = %d, want 1", n)
}
p.clearPID(9333)
if _, ok := p.getPID(9333); ok {
t.Fatalf("getPID(9333) sigue presente tras clearPID")
}
if n := p.launchedCount(); n != 0 {
t.Fatalf("launchedCount tras clearPID = %d, want 0", n)
}
}
// TestInstanceCapRejectsWithoutLaunching verifica el tope duro: con
// maxLaunchedChromes PIDs ya registrados, browser_launch en un puerto nuevo
// devuelve error de tool y NO intenta lanzar Chrome (el cap se evalúa antes de
// ChromeLaunch, por eso este test no necesita Chrome real). Cubre el edge
// "superar el tope → error claro".
func TestInstanceCapRejectsWithoutLaunching(t *testing.T) {
p := newConnPool()
for i := 0; i < maxLaunchedChromes; i++ {
p.setPID(9500+i, 100000+i) // PIDs ficticios: nunca se matan en este test
}
d := &deps{pool: p}
res, err := d.handleLaunch(context.Background(), mcp.CallToolRequest{}, launchArgs{Port: 9600})
if err != nil {
t.Fatalf("handleLaunch err = %v", err)
}
if !res.IsError {
t.Fatalf("esperaba IsError=true por cap, got text=%q", resultText(res))
}
if txt := resultText(res); !strings.Contains(txt, "cap") {
t.Fatalf("mensaje no menciona el cap: %q", txt)
}
// El puerto nuevo no debe haberse registrado.
if _, ok := p.getPID(9600); ok {
t.Fatalf("el puerto rechazado por cap no debe registrarse")
}
}
// TestLaunchReusesRegisteredPort verifica idempotencia: si el MCP ya lanzó un
// Chrome en el puerto (PID registrado), un segundo browser_launch lo reusa sin
// lanzar otro proceso. No necesita Chrome real (el reuse corta antes de
// ChromeLaunch). Cubre el edge "dos browser_launch al mismo puerto no duplica".
func TestLaunchReusesRegisteredPort(t *testing.T) {
p := newConnPool()
p.setPID(9333, 777777)
d := &deps{pool: p}
res, err := d.handleLaunch(context.Background(), mcp.CallToolRequest{}, launchArgs{Port: 9333})
if err != nil {
t.Fatalf("handleLaunch err = %v", err)
}
if res.IsError {
t.Fatalf("no esperaba error, got %q", resultText(res))
}
if txt := resultText(res); !strings.Contains(txt, "reused pid=777777") {
t.Fatalf("esperaba reuse del pid registrado, got %q", txt)
}
if n := p.launchedCount(); n != 1 {
t.Fatalf("launchedCount = %d, want 1 (no debe duplicar)", n)
}
}
// TestDropClearsMapsNoPID verifica que drop sobre un puerto sin conn ni pid no
// panica y deja los mapas limpios (no mata nada — caso del navegador externo
// del que solo se soltó el WebSocket).
func TestDropClearsMapsNoPID(t *testing.T) {
p := newConnPool()
p.drop(9222) // puerto externo, sin conn ni pid registrado: no-op seguro
if n := p.launchedCount(); n != 0 {
t.Fatalf("launchedCount = %d, want 0", n)
}
}
+4 -4
View File
@@ -30,7 +30,7 @@ type cookieGetArgs struct {
func cookieGetTool() mcp.Tool {
return mcp.NewTool("cookie_get",
mcp.WithDescription("Return all browser cookies (Network.getAllCookies) as JSON."),
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.")),
)
}
@@ -62,7 +62,7 @@ type cookieSetArgs struct {
func cookieSetTool() mcp.Tool {
return mcp.NewTool("cookie_set",
mcp.WithDescription("Set a cookie via Network.setCookie."),
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.")),
mcp.WithString("name", mcp.Required(), mcp.Description("Cookie name.")),
mcp.WithString("value", mcp.Description("Cookie value.")),
mcp.WithString("domain", mcp.Required(), mcp.Description("Cookie domain.")),
@@ -102,7 +102,7 @@ type cookieDeleteArgs struct {
func cookieDeleteTool() mcp.Tool {
return mcp.NewTool("cookie_delete",
mcp.WithDescription("Delete cookies by name (optionally scoped to a domain) via Network.deleteCookies."),
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.")),
mcp.WithString("name", mcp.Required(), mcp.Description("Cookie name to delete.")),
mcp.WithString("domain", mcp.Description("Optional domain scope.")),
)
@@ -130,7 +130,7 @@ type cookieClearArgs struct {
func cookieClearTool() mcp.Tool {
return mcp.NewTool("cookie_clear",
mcp.WithDescription("Clear all browser cookies via Network.clearBrowserCookies."),
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.")),
)
}
+90 -19
View File
@@ -14,6 +14,7 @@ import (
// registerDomTools wires DOM interaction tools. find/wait stay on under --read-only.
func registerDomTools(s *server.MCPServer, d *deps) {
s.AddTool(domFindByTextTool(), mcp.NewTypedToolHandler(d.handleDomFindByText))
s.AddTool(domFindRefByTextTool(), mcp.NewTypedToolHandler(d.handleDomFindRefByText))
s.AddTool(domWaitElementTool(), mcp.NewTypedToolHandler(d.handleDomWaitElement))
if !d.readOnly {
@@ -24,6 +25,7 @@ func registerDomTools(s *server.MCPServer, d *deps) {
s.AddTool(domClickRefTool(), mcp.NewTypedToolHandler(d.handleDomClickRef))
s.AddTool(domTypeRefTool(), mcp.NewTypedToolHandler(d.handleDomTypeRef))
s.AddTool(domHoverRefTool(), mcp.NewTypedToolHandler(d.handleDomHoverRef))
s.AddTool(domClickXYTool(), mcp.NewTypedToolHandler(d.handleDomClickXY))
}
}
@@ -34,29 +36,30 @@ const settleDelay = 400 * time.Millisecond
// ---- dom_click_ref (MUTA) — bucle percibir→actuar ----
type domClickRefArgs struct {
Port int `json:"port"`
Ref int `json:"ref"`
Port int `json:"port"`
Ref int `json:"ref"`
Mode string `json:"mode"`
}
func domClickRefTool() mcp.Tool {
return mcp.NewTool("dom_click_ref",
mcp.WithDescription("Click humanizado sobre el elemento por su #ref del outline de page_perceive (backendDOMNodeId estable). Usa humanización por defecto (Bézier+jitter). Devuelve el outline actualizado tras la acción (auto-observe)."),
mcp.WithDescription("Click sobre el elemento por su #ref del outline de page_perceive (backendDOMNodeId estable). Devuelve el outline actualizado tras la acción (auto-observe)."),
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
mcp.WithNumber("ref", mcp.Required(), mcp.Description("#ref del elemento (backendDOMNodeId) leído del outline de page_perceive.")),
mcp.WithString("mode", mcp.Description("Velocidad: 'human' (default, Bézier+jitter anti-bot), 'fast' (movimiento reducido, scraping masivo), 'instant' (element.click() JS, sin eventos de ratón; también fallback si el elemento no tiene geometría).")),
)
}
func (d *deps) handleDomClickRef(_ context.Context, _ mcp.CallToolRequest, a domClickRefArgs) (*mcp.CallToolResult, error) {
port := portOr(a.Port)
// TODO: preset de humanización por sesión (human/fast/instant)
err := d.withConn(port, func(c *browser.CDPConn) error {
return browser.CdpClickRef(c, a.Ref, browser.MouseHumanOpts{})
return browser.CdpClickRef(c, a.Ref, browser.MouseProfileForMode(a.Mode))
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
time.Sleep(settleDelay)
outline, _ := d.perceiveOutline(port, 4000)
outline, _ := d.perceiveOutline(port, 8000)
return mcp.NewToolResultText("clicked ref " + fmt.Sprint(a.Ref) + "\n\n" + outline), nil
}
@@ -90,39 +93,72 @@ func (d *deps) handleDomTypeRef(_ context.Context, _ mcp.CallToolRequest, a domT
return mcp.NewToolResultError(err.Error()), nil
}
time.Sleep(settleDelay)
outline, _ := d.perceiveOutline(port, 4000)
outline, _ := d.perceiveOutline(port, 8000)
return mcp.NewToolResultText("typed into ref " + fmt.Sprint(a.Ref) + "\n\n" + outline), nil
}
// ---- dom_hover_ref (MUTA) — bucle percibir→actuar ----
type domHoverRefArgs struct {
Port int `json:"port"`
Ref int `json:"ref"`
Port int `json:"port"`
Ref int `json:"ref"`
Mode string `json:"mode"`
}
func domHoverRefTool() mcp.Tool {
return mcp.NewTool("dom_hover_ref",
mcp.WithDescription("Hover humanizado sobre el elemento por su #ref del outline de page_perceive (backendDOMNodeId estable). Usa humanización por defecto (Bézier+jitter). Devuelve el outline actualizado tras la acción (auto-observe)."),
mcp.WithDescription("Hover sobre el elemento por su #ref del outline de page_perceive (backendDOMNodeId estable). Devuelve el outline actualizado tras la acción (auto-observe)."),
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
mcp.WithNumber("ref", mcp.Required(), mcp.Description("#ref del elemento (backendDOMNodeId) leído del outline de page_perceive.")),
mcp.WithString("mode", mcp.Description("Velocidad: 'human' (default, Bézier+jitter), 'fast' (movimiento reducido), 'instant' (sin movimiento de ratón).")),
)
}
func (d *deps) handleDomHoverRef(_ context.Context, _ mcp.CallToolRequest, a domHoverRefArgs) (*mcp.CallToolResult, error) {
port := portOr(a.Port)
// TODO: preset de humanización por sesión (human/fast/instant)
err := d.withConn(port, func(c *browser.CDPConn) error {
return browser.CdpHoverRef(c, a.Ref, browser.MouseHumanOpts{})
return browser.CdpHoverRef(c, a.Ref, browser.MouseProfileForMode(a.Mode))
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
time.Sleep(settleDelay)
outline, _ := d.perceiveOutline(port, 4000)
outline, _ := d.perceiveOutline(port, 8000)
return mcp.NewToolResultText("hovered ref " + fmt.Sprint(a.Ref) + "\n\n" + outline), nil
}
// ---- dom_click_xy (MUTA) — click humanizado por coordenadas absolutas ----
type domClickXYArgs struct {
Port int `json:"port"`
X float64 `json:"x"`
Y float64 `json:"y"`
Mode string `json:"mode"`
}
func domClickXYTool() mcp.Tool {
return mcp.NewTool("dom_click_xy",
mcp.WithDescription("Fallback de click por coordenadas absolutas (x, y) en CSS pixels del viewport, con movimiento de ratón humanizado por defecto. Pensado para usarse sobre lo que el agente VE en page_screenshot cuando el outline de page_perceive no basta (canvas, mapas, layouts visuales). Prefiere dom_click_ref cuando el elemento aparece en el outline. Devuelve el outline actualizado tras la acción (auto-observe)."),
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
mcp.WithNumber("x", mcp.Required(), mcp.Description("Coordenada X absoluta en CSS pixels del viewport.")),
mcp.WithNumber("y", mcp.Required(), mcp.Description("Coordenada Y absoluta en CSS pixels del viewport.")),
mcp.WithString("mode", mcp.Description("Velocidad: 'human' (default, Bézier+jitter anti-bot), 'fast' (movimiento reducido, scraping masivo), 'instant' (sin movimiento de ratón).")),
)
}
func (d *deps) handleDomClickXY(_ context.Context, _ mcp.CallToolRequest, a domClickXYArgs) (*mcp.CallToolResult, error) {
port := portOr(a.Port)
err := d.withConn(port, func(c *browser.CDPConn) error {
return browser.CdpClickXYHuman(c, a.X, a.Y, browser.MouseProfileForMode(a.Mode))
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
time.Sleep(settleDelay)
outline, _ := d.perceiveOutline(port, 8000)
return mcp.NewToolResultText(fmt.Sprintf("clicked at (%g, %g)\n\n%s", a.X, a.Y, outline)), nil
}
// ---- dom_click (MUTA) ----
type domClickArgs struct {
@@ -133,7 +169,7 @@ type domClickArgs struct {
func domClickTool() mcp.Tool {
return mcp.NewTool("dom_click",
mcp.WithDescription("Click the element matching the CSS selector (synthetic CDP click)."),
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.")),
mcp.WithString("selector", mcp.Required(), mcp.Description("CSS selector of the element to click.")),
)
}
@@ -161,7 +197,7 @@ type domClickHumanArgs struct {
func domClickHumanTool() mcp.Tool {
return mcp.NewTool("dom_click_human",
mcp.WithDescription("Click the element matching the CSS selector with human-like mouse movement (Bézier path + jitter + press/release pause)."),
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.")),
mcp.WithString("selector", mcp.Required(), mcp.Description("CSS selector of the element to click.")),
)
}
@@ -189,7 +225,7 @@ type domClickTextArgs struct {
func domClickTextTool() mcp.Tool {
return mcp.NewTool("dom_click_text",
mcp.WithDescription("Find the first element whose visible text matches and click it."),
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.")),
mcp.WithString("text", mcp.Required(), mcp.Description("Visible text to match (substring).")),
)
}
@@ -217,7 +253,7 @@ type domTypeArgs struct {
func domTypeTool() mcp.Tool {
return mcp.NewTool("dom_type",
mcp.WithDescription("Type text into the currently focused element (dispatches key events char by char)."),
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.")),
mcp.WithString("text", mcp.Required(), mcp.Description("Text to type.")),
)
}
@@ -245,7 +281,7 @@ type domFindByTextArgs struct {
func domFindByTextTool() mcp.Tool {
return mcp.NewTool("dom_find_by_text",
mcp.WithDescription("Find the first element whose visible text matches and return a unique CSS selector for it (empty string if none)."),
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.")),
mcp.WithString("text", mcp.Required(), mcp.Description("Visible text to match (substring).")),
)
}
@@ -266,6 +302,41 @@ func (d *deps) handleDomFindByText(_ context.Context, _ mcp.CallToolRequest, a d
return mcp.NewToolResultText(sel), nil
}
// ---- dom_find_ref_by_text ----
type domFindRefByTextArgs struct {
Port int `json:"port"`
Text string `json:"text"`
}
func domFindRefByTextTool() mcp.Tool {
return mcp.NewTool("dom_find_ref_by_text",
mcp.WithDescription("Find the first element whose visible text matches and return its #ref (backendDOMNodeId) ready for dom_click_ref/dom_hover_ref — no fragile CSS selector. Also reports how many elements match (count>1 = ambiguous)."),
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("text", mcp.Required(), mcp.Description("Visible text to match (substring).")),
)
}
func (d *deps) handleDomFindRefByText(_ context.Context, _ mcp.CallToolRequest, a domFindRefByTextArgs) (*mcp.CallToolResult, error) {
if a.Text == "" {
return mcp.NewToolResultError("text is required"), nil
}
var ref, count int
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
var e error
ref, count, e = browser.CdpFindRefByText(c, a.Text, browser.FindByTextOpts{})
return e
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
msg := fmt.Sprintf("ref=%d count=%d", ref, count)
if count > 1 {
msg += " (ambiguous: returning the first match; refine the text to disambiguate)"
}
return mcp.NewToolResultText(msg), nil
}
// ---- dom_wait_element ----
type domWaitElementArgs struct {
@@ -277,7 +348,7 @@ type domWaitElementArgs struct {
func domWaitElementTool() mcp.Tool {
return mcp.NewTool("dom_wait_element",
mcp.WithDescription("Block until an element matching the CSS selector appears in the DOM (or timeout)."),
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.")),
mcp.WithString("selector", mcp.Required(), mcp.Description("CSS selector to wait for.")),
mcp.WithNumber("timeout_ms", mcp.Description("Max wait in ms. Default 10000.")),
)
+43 -4
View File
@@ -10,10 +10,12 @@ import (
"fn-registry/functions/browser"
)
// registerFrameTools wires frame_list + frame_get_html (read) and frame_eval (MUTA).
// registerFrameTools wires frame_list + frame_get_html + frame_get_text (read)
// and frame_eval (MUTA).
func registerFrameTools(s *server.MCPServer, d *deps) {
s.AddTool(frameListTool(), mcp.NewTypedToolHandler(d.handleFrameList))
s.AddTool(frameGetHTMLTool(), mcp.NewTypedToolHandler(d.handleFrameGetHTML))
s.AddTool(frameGetTextTool(), mcp.NewTypedToolHandler(d.handleFrameGetText))
if !d.readOnly {
s.AddTool(frameEvalTool(), mcp.NewTypedToolHandler(d.handleFrameEval))
@@ -29,7 +31,7 @@ type frameListArgs struct {
func frameListTool() mcp.Tool {
return mcp.NewTool("frame_list",
mcp.WithDescription("List all frames (including iframes) of the current page via Page.getFrameTree. Returns JSON with frame IDs."),
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.")),
)
}
@@ -58,7 +60,7 @@ type frameEvalArgs struct {
func frameEvalTool() mcp.Tool {
return mcp.NewTool("frame_eval",
mcp.WithDescription("Evaluate a JavaScript expression inside a specific frame's execution context. Returns the stringified result."),
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.")),
mcp.WithString("frame_id", mcp.Required(), mcp.Description("Frame ID (from frame_list).")),
mcp.WithString("expression", mcp.Required(), mcp.Description("JavaScript expression to evaluate.")),
)
@@ -93,7 +95,7 @@ type frameGetHTMLArgs struct {
func frameGetHTMLTool() mcp.Tool {
return mcp.NewTool("frame_get_html",
mcp.WithDescription("Return the serialized HTML of a specific frame. Truncated to 200000 chars."),
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.")),
mcp.WithString("frame_id", mcp.Required(), mcp.Description("Frame ID (from frame_list).")),
)
}
@@ -113,3 +115,40 @@ func (d *deps) handleFrameGetHTML(_ context.Context, _ mcp.CallToolRequest, a fr
}
return mcp.NewToolResultText(truncate(html, htmlMax)), nil
}
// ---- frame_get_text ----
type frameGetTextArgs struct {
Port int `json:"port"`
FrameID string `json:"frame_id"`
MaxBytes int `json:"max_bytes"`
}
func frameGetTextTool() mcp.Tool {
return mcp.NewTool("frame_get_text",
mcp.WithDescription("Return the visible text (innerText) of a specific iframe, truncated to max_bytes. Use this to read content trapped inside an iframe — page_get_text only covers the top-level document. Get the frame_id from frame_list."),
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("frame_id", mcp.Required(), mcp.Description("Frame ID (from frame_list).")),
mcp.WithNumber("max_bytes", mcp.Description("Máximo de bytes a devolver. Default 20000. 0 = sin límite.")),
)
}
func (d *deps) handleFrameGetText(_ context.Context, _ mcp.CallToolRequest, a frameGetTextArgs) (*mcp.CallToolResult, error) {
if a.FrameID == "" {
return mcp.NewToolResultError("frame_id is required"), nil
}
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.CdpGetTextInFrame(c, a.FrameID, maxBytes)
return e
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(text), nil
}
+7 -5
View File
@@ -29,7 +29,7 @@ type pressKeyArgs struct {
func pressKeyTool() mcp.Tool {
return mcp.NewTool("press_key",
mcp.WithDescription("Press a named key (Enter, Tab, Escape, ArrowDown, Backspace, ...) on the focused element."),
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.")),
mcp.WithString("key", mcp.Required(), mcp.Description("Key name, e.g. Enter, Tab, Escape, ArrowDown.")),
)
}
@@ -58,7 +58,7 @@ type scrollArgs struct {
func scrollTool() mcp.Tool {
return mcp.NewTool("scroll",
mcp.WithDescription("Scroll the page by (delta_x, delta_y) pixels via a synthetic mouse wheel event."),
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.")),
mcp.WithNumber("delta_x", mcp.Description("Horizontal scroll delta in pixels. Default 0.")),
mcp.WithNumber("delta_y", mcp.Description("Vertical scroll delta in pixels. Default 300.")),
)
@@ -89,7 +89,7 @@ type handleDialogArgs struct {
func handleDialogTool() mcp.Tool {
return mcp.NewTool("handle_dialog",
mcp.WithDescription("Arm an auto-handler that responds to every JS dialog (alert/confirm/prompt/beforeunload) on the tab until disconnect. The handler lives in the pooled connection."),
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.")),
mcp.WithBoolean("accept", mcp.DefaultBool(true), mcp.Description("Whether to accept (true) or dismiss (false) dialogs. Default true.")),
mcp.WithString("prompt_text", mcp.Description("Text to enter for prompt() dialogs.")),
)
@@ -101,10 +101,12 @@ func (d *deps) handleHandleDialog(_ context.Context, _ mcp.CallToolRequest, a ha
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
cancel, err := browser.CdpHandleDialog(c, a.Accept, a.PromptText)
cancel, dlog, err := browser.CdpHandleDialog(c, a.Accept, a.PromptText)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
d.pool.setCancel(port, cancel)
// Guardamos el DialogLog junto al cancel para que browser_disconnect pueda
// reportar cuántos diálogos se auto-respondieron y cuál fue el último.
d.pool.setDialog(port, cancel, dlog)
return mcp.NewToolResultText("dialog auto-handler armed"), nil
}
+9 -9
View File
@@ -39,7 +39,7 @@ type tabNavigateArgs struct {
func tabNavigateTool() mcp.Tool {
return mcp.NewTool("tab_navigate",
mcp.WithDescription("Navigate the connected tab to a URL via Page.navigate."),
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.")),
mcp.WithString("url", mcp.Required(), mcp.Description("Target URL.")),
)
}
@@ -66,7 +66,7 @@ type tabListArgs struct {
func tabListTool() mcp.Tool {
return mcp.NewTool("tab_list",
mcp.WithDescription("List all CDP targets (tabs, iframes, workers) via GET /json. Returns JSON."),
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.")),
)
}
@@ -89,7 +89,7 @@ type tabNewArgs struct {
func tabNewTool() mcp.Tool {
return mcp.NewTool("tab_new",
mcp.WithDescription("Open a new tab via PUT /json/new. Returns the new tab's JSON."),
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.")),
mcp.WithString("url", mcp.Description("Optional start URL. Empty = about:blank.")),
)
}
@@ -113,7 +113,7 @@ type tabCloseArgs struct {
func tabCloseTool() mcp.Tool {
return mcp.NewTool("tab_close",
mcp.WithDescription("Close a tab by its target ID via GET /json/close/<id>."),
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.")),
mcp.WithString("tab_id", mcp.Required(), mcp.Description("Target ID of the tab to close.")),
)
}
@@ -138,7 +138,7 @@ type tabActivateArgs struct {
func tabActivateTool() mcp.Tool {
return mcp.NewTool("tab_activate",
mcp.WithDescription("Bring a tab to the foreground via GET /json/activate/<id>."),
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.")),
mcp.WithString("tab_id", mcp.Required(), mcp.Description("Target ID of the tab to activate.")),
)
}
@@ -184,7 +184,7 @@ type navBackArgs struct {
func navBackTool() mcp.Tool {
return mcp.NewTool("nav_back",
mcp.WithDescription("Navigate back in the connected tab's history."),
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.")),
)
}
@@ -207,7 +207,7 @@ type navForwardArgs struct {
func navForwardTool() mcp.Tool {
return mcp.NewTool("nav_forward",
mcp.WithDescription("Navigate forward in the connected tab's history."),
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.")),
)
}
@@ -231,7 +231,7 @@ type pageWaitLoadArgs struct {
func pageWaitLoadTool() mcp.Tool {
return mcp.NewTool("page_wait_load",
mcp.WithDescription("Block until the page fires the load event (or timeout)."),
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.")),
mcp.WithNumber("timeout_ms", mcp.Description("Max wait in ms. Default 10000.")),
)
}
@@ -260,7 +260,7 @@ type pageWaitIdleArgs struct {
func pageWaitIdleTool() mcp.Tool {
return mcp.NewTool("page_wait_idle",
mcp.WithDescription("Block until network activity quiets down (inflight requests reach 0 for a quiet window) or timeout. Immune to DOM-mutating extensions/animations."),
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.")),
mcp.WithNumber("timeout_ms", mcp.Description("Max wait in ms. Default 15000.")),
)
}
+48 -65
View File
@@ -2,10 +2,8 @@ package main
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
"encoding/base64"
"os"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
@@ -67,14 +65,16 @@ func (d *deps) handlePageGetText(_ context.Context, _ mcp.CallToolRequest, a pag
type pagePerceiveArgs struct {
Port int `json:"port"`
TabID string `json:"tab_id"`
FrameID string `json:"frame_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.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. Generado de forma nativa en Go sobre la conexión CDP viva (sin subprocess ni Python). Para elegir la pestaña, usa tab_select ANTES de percibir (la conexión del pool ya está fijada a esa pestaña). Si frame_id se pasa, percibe DENTRO de ese iframe (obtén el id con frame_list)."),
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.WithString("tab_id", mcp.Description("OBSOLETO: la conexión del pool ya está fijada a una pestaña vía tab_select. Para elegir pestaña usa tab_select primero; este campo se conserva por compatibilidad y se ignora.")),
mcp.WithString("frame_id", mcp.Description("Frame ID (de frame_list) para percibir DENTRO de ese iframe. Vacío = página entera.")),
mcp.WithNumber("max_chars", mcp.Description("Máximo de chars del outline. Default 20000.")),
)
}
@@ -86,66 +86,36 @@ func (d *deps) handlePagePerceive(_ context.Context, _ mcp.CallToolRequest, a pa
maxChars = 20000
}
outline, err := d.perceiveOutlineTab(port, a.TabID, maxChars)
outline, err := d.perceiveOutlineFrame(port, a.FrameID, maxChars)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(outline), nil
}
// perceiveOutline genera el outline AX accionable de la pestaña (vía el pipeline
// cdp_perceive_outline). Usa la primera pestaña 'page' del puerto.
// perceiveOutline genera el outline AX accionable de la página entera sobre la
// conexión viva del pool (sin subprocess). Lo usan los auto-observe de las tools
// *_ref tras una acción.
func (d *deps) perceiveOutline(port, maxChars int) (string, error) {
return d.perceiveOutlineTab(port, "", maxChars)
return d.perceiveOutlineFrame(port, "", maxChars)
}
// perceiveOutlineTab genera el outline AX accionable de la pestaña indicada (vía
// el pipeline cdp_perceive_outline). Si tabID es "", usa la primera pestaña 'page'.
// Resuelve la raíz del registry para localizar el binario `fn` + el venv de Python
// y ejecuta `<root>/fn run cdp_perceive_outline <port> <tabID> <maxChars>` por
// subprocess, devolviendo su stdout truncado a htmlMax.
func (d *deps) perceiveOutlineTab(port int, tabID string, maxChars int) (string, error) {
root, err := resolveRoot()
// perceiveOutlineFrame genera el outline AX accionable de forma NATIVA en Go,
// reusando la conexión CDP viva del pool (browser.CdpGetAXOutline). Si frameID
// != "", percibe DENTRO de ese iframe; frameID == "" = página entera. No lanza
// subprocess `fn run` ni levanta el venv de Python — la lógica de poda y render
// del árbol de accesibilidad vive en la función del registry.
func (d *deps) perceiveOutlineFrame(port int, frameID string, maxChars int) (string, error) {
var outline string
err := d.withConn(port, func(c *browser.CDPConn) error {
var e error
outline, e = browser.CdpGetAXOutline(c, frameID, maxChars)
return e
})
if err != nil {
return "", fmt.Errorf("resolve registry root: %w", err)
return "", err
}
if tabID == "" {
tabs, err := browser.CdpListTabs("localhost", port)
if err != nil {
return "", fmt.Errorf("list tabs: %w", err)
}
for _, t := range tabs {
if t.Type == "page" {
tabID = t.ID
break
}
}
if tabID == "" {
return "", fmt.Errorf("no 'page' tab found on port %d", port)
}
}
// `fn run` pasa los argumentos POSICIONALMENTE a la función del pipeline
// (no como flags argparse): el orden debe coincidir con la firma
// cdp_perceive_outline(debug_port, tab_id, max_chars).
cmd := exec.Command(filepath.Join(root, "fn"), "run", "cdp_perceive_outline",
fmt.Sprint(port),
tabID,
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 "", fmt.Errorf("cdp_perceive_outline failed: %s", msg)
}
return truncate(stdout.String(), htmlMax), nil
return truncate(outline, htmlMax), nil
}
// ---- page_get_html ----
@@ -157,7 +127,7 @@ type pageGetHTMLArgs struct {
func pageGetHTMLTool() mcp.Tool {
return mcp.NewTool("page_get_html",
mcp.WithDescription("Return the current page's full serialized HTML (outerHTML). Truncated to 200000 chars."),
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.")),
)
}
@@ -184,7 +154,7 @@ type pageEvalJSArgs struct {
func pageEvalJSTool() mcp.Tool {
return mcp.NewTool("page_eval_js",
mcp.WithDescription("Evaluate a JavaScript expression in the page context via Runtime.evaluate. Returns the stringified result."),
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.")),
mcp.WithString("expression", mcp.Required(), mcp.Description("JavaScript expression to evaluate.")),
)
}
@@ -215,23 +185,36 @@ type pageScreenshotArgs struct {
func pageScreenshotTool() mcp.Tool {
return mcp.NewTool("page_screenshot",
mcp.WithDescription("Capture a screenshot of the current page and write it to a local path (.png/.jpg)."),
mcp.WithNumber("port", mcp.Description("CDP port. Default 9222.")),
mcp.WithString("path", mcp.Required(), mcp.Description("Output file path (.png or .jpg).")),
mcp.WithDescription("Capture a screenshot of the current page and return it as image content so the LLM can actually see the pixels. Optionally also writes it to a local path. Use this when the accessibility outline (page_perceive) is not enough — e.g. canvas/visual layouts — then act with dom_click_xy over what you see."),
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("path", mcp.Description("Optional output file path (.png or .jpg). If given, the image is ALSO saved to disk; the image content is always returned regardless.")),
mcp.WithBoolean("full_page", mcp.Description("Capture the full scroll height instead of just the viewport.")),
)
}
func (d *deps) handlePageScreenshot(_ context.Context, _ mcp.CallToolRequest, a pageScreenshotArgs) (*mcp.CallToolResult, error) {
if a.Path == "" {
return mcp.NewToolResultError("path is required"), nil
}
opts := browser.CdpScreenshotOpts{FullPage: a.FullPage}
var data []byte
var mimeType string
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
return browser.CdpScreenshot(c, a.Path, opts)
var e error
data, mimeType, e = browser.CdpScreenshotBytes(c, opts)
return e
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText("screenshot saved to " + a.Path), nil
text := "screenshot captured"
// Si se pidió un path, persistimos además los bytes capturados (mismo origen
// que la imagen devuelta al LLM, así no se captura dos veces).
if a.Path != "" {
if e := os.WriteFile(a.Path, data, 0o644); e != nil {
return mcp.NewToolResultError("saving screenshot to " + a.Path + ": " + e.Error()), nil
}
text = "screenshot saved to " + a.Path
}
b64 := base64.StdEncoding.EncodeToString(data)
return mcp.NewToolResultImage(text, b64, mimeType), nil
}
+44 -4
View File
@@ -21,6 +21,12 @@ func registerSessionTools(s *server.MCPServer, d *deps) {
s.AddTool(disconnectTool(), mcp.NewTypedToolHandler(d.handleDisconnect))
}
// maxLaunchedChromes es el tope duro de instancias Chrome que el MCP puede tener
// vivas a la vez (una por puerto). Cada chromium ocioso pesa ~789 MiB RSS; sin
// tope, llamadas repetidas a browser_launch saturan la RAM (apagón 06/06/2026).
// Al superarlo, browser_launch devuelve un error de tool en vez de lanzar más.
const maxLaunchedChromes = 4
// ---- browser_launch (MUTA) ----
type launchArgs struct {
@@ -41,6 +47,22 @@ func launchTool() mcp.Tool {
}
func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchArgs) (*mcp.CallToolResult, error) {
port := portOr(a.Port)
// (1) Idempotente: si el MCP ya lanzó un Chrome en este puerto, reusarlo en
// vez de duplicar el proceso. (Si el proceso hubiera muerto, withConn/connect
// fallará y el usuario puede browser_disconnect + relanzar.)
if pid, ok := d.pool.getPID(port); ok && pid > 0 {
return mcp.NewToolResultText(fmt.Sprintf("reused pid=%d port=%d (already launched by this MCP)", pid, port)), nil
}
// (2) Tope duro de instancias propias. Cada chromium ocioso ~789 MiB RSS.
if d.pool.launchedCount() >= maxLaunchedChromes {
return mcp.NewToolResultError(fmt.Sprintf(
"instance cap reached: the MCP already launched %d Chrome instances (max %d); browser_disconnect one before launching another",
d.pool.launchedCount(), maxLaunchedChromes)), nil
}
// 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
@@ -49,9 +71,13 @@ func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchAr
_ = os.MkdirAll(userDataDir, 0o755)
}
opts := browser.ChromeLaunchOpts{
Port: portOr(a.Port),
Port: port,
Headless: a.Headless,
UserDataDir: userDataDir,
// (3) Anti-duplicado: si ya hay un Chrome vivo en el puerto (incluido el
// navegador diario externo en 9222), ChromeLaunch NO lanza otro y devuelve
// pid 0 — nos adjuntamos al existente sin registrarlo como nuestro.
ReuseExisting: true,
}
if a.URL != "" {
opts.ExtraArgs = append(opts.ExtraArgs, a.URL)
@@ -60,7 +86,15 @@ func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchAr
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(fmt.Sprintf("launched pid=%d port=%d user_data_dir=%s", pid, opts.Port, userDataDir)), nil
if pid == 0 {
// Había un Chrome externo en el puerto: lo reusamos pero NO lo registramos
// (no es nuestro → browser_disconnect no debe matarlo).
return mcp.NewToolResultText(fmt.Sprintf("reused existing chrome on port=%d (external, not killed by the MCP)", port)), nil
}
// (4) Registrar el PID: a partir de aquí el MCP puede matar este Chrome en
// browser_disconnect / shutdown. Esto es lo que cierra el leak de RAM.
d.pool.setPID(port, pid)
return mcp.NewToolResultText(fmt.Sprintf("launched pid=%d port=%d user_data_dir=%s", pid, port, userDataDir)), nil
}
// ---- browser_connect ----
@@ -92,13 +126,19 @@ type disconnectArgs struct {
func disconnectTool() mcp.Tool {
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 the pooled CDP connection for the given port (cancels any armed dialog handler). If the MCP LAUNCHED the Chrome on that port (via browser_launch), it also KILLS that Chrome process group, freeing its RAM. A Chrome the MCP did not launch (e.g. the user's daily browser on 9222) is never killed — only the WebSocket is closed."),
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
)
}
func (d *deps) handleDisconnect(_ context.Context, _ mcp.CallToolRequest, a disconnectArgs) (*mcp.CallToolResult, error) {
port := portOr(a.Port)
// Leer el log de diálogos ANTES de drop (drop lo limpia).
count, lastType, lastMsg := d.pool.dialogSnapshot(port)
d.pool.drop(port)
return mcp.NewToolResultText(fmt.Sprintf("disconnected port=%d", port)), nil
msg := fmt.Sprintf("disconnected port=%d", port)
if count > 0 {
msg += fmt.Sprintf(" (dialogs auto-handled: %d, last %s: %q)", count, lastType, lastMsg)
}
return mcp.NewToolResultText(msg), nil
}
+2 -2
View File
@@ -28,7 +28,7 @@ type storageSaveArgs struct {
func storageSaveTool() mcp.Tool {
return mcp.NewTool("storage_save",
mcp.WithDescription("Save the current session storage state (cookies + localStorage) to a JSON file for later reuse."),
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.")),
mcp.WithString("path", mcp.Required(), mcp.Description("Output JSON file path.")),
)
}
@@ -56,7 +56,7 @@ type storageLoadArgs struct {
func storageLoadTool() mcp.Tool {
return mcp.NewTool("storage_load",
mcp.WithDescription("Load a previously saved session storage state (cookies + localStorage) from a JSON file into the live browser."),
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.")),
mcp.WithString("path", mcp.Required(), mcp.Description("Input JSON file path.")),
)
}