Compare commits

...

31 Commits

Author SHA1 Message Date
egutierrez a681c79d96 Merge feat/playwright-grade-tools: dom_find_by_role/wait_actionable/select_dropdown/fill 2026-06-16 20:55:14 +02:00
Egutierrez 3b68c02b25 feat: tools de interaccion estilo Playwright (dropdowns, fill, role, actionable)
4 tools nuevas, wrappers de las primitivas CDP recien creadas:
- dom_find_by_role: localizar por rol ARIA + accessible name (getByRole), devuelve #ref
- dom_wait_actionable: visible+stable+enabled+hit-test antes de click (anti-overlay)
- dom_select_dropdown: desplegables custom (combobox/MUI/select2/headlessui)
- dom_fill: rellenar inputs React/Vue de forma fiable (reemplaza, no concatena)

Total tools: 50 -> 54. uses_functions del app.md actualizado.
Smoke real (Chrome headless 9333) verde para las 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:55:13 +02:00
egutierrez d687a501ba Merge quick/console-cap: page_collect_console con max_entries 2026-06-16 20:44:07 +02:00
Egutierrez 70ab1a4d30 feat(page_collect_console): expone max_entries (cap + backlog descartado) 2026-06-16 20:44:07 +02:00
egutierrez 9e9c690f06 Merge quick/drop-unused-screenshot-dep: limpia drift uses_functions 2026-06-16 20:29:03 +02:00
Egutierrez 8e421a3500 docs(app.md): quita dep cdp_screenshot no usada (el codigo usa cdp_screenshot_bytes) 2026-06-16 20:29:03 +02:00
egutierrez 3ce9b12eab Merge quick/build-ldflags-guard: build.sh ldflags + pre-commit anti-stale + uses_functions 2026-06-16 20:27:46 +02:00
Egutierrez cb587a7005 docs(app.md): declara las 4 funciones nuevas en uses_functions
cdp_collect_console / cdp_print_pdf / cdp_select_option / cdp_set_file_input,
consumidas por las tools page_collect_console/page_pdf/dom_select_option/dom_set_files.
2026-06-16 20:27:46 +02:00
Egutierrez 33358bca6c chore: build.sh con version desde app.md + pre-commit anti-stale
build.sh inyecta la version de app.md por -ldflags (-X main.version), haciendo de
app.md la unica fuente de verdad — el binario ya no puede quedar por detras
(drift 0.7.0 vs 0.8.0 del 16/06/2026). main.go pasa la version de const a var
para permitir el override por ldflags.

scripts/pre-commit recompila en cada commit para que el binario que sirve el
.mcp.json nunca quede stale respecto a los .go commiteados (la causa raiz del
mismo bug). scripts/install-hooks.sh lo instala via symlink.
2026-06-16 20:27:45 +02:00
egutierrez 82993179dc Merge feat/new-tools-and-richer-list: 4 tools nuevas + browser_list enriquecido 2026-06-16 20:25:35 +02:00
Egutierrez 6b7f71c39f feat: 4 tools nuevas + browser_list enriquecido
Tools nuevas (wrappers finos sobre funciones del registry functions/browser):
- page_collect_console  -> cdp_collect_console (console + exceptions + log, snapshot)
- page_pdf              -> cdp_print_pdf (Page.printToPDF a archivo)
- dom_select_option     -> cdp_select_option (<select> por value/texto + input/change)
- dom_set_files         -> cdp_set_file_input (subir archivos a <input type=file>)

browser_list ahora enriquece cada master con CDP con pages (nº de page targets),
active_title y active_url via GET /json (best-effort: si el puerto no responde
los campos quedan a cero y el listado de procesos no falla).

Total tools: 46 -> 50.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:25:35 +02:00
egutierrez 15949bf4ed Merge quick/align-version: serverInfo version 0.8.0 2026-06-16 20:07:30 +02:00
Egutierrez 5706c84a15 chore: alinea constante version del binario a 0.8.0 (coincide con app.md) 2026-06-16 20:07:30 +02:00
egutierrez f2587d6fee Merge quick/browser-list-headless: campo headless en browser_list 2026-06-16 20:05:51 +02:00
Egutierrez 91973ed6f9 feat(browser_list): añade campo headless por master
browser_list ahora reporta si cada Chromium master se lanzo en modo headless,
detectado por el flag de arranque (--headless / --headless=new / --headless=old)
leido del cmdline. Una sola llamada devuelve navegadores activos + CDP + headless,
sin tener que conectar a cada pagina para fingerprintear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:05:51 +02:00
egutierrez c56004da5c Merge quick/fix-cmdline-space-collapse: browser_list ve Chromium con cmdline colapsado 2026-06-16 20:02:27 +02:00
Egutierrez c2470f4f67 fix(browser_list): parse cmdline colapsado por espacios de Chromium
/proc/<pid>/cmdline normalmente separa argv por NUL, pero Chromium reescribe
su titulo de proceso in-place colapsando la region de argv a una sola cadena
separada por espacios. readProcCmdline asumia solo NUL, asi que para los
masters de Chromium devolvia un unico argv[0] gigante: isChromiumExe y el
prefijo --user-data-dir= fallaban y browser_list devolvia [] aunque hubiera
navegadores vivos.

Extrae parseCmdline (pura, testeable) con fallback a split por espacios cuando
no hay NUL. Test cubre ambos formatos + regresion de deteccion de master.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:02:27 +02:00
egutierrez 1c5b81f711 merge: quick/cdp-speed-modes — modo de velocidad de sesión + aceleraciones CDP (v0.8.0) 2026-06-13 14:28:20 +02:00
egutierrez a48e262371 docs: app.md v0.8.0 — browser_set_mode + aceleraciones CDP
Documenta los cambios de la v0.8.0 en el app.md del browser_mcp:

- Bump de versión 0.7.0 -> 0.8.0 y descripción (45 -> 46 tools, mención del modo de velocidad de sesión).
- Sección Tools (46): añade browser_set_mode en el grupo Sesión.
- Capability growth log: entrada v0.8.0 detallando el flag de velocidad, el settle adaptativo, la escritura insertText en auto, el poll del puerto en launch_profile, los enable cacheados, wait_load por evento, el timeout de sendCDP y las nuevas CdpInsertText/CdpTypeRefFast, con los números del smoke contra Chrome 9333.
2026-06-13 14:28:03 +02:00
egutierrez fa1efe6fd5 feat: modo de velocidad de sesión (browser_set_mode) + acciones más rápidas en auto
Añade un flag de velocidad por sesión para que el manejo del navegador sea muy rápido por defecto, conservando un modo sigiloso para cuando haya detección anti-bot fuerte.

- Nueva tool browser_set_mode (tools_session.go): fija el modo de la sesión por puerto en el pool. 'auto' (default del MCP) = rápido; 'human' = sigiloso anti-detección; también admite 'fast'/'instant'. Cada tool de acción puede overridearlo con su arg mode.
- pool.go: estado de modo por puerto (modes map + setMode/getMode), limpiado en drop y closeAll.
- tools_dom.go: effectiveMode resuelve el modo (arg de la llamada > modo de sesión > 'auto'). settleForMode reemplaza el sleep ciego fijo de 400ms tras cada acción mutante: 60ms en auto/fast, aleatorio 250-650ms en human (ritmo no-máquina), 0 en instant. dom_type_ref gana arg mode y rutea a CdpTypeRefFast (insertText, un round-trip) en auto o CdpTypeRef (carácter a carácter) en human. Descripciones del arg mode actualizadas (el default ya no es human).
- tools_lifecycle.go: browser_launch_profile reemplaza el sleep(1s) ciego por un poll del puerto CDP (waitCDPPort).
- .gitignore: ignora registry.db/operations.db (no deben vivir en la app; regla db_locations).

