Compare commits
31 Commits
ae324562e8
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a681c79d96 | |||
| 3b68c02b25 | |||
| d687a501ba | |||
| 70ab1a4d30 | |||
| 9e9c690f06 | |||
| 8e421a3500 | |||
| 3ce9b12eab | |||
| cb587a7005 | |||
| 33358bca6c | |||
| 82993179dc | |||
| 6b7f71c39f | |||
| 15949bf4ed | |||
| 5706c84a15 | |||
| f2587d6fee | |||
| 91973ed6f9 | |||
| c56004da5c | |||
| c2470f4f67 | |||
| 1c5b81f711 | |||
| a48e262371 | |||
| fa1efe6fd5 | |||
| f0bfc3e300 | |||
| 1fae6c1df9 | |||
| 54fe1b7f17 | |||
| fed245a738 | |||
| 9c170b9c43 | |||
| 254f089982 | |||
| 9b437f1e5e | |||
| 71fdae9e35 | |||
| 9e6d9f7886 | |||
| 71bc7ab8d8 | |||
| 4307fb2e58 |
@@ -1,2 +1,6 @@
|
||||
/browser_mcp
|
||||
*.log
|
||||
# registry.db sólo existe en la raíz del repo (regla db_locations). Si un test o el
|
||||
# binario lo crea aquí por un path relativo, es basura: ignorarlo evita trackearlo.
|
||||
registry.db
|
||||
operations.db*
|
||||
|
||||
@@ -5,9 +5,13 @@ MCP server (Go) that exposes the registry's CDP browser-control functions
|
||||
Chrome DevTools Protocol: navigate, read the DOM, click, manage cookies, evaluate
|
||||
JavaScript, operate iframes, and persist/restore session state.
|
||||
|
||||
36 tools total, grouped by domain. See `app.md` for the full per-tool reference and the
|
||||
45 tools total, grouped by domain. See `app.md` for the full per-tool reference and the
|
||||
"Omitido en v1" section.
|
||||
|
||||
Includes per-profile Chromium lifecycle tools (`browser_list`, `browser_launch_profile`,
|
||||
`browser_close`) that manage the user's profiled Chromium windows (e.g. "Personal", "Work"),
|
||||
separate from the MCP's own isolated automation Chrome on port 9333.
|
||||
|
||||
## Security: isolated Chrome by default (port 9333)
|
||||
|
||||
**By default the MCP operates on its OWN isolated Chrome, NOT the user's daily browser.**
|
||||
|
||||
@@ -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.8.0
|
||||
description: "Servidor MCP que expone control total del navegador via CDP (46 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión, selección determinista de pestaña, modo de velocidad de sesión (browser_set_mode: 'auto' rápido por defecto / 'human' sigiloso anti-detección), 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, screenshot devuelto como image content que el LLM ve, y gestión del ciclo de vida de Chromium por perfil: listar masters en ejecución, lanzar un perfil concreto con o sin CDP, y cerrar limpio) 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
|
||||
@@ -20,12 +31,12 @@ uses_functions:
|
||||
- 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
|
||||
@@ -40,12 +51,22 @@ 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
|
||||
- cdp_click_xy_human_go_browser
|
||||
- cdp_collect_console_go_browser
|
||||
- cdp_print_pdf_go_browser
|
||||
- cdp_select_option_go_browser
|
||||
- cdp_set_file_input_go_browser
|
||||
- cdp_wait_actionable_go_browser
|
||||
- cdp_select_dropdown_go_browser
|
||||
- cdp_fill_go_browser
|
||||
- cdp_find_by_role_go_browser
|
||||
uses_types: []
|
||||
framework: ""
|
||||
entry_point: "main.go"
|
||||
@@ -104,12 +125,33 @@ 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 (46)
|
||||
|
||||
### 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.
|
||||
- `browser_set_mode` — fija el modo de velocidad de sesión del puerto: `auto` (default, rápido) o `human` (sigiloso anti-detección). args: port, mode. Cada tool de acción puede overridearlo con su arg `mode`.
|
||||
|
||||
### Ciclo de vida por perfil (`tools_lifecycle.go`)
|
||||
Gestionan los Chromium del USUARIO por perfil (`Personal`, `Work`, ...), distintos del Chrome
|
||||
de automatización aislado de `browser_launch`. Las instancias lanzadas aquí NO se registran en el
|
||||
pool: son de uso humano y sobreviven a la muerte del MCP; se cierran explícitamente con
|
||||
`browser_close`.
|
||||
- `browser_list` — lista los procesos MASTER de Chromium en ejecución (con `--user-data-dir`,
|
||||
SIN `--type=`). Para cada uno: pid, profile, user_data_dir, cdp_port, has_cdp. Devuelve JSON
|
||||
array. Read-only. args: (ninguno).
|
||||
- `browser_launch_profile` (MUTA) — lanza Chromium para un perfil concreto en la pantalla del
|
||||
usuario, usando el binario REAL `/usr/lib/chromium/chromium` (salta el wrapper). Con `cdp=false`
|
||||
(default) NO añade flags de remote-debugging — necesario para perfiles humanos (Google mantiene
|
||||
la sesión; con CDP la trata como automatizada y la tira). Con `cdp=true` añade
|
||||
`--remote-debugging-port` + `--remote-allow-origins=*`. Detecta DISPLAY/XAUTHORITY de la sesión
|
||||
XFCE y lanza DESACOPLADO (setsid). Si un master ya posee el user_data_dir, Chromium reenvía la
|
||||
apertura a él (`note` en el resultado). args: profile (requerido), user_data_dir
|
||||
(default `~/.config/chromium-cdp`), url, cdp (default false), cdp_port (default 9222).
|
||||
- `browser_close` (MUTA) — cierra un master limpio. Lo localiza por `profile`, `cdp_port` o `pid`.
|
||||
Envía SIGTERM, espera hasta 10s, y SIGKILL como último recurso (indicado en `method`). Devuelve
|
||||
{closed, pid, method}. args: uno de profile, cdp_port o pid.
|
||||
|
||||
### Navegación + tabs (`tools_nav.go`)
|
||||
- `tab_navigate` (MUTA) — `Page.navigate`. args: port, url.
|
||||
@@ -132,11 +174,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 +193,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 +217,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 +241,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 +269,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`,
|
||||
`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.
|
||||
solo expone las 20 tools de lectura/control (`browser_connect`, `browser_disconnect`, `browser_list`,
|
||||
`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_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 +283,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 +294,56 @@ Funciones del dominio `browser` que NO se exponen como tools en esta versión, c
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.8.0 (2026-06-13) — Aceleración del manejo del navegador via CDP + flag de velocidad de
|
||||
sesión. (1) Nueva tool `browser_set_mode` (45 → 46 tools): fija el modo de velocidad por puerto
|
||||
en el pool — `auto` (default del MCP, rápido) vs `human` (sigiloso anti-detección). El modo se
|
||||
resuelve por acción con `effectiveMode`: arg `mode` de la tool > modo de sesión > `auto`. (2) Settle
|
||||
adaptativo: el sleep ciego fijo de 400ms tras cada acción mutante (`dom_click_ref`/`dom_type_ref`/
|
||||
`dom_hover_ref`/`dom_click_xy`) pasa a `settleForMode` — 60ms en `auto`, aleatorio 250-650ms en
|
||||
`human` (ritmo no-máquina), 0 en `instant`. (3) `dom_type_ref` ahora tiene arg `mode`: en `auto`
|
||||
usa `CdpTypeRefFast` (`Input.insertText`, un solo round-trip) y en `human` teclea carácter a
|
||||
carácter (`CdpTypeRef`) con pausas aleatorias. (4) `browser_launch_profile` reemplaza el `sleep(1s)`
|
||||
ciego por un poll del puerto CDP (`waitCDPPort`). Cambios en el dominio `browser` del registry que
|
||||
aprovecha el MCP: `Accessibility.enable`/`Network.enable`/`Page.enable` cacheados por conexión
|
||||
(`ensureAX`/`ensureNetwork`/`ensurePage` en `CDPConn`) — se eliminan round-trips redundantes en cada
|
||||
percepción/espera; `cdp_wait_load` pasa de polling de `document.readyState` cada 200ms a esperar el
|
||||
evento `Page.loadEventFired` (fast path si ya está `complete`); `sendCDP` adquiere timeout
|
||||
(`cdpCmdTimeout` 30s) para no colgar el tool indefinidamente; nuevas `CdpInsertText` y
|
||||
`CdpTypeRefFast` (camino rápido de escritura); el modo `auto` se añade al perfil de ratón
|
||||
(`MouseProfileForMode`) como alias rápido de `fast`. Smoke contra Chrome 9333: percepción #2 con
|
||||
enable cacheado 1.7ms (vs 3.7ms la #1), `wait_load` fast-path 245µs (vs ≥200ms del polling previo).
|
||||
- v0.7.0 (2026-06-10) — Ciclo de vida de Chromium por perfil (`tools_lifecycle.go`). Tres tools
|
||||
nuevas: `browser_list` (enumera los procesos master de Chromium leyendo `/proc/*/cmdline`,
|
||||
filtrando por `--user-data-dir` presente y `--type=` ausente), `browser_launch_profile` (lanza un
|
||||
perfil concreto con el binario REAL `/usr/lib/chromium/chromium` para saltar el wrapper, con/sin
|
||||
CDP — sin CDP por defecto para que Google mantenga la sesión de perfiles humanos; detecta
|
||||
DISPLAY/XAUTHORITY de la sesión XFCE y lanza desacoplado con setsid) y `browser_close` (localiza el
|
||||
master por profile/cdp_port/pid, SIGTERM con espera de 10s, SIGKILL como último recurso). Las
|
||||
instancias por perfil NO se registran en el pool: son de uso humano y sobreviven a la muerte del
|
||||
MCP. 42 → 45 tools.
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compila browser_mcp inyectando la versión declarada en app.md como única fuente
|
||||
# de verdad. Evita el drift entre la constante del binario y app.md (bug 16/06/2026:
|
||||
# serverInfo reportaba 0.7.0 mientras app.md ya iba por 0.8.0).
|
||||
#
|
||||
# Uso: ./build.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
version="$(grep -m1 '^version:' app.md | awk '{print $2}')"
|
||||
if [ -z "${version}" ]; then
|
||||
echo "build.sh: no pude leer 'version:' de app.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CGO_ENABLED=0 go build -ldflags "-X main.version=${version}" -o browser_mcp .
|
||||
echo "built browser_mcp version=${version}"
|
||||
@@ -6,15 +6,20 @@ 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"
|
||||
// version is the server version reported in serverInfo. The literal here is a
|
||||
// fallback for `go build` with no flags; build.sh overrides it via
|
||||
// -ldflags "-X main.version=<app.md version>" so app.md stays the single source
|
||||
// of truth and the binary can never drift behind it (see build.sh).
|
||||
var version = "0.8.0"
|
||||
|
||||
type config struct {
|
||||
httpAddr string
|
||||
@@ -42,8 +47,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(
|
||||
@@ -77,6 +96,7 @@ func main() {
|
||||
// registerTools wires every tool group. Mutating tools are skipped under --read-only.
|
||||
func registerTools(s *server.MCPServer, d *deps) {
|
||||
registerSessionTools(s, d)
|
||||
registerLifecycleTools(s, d)
|
||||
registerNavTools(s, d)
|
||||
registerReadTools(s, d)
|
||||
registerDomTools(s, d)
|
||||
@@ -109,7 +129,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 +202,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)
|
||||
}
|
||||
|
||||
@@ -10,14 +10,45 @@ 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
|
||||
modes map[int]string // puerto -> modo de velocidad de sesión ("auto"|"human"|...)
|
||||
}
|
||||
|
||||
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{},
|
||||
modes: map[int]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// setMode fija el modo de velocidad de sesión para un puerto (lo lee
|
||||
// effectiveMode cuando una tool de acción no trae su propio arg `mode`).
|
||||
func (p *connPool) setMode(port int, mode string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.modes[port] = mode
|
||||
}
|
||||
|
||||
// getMode devuelve el modo de sesión del puerto ("" si no se fijó ninguno).
|
||||
func (p *connPool) getMode(port int) string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.modes[port]
|
||||
}
|
||||
|
||||
func (p *connPool) get(port int) (*browser.CDPConn, error) {
|
||||
@@ -34,6 +65,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 +128,25 @@ 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)
|
||||
delete(p.modes, 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 +157,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 +191,22 @@ 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{}
|
||||
p.modes = map[int]string{}
|
||||
}
|
||||
|
||||
// isConnErr reconoce errores de conexión CDP muerta para reintentar UNA vez.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Instala los git hooks versionados de este repo en .git/hooks.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
ln -sf ../../scripts/pre-commit .git/hooks/pre-commit
|
||||
echo "instalado .git/hooks/pre-commit -> scripts/pre-commit"
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Anti-stale binary guard. El .mcp.json ejecuta el binario ./browser_mcp; si se
|
||||
# commitea un cambio en los .go sin recompilar, la sesión sirve código viejo
|
||||
# (bug 16/06/2026). Este hook recompila en cada commit. Instálalo con
|
||||
# scripts/install-hooks.sh.
|
||||
set -euo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
if ! ./build.sh >/tmp/browser_mcp_build.log 2>&1; then
|
||||
echo "pre-commit: build.sh falló — commit abortado. Log:" >&2
|
||||
cat /tmp/browser_mcp_build.log >&2
|
||||
exit 1
|
||||
fi
|
||||
+4
-4
@@ -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.")),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+359
-27
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
@@ -14,7 +15,10 @@ 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))
|
||||
s.AddTool(domFindByRoleTool(), mcp.NewTypedToolHandler(d.handleDomFindByRole))
|
||||
s.AddTool(domWaitActionableTool(), mcp.NewTypedToolHandler(d.handleDomWaitActionable))
|
||||
|
||||
if !d.readOnly {
|
||||
s.AddTool(domClickTool(), mcp.NewTypedToolHandler(d.handleDomClick))
|
||||
@@ -24,39 +28,284 @@ 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))
|
||||
s.AddTool(domSelectOptionTool(), mcp.NewTypedToolHandler(d.handleDomSelectOption))
|
||||
s.AddTool(domSetFilesTool(), mcp.NewTypedToolHandler(d.handleDomSetFiles))
|
||||
s.AddTool(domSelectDropdownTool(), mcp.NewTypedToolHandler(d.handleDomSelectDropdown))
|
||||
s.AddTool(domFillTool(), mcp.NewTypedToolHandler(d.handleDomFill))
|
||||
}
|
||||
}
|
||||
|
||||
// settleDelay es la espera breve tras una acción mutante antes de re-percibir,
|
||||
// dando tiempo a que el DOM se asiente (navegación, focus, repaint).
|
||||
const settleDelay = 400 * time.Millisecond
|
||||
// ---- dom_find_by_role ----
|
||||
|
||||
type domFindByRoleArgs struct {
|
||||
Port int `json:"port"`
|
||||
Role string `json:"role"`
|
||||
Name string `json:"name"`
|
||||
Exact bool `json:"exact"`
|
||||
Regex bool `json:"regex"`
|
||||
}
|
||||
|
||||
func domFindByRoleTool() mcp.Tool {
|
||||
return mcp.NewTool("dom_find_by_role",
|
||||
mcp.WithDescription("Find an element by ARIA role + accessible name (like Playwright getByRole), reusing the accessibility tree. Returns its #ref (usable with dom_click_ref/dom_hover_ref/dom_type_ref) and how many elements matched (count>1 means ambiguous). More robust to DOM/CSS changes than CSS or text selectors — prefer it to move around the page."),
|
||||
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("role", mcp.Required(), mcp.Description("ARIA role, e.g. button, link, textbox, checkbox, combobox, option, tab.")),
|
||||
mcp.WithString("name", mcp.Description("Accessible name to match (computed, not innerText). Empty = match any element of that role.")),
|
||||
mcp.WithBoolean("exact", mcp.Description("Exact name match instead of substring. Default false (substring).")),
|
||||
mcp.WithBoolean("regex", mcp.Description("Treat name as a regular expression. Takes precedence over exact.")),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *deps) handleDomFindByRole(_ context.Context, _ mcp.CallToolRequest, a domFindByRoleArgs) (*mcp.CallToolResult, error) {
|
||||
if a.Role == "" {
|
||||
return mcp.NewToolResultError("role 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.CdpFindByRole(c, a.Role, browser.CdpFindByRoleOpts{Name: a.Name, Exact: a.Exact, Regex: a.Regex})
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf(`{"ref":%d,"count":%d}`, ref, count)), nil
|
||||
}
|
||||
|
||||
// ---- dom_wait_actionable ----
|
||||
|
||||
type domWaitActionableArgs struct {
|
||||
Port int `json:"port"`
|
||||
Ref int `json:"ref"`
|
||||
NeedEnabled bool `json:"need_enabled"`
|
||||
TimeoutMs int `json:"timeout_ms"`
|
||||
}
|
||||
|
||||
func domWaitActionableTool() mcp.Tool {
|
||||
return mcp.NewTool("dom_wait_actionable",
|
||||
mcp.WithDescription("Wait until a #ref element is truly actionable before clicking: visible + stable (not animating) + optionally enabled + hit-test passes (no overlay/cookie-banner intercepting the click point). Returns the validated center point {x,y}. Use it before dom_click_xy when a click seems to do nothing — it catches the #1 cause: an overlay swallowing the click, or the element still mounting/animating."),
|
||||
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 (backend node id) from page_perceive / dom_find_*.")),
|
||||
mcp.WithBoolean("need_enabled", mcp.Description("Also require the element not be disabled/aria-disabled. Default false.")),
|
||||
mcp.WithNumber("timeout_ms", mcp.Description("Max wait in milliseconds. Default 3000.")),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *deps) handleDomWaitActionable(_ context.Context, _ mcp.CallToolRequest, a domWaitActionableArgs) (*mcp.CallToolResult, error) {
|
||||
if a.Ref == 0 {
|
||||
return mcp.NewToolResultError("ref is required"), nil
|
||||
}
|
||||
timeout := time.Duration(a.TimeoutMs) * time.Millisecond
|
||||
if a.TimeoutMs == 0 {
|
||||
timeout = 3 * time.Second
|
||||
}
|
||||
var x, y float64
|
||||
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||
var e error
|
||||
x, y, e = browser.CdpWaitActionable(c, a.Ref, a.NeedEnabled, timeout)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf(`{"actionable":true,"x":%.1f,"y":%.1f}`, x, y)), nil
|
||||
}
|
||||
|
||||
// ---- dom_select_dropdown (MUTA) ----
|
||||
|
||||
type domSelectDropdownArgs struct {
|
||||
Port int `json:"port"`
|
||||
Trigger string `json:"trigger"`
|
||||
Option string `json:"option"`
|
||||
Exact bool `json:"exact"`
|
||||
TimeoutMs int `json:"timeout_ms"`
|
||||
OptionRole string `json:"option_role"`
|
||||
}
|
||||
|
||||
func domSelectDropdownTool() mcp.Tool {
|
||||
return mcp.NewTool("dom_select_dropdown",
|
||||
mcp.WithDescription("Select an option in a CUSTOM dropdown (combobox/listbox built with divs — MUI, react-select, headlessui, select2), NOT a native <select>. Clicks the trigger, waits for the list to actually open (aria-expanded / visible [role=option]), then real-clicks the matching option. For native <select> use dom_select_option instead."),
|
||||
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("trigger", mcp.Required(), mcp.Description("CSS selector of the element that opens the dropdown.")),
|
||||
mcp.WithString("option", mcp.Required(), mcp.Description("Visible text of the option to pick.")),
|
||||
mcp.WithBoolean("exact", mcp.Description("Exact option text match instead of substring. Default false.")),
|
||||
mcp.WithNumber("timeout_ms", mcp.Description("Max wait for open + option in milliseconds. Default 3000.")),
|
||||
mcp.WithString("option_role", mcp.Description("ARIA role of options. Default \"option\".")),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *deps) handleDomSelectDropdown(_ context.Context, _ mcp.CallToolRequest, a domSelectDropdownArgs) (*mcp.CallToolResult, error) {
|
||||
if a.Trigger == "" || a.Option == "" {
|
||||
return mcp.NewToolResultError("trigger and option are required"), nil
|
||||
}
|
||||
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||
return browser.CdpSelectDropdown(c, a.Trigger, a.Option, browser.CdpDropdownOpts{Exact: a.Exact, TimeoutMs: a.TimeoutMs, OptionRole: a.OptionRole})
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("selected %q in dropdown %s", a.Option, a.Trigger)), nil
|
||||
}
|
||||
|
||||
// ---- dom_fill (MUTA) ----
|
||||
|
||||
type domFillArgs struct {
|
||||
Port int `json:"port"`
|
||||
Selector string `json:"selector"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func domFillTool() mcp.Tool {
|
||||
return mcp.NewTool("dom_fill",
|
||||
mcp.WithDescription("Fill a text input/textarea/contenteditable reliably (like Playwright fill): focus + select existing text + insert the value via real input events, so React/Vue-controlled fields update correctly. Replaces the focus+type pattern that concatenates onto the old value. For native special inputs (date/range/color) it sets the value and fires input/change."),
|
||||
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 field.")),
|
||||
mcp.WithString("value", mcp.Description("Value to set. Empty string clears the field.")),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *deps) handleDomFill(_ context.Context, _ mcp.CallToolRequest, a domFillArgs) (*mcp.CallToolResult, error) {
|
||||
if a.Selector == "" {
|
||||
return mcp.NewToolResultError("selector is required"), nil
|
||||
}
|
||||
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||
return browser.CdpFillSelector(c, a.Selector, a.Value)
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("filled %s", a.Selector)), nil
|
||||
}
|
||||
|
||||
// ---- dom_select_option (MUTA) ----
|
||||
|
||||
type domSelectOptionArgs struct {
|
||||
Port int `json:"port"`
|
||||
Selector string `json:"selector"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func domSelectOptionTool() mcp.Tool {
|
||||
return mcp.NewTool("dom_select_option",
|
||||
mcp.WithDescription("Select an <option> in a native <select> element (by CSS selector), matching by option value first, then by visible text, and firing input/change events so React/Vue react. For custom (non-<select>) dropdowns use dom_click_ref on the trigger then on the option instead."),
|
||||
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 <select> element.")),
|
||||
mcp.WithString("value", mcp.Required(), mcp.Description("Option value (or visible text if no value matches).")),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *deps) handleDomSelectOption(_ context.Context, _ mcp.CallToolRequest, a domSelectOptionArgs) (*mcp.CallToolResult, error) {
|
||||
if a.Selector == "" || a.Value == "" {
|
||||
return mcp.NewToolResultError("selector and value are required"), nil
|
||||
}
|
||||
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||
return browser.CdpSelectOption(c, a.Selector, a.Value)
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("selected %q in %s", a.Value, a.Selector)), nil
|
||||
}
|
||||
|
||||
// ---- dom_set_files (MUTA) ----
|
||||
|
||||
type domSetFilesArgs struct {
|
||||
Port int `json:"port"`
|
||||
Selector string `json:"selector"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
func domSetFilesTool() mcp.Tool {
|
||||
return mcp.NewTool("dom_set_files",
|
||||
mcp.WithDescription("Upload files to an <input type=\"file\"> (by CSS selector) via DOM.setFileInputFiles, without driving the OS file picker. Paths must be absolute and readable by the Chrome process."),
|
||||
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 file input element.")),
|
||||
mcp.WithArray("paths", mcp.Required(), mcp.Description("Absolute file paths to attach."), mcp.Items(map[string]any{"type": "string"})),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *deps) handleDomSetFiles(_ context.Context, _ mcp.CallToolRequest, a domSetFilesArgs) (*mcp.CallToolResult, error) {
|
||||
if a.Selector == "" {
|
||||
return mcp.NewToolResultError("selector is required"), nil
|
||||
}
|
||||
if len(a.Paths) == 0 {
|
||||
return mcp.NewToolResultError("paths is required (at least one file)"), nil
|
||||
}
|
||||
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||
return browser.CdpSetFileInput(c, a.Selector, a.Paths)
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("attached %d file(s) to %s", len(a.Paths), a.Selector)), nil
|
||||
}
|
||||
|
||||
// defaultMode es el modo de velocidad cuando ni la llamada ni la sesión fijan uno.
|
||||
// "auto" = rápido (movimiento de ratón mínimo, escritura en un solo evento, settle
|
||||
// breve) — el modo por defecto del MCP. "human" (Bézier + esperas aleatorias) se
|
||||
// activa explícitamente vía browser_set_mode o el arg `mode` cuando un sitio
|
||||
// aplique detección anti-bot fuerte.
|
||||
const defaultMode = "auto"
|
||||
|
||||
// effectiveMode resuelve el modo de velocidad de una acción: el arg de la llamada
|
||||
// gana; si está vacío, el modo de sesión fijado por browser_set_mode; si tampoco
|
||||
// hay, defaultMode.
|
||||
func (d *deps) effectiveMode(port int, callMode string) string {
|
||||
if callMode != "" {
|
||||
return callMode
|
||||
}
|
||||
if m := d.pool.getMode(port); m != "" {
|
||||
return m
|
||||
}
|
||||
return defaultMode
|
||||
}
|
||||
|
||||
// settleForMode es la espera tras una acción mutante antes de re-percibir, dando
|
||||
// tiempo a que el DOM se asiente (navegación, focus, repaint). En "human" es
|
||||
// ALEATORIA (250-650ms) para no exhibir un ritmo de máquina; en auto/fast es breve
|
||||
// y fija (60ms); en "instant" es nula.
|
||||
func settleForMode(mode string) time.Duration {
|
||||
switch mode {
|
||||
case "human", "":
|
||||
return time.Duration(250+rand.Intn(401)) * time.Millisecond // 250..650
|
||||
case "instant":
|
||||
return 0
|
||||
default: // auto, fast
|
||||
return 60 * 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: 'auto' (default de sesión: movimiento de ratón reducido, rápido), 'human' (Bézier+jitter+pausas aleatorias anti-bot, para detección fuerte), 'instant' (element.click() JS, sin eventos de ratón; también fallback si el elemento no tiene geometría). Vacío = modo de sesión (browser_set_mode) o 'auto'.")),
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
mode := d.effectiveMode(port, a.Mode)
|
||||
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(mode))
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
time.Sleep(settleDelay)
|
||||
outline, _ := d.perceiveOutline(port, 4000)
|
||||
if dl := settleForMode(mode); dl > 0 {
|
||||
time.Sleep(dl)
|
||||
}
|
||||
outline, _ := d.perceiveOutline(port, 8000)
|
||||
return mcp.NewToolResultText("clicked ref " + fmt.Sprint(a.Ref) + "\n\n" + outline), nil
|
||||
}
|
||||
|
||||
@@ -66,6 +315,7 @@ type domTypeRefArgs struct {
|
||||
Port int `json:"port"`
|
||||
Ref int `json:"ref"`
|
||||
Text string `json:"text"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
func domTypeRefTool() mcp.Tool {
|
||||
@@ -74,6 +324,7 @@ func domTypeRefTool() mcp.Tool {
|
||||
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("text", mcp.Required(), mcp.Description("Texto a escribir en el elemento.")),
|
||||
mcp.WithString("mode", mcp.Description("Velocidad: 'auto' (default de sesión, escribe en un solo evento Input.insertText — rápido) o 'human' (caracter a caracter con pausas aleatorias, anti-detección). Vacío = modo de sesión (browser_set_mode) o 'auto'.")),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,47 +333,93 @@ func (d *deps) handleDomTypeRef(_ context.Context, _ mcp.CallToolRequest, a domT
|
||||
return mcp.NewToolResultError("text is required"), nil
|
||||
}
|
||||
port := portOr(a.Port)
|
||||
// TODO: preset de humanización por sesión (human/fast/instant)
|
||||
mode := d.effectiveMode(port, a.Mode)
|
||||
err := d.withConn(port, func(c *browser.CDPConn) error {
|
||||
return browser.CdpTypeRef(c, a.Ref, a.Text)
|
||||
// human => teclea caracter a caracter (eventos de tecla reales + ritmo
|
||||
// irregular). auto/fast/instant => inserta todo en un solo round-trip.
|
||||
if mode == "human" {
|
||||
return browser.CdpTypeRef(c, a.Ref, a.Text)
|
||||
}
|
||||
return browser.CdpTypeRefFast(c, a.Ref, a.Text)
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
time.Sleep(settleDelay)
|
||||
outline, _ := d.perceiveOutline(port, 4000)
|
||||
if dl := settleForMode(mode); dl > 0 {
|
||||
time.Sleep(dl)
|
||||
}
|
||||
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: 'auto' (default de sesión: movimiento reducido, rápido), 'human' (Bézier+jitter+pausas aleatorias anti-bot), 'instant' (sin movimiento de ratón). Vacío = modo de sesión (browser_set_mode) o 'auto'.")),
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
mode := d.effectiveMode(port, a.Mode)
|
||||
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(mode))
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
time.Sleep(settleDelay)
|
||||
outline, _ := d.perceiveOutline(port, 4000)
|
||||
if dl := settleForMode(mode); dl > 0 {
|
||||
time.Sleep(dl)
|
||||
}
|
||||
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: 'auto' (default de sesión: movimiento reducido, rápido), 'human' (Bézier+jitter+pausas aleatorias anti-bot), 'instant' (sin movimiento de ratón). Vacío = modo de sesión (browser_set_mode) o 'auto'.")),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *deps) handleDomClickXY(_ context.Context, _ mcp.CallToolRequest, a domClickXYArgs) (*mcp.CallToolResult, error) {
|
||||
port := portOr(a.Port)
|
||||
mode := d.effectiveMode(port, a.Mode)
|
||||
err := d.withConn(port, func(c *browser.CDPConn) error {
|
||||
return browser.CdpClickXYHuman(c, a.X, a.Y, browser.MouseProfileForMode(mode))
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
if dl := settleForMode(mode); dl > 0 {
|
||||
time.Sleep(dl)
|
||||
}
|
||||
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 +430,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 +458,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 +486,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 +514,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 +542,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 +563,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 +609,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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"fn-registry/functions/browser"
|
||||
)
|
||||
|
||||
// registerLifecycleTools wires the per-profile Chromium lifecycle tools:
|
||||
// - browser_list (read) — enumerate running Chromium master processes.
|
||||
// - browser_launch_profile (MUTA) — launch Chromium for a concrete profile, with/without CDP.
|
||||
// - browser_close (MUTA) — terminate a master process (SIGTERM, then SIGKILL).
|
||||
//
|
||||
// These manage the USER's Chromium instances by profile (e.g. "Personal", "Work"),
|
||||
// distinct from browser_launch which spins the MCP's own isolated automation Chrome.
|
||||
// Because the launched instances are user-facing (not driven by the MCP), they are
|
||||
// NOT registered in the connection pool: the pool's shutdown-kill is reserved for
|
||||
// automation Chromes the MCP owns, so a user's "Personal" window survives the MCP
|
||||
// dying. Cleanup is explicit via browser_close.
|
||||
func registerLifecycleTools(s *server.MCPServer, d *deps) {
|
||||
s.AddTool(browserListTool(), mcp.NewTypedToolHandler(d.handleBrowserList))
|
||||
if !d.readOnly {
|
||||
s.AddTool(browserLaunchProfileTool(), mcp.NewTypedToolHandler(d.handleBrowserLaunchProfile))
|
||||
s.AddTool(browserCloseTool(), mcp.NewTypedToolHandler(d.handleBrowserClose))
|
||||
}
|
||||
}
|
||||
|
||||
// realChromiumBin is the REAL Chromium binary, bypassing the /usr/bin/chromium
|
||||
// wrapper. The wrapper sources /etc/chromium.d/* and injects global flags
|
||||
// (--user-data-dir=$HOME/.config/chromium-cdp, --remote-debugging-port=9222,
|
||||
// --remote-allow-origins=*). Launching the wrapper would force CDP on every
|
||||
// instance, which breaks Google's session-keeping for human profiles. The real
|
||||
// binary sources none of that, so we control the flags exactly.
|
||||
const realChromiumBin = "/usr/lib/chromium/chromium"
|
||||
|
||||
// ---- master process discovery ----
|
||||
|
||||
// chromiumMaster describes one running Chromium master process (the top process
|
||||
// that owns a user-data-dir, NOT a zygote/gpu/renderer child which carries --type=).
|
||||
type chromiumMaster struct {
|
||||
PID int `json:"pid"`
|
||||
Profile string `json:"profile"` // value of --profile-directory ("" if absent)
|
||||
UserDataDir string `json:"user_data_dir"` // value of --user-data-dir
|
||||
CDPPort string `json:"cdp_port"` // value of --remote-debugging-port ("" if none)
|
||||
HasCDP bool `json:"has_cdp"`
|
||||
Headless bool `json:"headless"` // true if launched with --headless / --headless=new / --headless=old
|
||||
Pages int `json:"pages"` // count of "page" targets (best-effort via GET /json; 0 if no CDP or unreachable)
|
||||
ActiveTitle string `json:"active_title,omitempty"` // title of the first "page" target
|
||||
ActiveURL string `json:"active_url,omitempty"` // URL of the first "page" target
|
||||
}
|
||||
|
||||
// parseCmdline turns the raw bytes of /proc/<pid>/cmdline into argv.
|
||||
//
|
||||
// Canonically the kernel separates arguments with NUL bytes. But Chromium (and
|
||||
// other programs that rewrite their process title in place) collapse the argv
|
||||
// region into a single space-separated string, losing the NUL separators. In
|
||||
// that case splitting on NUL yields a single giant element holding the whole
|
||||
// command line, which breaks argv[0] detection and "--flag=" prefix matching.
|
||||
//
|
||||
// So: if the data still carries NUL separators we split on NUL (the correct,
|
||||
// space-safe path). Otherwise we fall back to splitting on whitespace. The
|
||||
// fallback is best-effort and would mis-split a flag value containing spaces
|
||||
// (e.g. a user-data-dir path with a space), but Chromium's own flags don't, so
|
||||
// it recovers the master-detection flags (--user-data-dir, --type=,
|
||||
// --remote-debugging-port, --profile-directory) reliably in practice.
|
||||
func parseCmdline(b []byte) []string {
|
||||
s := strings.TrimRight(string(b), "\x00")
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var raw []string
|
||||
if strings.Contains(s, "\x00") {
|
||||
raw = strings.Split(s, "\x00")
|
||||
} else {
|
||||
raw = strings.Fields(s)
|
||||
}
|
||||
args := make([]string, 0, len(raw))
|
||||
for _, a := range raw {
|
||||
if a != "" {
|
||||
args = append(args, a)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// readProcCmdline reads /proc/<pid>/cmdline and parses it into argv.
|
||||
// Returns nil if the process is gone or unreadable.
|
||||
func readProcCmdline(pid int) []string {
|
||||
b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"))
|
||||
if err != nil || len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
return parseCmdline(b)
|
||||
}
|
||||
|
||||
// flagValue returns the value of a "--name=value" flag from argv, plus whether it
|
||||
// was present. Matches the exact "--name=" prefix; the first occurrence wins.
|
||||
func flagValue(args []string, name string) (string, bool) {
|
||||
prefix := "--" + name + "="
|
||||
for _, a := range args {
|
||||
if strings.HasPrefix(a, prefix) {
|
||||
return strings.TrimPrefix(a, prefix), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// hasFlagPrefix reports whether any arg starts with the given prefix (e.g. "--type=").
|
||||
func hasFlagPrefix(args []string, prefix string) bool {
|
||||
for _, a := range args {
|
||||
if strings.HasPrefix(a, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isChromiumExe reports whether argv[0] looks like a chromium/chrome executable.
|
||||
func isChromiumExe(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return false
|
||||
}
|
||||
base := strings.ToLower(filepath.Base(args[0]))
|
||||
return strings.Contains(base, "chromium") || strings.Contains(base, "chrome")
|
||||
}
|
||||
|
||||
// parseChromiumMaster builds a chromiumMaster from argv if (and only if) the process
|
||||
// is a Chromium MASTER: argv[0] is a chromium/chrome binary, it carries
|
||||
// --user-data-dir, and it does NOT carry --type= (which all child processes have:
|
||||
// zygote, gpu-process, renderer, utility...). Returns ok=false otherwise.
|
||||
func parseChromiumMaster(pid int, args []string) (chromiumMaster, bool) {
|
||||
if !isChromiumExe(args) {
|
||||
return chromiumMaster{}, false
|
||||
}
|
||||
udd, hasUDD := flagValue(args, "user-data-dir")
|
||||
if !hasUDD {
|
||||
return chromiumMaster{}, false
|
||||
}
|
||||
if hasFlagPrefix(args, "--type=") {
|
||||
return chromiumMaster{}, false // child process, not the master
|
||||
}
|
||||
port, hasCDP := flagValue(args, "remote-debugging-port")
|
||||
return chromiumMaster{
|
||||
PID: pid,
|
||||
Profile: firstNonEmpty(args, "profile-directory"),
|
||||
UserDataDir: udd,
|
||||
CDPPort: port,
|
||||
HasCDP: hasCDP,
|
||||
Headless: isHeadless(args),
|
||||
}, true
|
||||
}
|
||||
|
||||
// isHeadless reports whether the process was launched in headless mode. Chromium
|
||||
// spells it "--headless", "--headless=new" or "--headless=old"; matching the
|
||||
// "--headless" prefix covers all three. There is no current Chromium flag that
|
||||
// starts with "--headless" but means something else, so the prefix is safe.
|
||||
func isHeadless(args []string) bool {
|
||||
for _, a := range args {
|
||||
if a == "--headless" || strings.HasPrefix(a, "--headless=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the flag value or "" if absent.
|
||||
func firstNonEmpty(args []string, name string) string {
|
||||
v, _ := flagValue(args, name)
|
||||
return v
|
||||
}
|
||||
|
||||
// listChromiumMasters walks /proc and returns every running Chromium master process,
|
||||
// sorted by PID for stable output.
|
||||
func listChromiumMasters() ([]chromiumMaster, error) {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read /proc: %w", err)
|
||||
}
|
||||
var masters []chromiumMaster
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue // not a PID dir
|
||||
}
|
||||
args := readProcCmdline(pid)
|
||||
if m, ok := parseChromiumMaster(pid, args); ok {
|
||||
masters = append(masters, m)
|
||||
}
|
||||
}
|
||||
sort.Slice(masters, func(i, j int) bool { return masters[i].PID < masters[j].PID })
|
||||
return masters, nil
|
||||
}
|
||||
|
||||
// ---- X session env detection ----
|
||||
|
||||
// xSessionEnv returns DISPLAY and XAUTHORITY scraped from a live XFCE session
|
||||
// process. A decoupled Chromium launched from the MCP (no inherited X env) needs
|
||||
// these to open a window on the user's screen. Falls back to :0 + ~/.Xauthority.
|
||||
func xSessionEnv() (display, xauthority string) {
|
||||
display = ":0"
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
xauthority = filepath.Join(home, ".Xauthority")
|
||||
}
|
||||
for _, proc := range []string{"xfwm4", "xfce4-session", "xfdesktop"} {
|
||||
out, err := exec.Command("pgrep", "-x", proc).Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Fields(string(out)) {
|
||||
pid, err := strconv.Atoi(line)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
d, x, ok := readProcEnviron(pid)
|
||||
if ok {
|
||||
if d != "" {
|
||||
display = d
|
||||
}
|
||||
if x != "" {
|
||||
xauthority = x
|
||||
}
|
||||
return display, xauthority
|
||||
}
|
||||
}
|
||||
}
|
||||
return display, xauthority
|
||||
}
|
||||
|
||||
// readProcEnviron reads DISPLAY and XAUTHORITY from /proc/<pid>/environ (NUL-separated).
|
||||
// ok is true if the environ was readable.
|
||||
func readProcEnviron(pid int) (display, xauthority string, ok bool) {
|
||||
b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "environ"))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
for _, kv := range strings.Split(string(b), "\x00") {
|
||||
if v, found := strings.CutPrefix(kv, "DISPLAY="); found {
|
||||
display = v
|
||||
} else if v, found := strings.CutPrefix(kv, "XAUTHORITY="); found {
|
||||
xauthority = v
|
||||
}
|
||||
}
|
||||
return display, xauthority, true
|
||||
}
|
||||
|
||||
// defaultProfileUserDataDir is the user's daily Chromium user-data-dir where the
|
||||
// named profiles (Automation, Default, Personal, "Profile 1", osint_01) live.
|
||||
func defaultProfileUserDataDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".config/chromium-cdp"
|
||||
}
|
||||
return filepath.Join(home, ".config", "chromium-cdp")
|
||||
}
|
||||
|
||||
// ---- browser_list ----
|
||||
|
||||
type browserListArgs struct{}
|
||||
|
||||
func browserListTool() mcp.Tool {
|
||||
return mcp.NewTool("browser_list",
|
||||
mcp.WithDescription("List the running Chromium MASTER processes (one per user-data-dir master, NOT zygote/gpu/renderer children). For each: pid, profile (--profile-directory value), user_data_dir, cdp_port (--remote-debugging-port value, empty if none), has_cdp, headless (true if launched with --headless), pages (count of open page targets via GET /json, best-effort), active_title/active_url (first open page). Returns a JSON array. Read-only."),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *deps) handleBrowserList(_ context.Context, _ mcp.CallToolRequest, _ browserListArgs) (*mcp.CallToolResult, error) {
|
||||
masters, err := listChromiumMasters()
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
if masters == nil {
|
||||
masters = []chromiumMaster{}
|
||||
}
|
||||
// Enriquecer cada master con CDP con su nº de páginas y la primera página
|
||||
// (título/URL) consultando GET /json. Best-effort: si el puerto no responde,
|
||||
// se dejan los campos a cero — el listado de procesos nunca falla por esto.
|
||||
for i := range masters {
|
||||
enrichMasterTabs(&masters[i])
|
||||
}
|
||||
b, _ := json.MarshalIndent(masters, "", " ")
|
||||
return mcp.NewToolResultText(string(b)), nil
|
||||
}
|
||||
|
||||
// enrichMasterTabs rellena Pages/ActiveTitle/ActiveURL de un master consultando
|
||||
// sus targets CDP por HTTP. No devuelve error: cualquier fallo (sin CDP, puerto
|
||||
// caído, timeout) deja los campos en su cero y el master se reporta igual.
|
||||
func enrichMasterTabs(m *chromiumMaster) {
|
||||
if m.CDPPort == "" {
|
||||
return
|
||||
}
|
||||
port, err := strconv.Atoi(m.CDPPort)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tabs, err := browser.CdpListTabs("localhost", port)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, t := range tabs {
|
||||
if t.Type != "page" {
|
||||
continue
|
||||
}
|
||||
m.Pages++
|
||||
if m.ActiveURL == "" {
|
||||
m.ActiveTitle = t.Title
|
||||
m.ActiveURL = t.URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- browser_launch_profile (MUTA) ----
|
||||
|
||||
type launchProfileArgs struct {
|
||||
Profile string `json:"profile"`
|
||||
UserDataDir string `json:"user_data_dir"`
|
||||
URL string `json:"url"`
|
||||
CDP bool `json:"cdp"`
|
||||
CDPPort int `json:"cdp_port"`
|
||||
}
|
||||
|
||||
func browserLaunchProfileTool() mcp.Tool {
|
||||
return mcp.NewTool("browser_launch_profile",
|
||||
mcp.WithDescription("Launch Chromium for a CONCRETE profile (e.g. \"Personal\", \"Work\") on the user's screen. Uses the REAL chromium binary (/usr/lib/chromium/chromium), bypassing the /usr/bin/chromium wrapper, so flags are controlled exactly. With cdp=false (default) NO remote-debugging flags are added — REQUIRED for human profiles where Google must keep the session (CDP makes Google treat the browser as automated and drop the login). With cdp=true adds --remote-debugging-port=<cdp_port> and --remote-allow-origins=*. Detects DISPLAY/XAUTHORITY from the XFCE session and launches DECOUPLED (setsid). If a master already owns the user_data_dir, Chromium forwards the open to it (note in the result). Returns {pid, profile, cdp, cdp_port[, note]}."),
|
||||
mcp.WithString("profile", mcp.Required(), mcp.Description("Profile directory name to launch (--profile-directory value), e.g. \"Personal\", \"Default\", \"Automation\".")),
|
||||
mcp.WithString("user_data_dir", mcp.Description("Chromium user-data-dir holding the profiles. Default ~/.config/chromium-cdp.")),
|
||||
mcp.WithString("url", mcp.Description("Optional URL to open.")),
|
||||
mcp.WithBoolean("cdp", mcp.Description("Enable CDP remote debugging. Default false. Leave false for human profiles (Google session-keeping). true only for automation.")),
|
||||
mcp.WithNumber("cdp_port", mcp.Description("CDP port when cdp=true. Default 9222.")),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *deps) handleBrowserLaunchProfile(_ context.Context, _ mcp.CallToolRequest, a launchProfileArgs) (*mcp.CallToolResult, error) {
|
||||
if a.Profile == "" {
|
||||
return mcp.NewToolResultError("profile is required"), nil
|
||||
}
|
||||
userDataDir := a.UserDataDir
|
||||
if userDataDir == "" {
|
||||
userDataDir = defaultProfileUserDataDir()
|
||||
}
|
||||
cdpPort := a.CDPPort
|
||||
if cdpPort == 0 {
|
||||
cdpPort = 9222
|
||||
}
|
||||
|
||||
// Detect whether a master already owns this user-data-dir. If so, Chromium will
|
||||
// forward the open to that master (it can't run two masters on one dir).
|
||||
note := ""
|
||||
if masters, err := listChromiumMasters(); err == nil {
|
||||
for _, m := range masters {
|
||||
if m.UserDataDir == userDataDir {
|
||||
note = "forwarded to existing master"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--user-data-dir=" + userDataDir,
|
||||
"--profile-directory=" + a.Profile,
|
||||
}
|
||||
if a.CDP {
|
||||
args = append(args,
|
||||
fmt.Sprintf("--remote-debugging-port=%d", cdpPort),
|
||||
"--remote-allow-origins=*",
|
||||
)
|
||||
}
|
||||
if a.URL != "" {
|
||||
args = append(args, a.URL)
|
||||
}
|
||||
|
||||
display, xauthority := xSessionEnv()
|
||||
|
||||
cmd := exec.Command(realChromiumBin, args...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DISPLAY="+display,
|
||||
"XAUTHORITY="+xauthority,
|
||||
)
|
||||
// Decouple from the MCP: new session leader (setsid) so the child survives the
|
||||
// launcher dying, and no inherited stdio (avoids the exit-144 / SIGPIPE death
|
||||
// when the parent's pipes close). We Release the process: never reaped here.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("launch chromium: %v", err)), nil
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
_ = cmd.Process.Release()
|
||||
|
||||
// Give Chromium a moment to come up. With CDP we poll the port instead of a
|
||||
// blind 1s sleep: we return as soon as it responds (best-effort: a forwarded
|
||||
// launch may not bind the port if the master had no CDP). Without CDP there's
|
||||
// no port to poll, so we give the window a short margin to appear / forward.
|
||||
if a.CDP && note == "" {
|
||||
if !waitCDPPort(cdpPort, 5*time.Second) {
|
||||
note = "cdp port not confirmed listening yet"
|
||||
}
|
||||
} else {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"pid": pid,
|
||||
"profile": a.Profile,
|
||||
"cdp": a.CDP,
|
||||
"cdp_port": cdpPort,
|
||||
}
|
||||
if note != "" {
|
||||
out["note"] = note
|
||||
}
|
||||
b, _ := json.MarshalIndent(out, "", " ")
|
||||
return mcp.NewToolResultText(string(b)), nil
|
||||
}
|
||||
|
||||
// ---- browser_close (MUTA) ----
|
||||
|
||||
type browserCloseArgs struct {
|
||||
Profile string `json:"profile"`
|
||||
CDPPort int `json:"cdp_port"`
|
||||
PID int `json:"pid"`
|
||||
}
|
||||
|
||||
func browserCloseTool() mcp.Tool {
|
||||
return mcp.NewTool("browser_close",
|
||||
mcp.WithDescription("Cleanly close a running Chromium master. Identify it by one of: profile (--profile-directory), cdp_port (--remote-debugging-port), or pid. Sends SIGTERM, waits up to 10s for it to die, then SIGKILL as a last resort (flagged in the result). Returns {closed, pid, method}."),
|
||||
mcp.WithString("profile", mcp.Description("Match the master by --profile-directory value.")),
|
||||
mcp.WithNumber("cdp_port", mcp.Description("Match the master by --remote-debugging-port value.")),
|
||||
mcp.WithNumber("pid", mcp.Description("Match the master by exact PID.")),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *deps) handleBrowserClose(_ context.Context, _ mcp.CallToolRequest, a browserCloseArgs) (*mcp.CallToolResult, error) {
|
||||
if a.Profile == "" && a.CDPPort == 0 && a.PID == 0 {
|
||||
return mcp.NewToolResultError("one of profile, cdp_port or pid is required"), nil
|
||||
}
|
||||
masters, err := listChromiumMasters()
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
target, found := matchMaster(masters, a)
|
||||
if !found {
|
||||
return mcp.NewToolResultError("no running Chromium master matched the given criteria"), nil
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(target.PID)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("find process %d: %v", target.PID, err)), nil
|
||||
}
|
||||
|
||||
method := "SIGTERM"
|
||||
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("SIGTERM pid=%d: %v", target.PID, err)), nil
|
||||
}
|
||||
// Wait up to ~10s for the process to die (poll /proc liveness).
|
||||
if !waitProcessGone(target.PID, 10*time.Second) {
|
||||
method = "SIGKILL"
|
||||
_ = proc.Signal(syscall.SIGKILL)
|
||||
waitProcessGone(target.PID, 3*time.Second)
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"closed": true,
|
||||
"pid": target.PID,
|
||||
"method": method,
|
||||
}
|
||||
b, _ := json.MarshalIndent(out, "", " ")
|
||||
return mcp.NewToolResultText(string(b)), nil
|
||||
}
|
||||
|
||||
// matchMaster picks the master matching the close criteria. PID is most specific,
|
||||
// then cdp_port, then profile (first match wins for the latter two).
|
||||
func matchMaster(masters []chromiumMaster, a browserCloseArgs) (chromiumMaster, bool) {
|
||||
if a.PID != 0 {
|
||||
for _, m := range masters {
|
||||
if m.PID == a.PID {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return chromiumMaster{}, false
|
||||
}
|
||||
if a.CDPPort != 0 {
|
||||
want := strconv.Itoa(a.CDPPort)
|
||||
for _, m := range masters {
|
||||
if m.CDPPort == want {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return chromiumMaster{}, false
|
||||
}
|
||||
for _, m := range masters {
|
||||
if m.Profile == a.Profile {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return chromiumMaster{}, false
|
||||
}
|
||||
|
||||
// waitProcessGone polls until the PID no longer exists in /proc or the timeout
|
||||
// elapses. Returns true if the process is gone.
|
||||
func waitProcessGone(pid int, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !processAlive(pid) {
|
||||
return true
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
return !processAlive(pid)
|
||||
}
|
||||
|
||||
// processAlive reports whether /proc/<pid> still exists.
|
||||
func processAlive(pid int) bool {
|
||||
_, err := os.Stat(filepath.Join("/proc", strconv.Itoa(pid)))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// waitCDPPort polls the CDP port until it accepts a TCP connection or the timeout
|
||||
// elapses. Replaces a blind sleep: returns as soon as Chromium binds the port.
|
||||
func waitCDPPort(port int, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if cdpPortResponds(port) {
|
||||
return true
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return cdpPortResponds(port)
|
||||
}
|
||||
|
||||
// cdpPortResponds reports whether something is listening on the CDP port on
|
||||
// 127.0.0.1. Single TCP dial with a short timeout; best-effort confirmation only.
|
||||
func cdpPortResponds(port int) bool {
|
||||
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||
conn, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
conn.Close()
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestParseChromiumMaster cubre la deteccion de master: solo procesos chromium con
|
||||
// --user-data-dir y SIN --type= cuentan; el resto (wrapper sin udd, children con
|
||||
// --type=, no-chromium) se descartan. Tambien valida que profile/cdp_port se
|
||||
// extraen y que has_cdp refleja la presencia del flag.
|
||||
func TestParseChromiumMaster(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantOK bool
|
||||
wantProfile string
|
||||
wantUDD string
|
||||
wantPort string
|
||||
wantHasCDP bool
|
||||
}{
|
||||
{
|
||||
name: "master con CDP y profile",
|
||||
args: []string{
|
||||
"/usr/lib/chromium/chromium",
|
||||
"--user-data-dir=/home/u/.config/chromium-cdp",
|
||||
"--profile-directory=Personal",
|
||||
"--remote-debugging-port=9222",
|
||||
"--remote-allow-origins=*",
|
||||
},
|
||||
wantOK: true,
|
||||
wantProfile: "Personal",
|
||||
wantUDD: "/home/u/.config/chromium-cdp",
|
||||
wantPort: "9222",
|
||||
wantHasCDP: true,
|
||||
},
|
||||
{
|
||||
name: "master humano sin CDP",
|
||||
args: []string{
|
||||
"/usr/lib/chromium/chromium",
|
||||
"--user-data-dir=/home/u/.config/chromium-cdp",
|
||||
"--profile-directory=Default",
|
||||
},
|
||||
wantOK: true,
|
||||
wantProfile: "Default",
|
||||
wantUDD: "/home/u/.config/chromium-cdp",
|
||||
wantPort: "",
|
||||
wantHasCDP: false,
|
||||
},
|
||||
{
|
||||
name: "child renderer con --type= se descarta",
|
||||
args: []string{
|
||||
"/usr/lib/chromium/chromium",
|
||||
"--type=renderer",
|
||||
"--user-data-dir=/home/u/.config/chromium-cdp",
|
||||
},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "child gpu-process con --type= se descarta",
|
||||
args: []string{
|
||||
"/usr/lib/chromium/chromium",
|
||||
"--type=gpu-process",
|
||||
"--user-data-dir=/home/u/.config/chromium-cdp",
|
||||
"--profile-directory=Personal",
|
||||
},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "chromium sin --user-data-dir se descarta",
|
||||
args: []string{"/usr/lib/chromium/chromium", "--profile-directory=Personal"},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "proceso no-chromium se descarta",
|
||||
args: []string{"/usr/bin/firefox", "--user-data-dir=/x"},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "argv vacio se descarta",
|
||||
args: nil,
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m, ok := parseChromiumMaster(1234, tc.args)
|
||||
if ok != tc.wantOK {
|
||||
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if m.PID != 1234 {
|
||||
t.Errorf("PID = %d, want 1234", m.PID)
|
||||
}
|
||||
if m.Profile != tc.wantProfile {
|
||||
t.Errorf("Profile = %q, want %q", m.Profile, tc.wantProfile)
|
||||
}
|
||||
if m.UserDataDir != tc.wantUDD {
|
||||
t.Errorf("UserDataDir = %q, want %q", m.UserDataDir, tc.wantUDD)
|
||||
}
|
||||
if m.CDPPort != tc.wantPort {
|
||||
t.Errorf("CDPPort = %q, want %q", m.CDPPort, tc.wantPort)
|
||||
}
|
||||
if m.HasCDP != tc.wantHasCDP {
|
||||
t.Errorf("HasCDP = %v, want %v", m.HasCDP, tc.wantHasCDP)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagValue valida el parseo exacto de "--name=value".
|
||||
func TestFlagValue(t *testing.T) {
|
||||
args := []string{"--user-data-dir=/x/y", "--profile-directory=Work", "--flag-without-value"}
|
||||
if v, ok := flagValue(args, "user-data-dir"); !ok || v != "/x/y" {
|
||||
t.Errorf("user-data-dir = (%q,%v), want (/x/y,true)", v, ok)
|
||||
}
|
||||
if v, ok := flagValue(args, "profile-directory"); !ok || v != "Work" {
|
||||
t.Errorf("profile-directory = (%q,%v), want (Work,true)", v, ok)
|
||||
}
|
||||
if _, ok := flagValue(args, "remote-debugging-port"); ok {
|
||||
t.Errorf("remote-debugging-port should be absent")
|
||||
}
|
||||
// Prefijo no debe hacer match parcial: "user-data" != "user-data-dir".
|
||||
if _, ok := flagValue(args, "user-data"); ok {
|
||||
t.Errorf("partial prefix user-data should NOT match user-data-dir")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchMaster valida la prioridad pid > cdp_port > profile y el no-match.
|
||||
func TestMatchMaster(t *testing.T) {
|
||||
masters := []chromiumMaster{
|
||||
{PID: 100, Profile: "Personal", CDPPort: ""},
|
||||
{PID: 200, Profile: "Work", CDPPort: "9222"},
|
||||
{PID: 300, Profile: "Personal", CDPPort: "9333"},
|
||||
}
|
||||
|
||||
if m, ok := matchMaster(masters, browserCloseArgs{PID: 200}); !ok || m.PID != 200 {
|
||||
t.Errorf("by pid: got (%d,%v), want (200,true)", m.PID, ok)
|
||||
}
|
||||
if m, ok := matchMaster(masters, browserCloseArgs{CDPPort: 9333}); !ok || m.PID != 300 {
|
||||
t.Errorf("by cdp_port: got (%d,%v), want (300,true)", m.PID, ok)
|
||||
}
|
||||
// profile "Personal" tiene dos: gana el primero (PID 100).
|
||||
if m, ok := matchMaster(masters, browserCloseArgs{Profile: "Personal"}); !ok || m.PID != 100 {
|
||||
t.Errorf("by profile: got (%d,%v), want (100,true)", m.PID, ok)
|
||||
}
|
||||
if _, ok := matchMaster(masters, browserCloseArgs{PID: 999}); ok {
|
||||
t.Errorf("unknown pid should not match")
|
||||
}
|
||||
if _, ok := matchMaster(masters, browserCloseArgs{Profile: "Nope"}); ok {
|
||||
t.Errorf("unknown profile should not match")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCmdline cubre el parsing de /proc/<pid>/cmdline en sus dos formatos:
|
||||
// el canonico separado por NUL y el colapsado por espacios que produce Chromium
|
||||
// al reescribir su titulo de proceso in-place. El segundo caso es el que rompia
|
||||
// browser_list (los flags quedaban dentro de un unico argv[0] gigante).
|
||||
func TestParseCmdline(t *testing.T) {
|
||||
// Caso canonico: argv separado por NUL (proceso normal).
|
||||
nul := []byte("/usr/lib/chromium/chromium\x00--user-data-dir=/tmp/x\x00--remote-debugging-port=9333\x00")
|
||||
got := parseCmdline(nul)
|
||||
want := []string{"/usr/lib/chromium/chromium", "--user-data-dir=/tmp/x", "--remote-debugging-port=9333"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("NUL: got %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("NUL[%d]: got %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Caso Chromium: cmdline colapsado a una sola cadena separada por espacios.
|
||||
collapsed := []byte("/usr/lib/chromium/chromium --remote-debugging-port=9333 --user-data-dir=/tmp/browser_mcp_userdata --no-first-run https://www.alsa.es/")
|
||||
args := parseCmdline(collapsed)
|
||||
if len(args) == 1 {
|
||||
t.Fatalf("space-collapsed: parse devolvio un unico elemento gigante: %q", args[0])
|
||||
}
|
||||
if args[0] != "/usr/lib/chromium/chromium" {
|
||||
t.Errorf("space-collapsed argv[0]: got %q, want chromium binary", args[0])
|
||||
}
|
||||
|
||||
// El master debe detectarse a partir del cmdline colapsado (regresion de browser_list).
|
||||
m, ok := parseChromiumMaster(18148, args)
|
||||
if !ok {
|
||||
t.Fatalf("space-collapsed: parseChromiumMaster no detecto el master")
|
||||
}
|
||||
if m.UserDataDir != "/tmp/browser_mcp_userdata" {
|
||||
t.Errorf("space-collapsed udd: got %q, want /tmp/browser_mcp_userdata", m.UserDataDir)
|
||||
}
|
||||
if m.CDPPort != "9333" || !m.HasCDP {
|
||||
t.Errorf("space-collapsed cdp: got port=%q hasCDP=%v, want 9333/true", m.CDPPort, m.HasCDP)
|
||||
}
|
||||
|
||||
if parseCmdline([]byte("")) != nil || parseCmdline([]byte("\x00\x00")) != nil {
|
||||
t.Errorf("cmdline vacio debe devolver nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsHeadless valida la deteccion de modo headless por el flag de lanzamiento:
|
||||
// --headless, --headless=new y --headless=old cuentan; su ausencia es headed.
|
||||
func TestIsHeadless(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{"sin flag (headed)", []string{"/usr/lib/chromium/chromium", "--user-data-dir=/tmp/x"}, false},
|
||||
{"--headless legacy", []string{"/usr/lib/chromium/chromium", "--headless", "--user-data-dir=/tmp/x"}, true},
|
||||
{"--headless=new", []string{"/usr/lib/chromium/chromium", "--headless=new"}, true},
|
||||
{"--headless=old", []string{"/usr/lib/chromium/chromium", "--headless=old"}, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isHeadless(c.args); got != c.want {
|
||||
t.Errorf("isHeadless(%v) = %v, want %v", c.args, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// El master headed real (cmdline colapsado por espacios) debe reportar headless=false.
|
||||
headed := parseCmdline([]byte("/usr/lib/chromium/chromium --remote-debugging-port=9333 --user-data-dir=/tmp/browser_mcp_userdata"))
|
||||
if m, ok := parseChromiumMaster(1, headed); !ok || m.Headless {
|
||||
t.Errorf("master headed: ok=%v headless=%v, want ok=true headless=false", ok, m.Headless)
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -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.")),
|
||||
)
|
||||
}
|
||||
|
||||
+130
-64
@@ -2,10 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"os"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
@@ -22,12 +22,93 @@ func registerReadTools(s *server.MCPServer, d *deps) {
|
||||
s.AddTool(pageGetTextTool(), mcp.NewTypedToolHandler(d.handlePageGetText))
|
||||
s.AddTool(pagePerceiveTool(), mcp.NewTypedToolHandler(d.handlePagePerceive))
|
||||
s.AddTool(pageScreenshotTool(), mcp.NewTypedToolHandler(d.handlePageScreenshot))
|
||||
s.AddTool(pageCollectConsoleTool(), mcp.NewTypedToolHandler(d.handlePageCollectConsole))
|
||||
s.AddTool(pagePDFTool(), mcp.NewTypedToolHandler(d.handlePagePDF))
|
||||
|
||||
if !d.readOnly {
|
||||
s.AddTool(pageEvalJSTool(), mcp.NewTypedToolHandler(d.handlePageEvalJS))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- page_collect_console ----
|
||||
|
||||
type pageCollectConsoleArgs struct {
|
||||
Port int `json:"port"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
MaxEntries int `json:"max_entries"`
|
||||
}
|
||||
|
||||
func pageCollectConsoleTool() mcp.Tool {
|
||||
return mcp.NewTool("page_collect_console",
|
||||
mcp.WithDescription("Capture the page's console output (console.log/info/warn/error), uncaught JS exceptions and browser log entries during a time window, and return them as JSON. It is a SNAPSHOT: it records only what happens during duration_ms AFTER the call starts (past backlog is discarded) — so trigger the action you want to observe (reload, click) right before or during the window. Capped at max_entries (default 200) to avoid flooding on verbose pages. Use this to debug why a page misbehaves without flying blind."),
|
||||
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("duration_ms", mcp.Description("Capture window in milliseconds. Default 1500.")),
|
||||
mcp.WithNumber("max_entries", mcp.Description("Max entries returned before truncating. Default 200.")),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *deps) handlePageCollectConsole(_ context.Context, _ mcp.CallToolRequest, a pageCollectConsoleArgs) (*mcp.CallToolResult, error) {
|
||||
var entries []browser.ConsoleEntry
|
||||
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||
var e error
|
||||
entries, e = browser.CdpCollectConsole(c, a.DurationMs, a.MaxEntries)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
if entries == nil {
|
||||
entries = []browser.ConsoleEntry{}
|
||||
}
|
||||
b, _ := json.MarshalIndent(entries, "", " ")
|
||||
return mcp.NewToolResultText(truncate(string(b), htmlMax)), nil
|
||||
}
|
||||
|
||||
// ---- page_pdf ----
|
||||
|
||||
type pagePDFArgs struct {
|
||||
Port int `json:"port"`
|
||||
Path string `json:"path"`
|
||||
Landscape bool `json:"landscape"`
|
||||
PrintBackground bool `json:"print_background"`
|
||||
Scale float64 `json:"scale"`
|
||||
}
|
||||
|
||||
func pagePDFTool() mcp.Tool {
|
||||
return mcp.NewTool("page_pdf",
|
||||
mcp.WithDescription("Render the current page to a PDF (Page.printToPDF) and write it to a local file path. Use for archiving an article/invoice/report exactly as laid out, when a screenshot is not enough (multi-page, selectable text)."),
|
||||
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 .pdf file path.")),
|
||||
mcp.WithBoolean("landscape", mcp.Description("Landscape orientation. Default false (portrait).")),
|
||||
mcp.WithBoolean("print_background", mcp.Description("Include background graphics/colors. Default false.")),
|
||||
mcp.WithNumber("scale", mcp.Description("Render scale. Default 1.0.")),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *deps) handlePagePDF(_ context.Context, _ mcp.CallToolRequest, a pagePDFArgs) (*mcp.CallToolResult, error) {
|
||||
if a.Path == "" {
|
||||
return mcp.NewToolResultError("path is required"), nil
|
||||
}
|
||||
opts := browser.CdpPrintPDFOpts{
|
||||
Landscape: a.Landscape,
|
||||
PrintBackground: a.PrintBackground,
|
||||
Scale: a.Scale,
|
||||
}
|
||||
var data []byte
|
||||
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||
var e error
|
||||
data, e = browser.CdpPrintPDF(c, opts)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
if e := os.WriteFile(a.Path, data, 0o644); e != nil {
|
||||
return mcp.NewToolResultError("saving pdf to " + a.Path + ": " + e.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("pdf saved to %s (%d bytes)", a.Path, len(data))), nil
|
||||
}
|
||||
|
||||
// ---- page_get_text ----
|
||||
|
||||
type pageGetTextArgs struct {
|
||||
@@ -67,14 +148,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 +169,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 +210,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 +237,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 +268,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
|
||||
}
|
||||
|
||||
+74
-5
@@ -12,15 +12,23 @@ import (
|
||||
"fn-registry/functions/browser"
|
||||
)
|
||||
|
||||
// registerSessionTools wires browser_launch (MUTA), browser_connect, browser_disconnect.
|
||||
// registerSessionTools wires browser_launch (MUTA), browser_connect, browser_disconnect,
|
||||
// browser_set_mode.
|
||||
func registerSessionTools(s *server.MCPServer, d *deps) {
|
||||
if !d.readOnly {
|
||||
s.AddTool(launchTool(), mcp.NewTypedToolHandler(d.handleLaunch))
|
||||
}
|
||||
s.AddTool(connectTool(), mcp.NewTypedToolHandler(d.handleConnect))
|
||||
s.AddTool(disconnectTool(), mcp.NewTypedToolHandler(d.handleDisconnect))
|
||||
s.AddTool(setModeTool(), mcp.NewTypedToolHandler(d.handleSetMode))
|
||||
}
|
||||
|
||||
// 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 +49,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 +73,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 +88,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 +128,46 @@ 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
|
||||
}
|
||||
|
||||
// ---- browser_set_mode ----
|
||||
|
||||
type setModeArgs struct {
|
||||
Port int `json:"port"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
func setModeTool() mcp.Tool {
|
||||
return mcp.NewTool("browser_set_mode",
|
||||
mcp.WithDescription("Fija el modo de velocidad de SESIÓN de las acciones del navegador en este puerto. 'auto' (default del MCP) = rápido: movimiento de ratón mínimo, escritura en un solo evento (Input.insertText) y esperas breves — para scraping y automatización propia. 'human' = sigiloso anti-detección: trayectoria de ratón Bézier con jitter, escritura carácter a carácter y esperas ALEATORIAS entre acción y percepción — actívalo cuando un sitio aplique detección anti-bot fuerte. El arg 'mode' de cada tool de acción (dom_click_ref, dom_type_ref, dom_hover_ref, dom_click_xy) sigue ganando puntualmente sobre este ajuste de sesión."),
|
||||
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("mode", mcp.Required(), mcp.Description("'auto' (rápido, default) o 'human' (sigiloso, anti-detección). También admite 'fast' (alias de auto) e 'instant' (sin movimiento de ratón) para casos puntuales.")),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *deps) handleSetMode(_ context.Context, _ mcp.CallToolRequest, a setModeArgs) (*mcp.CallToolResult, error) {
|
||||
switch a.Mode {
|
||||
case "auto", "human", "fast", "instant":
|
||||
// válido
|
||||
default:
|
||||
return mcp.NewToolResultError("mode debe ser 'auto' o 'human' (también 'fast'/'instant')"), nil
|
||||
}
|
||||
port := portOr(a.Port)
|
||||
d.pool.setMode(port, a.Mode)
|
||||
return mcp.NewToolResultText(fmt.Sprintf("session mode set to %q for port=%d (cada tool de acción puede overridearlo con su arg mode)", a.Mode, port)), nil
|
||||
}
|
||||
|
||||
+2
-2
@@ -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.")),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user