Doctrina invertida respecto a la anterior 'humanizado siempre': ahora rápido por defecto, sigiloso bajo demanda.
2026-06-13 14:27:56 +02:00
egutierrez f0bfc3e300 Merge quick/browser-lifecycle: per-profile Chromium lifecycle tools (browser_list/launch_profile/close) 2026-06-10 18:23:51 +02:00
egutierrez 1fae6c1df9 feat(browser_mcp): add browser_list/launch_profile/close lifecycle tools
Three MCP tools to manage the user's Chromium instances by profile, distinct
from browser_launch's isolated automation Chrome:

- browser_list: enumerate running Chromium master processes by scanning
  /proc/*/cmdline (has --user-data-dir, no --type=). Returns pid, profile,
  user_data_dir, cdp_port, has_cdp as a JSON array.
- browser_launch_profile: launch a concrete profile using the REAL binary
  /usr/lib/chromium/chromium (bypassing the /usr/bin/chromium wrapper). No CDP
  by default so Google keeps the session for human profiles; cdp=true adds
  --remote-debugging-port + --remote-allow-origins=*. Detects DISPLAY/XAUTHORITY
  from the XFCE session and launches decoupled via setsid.
- browser_close: locate a master by profile/cdp_port/pid, SIGTERM with a 10s
  wait, then SIGKILL as a last resort.

Per-profile instances are NOT registered in the connection pool: they are
user-facing and survive the MCP dying; cleanup is explicit via browser_close.

Unit tests for cmdline master detection, flag parsing, and close-target
matching. Bumps version 0.6.0 -> 0.7.0 (42 -> 45 tools).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:23:45 +02:00
egutierrez 54fe1b7f17 Merge quick/visual-iframe-tools: perceive nativo + iframe data + click XY + screenshot imagen (v0.6.0) 2026-06-06 17:38:42 +02:00
egutierrez fed245a738 feat(browser_mcp): perceive nativo Go, datos de iframe, click XY y screenshot como imagen (v0.6.0)
Capacidades nuevas y cambios (40 -> 42 tools):

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:35:33 +02:00
egutierrez 9c170b9c43 Merge issue/fix-chromium-ram-leak: cerrar leak de RAM de chromium huérfanos del MCP 2026-06-06 17:06:19 +02:00
egutierrez 254f089982 fix: matar los chromium que el MCP lanza para cerrar el leak de RAM
El pool nunca guardaba el PID del Chrome lanzado por browser_launch, así que
closeAll() y drop() cerraban con CdpClose(c, 0): solo soltaban el WebSocket y
dejaban el proceso chromium vivo y huérfano (~789 MiB RSS cada uno). Llamadas
repetidas a browser_launch acumulaban instancias sin límite hasta saturar la RAM
(apagón del 06/06/2026, ~35 chromium huérfanos).

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

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

Incluye WIP pre-existente ya estable: dom_click_ref/dom_hover_ref exponen
'mode' (human/fast/instant) vía MouseProfileForMode. Compila + 9 e2e verdes.
2026-06-06 15:33:18 +02:00
Egutierrez 4307fb2e58 feat: adapta CdpHandleDialog (nueva firma + DialogLog) y reporta diálogos en disconnect
CdpHandleDialog ahora devuelve (cancel, *DialogLog, error). El pool guarda el
DialogLog por puerto y browser_disconnect reporta cuántos diálogos se
auto-respondieron y el último (tipo + mensaje). drop/closeAll usan CdpDisconnect
(alias legible de CdpClose(c,0)).
2026-06-06 15:33:02 +02:00
20 changed files with 2076 additions and 187 deletions
+4
View File
@@ -1,2 +1,6 @@
/browser_mcp /browser_mcp
*.log *.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 -1
View File
@@ -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 Chrome DevTools Protocol: navigate, read the DOM, click, manage cookies, evaluate
JavaScript, operate iframes, and persist/restore session state. JavaScript, operate iframes, and persist/restore session state.
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. "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) ## Security: isolated Chrome by default (port 9333)
**By default the MCP operates on its OWN isolated Chrome, NOT the user's daily browser.** **By default the MCP operates on its OWN isolated Chrome, NOT the user's daily browser.**
+140 -25
View File
@@ -2,9 +2,20 @@
name: browser_mcp name: browser_mcp
lang: go lang: go
domain: infra domain: infra
version: 0.3.0 version: 0.8.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." 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] 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: uses_functions:
- chrome_launch_go_browser - chrome_launch_go_browser
- cdp_connect_go_browser - cdp_connect_go_browser
@@ -20,12 +31,12 @@ uses_functions:
- cdp_wait_idle_go_browser - cdp_wait_idle_go_browser
- cdp_get_html_go_browser - cdp_get_html_go_browser
- cdp_evaluate_go_browser - cdp_evaluate_go_browser
- cdp_screenshot_go_browser
- cdp_click_go_browser - cdp_click_go_browser
- cdp_click_human_go_browser - cdp_click_human_go_browser
- cdp_click_text_go_browser - cdp_click_text_go_browser
- cdp_type_text_go_browser - cdp_type_text_go_browser
- cdp_find_by_text_go_browser - cdp_find_by_text_go_browser
- cdp_find_ref_by_text_go_browser
- cdp_wait_element_go_browser - cdp_wait_element_go_browser
- cdp_press_key_go_browser - cdp_press_key_go_browser
- cdp_scroll_go_browser - cdp_scroll_go_browser
@@ -40,12 +51,22 @@ uses_functions:
- cdp_save_storage_state_go_browser - cdp_save_storage_state_go_browser
- cdp_load_storage_state_go_browser - cdp_load_storage_state_go_browser
- cdp_get_text_go_browser - cdp_get_text_go_browser
- cdp_get_text_in_frame_go_browser
- cdp_connect_target_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_click_ref_go_browser
- cdp_type_ref_go_browser - cdp_type_ref_go_browser
- cdp_hover_ref_go_browser - cdp_hover_ref_go_browser
- cdp_click_xy_human_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: [] uses_types: []
framework: "" framework: ""
entry_point: "main.go" 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 - Para adjuntarte deliberadamente al navegador diario, pasa `port: 9222` explícito en cada
tool. Hazlo solo con cuidado. tool. Hazlo solo con cuidado.
## Tools (39) ## Tools (46)
### Sesión (`tools_session.go`) ### Sesión (`tools_session.go`)
- `browser_launch` (MUTA) — lanza Chrome con CDP. args: port, headless, user_data_dir, url. - `browser_launch` (MUTA) — lanza Chrome con CDP. args: port, headless, user_data_dir, url.
- `browser_connect` — abre/poolea la conexión CDP del puerto. args: port. - `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_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`) ### Navegación + tabs (`tools_nav.go`)
- `tab_navigate` (MUTA) — `Page.navigate`. args: port, url. - `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). — no revienta el contexto. args: port, selector (opcional), max_bytes (default 20000).
- `page_perceive` — outline indentado y accionable del árbol de accesibilidad (roles, nombres, - `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. `#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 **Nativo en Go** sobre la conexión CDP viva del pool (`cdp_get_ax_outline_go_browser`) — ya
primera pestaña page. args: port, tab_id (opcional), max_chars (default 20000). no lanza subprocess `fn run` ni levanta el venv de Python. Para elegir la pestaña usa `tab_select`
**Gotcha:** requiere el binario `fn` y el venv de Python del registry disponibles en runtime. 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_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 (`tools_dom.go`)
- `dom_click` (MUTA) — click por selector. args: port, selector. - `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_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_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_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_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`) #### Bucle percibir→actuar (por `#ref`)
`page_perceive` devuelve un outline accionable donde cada elemento lleva un `#ref` `page_perceive` devuelve un outline accionable (generado de forma nativa en Go
estable (su `backendDOMNodeId`). Las tools `dom_click_ref` / `dom_type_ref` / sobre la conexión CDP viva) donde cada elemento lleva un `#ref` estable (su
`dom_hover_ref` actúan directamente sobre ese `#ref` — no necesitas resolver un `backendDOMNodeId`). Las tools `dom_click_ref` / `dom_type_ref` / `dom_hover_ref`
selector CSS. Tras la acción esperan un settle breve (400ms) y **devuelven el actúan directamente sobre ese `#ref` — no necesitas resolver un selector CSS.
outline actualizado** (auto-observe), cerrando el bucle percibir→actuar: 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 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 dom_type_ref → escribe + outline nuevo
``` ```
Las tools `*_ref` usan humanización por defecto (Bézier+jitter). Una política de Cuando el elemento no aparece en el outline (canvas, mapas, layouts puramente
sesión `fast`/`instant` para scraping masivo está pendiente (ver TODO en el código). 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 ### Input (`tools_input.go`) — todas MUTA
- `press_key` — presiona una tecla nombrada (Enter/Tab/Escape/ArrowDown/...). args: port, key. - `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_list` — lista frames con sus IDs. args: port.
- `frame_eval` (MUTA) — evalúa JS dentro de un frame. args: port, frame_id, expression. - `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_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`) ### Estado de sesión (`tools_storage.go`)
- `storage_save` — guarda cookies + localStorage a JSON. args: port, path. - `storage_save` — guarda cookies + localStorage a JSON. args: port, path.
@@ -206,11 +269,11 @@ Transporte HTTP (Streamable HTTP):
### Flag `--read-only` ### Flag `--read-only`
Con `--read-only`, el servidor NO registra las tools mutantes (marcadas MUTA arriba): Con `--read-only`, el servidor NO registra las tools mutantes (marcadas MUTA arriba):
solo expone las 17 tools de lectura/control (`browser_connect`, `browser_disconnect`, `tab_list`, solo expone las 20 tools de lectura/control (`browser_connect`, `browser_disconnect`, `browser_list`,
`tab_activate`, `tab_select`, `page_wait_load`, `page_wait_idle`, `page_get_html`, `page_get_text`, `tab_list`, `tab_activate`, `tab_select`, `page_wait_load`, `page_wait_idle`, `page_get_html`,
`page_perceive`, `page_screenshot`, `dom_find_by_text`, `dom_wait_element`, `cookie_get`, `page_get_text`, `page_perceive`, `page_screenshot`, `dom_find_by_text`, `dom_find_ref_by_text`,
`frame_list`, `frame_get_html`, `storage_save`). Útil para sesiones de inspección sin riesgo de `dom_wait_element`, `cookie_get`, `frame_list`, `frame_get_html`, `frame_get_text`, `storage_save`).
modificar el estado del navegador. Útil para sesiones de inspección sin riesgo de modificar el estado del navegador.
## Omitido en v1 ## 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 larga duración (registrar handlers + un punto de "stop" que devuelve los datos
acumulados); no encaja en el modelo request/response de una tool MCP simple. Pendiente acumulados); no encaja en el modelo request/response de una tool MCP simple. Pendiente
de un diseño con tool de start + tool de stop. de un diseño con tool de start + tool de stop.
- **`cdp_get_ax_tree`** — ya expuesto desde v0.2.0 via la tool `page_perceive`, que invoca - **`cdp_get_ax_tree`** — expuesto via la tool `page_perceive`. Desde v0.6.0 el outline se genera
el pipeline `cdp_perceive_outline` por subprocess (`fn run`) en vez de duplicar la lógica aquí. 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 - **Funciones de perfiles Chrome (Bash: create/delete/appearance/reset)** — requieren que
Chrome esté CERRADO para modificar el `Local State` / `Preferences` del perfil; son Chrome esté CERRADO para modificar el `Local State` / `Preferences` del perfil; son
incompatibles con un MCP cuyo propósito es controlar un Chrome vivo. Quedan disponibles incompatibles con un MCP cuyo propósito es controlar un Chrome vivo. Quedan disponibles
@@ -229,6 +294,56 @@ Funciones del dominio `browser` que NO se exponen como tools en esta versión, c
## Capability growth log ## 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`, - 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 `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 outline de `page_perceive` con humanización por defecto (Bézier+jitter) y auto-observe
Executable
+17
View File
@@ -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}"
+26 -28
View File
@@ -6,15 +6,20 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "os/signal"
"strings" "strings"
"syscall"
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
"fn-registry/functions/browser" "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 { type config struct {
httpAddr string httpAddr string
@@ -42,8 +47,22 @@ func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}))) slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})))
pool := newConnPool() pool := newConnPool()
// Cierre por EOF de stdio (ServeStdio retorna) o salida normal de serveHTTP.
defer pool.closeAll() 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} d := &deps{pool: pool, readOnly: cfg.readOnly}
srv := server.NewMCPServer( srv := server.NewMCPServer(
@@ -77,6 +96,7 @@ func main() {
// registerTools wires every tool group. Mutating tools are skipped under --read-only. // registerTools wires every tool group. Mutating tools are skipped under --read-only.
func registerTools(s *server.MCPServer, d *deps) { func registerTools(s *server.MCPServer, d *deps) {
registerSessionTools(s, d) registerSessionTools(s, d)
registerLifecycleTools(s, d)
registerNavTools(s, d) registerNavTools(s, d)
registerReadTools(s, d) registerReadTools(s, d)
registerDomTools(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) err = fn(c)
if err != nil && isConnErr(err) { 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) c2, err2 := d.pool.get(port)
if err2 != nil { if err2 != nil {
return err2 return err2
@@ -179,28 +202,3 @@ func truncate(s string, n int) string {
} }
return s[:n] + "\n... [truncated]" return s[:n] + "\n... [truncated]"
} }
// resolveRoot finds the fn_registry root so we can locate the `fn` binary and
// the Python venv at runtime. Mirrors registry_mcp's resolveRoot: honors
// FN_REGISTRY_ROOT, otherwise walks up from cwd looking for registry.db.
func resolveRoot() (string, error) {
if env := os.Getenv("FN_REGISTRY_ROOT"); env != "" {
return filepath.Abs(env)
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
dir := cwd
for {
if _, err := os.Stat(filepath.Join(dir, "registry.db")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", fmt.Errorf("registry.db not found upward from %s", cwd)
}
+136 -13
View File
@@ -10,14 +10,45 @@ import (
// connPool reusa conexiones CDP entre invocaciones de tools. Clave = puerto CDP. // 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 // 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). // 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 { type connPool struct {
mu sync.Mutex mu sync.Mutex
conns map[int]*browser.CDPConn conns map[int]*browser.CDPConn
cancels map[int]func() // cancels de handlers persistentes (handle_dialog) 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 { 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) { 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 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) { func (p *connPool) drop(port int) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
@@ -41,17 +128,25 @@ func (p *connPool) drop(port int) {
cancel() cancel()
delete(p.cancels, port) delete(p.cancels, port)
} }
if c, ok := p.conns[port]; ok && c != nil { delete(p.dialogLogs, port)
_ = browser.CdpClose(c, 0)
delete(p.conns, 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 // 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 // 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) { func (p *connPool) connectTarget(port int, match string) (*browser.CDPConn, error) {
p.drop(port) p.releaseConn(port)
c, err := browser.CdpConnectTarget("localhost", port, match) c, err := browser.CdpConnectTarget("localhost", port, match)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -62,15 +157,33 @@ func (p *connPool) connectTarget(port int, match string) (*browser.CDPConn, erro
return c, nil 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() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if old := p.cancels[port]; old != nil { if old := p.cancels[port]; old != nil {
old() old()
} }
p.cancels[port] = cancel 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() { func (p *connPool) closeAll() {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
@@ -78,12 +191,22 @@ func (p *connPool) closeAll() {
if cancel := p.cancels[port]; cancel != nil { if cancel := p.cancels[port]; cancel != nil {
cancel() cancel()
} }
if c != nil { _ = browser.CdpClose(c, p.pids[port]) // mata nuestro Chrome; pid=0 para externos
_ = browser.CdpClose(c, 0) 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.conns = map[int]*browser.CDPConn{}
p.pids = map[int]int{}
p.cancels = map[int]func(){} 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. // isConnErr reconoce errores de conexión CDP muerta para reintentar UNA vez.
+215
View File
@@ -0,0 +1,215 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"fn-registry/functions/browser"
)
// Estos tests lanzan y matan Chrome REAL. Gate BMCP_E2E=1 y deben correr
// AISLADOS en un servicio transitorio systemd-run --user: matar chromium desde
// el árbol de procesos del Bash tool dispara exit-144. Ver
// .claude/rules y la memoria harness-exit-144-chromium.
func requireE2E(t *testing.T) {
t.Helper()
if os.Getenv("BMCP_E2E") != "1" {
t.Skip("skip: requiere BMCP_E2E=1 + Chrome real, correr bajo systemd-run --user")
}
}
// chromePIDsByUDD cuenta los procesos chromium (browser + zygotes + renderers)
// que comparten un user-data-dir concreto, leyendo /proc/<pid>/cmdline. Usar el
// UDD como aguja cuenta el ÁRBOL completo (los hijos heredan --user-data-dir),
// y aísla el conteo del navegador diario en 9222 (UDD distinto).
func chromePIDsByUDD(udd string) []int {
var pids []int
needle := "--user-data-dir=" + udd
matches, _ := filepath.Glob("/proc/[0-9]*/cmdline")
for _, m := range matches {
b, err := os.ReadFile(m)
if err != nil {
continue
}
cmd := strings.ReplaceAll(string(b), "\x00", " ")
if strings.Contains(cmd, needle) {
parts := strings.Split(m, "/")
if len(parts) >= 3 {
if pid, err := strconv.Atoi(parts[2]); err == nil {
pids = append(pids, pid)
}
}
}
}
return pids
}
// rssKB suma el VmRSS (KiB) de un conjunto de PIDs.
func rssKB(pids []int) int64 {
var total int64
for _, pid := range pids {
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(line, "VmRSS:") {
f := strings.Fields(line)
if len(f) >= 2 {
if v, err := strconv.ParseInt(f[1], 10, 64); err == nil {
total += v
}
}
}
}
}
return total
}
// TestE2EPoolKillsLaunchedChromes — GOLDEN PATH del fix del leak.
// Lanza 3 Chrome headless en puertos aislados, los registra en el pool, mide su
// RSS, llama closeAll() (lo que hace el shutdown del MCP) y verifica CERO
// huérfanos. Reporta el RSS liberado.
func TestE2EPoolKillsLaunchedChromes(t *testing.T) {
requireE2E(t)
base := filepath.Join(os.TempDir(), "bmcp_e2e_golden")
_ = os.RemoveAll(base)
defer os.RemoveAll(base)
ports := []int{9401, 9402, 9403}
udds := map[int]string{}
pool := newConnPool()
defer pool.closeAll() // red de seguridad si el test aborta a mitad
for _, p := range ports {
udd := filepath.Join(base, strconv.Itoa(p))
if err := os.MkdirAll(udd, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", udd, err)
}
udds[p] = udd
pid, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{
Port: p,
Headless: true,
UserDataDir: udd,
ReuseExisting: true,
})
if err != nil {
t.Fatalf("ChromeLaunch port=%d: %v", p, err)
}
if pid == 0 {
t.Fatalf("port=%d ya estaba ocupado (ReuseExisting devolvió 0); usa otro puerto", p)
}
pool.setPID(p, pid)
t.Logf("lanzado Chrome pid=%d port=%d", pid, p)
}
// Verificar que los 3 árboles están vivos + medir RSS.
var alive int
var rssBefore int64
for _, p := range ports {
pids := chromePIDsByUDD(udds[p])
alive += len(pids)
rssBefore += rssKB(pids)
}
if alive < len(ports) {
t.Fatalf("esperaba >=%d procesos chrome vivos, vivos=%d", len(ports), alive)
}
t.Logf("ANTES: %d procesos chrome vivos, RSS total ~%d MiB", alive, rssBefore/1024)
// El kill: closeAll mata cada grupo de proceso registrado.
pool.closeAll()
time.Sleep(2 * time.Second) // dar tiempo al SIGKILL del grupo
var after int
for _, p := range ports {
after += len(chromePIDsByUDD(udds[p]))
}
if after != 0 {
t.Fatalf("LEAK: %d procesos chrome siguen vivos tras closeAll (esperaba 0)", after)
}
t.Logf("DESPUES: 0 huérfanos. RSS liberado ~%d MiB (%d → 0)", rssBefore/1024, rssBefore/1024)
}
// TestE2EDedupSamePort — EDGE: dos ChromeLaunch(ReuseExisting) al mismo puerto
// no duplican el proceso; el segundo devuelve pid 0.
func TestE2EDedupSamePort(t *testing.T) {
requireE2E(t)
base := filepath.Join(os.TempDir(), "bmcp_e2e_dedup")
_ = os.RemoveAll(base)
defer os.RemoveAll(base)
udd := filepath.Join(base, "9404")
if err := os.MkdirAll(udd, 0o755); err != nil {
t.Fatal(err)
}
pid1, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9404, Headless: true, UserDataDir: udd, ReuseExisting: true})
if err != nil {
t.Fatalf("primer launch: %v", err)
}
if pid1 == 0 {
t.Fatal("primer launch devolvió 0 (puerto ya ocupado)")
}
defer browser.CdpClose(nil, pid1) // cleanup: mata el grupo
pid2, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9404, Headless: true, UserDataDir: udd, ReuseExisting: true})
if err != nil {
t.Fatalf("segundo launch: %v", err)
}
if pid2 != 0 {
// matar el duplicado antes de fallar para no dejar huérfanos
_ = browser.CdpClose(nil, pid2)
t.Fatalf("segundo launch lanzó un DUPLICADO pid=%d (esperaba 0 = reuso)", pid2)
}
if n := len(chromePIDsByUDD(udd)); n == 0 {
t.Fatalf("el primer Chrome debería seguir vivo")
}
t.Logf("dedup OK: pid1=%d vivo, segundo launch reusó (pid 0)", pid1)
}
// TestE2EDropKillsOwnNotExternal — EDGE + SEGURIDAD: drop mata el Chrome que el
// MCP lanzó (pid registrado), pero NO mata un Chrome que el MCP no lanzó (pid no
// registrado en el pool) — la salvaguarda que protege el navegador diario.
func TestE2EDropKillsOwnNotExternal(t *testing.T) {
requireE2E(t)
base := filepath.Join(os.TempDir(), "bmcp_e2e_drop")
_ = os.RemoveAll(base)
defer os.RemoveAll(base)
// (a) Chrome PROPIO en 9405: registrado → drop debe matarlo.
uddOwn := filepath.Join(base, "9405")
_ = os.MkdirAll(uddOwn, 0o755)
ownPID, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9405, Headless: true, UserDataDir: uddOwn, ReuseExisting: true})
if err != nil || ownPID == 0 {
t.Fatalf("launch propio 9405: pid=%d err=%v", ownPID, err)
}
pool := newConnPool()
pool.setPID(9405, ownPID)
// (b) Chrome EXTERNO en 9406: NO registrado en el pool → drop NO debe matarlo.
uddExt := filepath.Join(base, "9406")
_ = os.MkdirAll(uddExt, 0o755)
extPID, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9406, Headless: true, UserDataDir: uddExt, ReuseExisting: true})
if err != nil || extPID == 0 {
t.Fatalf("launch externo 9406: pid=%d err=%v", extPID, err)
}
defer browser.CdpClose(nil, extPID) // lo mata el test, no el pool
// drop sobre ambos puertos.
pool.drop(9405) // pid registrado → mata
pool.drop(9406) // pid NO registrado → solo cierra WS, NO mata
time.Sleep(2 * time.Second)
if n := len(chromePIDsByUDD(uddOwn)); n != 0 {
t.Fatalf("drop NO mató el Chrome propio 9405: %d vivos", n)
}
if n := len(chromePIDsByUDD(uddExt)); n == 0 {
t.Fatalf("drop MATÓ un Chrome externo 9406 (debía respetarlo)")
}
t.Logf("OK: propio 9405 muerto, externo 9406 respetado (salvaguarda navegador diario)")
}
+104
View File
@@ -0,0 +1,104 @@
package main
import (
"context"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
// resultText concatena el texto de un CallToolResult para asserts.
func resultText(r *mcp.CallToolResult) string {
var sb strings.Builder
for _, c := range r.Content {
if tc, ok := c.(mcp.TextContent); ok {
sb.WriteString(tc.Text)
}
}
return sb.String()
}
// TestPoolPIDLifecycle verifica set/get/clear/count del registro de PIDs sin
// tocar Chrome real.
func TestPoolPIDLifecycle(t *testing.T) {
p := newConnPool()
if n := p.launchedCount(); n != 0 {
t.Fatalf("launchedCount inicial = %d, want 0", n)
}
p.setPID(9333, 4242)
if pid, ok := p.getPID(9333); !ok || pid != 4242 {
t.Fatalf("getPID(9333) = (%d,%v), want (4242,true)", pid, ok)
}
if n := p.launchedCount(); n != 1 {
t.Fatalf("launchedCount tras setPID = %d, want 1", n)
}
p.clearPID(9333)
if _, ok := p.getPID(9333); ok {
t.Fatalf("getPID(9333) sigue presente tras clearPID")
}
if n := p.launchedCount(); n != 0 {
t.Fatalf("launchedCount tras clearPID = %d, want 0", n)
}
}
// TestInstanceCapRejectsWithoutLaunching verifica el tope duro: con
// maxLaunchedChromes PIDs ya registrados, browser_launch en un puerto nuevo
// devuelve error de tool y NO intenta lanzar Chrome (el cap se evalúa antes de
// ChromeLaunch, por eso este test no necesita Chrome real). Cubre el edge
// "superar el tope → error claro".
func TestInstanceCapRejectsWithoutLaunching(t *testing.T) {
p := newConnPool()
for i := 0; i < maxLaunchedChromes; i++ {
p.setPID(9500+i, 100000+i) // PIDs ficticios: nunca se matan en este test
}
d := &deps{pool: p}
res, err := d.handleLaunch(context.Background(), mcp.CallToolRequest{}, launchArgs{Port: 9600})
if err != nil {
t.Fatalf("handleLaunch err = %v", err)
}
if !res.IsError {
t.Fatalf("esperaba IsError=true por cap, got text=%q", resultText(res))
}
if txt := resultText(res); !strings.Contains(txt, "cap") {
t.Fatalf("mensaje no menciona el cap: %q", txt)
}
// El puerto nuevo no debe haberse registrado.
if _, ok := p.getPID(9600); ok {
t.Fatalf("el puerto rechazado por cap no debe registrarse")
}
}
// TestLaunchReusesRegisteredPort verifica idempotencia: si el MCP ya lanzó un
// Chrome en el puerto (PID registrado), un segundo browser_launch lo reusa sin
// lanzar otro proceso. No necesita Chrome real (el reuse corta antes de
// ChromeLaunch). Cubre el edge "dos browser_launch al mismo puerto no duplica".
func TestLaunchReusesRegisteredPort(t *testing.T) {
p := newConnPool()
p.setPID(9333, 777777)
d := &deps{pool: p}
res, err := d.handleLaunch(context.Background(), mcp.CallToolRequest{}, launchArgs{Port: 9333})
if err != nil {
t.Fatalf("handleLaunch err = %v", err)
}
if res.IsError {
t.Fatalf("no esperaba error, got %q", resultText(res))
}
if txt := resultText(res); !strings.Contains(txt, "reused pid=777777") {
t.Fatalf("esperaba reuse del pid registrado, got %q", txt)
}
if n := p.launchedCount(); n != 1 {
t.Fatalf("launchedCount = %d, want 1 (no debe duplicar)", n)
}
}
// TestDropClearsMapsNoPID verifica que drop sobre un puerto sin conn ni pid no
// panica y deja los mapas limpios (no mata nada — caso del navegador externo
// del que solo se soltó el WebSocket).
func TestDropClearsMapsNoPID(t *testing.T) {
p := newConnPool()
p.drop(9222) // puerto externo, sin conn ni pid registrado: no-op seguro
if n := p.launchedCount(); n != 0 {
t.Fatalf("launchedCount = %d, want 0", n)
}
}
+6
View File
@@ -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"
+12
View File
@@ -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
View File
@@ -30,7 +30,7 @@ type cookieGetArgs struct {
func cookieGetTool() mcp.Tool { func cookieGetTool() mcp.Tool {
return mcp.NewTool("cookie_get", return mcp.NewTool("cookie_get",
mcp.WithDescription("Return all browser cookies (Network.getAllCookies) as JSON."), 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 { func cookieSetTool() mcp.Tool {
return mcp.NewTool("cookie_set", return mcp.NewTool("cookie_set",
mcp.WithDescription("Set a cookie via Network.setCookie."), 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("name", mcp.Required(), mcp.Description("Cookie name.")),
mcp.WithString("value", mcp.Description("Cookie value.")), mcp.WithString("value", mcp.Description("Cookie value.")),
mcp.WithString("domain", mcp.Required(), mcp.Description("Cookie domain.")), mcp.WithString("domain", mcp.Required(), mcp.Description("Cookie domain.")),
@@ -102,7 +102,7 @@ type cookieDeleteArgs struct {
func cookieDeleteTool() mcp.Tool { func cookieDeleteTool() mcp.Tool {
return mcp.NewTool("cookie_delete", return mcp.NewTool("cookie_delete",
mcp.WithDescription("Delete cookies by name (optionally scoped to a domain) via Network.deleteCookies."), 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("name", mcp.Required(), mcp.Description("Cookie name to delete.")),
mcp.WithString("domain", mcp.Description("Optional domain scope.")), mcp.WithString("domain", mcp.Description("Optional domain scope.")),
) )
@@ -130,7 +130,7 @@ type cookieClearArgs struct {
func cookieClearTool() mcp.Tool { func cookieClearTool() mcp.Tool {
return mcp.NewTool("cookie_clear", return mcp.NewTool("cookie_clear",
mcp.WithDescription("Clear all browser cookies via Network.clearBrowserCookies."), 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
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"math/rand"
"time" "time"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
@@ -14,7 +15,10 @@ import (
// registerDomTools wires DOM interaction tools. find/wait stay on under --read-only. // registerDomTools wires DOM interaction tools. find/wait stay on under --read-only.
func registerDomTools(s *server.MCPServer, d *deps) { func registerDomTools(s *server.MCPServer, d *deps) {
s.AddTool(domFindByTextTool(), mcp.NewTypedToolHandler(d.handleDomFindByText)) s.AddTool(domFindByTextTool(), mcp.NewTypedToolHandler(d.handleDomFindByText))
s.AddTool(domFindRefByTextTool(), mcp.NewTypedToolHandler(d.handleDomFindRefByText))
s.AddTool(domWaitElementTool(), mcp.NewTypedToolHandler(d.handleDomWaitElement)) s.AddTool(domWaitElementTool(), mcp.NewTypedToolHandler(d.handleDomWaitElement))
s.AddTool(domFindByRoleTool(), mcp.NewTypedToolHandler(d.handleDomFindByRole))
s.AddTool(domWaitActionableTool(), mcp.NewTypedToolHandler(d.handleDomWaitActionable))
if !d.readOnly { if !d.readOnly {
s.AddTool(domClickTool(), mcp.NewTypedToolHandler(d.handleDomClick)) 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(domClickRefTool(), mcp.NewTypedToolHandler(d.handleDomClickRef))
s.AddTool(domTypeRefTool(), mcp.NewTypedToolHandler(d.handleDomTypeRef)) s.AddTool(domTypeRefTool(), mcp.NewTypedToolHandler(d.handleDomTypeRef))
s.AddTool(domHoverRefTool(), mcp.NewTypedToolHandler(d.handleDomHoverRef)) 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, // ---- dom_find_by_role ----
// dando tiempo a que el DOM se asiente (navegación, focus, repaint).
const settleDelay = 400 * time.Millisecond 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 ---- // ---- dom_click_ref (MUTA) — bucle percibir→actuar ----
type domClickRefArgs struct { type domClickRefArgs struct {
Port int `json:"port"` Port int `json:"port"`
Ref int `json:"ref"` Ref int `json:"ref"`
Mode string `json:"mode"`
} }
func domClickRefTool() mcp.Tool { func domClickRefTool() mcp.Tool {
return mcp.NewTool("dom_click_ref", 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("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.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) { func (d *deps) handleDomClickRef(_ context.Context, _ mcp.CallToolRequest, a domClickRefArgs) (*mcp.CallToolResult, error) {
port := portOr(a.Port) 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 { 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 { if err != nil {
return mcp.NewToolResultError(err.Error()), nil return mcp.NewToolResultError(err.Error()), nil
} }
time.Sleep(settleDelay) if dl := settleForMode(mode); dl > 0 {
outline, _ := d.perceiveOutline(port, 4000) time.Sleep(dl)
}
outline, _ := d.perceiveOutline(port, 8000)
return mcp.NewToolResultText("clicked ref " + fmt.Sprint(a.Ref) + "\n\n" + outline), nil return mcp.NewToolResultText("clicked ref " + fmt.Sprint(a.Ref) + "\n\n" + outline), nil
} }
@@ -66,6 +315,7 @@ type domTypeRefArgs struct {
Port int `json:"port"` Port int `json:"port"`
Ref int `json:"ref"` Ref int `json:"ref"`
Text string `json:"text"` Text string `json:"text"`
Mode string `json:"mode"`
} }
func domTypeRefTool() mcp.Tool { 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("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.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("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 return mcp.NewToolResultError("text is required"), nil
} }
port := portOr(a.Port) 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 { 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 { if err != nil {
return mcp.NewToolResultError(err.Error()), nil return mcp.NewToolResultError(err.Error()), nil
} }
time.Sleep(settleDelay) if dl := settleForMode(mode); dl > 0 {
outline, _ := d.perceiveOutline(port, 4000) time.Sleep(dl)
}
outline, _ := d.perceiveOutline(port, 8000)
return mcp.NewToolResultText("typed into ref " + fmt.Sprint(a.Ref) + "\n\n" + outline), nil return mcp.NewToolResultText("typed into ref " + fmt.Sprint(a.Ref) + "\n\n" + outline), nil
} }
// ---- dom_hover_ref (MUTA) — bucle percibir→actuar ---- // ---- dom_hover_ref (MUTA) — bucle percibir→actuar ----
type domHoverRefArgs struct { type domHoverRefArgs struct {
Port int `json:"port"` Port int `json:"port"`
Ref int `json:"ref"` Ref int `json:"ref"`
Mode string `json:"mode"`
} }
func domHoverRefTool() mcp.Tool { func domHoverRefTool() mcp.Tool {
return mcp.NewTool("dom_hover_ref", 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("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.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) { func (d *deps) handleDomHoverRef(_ context.Context, _ mcp.CallToolRequest, a domHoverRefArgs) (*mcp.CallToolResult, error) {
port := portOr(a.Port) 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 { 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 { if err != nil {
return mcp.NewToolResultError(err.Error()), nil return mcp.NewToolResultError(err.Error()), nil
} }
time.Sleep(settleDelay) if dl := settleForMode(mode); dl > 0 {
outline, _ := d.perceiveOutline(port, 4000) time.Sleep(dl)
}
outline, _ := d.perceiveOutline(port, 8000)
return mcp.NewToolResultText("hovered ref " + fmt.Sprint(a.Ref) + "\n\n" + outline), nil 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) ---- // ---- dom_click (MUTA) ----
type domClickArgs struct { type domClickArgs struct {
@@ -133,7 +430,7 @@ type domClickArgs struct {
func domClickTool() mcp.Tool { func domClickTool() mcp.Tool {
return mcp.NewTool("dom_click", return mcp.NewTool("dom_click",
mcp.WithDescription("Click the element matching the CSS selector (synthetic CDP 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.")), 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 { func domClickHumanTool() mcp.Tool {
return mcp.NewTool("dom_click_human", 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.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.")), 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 { func domClickTextTool() mcp.Tool {
return mcp.NewTool("dom_click_text", return mcp.NewTool("dom_click_text",
mcp.WithDescription("Find the first element whose visible text matches and click it."), 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).")), mcp.WithString("text", mcp.Required(), mcp.Description("Visible text to match (substring).")),
) )
} }
@@ -217,7 +514,7 @@ type domTypeArgs struct {
func domTypeTool() mcp.Tool { func domTypeTool() mcp.Tool {
return mcp.NewTool("dom_type", return mcp.NewTool("dom_type",
mcp.WithDescription("Type text into the currently focused element (dispatches key events char by char)."), 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.")), mcp.WithString("text", mcp.Required(), mcp.Description("Text to type.")),
) )
} }
@@ -245,7 +542,7 @@ type domFindByTextArgs struct {
func domFindByTextTool() mcp.Tool { func domFindByTextTool() mcp.Tool {
return mcp.NewTool("dom_find_by_text", 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.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).")), 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 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 ---- // ---- dom_wait_element ----
type domWaitElementArgs struct { type domWaitElementArgs struct {
@@ -277,7 +609,7 @@ type domWaitElementArgs struct {
func domWaitElementTool() mcp.Tool { func domWaitElementTool() mcp.Tool {
return mcp.NewTool("dom_wait_element", return mcp.NewTool("dom_wait_element",
mcp.WithDescription("Block until an element matching the CSS selector appears in the DOM (or timeout)."), 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.WithString("selector", mcp.Required(), mcp.Description("CSS selector to wait for.")),
mcp.WithNumber("timeout_ms", mcp.Description("Max wait in ms. Default 10000.")), mcp.WithNumber("timeout_ms", mcp.Description("Max wait in ms. Default 10000.")),
) )
+43 -4
View File
@@ -10,10 +10,12 @@ import (
"fn-registry/functions/browser" "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) { func registerFrameTools(s *server.MCPServer, d *deps) {
s.AddTool(frameListTool(), mcp.NewTypedToolHandler(d.handleFrameList)) s.AddTool(frameListTool(), mcp.NewTypedToolHandler(d.handleFrameList))
s.AddTool(frameGetHTMLTool(), mcp.NewTypedToolHandler(d.handleFrameGetHTML)) s.AddTool(frameGetHTMLTool(), mcp.NewTypedToolHandler(d.handleFrameGetHTML))
s.AddTool(frameGetTextTool(), mcp.NewTypedToolHandler(d.handleFrameGetText))
if !d.readOnly { if !d.readOnly {
s.AddTool(frameEvalTool(), mcp.NewTypedToolHandler(d.handleFrameEval)) s.AddTool(frameEvalTool(), mcp.NewTypedToolHandler(d.handleFrameEval))
@@ -29,7 +31,7 @@ type frameListArgs struct {
func frameListTool() mcp.Tool { func frameListTool() mcp.Tool {
return mcp.NewTool("frame_list", 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.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 { func frameEvalTool() mcp.Tool {
return mcp.NewTool("frame_eval", return mcp.NewTool("frame_eval",
mcp.WithDescription("Evaluate a JavaScript expression inside a specific frame's execution context. Returns the stringified result."), 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("frame_id", mcp.Required(), mcp.Description("Frame ID (from frame_list).")),
mcp.WithString("expression", mcp.Required(), mcp.Description("JavaScript expression to evaluate.")), mcp.WithString("expression", mcp.Required(), mcp.Description("JavaScript expression to evaluate.")),
) )
@@ -93,7 +95,7 @@ type frameGetHTMLArgs struct {
func frameGetHTMLTool() mcp.Tool { func frameGetHTMLTool() mcp.Tool {
return mcp.NewTool("frame_get_html", return mcp.NewTool("frame_get_html",
mcp.WithDescription("Return the serialized HTML of a specific frame. Truncated to 200000 chars."), 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).")), 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 return mcp.NewToolResultText(truncate(html, htmlMax)), nil
} }
// ---- frame_get_text ----
type frameGetTextArgs struct {
Port int `json:"port"`
FrameID string `json:"frame_id"`
MaxBytes int `json:"max_bytes"`
}
func frameGetTextTool() mcp.Tool {
return mcp.NewTool("frame_get_text",
mcp.WithDescription("Return the visible text (innerText) of a specific iframe, truncated to max_bytes. Use this to read content trapped inside an iframe — page_get_text only covers the top-level document. Get the frame_id from frame_list."),
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
mcp.WithString("frame_id", mcp.Required(), mcp.Description("Frame ID (from frame_list).")),
mcp.WithNumber("max_bytes", mcp.Description("Máximo de bytes a devolver. Default 20000. 0 = sin límite.")),
)
}
func (d *deps) handleFrameGetText(_ context.Context, _ mcp.CallToolRequest, a frameGetTextArgs) (*mcp.CallToolResult, error) {
if a.FrameID == "" {
return mcp.NewToolResultError("frame_id is required"), nil
}
maxBytes := a.MaxBytes
if maxBytes == 0 {
maxBytes = 20000
}
var text string
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
var e error
text, e = browser.CdpGetTextInFrame(c, a.FrameID, maxBytes)
return e
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(text), nil
}
+7 -5
View File
@@ -29,7 +29,7 @@ type pressKeyArgs struct {
func pressKeyTool() mcp.Tool { func pressKeyTool() mcp.Tool {
return mcp.NewTool("press_key", return mcp.NewTool("press_key",
mcp.WithDescription("Press a named key (Enter, Tab, Escape, ArrowDown, Backspace, ...) on the focused element."), 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.")), 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 { func scrollTool() mcp.Tool {
return mcp.NewTool("scroll", return mcp.NewTool("scroll",
mcp.WithDescription("Scroll the page by (delta_x, delta_y) pixels via a synthetic mouse wheel event."), 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_x", mcp.Description("Horizontal scroll delta in pixels. Default 0.")),
mcp.WithNumber("delta_y", mcp.Description("Vertical scroll delta in pixels. Default 300.")), mcp.WithNumber("delta_y", mcp.Description("Vertical scroll delta in pixels. Default 300.")),
) )
@@ -89,7 +89,7 @@ type handleDialogArgs struct {
func handleDialogTool() mcp.Tool { func handleDialogTool() mcp.Tool {
return mcp.NewTool("handle_dialog", 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.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.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.")), 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 { if err != nil {
return mcp.NewToolResultError(err.Error()), 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 { if err != nil {
return mcp.NewToolResultError(err.Error()), 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 return mcp.NewToolResultText("dialog auto-handler armed"), nil
} }
+558
View File
@@ -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
}
+225
View File
@@ -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
View File
@@ -39,7 +39,7 @@ type tabNavigateArgs struct {
func tabNavigateTool() mcp.Tool { func tabNavigateTool() mcp.Tool {
return mcp.NewTool("tab_navigate", return mcp.NewTool("tab_navigate",
mcp.WithDescription("Navigate the connected tab to a URL via Page.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.")), mcp.WithString("url", mcp.Required(), mcp.Description("Target URL.")),
) )
} }
@@ -66,7 +66,7 @@ type tabListArgs struct {
func tabListTool() mcp.Tool { func tabListTool() mcp.Tool {
return mcp.NewTool("tab_list", return mcp.NewTool("tab_list",
mcp.WithDescription("List all CDP targets (tabs, iframes, workers) via GET /json. Returns JSON."), 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 { func tabNewTool() mcp.Tool {
return mcp.NewTool("tab_new", return mcp.NewTool("tab_new",
mcp.WithDescription("Open a new tab via PUT /json/new. Returns the new tab's JSON."), 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.")), mcp.WithString("url", mcp.Description("Optional start URL. Empty = about:blank.")),
) )
} }
@@ -113,7 +113,7 @@ type tabCloseArgs struct {
func tabCloseTool() mcp.Tool { func tabCloseTool() mcp.Tool {
return mcp.NewTool("tab_close", return mcp.NewTool("tab_close",
mcp.WithDescription("Close a tab by its target ID via GET /json/close/<id>."), 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.")), 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 { func tabActivateTool() mcp.Tool {
return mcp.NewTool("tab_activate", return mcp.NewTool("tab_activate",
mcp.WithDescription("Bring a tab to the foreground via GET /json/activate/<id>."), 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.")), 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 { func navBackTool() mcp.Tool {
return mcp.NewTool("nav_back", return mcp.NewTool("nav_back",
mcp.WithDescription("Navigate back in the connected tab's history."), 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 { func navForwardTool() mcp.Tool {
return mcp.NewTool("nav_forward", return mcp.NewTool("nav_forward",
mcp.WithDescription("Navigate forward in the connected tab's history."), 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 { func pageWaitLoadTool() mcp.Tool {
return mcp.NewTool("page_wait_load", return mcp.NewTool("page_wait_load",
mcp.WithDescription("Block until the page fires the load event (or timeout)."), 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.")), mcp.WithNumber("timeout_ms", mcp.Description("Max wait in ms. Default 10000.")),
) )
} }
@@ -260,7 +260,7 @@ type pageWaitIdleArgs struct {
func pageWaitIdleTool() mcp.Tool { func pageWaitIdleTool() mcp.Tool {
return mcp.NewTool("page_wait_idle", 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.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.")), mcp.WithNumber("timeout_ms", mcp.Description("Max wait in ms. Default 15000.")),
) )
} }
+130 -64
View File
@@ -2,10 +2,10 @@ package main
import ( import (
"context" "context"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"os/exec" "os"
"path/filepath"
"strings"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
@@ -22,12 +22,93 @@ func registerReadTools(s *server.MCPServer, d *deps) {
s.AddTool(pageGetTextTool(), mcp.NewTypedToolHandler(d.handlePageGetText)) s.AddTool(pageGetTextTool(), mcp.NewTypedToolHandler(d.handlePageGetText))
s.AddTool(pagePerceiveTool(), mcp.NewTypedToolHandler(d.handlePagePerceive)) s.AddTool(pagePerceiveTool(), mcp.NewTypedToolHandler(d.handlePagePerceive))
s.AddTool(pageScreenshotTool(), mcp.NewTypedToolHandler(d.handlePageScreenshot)) s.AddTool(pageScreenshotTool(), mcp.NewTypedToolHandler(d.handlePageScreenshot))
s.AddTool(pageCollectConsoleTool(), mcp.NewTypedToolHandler(d.handlePageCollectConsole))
s.AddTool(pagePDFTool(), mcp.NewTypedToolHandler(d.handlePagePDF))
if !d.readOnly { if !d.readOnly {
s.AddTool(pageEvalJSTool(), mcp.NewTypedToolHandler(d.handlePageEvalJS)) 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 ---- // ---- page_get_text ----
type pageGetTextArgs struct { type pageGetTextArgs struct {
@@ -67,14 +148,16 @@ func (d *deps) handlePageGetText(_ context.Context, _ mcp.CallToolRequest, a pag
type pagePerceiveArgs struct { type pagePerceiveArgs struct {
Port int `json:"port"` Port int `json:"port"`
TabID string `json:"tab_id"` TabID string `json:"tab_id"`
FrameID string `json:"frame_id"`
MaxChars int `json:"max_chars"` MaxChars int `json:"max_chars"`
} }
func pagePerceiveTool() mcp.Tool { func pagePerceiveTool() mcp.Tool {
return mcp.NewTool("page_perceive", 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.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.")), 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 maxChars = 20000
} }
outline, err := d.perceiveOutlineTab(port, a.TabID, maxChars) outline, err := d.perceiveOutlineFrame(port, a.FrameID, maxChars)
if err != nil { if err != nil {
return mcp.NewToolResultError(err.Error()), nil return mcp.NewToolResultError(err.Error()), nil
} }
return mcp.NewToolResultText(outline), nil return mcp.NewToolResultText(outline), nil
} }
// perceiveOutline genera el outline AX accionable de la pestaña (vía el pipeline // perceiveOutline genera el outline AX accionable de la página entera sobre la
// cdp_perceive_outline). Usa la primera pestaña 'page' del puerto. // 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) { 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 // perceiveOutlineFrame genera el outline AX accionable de forma NATIVA en Go,
// el pipeline cdp_perceive_outline). Si tabID es "", usa la primera pestaña 'page'. // reusando la conexión CDP viva del pool (browser.CdpGetAXOutline). Si frameID
// Resuelve la raíz del registry para localizar el binario `fn` + el venv de Python // != "", percibe DENTRO de ese iframe; frameID == "" = página entera. No lanza
// y ejecuta `<root>/fn run cdp_perceive_outline <port> <tabID> <maxChars>` por // subprocess `fn run` ni levanta el venv de Python — la lógica de poda y render
// subprocess, devolviendo su stdout truncado a htmlMax. // del árbol de accesibilidad vive en la función del registry.
func (d *deps) perceiveOutlineTab(port int, tabID string, maxChars int) (string, error) { func (d *deps) perceiveOutlineFrame(port int, frameID string, maxChars int) (string, error) {
root, err := resolveRoot() 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 { if err != nil {
return "", fmt.Errorf("resolve registry root: %w", err) return "", err
} }
return truncate(outline, htmlMax), nil
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
} }
// ---- page_get_html ---- // ---- page_get_html ----
@@ -157,7 +210,7 @@ type pageGetHTMLArgs struct {
func pageGetHTMLTool() mcp.Tool { func pageGetHTMLTool() mcp.Tool {
return mcp.NewTool("page_get_html", return mcp.NewTool("page_get_html",
mcp.WithDescription("Return the current page's full serialized HTML (outerHTML). Truncated to 200000 chars."), 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 { func pageEvalJSTool() mcp.Tool {
return mcp.NewTool("page_eval_js", return mcp.NewTool("page_eval_js",
mcp.WithDescription("Evaluate a JavaScript expression in the page context via Runtime.evaluate. Returns the stringified result."), 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.")), mcp.WithString("expression", mcp.Required(), mcp.Description("JavaScript expression to evaluate.")),
) )
} }
@@ -215,23 +268,36 @@ type pageScreenshotArgs struct {
func pageScreenshotTool() mcp.Tool { func pageScreenshotTool() mcp.Tool {
return mcp.NewTool("page_screenshot", return mcp.NewTool("page_screenshot",
mcp.WithDescription("Capture a screenshot of the current page and write it to a local path (.png/.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 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 file path (.png or .jpg).")), 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.")), 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) { 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} opts := browser.CdpScreenshotOpts{FullPage: a.FullPage}
var data []byte
var mimeType string
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error { 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 { if err != nil {
return mcp.NewToolResultError(err.Error()), 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
View File
@@ -12,15 +12,23 @@ import (
"fn-registry/functions/browser" "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) { func registerSessionTools(s *server.MCPServer, d *deps) {
if !d.readOnly { if !d.readOnly {
s.AddTool(launchTool(), mcp.NewTypedToolHandler(d.handleLaunch)) s.AddTool(launchTool(), mcp.NewTypedToolHandler(d.handleLaunch))
} }
s.AddTool(connectTool(), mcp.NewTypedToolHandler(d.handleConnect)) s.AddTool(connectTool(), mcp.NewTypedToolHandler(d.handleConnect))
s.AddTool(disconnectTool(), mcp.NewTypedToolHandler(d.handleDisconnect)) 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) ---- // ---- browser_launch (MUTA) ----
type launchArgs struct { type launchArgs struct {
@@ -41,6 +49,22 @@ func launchTool() mcp.Tool {
} }
func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchArgs) (*mcp.CallToolResult, error) { func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchArgs) (*mcp.CallToolResult, error) {
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 // SECURITY (P0.3): default to an isolated user-data-dir so the MCP never
// reuses the user's daily browser profile. Created on demand. // reuses the user's daily browser profile. Created on demand.
userDataDir := a.UserDataDir userDataDir := a.UserDataDir
@@ -49,9 +73,13 @@ func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchAr
_ = os.MkdirAll(userDataDir, 0o755) _ = os.MkdirAll(userDataDir, 0o755)
} }
opts := browser.ChromeLaunchOpts{ opts := browser.ChromeLaunchOpts{
Port: portOr(a.Port), Port: port,
Headless: a.Headless, Headless: a.Headless,
UserDataDir: userDataDir, 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 != "" { if a.URL != "" {
opts.ExtraArgs = append(opts.ExtraArgs, 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 { if err != nil {
return mcp.NewToolResultError(err.Error()), nil return mcp.NewToolResultError(err.Error()), nil
} }
return mcp.NewToolResultText(fmt.Sprintf("launched pid=%d port=%d 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 ---- // ---- browser_connect ----
@@ -92,13 +128,46 @@ type disconnectArgs struct {
func disconnectTool() mcp.Tool { func disconnectTool() mcp.Tool {
return mcp.NewTool("browser_disconnect", return mcp.NewTool("browser_disconnect",
mcp.WithDescription("Close and drop the pooled CDP connection for the given port (cancels any armed dialog handler). Does NOT kill Chrome."), mcp.WithDescription("Close 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.")), 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) { func (d *deps) handleDisconnect(_ context.Context, _ mcp.CallToolRequest, a disconnectArgs) (*mcp.CallToolResult, error) {
port := portOr(a.Port) 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) 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
View File
@@ -28,7 +28,7 @@ type storageSaveArgs struct {
func storageSaveTool() mcp.Tool { func storageSaveTool() mcp.Tool {
return mcp.NewTool("storage_save", return mcp.NewTool("storage_save",
mcp.WithDescription("Save the current session storage state (cookies + localStorage) to a JSON file for later reuse."), 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.")), mcp.WithString("path", mcp.Required(), mcp.Description("Output JSON file path.")),
) )
} }
@@ -56,7 +56,7 @@ type storageLoadArgs struct {
func storageLoadTool() mcp.Tool { func storageLoadTool() mcp.Tool {
return mcp.NewTool("storage_load", 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.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.")), mcp.WithString("path", mcp.Required(), mcp.Description("Input JSON file path.")),
) )
} }