Compare commits
22 Commits
54fe1b7f17
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a681c79d96 | |||
| 3b68c02b25 | |||
| d687a501ba | |||
| 70ab1a4d30 | |||
| 9e9c690f06 | |||
| 8e421a3500 | |||
| 3ce9b12eab | |||
| cb587a7005 | |||
| 33358bca6c | |||
| 82993179dc | |||
| 6b7f71c39f | |||
| 15949bf4ed | |||
| 5706c84a15 | |||
| f2587d6fee | |||
| 91973ed6f9 | |||
| c56004da5c | |||
| c2470f4f67 | |||
| 1c5b81f711 | |||
| a48e262371 | |||
| fa1efe6fd5 | |||
| f0bfc3e300 | |||
| 1fae6c1df9 |
@@ -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,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.**
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
name: browser_mcp
|
name: browser_mcp
|
||||||
lang: go
|
lang: go
|
||||||
domain: infra
|
domain: infra
|
||||||
version: 0.6.0
|
version: 0.8.0
|
||||||
description: "Servidor MCP que expone control total del navegador via CDP (42 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión, selección determinista de pestaña, lectura compacta texto/AX nativa + bucle percibir→actuar por #ref con auto-observe, percepción y lectura de texto dentro de iframes, click por coordenadas, y screenshot devuelto como image content que el LLM ve) reusando funciones del dominio browser del registry con un pool de conexiones CDP vivas. Por defecto opera sobre un Chrome aislado (puerto 9333) separado del navegador diario."
|
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:
|
e2e_checks:
|
||||||
- id: build
|
- id: build
|
||||||
@@ -31,7 +31,6 @@ 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
|
||||||
@@ -60,6 +59,14 @@ uses_functions:
|
|||||||
- 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"
|
||||||
@@ -118,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 (42)
|
## 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.
|
||||||
@@ -241,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 19 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_find_ref_by_text`, `dom_wait_element`,
|
`page_get_text`, `page_perceive`, `page_screenshot`, `dom_find_by_text`, `dom_find_ref_by_text`,
|
||||||
`cookie_get`, `frame_list`, `frame_get_html`, `frame_get_text`, `storage_save`). Útil para sesiones
|
`dom_wait_element`, `cookie_get`, `frame_list`, `frame_get_html`, `frame_get_text`, `storage_save`).
|
||||||
de inspección sin riesgo de 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
|
||||||
|
|
||||||
@@ -266,6 +294,33 @@ 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
|
- 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**
|
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
|
sobre la conexión CDP viva del pool (`cdp_get_ax_outline_go_browser`) — mata el subprocess, el venv
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -15,7 +15,11 @@ import (
|
|||||||
"fn-registry/functions/browser"
|
"fn-registry/functions/browser"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "0.6.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
|
||||||
@@ -92,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)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type connPool struct {
|
|||||||
pids map[int]int // puerto -> PID del Chrome lanzado por el MCP (solo los SUYOS)
|
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)
|
cancels map[int]func() // cancels de handlers persistentes (handle_dialog)
|
||||||
dialogLogs map[int]*browser.DialogLog // log de diálogos auto-respondidos por puerto
|
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 {
|
||||||
@@ -31,9 +32,25 @@ func newConnPool() *connPool {
|
|||||||
pids: map[int]int{},
|
pids: map[int]int{},
|
||||||
cancels: map[int]func(){},
|
cancels: map[int]func(){},
|
||||||
dialogLogs: map[int]*browser.DialogLog{},
|
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) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
@@ -121,6 +138,7 @@ func (p *connPool) drop(port int) {
|
|||||||
_ = browser.CdpClose(c, pid)
|
_ = browser.CdpClose(c, pid)
|
||||||
delete(p.conns, port)
|
delete(p.conns, port)
|
||||||
delete(p.pids, 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
|
||||||
@@ -188,6 +206,7 @@ func (p *connPool) closeAll() {
|
|||||||
p.pids = map[int]int{}
|
p.pids = map[int]int{}
|
||||||
p.cancels = map[int]func(){}
|
p.cancels = map[int]func(){}
|
||||||
p.dialogLogs = map[int]*browser.DialogLog{}
|
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.
|
||||||
|
|||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Instala los git hooks versionados de este repo en .git/hooks.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
ln -sf ../../scripts/pre-commit .git/hooks/pre-commit
|
||||||
|
echo "instalado .git/hooks/pre-commit -> scripts/pre-commit"
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Anti-stale binary guard. El .mcp.json ejecuta el binario ./browser_mcp; si se
|
||||||
|
# commitea un cambio en los .go sin recompilar, la sesión sirve código viejo
|
||||||
|
# (bug 16/06/2026). Este hook recompila en cada commit. Instálalo con
|
||||||
|
# scripts/install-hooks.sh.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
if ! ./build.sh >/tmp/browser_mcp_build.log 2>&1; then
|
||||||
|
echo "pre-commit: build.sh falló — commit abortado. Log:" >&2
|
||||||
|
cat /tmp/browser_mcp_build.log >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+276
-15
@@ -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"
|
||||||
@@ -16,6 +17,8 @@ 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(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))
|
||||||
@@ -26,12 +29,252 @@ func registerDomTools(s *server.MCPServer, d *deps) {
|
|||||||
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(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 ----
|
||||||
|
|
||||||
@@ -46,19 +289,22 @@ func domClickRefTool() mcp.Tool {
|
|||||||
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.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: 'human' (default, Bézier+jitter anti-bot), 'fast' (movimiento reducido, scraping masivo), 'instant' (element.click() JS, sin eventos de ratón; también fallback si el elemento no tiene geometría).")),
|
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)
|
||||||
|
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.MouseProfileForMode(a.Mode))
|
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 {
|
||||||
|
time.Sleep(dl)
|
||||||
|
}
|
||||||
outline, _ := d.perceiveOutline(port, 8000)
|
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
|
||||||
}
|
}
|
||||||
@@ -69,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 {
|
||||||
@@ -77,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'.")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,14 +333,21 @@ 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 {
|
||||||
|
time.Sleep(dl)
|
||||||
|
}
|
||||||
outline, _ := d.perceiveOutline(port, 8000)
|
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
|
||||||
}
|
}
|
||||||
@@ -110,19 +365,22 @@ func domHoverRefTool() mcp.Tool {
|
|||||||
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.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: 'human' (default, Bézier+jitter), 'fast' (movimiento reducido), 'instant' (sin movimiento de ratón).")),
|
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)
|
||||||
|
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.MouseProfileForMode(a.Mode))
|
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 {
|
||||||
|
time.Sleep(dl)
|
||||||
|
}
|
||||||
outline, _ := d.perceiveOutline(port, 8000)
|
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
|
||||||
}
|
}
|
||||||
@@ -142,19 +400,22 @@ func domClickXYTool() 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("x", mcp.Required(), mcp.Description("Coordenada X absoluta en CSS pixels del viewport.")),
|
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.WithNumber("y", mcp.Required(), mcp.Description("Coordenada Y absoluta en CSS pixels del viewport.")),
|
||||||
mcp.WithString("mode", mcp.Description("Velocidad: 'human' (default, Bézier+jitter anti-bot), 'fast' (movimiento reducido, scraping masivo), 'instant' (sin movimiento de ratón).")),
|
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) {
|
func (d *deps) handleDomClickXY(_ context.Context, _ mcp.CallToolRequest, a domClickXYArgs) (*mcp.CallToolResult, error) {
|
||||||
port := portOr(a.Port)
|
port := portOr(a.Port)
|
||||||
|
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.CdpClickXYHuman(c, a.X, a.Y, browser.MouseProfileForMode(a.Mode))
|
return browser.CdpClickXYHuman(c, a.X, a.Y, 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 {
|
||||||
|
time.Sleep(dl)
|
||||||
|
}
|
||||||
outline, _ := d.perceiveOutline(port, 8000)
|
outline, _ := d.perceiveOutline(port, 8000)
|
||||||
return mcp.NewToolResultText(fmt.Sprintf("clicked at (%g, %g)\n\n%s", a.X, a.Y, outline)), nil
|
return mcp.NewToolResultText(fmt.Sprintf("clicked at (%g, %g)\n\n%s", a.X, a.Y, outline)), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,558 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
|
||||||
|
"fn-registry/functions/browser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerLifecycleTools wires the per-profile Chromium lifecycle tools:
|
||||||
|
// - browser_list (read) — enumerate running Chromium master processes.
|
||||||
|
// - browser_launch_profile (MUTA) — launch Chromium for a concrete profile, with/without CDP.
|
||||||
|
// - browser_close (MUTA) — terminate a master process (SIGTERM, then SIGKILL).
|
||||||
|
//
|
||||||
|
// These manage the USER's Chromium instances by profile (e.g. "Personal", "Work"),
|
||||||
|
// distinct from browser_launch which spins the MCP's own isolated automation Chrome.
|
||||||
|
// Because the launched instances are user-facing (not driven by the MCP), they are
|
||||||
|
// NOT registered in the connection pool: the pool's shutdown-kill is reserved for
|
||||||
|
// automation Chromes the MCP owns, so a user's "Personal" window survives the MCP
|
||||||
|
// dying. Cleanup is explicit via browser_close.
|
||||||
|
func registerLifecycleTools(s *server.MCPServer, d *deps) {
|
||||||
|
s.AddTool(browserListTool(), mcp.NewTypedToolHandler(d.handleBrowserList))
|
||||||
|
if !d.readOnly {
|
||||||
|
s.AddTool(browserLaunchProfileTool(), mcp.NewTypedToolHandler(d.handleBrowserLaunchProfile))
|
||||||
|
s.AddTool(browserCloseTool(), mcp.NewTypedToolHandler(d.handleBrowserClose))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// realChromiumBin is the REAL Chromium binary, bypassing the /usr/bin/chromium
|
||||||
|
// wrapper. The wrapper sources /etc/chromium.d/* and injects global flags
|
||||||
|
// (--user-data-dir=$HOME/.config/chromium-cdp, --remote-debugging-port=9222,
|
||||||
|
// --remote-allow-origins=*). Launching the wrapper would force CDP on every
|
||||||
|
// instance, which breaks Google's session-keeping for human profiles. The real
|
||||||
|
// binary sources none of that, so we control the flags exactly.
|
||||||
|
const realChromiumBin = "/usr/lib/chromium/chromium"
|
||||||
|
|
||||||
|
// ---- master process discovery ----
|
||||||
|
|
||||||
|
// chromiumMaster describes one running Chromium master process (the top process
|
||||||
|
// that owns a user-data-dir, NOT a zygote/gpu/renderer child which carries --type=).
|
||||||
|
type chromiumMaster struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Profile string `json:"profile"` // value of --profile-directory ("" if absent)
|
||||||
|
UserDataDir string `json:"user_data_dir"` // value of --user-data-dir
|
||||||
|
CDPPort string `json:"cdp_port"` // value of --remote-debugging-port ("" if none)
|
||||||
|
HasCDP bool `json:"has_cdp"`
|
||||||
|
Headless bool `json:"headless"` // true if launched with --headless / --headless=new / --headless=old
|
||||||
|
Pages int `json:"pages"` // count of "page" targets (best-effort via GET /json; 0 if no CDP or unreachable)
|
||||||
|
ActiveTitle string `json:"active_title,omitempty"` // title of the first "page" target
|
||||||
|
ActiveURL string `json:"active_url,omitempty"` // URL of the first "page" target
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCmdline turns the raw bytes of /proc/<pid>/cmdline into argv.
|
||||||
|
//
|
||||||
|
// Canonically the kernel separates arguments with NUL bytes. But Chromium (and
|
||||||
|
// other programs that rewrite their process title in place) collapse the argv
|
||||||
|
// region into a single space-separated string, losing the NUL separators. In
|
||||||
|
// that case splitting on NUL yields a single giant element holding the whole
|
||||||
|
// command line, which breaks argv[0] detection and "--flag=" prefix matching.
|
||||||
|
//
|
||||||
|
// So: if the data still carries NUL separators we split on NUL (the correct,
|
||||||
|
// space-safe path). Otherwise we fall back to splitting on whitespace. The
|
||||||
|
// fallback is best-effort and would mis-split a flag value containing spaces
|
||||||
|
// (e.g. a user-data-dir path with a space), but Chromium's own flags don't, so
|
||||||
|
// it recovers the master-detection flags (--user-data-dir, --type=,
|
||||||
|
// --remote-debugging-port, --profile-directory) reliably in practice.
|
||||||
|
func parseCmdline(b []byte) []string {
|
||||||
|
s := strings.TrimRight(string(b), "\x00")
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var raw []string
|
||||||
|
if strings.Contains(s, "\x00") {
|
||||||
|
raw = strings.Split(s, "\x00")
|
||||||
|
} else {
|
||||||
|
raw = strings.Fields(s)
|
||||||
|
}
|
||||||
|
args := make([]string, 0, len(raw))
|
||||||
|
for _, a := range raw {
|
||||||
|
if a != "" {
|
||||||
|
args = append(args, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// readProcCmdline reads /proc/<pid>/cmdline and parses it into argv.
|
||||||
|
// Returns nil if the process is gone or unreadable.
|
||||||
|
func readProcCmdline(pid int) []string {
|
||||||
|
b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"))
|
||||||
|
if err != nil || len(b) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return parseCmdline(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// flagValue returns the value of a "--name=value" flag from argv, plus whether it
|
||||||
|
// was present. Matches the exact "--name=" prefix; the first occurrence wins.
|
||||||
|
func flagValue(args []string, name string) (string, bool) {
|
||||||
|
prefix := "--" + name + "="
|
||||||
|
for _, a := range args {
|
||||||
|
if strings.HasPrefix(a, prefix) {
|
||||||
|
return strings.TrimPrefix(a, prefix), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasFlagPrefix reports whether any arg starts with the given prefix (e.g. "--type=").
|
||||||
|
func hasFlagPrefix(args []string, prefix string) bool {
|
||||||
|
for _, a := range args {
|
||||||
|
if strings.HasPrefix(a, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isChromiumExe reports whether argv[0] looks like a chromium/chrome executable.
|
||||||
|
func isChromiumExe(args []string) bool {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
base := strings.ToLower(filepath.Base(args[0]))
|
||||||
|
return strings.Contains(base, "chromium") || strings.Contains(base, "chrome")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseChromiumMaster builds a chromiumMaster from argv if (and only if) the process
|
||||||
|
// is a Chromium MASTER: argv[0] is a chromium/chrome binary, it carries
|
||||||
|
// --user-data-dir, and it does NOT carry --type= (which all child processes have:
|
||||||
|
// zygote, gpu-process, renderer, utility...). Returns ok=false otherwise.
|
||||||
|
func parseChromiumMaster(pid int, args []string) (chromiumMaster, bool) {
|
||||||
|
if !isChromiumExe(args) {
|
||||||
|
return chromiumMaster{}, false
|
||||||
|
}
|
||||||
|
udd, hasUDD := flagValue(args, "user-data-dir")
|
||||||
|
if !hasUDD {
|
||||||
|
return chromiumMaster{}, false
|
||||||
|
}
|
||||||
|
if hasFlagPrefix(args, "--type=") {
|
||||||
|
return chromiumMaster{}, false // child process, not the master
|
||||||
|
}
|
||||||
|
port, hasCDP := flagValue(args, "remote-debugging-port")
|
||||||
|
return chromiumMaster{
|
||||||
|
PID: pid,
|
||||||
|
Profile: firstNonEmpty(args, "profile-directory"),
|
||||||
|
UserDataDir: udd,
|
||||||
|
CDPPort: port,
|
||||||
|
HasCDP: hasCDP,
|
||||||
|
Headless: isHeadless(args),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHeadless reports whether the process was launched in headless mode. Chromium
|
||||||
|
// spells it "--headless", "--headless=new" or "--headless=old"; matching the
|
||||||
|
// "--headless" prefix covers all three. There is no current Chromium flag that
|
||||||
|
// starts with "--headless" but means something else, so the prefix is safe.
|
||||||
|
func isHeadless(args []string) bool {
|
||||||
|
for _, a := range args {
|
||||||
|
if a == "--headless" || strings.HasPrefix(a, "--headless=") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstNonEmpty returns the flag value or "" if absent.
|
||||||
|
func firstNonEmpty(args []string, name string) string {
|
||||||
|
v, _ := flagValue(args, name)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// listChromiumMasters walks /proc and returns every running Chromium master process,
|
||||||
|
// sorted by PID for stable output.
|
||||||
|
func listChromiumMasters() ([]chromiumMaster, error) {
|
||||||
|
entries, err := os.ReadDir("/proc")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read /proc: %w", err)
|
||||||
|
}
|
||||||
|
var masters []chromiumMaster
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue // not a PID dir
|
||||||
|
}
|
||||||
|
args := readProcCmdline(pid)
|
||||||
|
if m, ok := parseChromiumMaster(pid, args); ok {
|
||||||
|
masters = append(masters, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(masters, func(i, j int) bool { return masters[i].PID < masters[j].PID })
|
||||||
|
return masters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- X session env detection ----
|
||||||
|
|
||||||
|
// xSessionEnv returns DISPLAY and XAUTHORITY scraped from a live XFCE session
|
||||||
|
// process. A decoupled Chromium launched from the MCP (no inherited X env) needs
|
||||||
|
// these to open a window on the user's screen. Falls back to :0 + ~/.Xauthority.
|
||||||
|
func xSessionEnv() (display, xauthority string) {
|
||||||
|
display = ":0"
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
xauthority = filepath.Join(home, ".Xauthority")
|
||||||
|
}
|
||||||
|
for _, proc := range []string{"xfwm4", "xfce4-session", "xfdesktop"} {
|
||||||
|
out, err := exec.Command("pgrep", "-x", proc).Output()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, line := range strings.Fields(string(out)) {
|
||||||
|
pid, err := strconv.Atoi(line)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d, x, ok := readProcEnviron(pid)
|
||||||
|
if ok {
|
||||||
|
if d != "" {
|
||||||
|
display = d
|
||||||
|
}
|
||||||
|
if x != "" {
|
||||||
|
xauthority = x
|
||||||
|
}
|
||||||
|
return display, xauthority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return display, xauthority
|
||||||
|
}
|
||||||
|
|
||||||
|
// readProcEnviron reads DISPLAY and XAUTHORITY from /proc/<pid>/environ (NUL-separated).
|
||||||
|
// ok is true if the environ was readable.
|
||||||
|
func readProcEnviron(pid int) (display, xauthority string, ok bool) {
|
||||||
|
b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "environ"))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
for _, kv := range strings.Split(string(b), "\x00") {
|
||||||
|
if v, found := strings.CutPrefix(kv, "DISPLAY="); found {
|
||||||
|
display = v
|
||||||
|
} else if v, found := strings.CutPrefix(kv, "XAUTHORITY="); found {
|
||||||
|
xauthority = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return display, xauthority, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultProfileUserDataDir is the user's daily Chromium user-data-dir where the
|
||||||
|
// named profiles (Automation, Default, Personal, "Profile 1", osint_01) live.
|
||||||
|
func defaultProfileUserDataDir() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ".config/chromium-cdp"
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".config", "chromium-cdp")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- browser_list ----
|
||||||
|
|
||||||
|
type browserListArgs struct{}
|
||||||
|
|
||||||
|
func browserListTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("browser_list",
|
||||||
|
mcp.WithDescription("List the running Chromium MASTER processes (one per user-data-dir master, NOT zygote/gpu/renderer children). For each: pid, profile (--profile-directory value), user_data_dir, cdp_port (--remote-debugging-port value, empty if none), has_cdp, headless (true if launched with --headless), pages (count of open page targets via GET /json, best-effort), active_title/active_url (first open page). Returns a JSON array. Read-only."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handleBrowserList(_ context.Context, _ mcp.CallToolRequest, _ browserListArgs) (*mcp.CallToolResult, error) {
|
||||||
|
masters, err := listChromiumMasters()
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
if masters == nil {
|
||||||
|
masters = []chromiumMaster{}
|
||||||
|
}
|
||||||
|
// Enriquecer cada master con CDP con su nº de páginas y la primera página
|
||||||
|
// (título/URL) consultando GET /json. Best-effort: si el puerto no responde,
|
||||||
|
// se dejan los campos a cero — el listado de procesos nunca falla por esto.
|
||||||
|
for i := range masters {
|
||||||
|
enrichMasterTabs(&masters[i])
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(masters, "", " ")
|
||||||
|
return mcp.NewToolResultText(string(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrichMasterTabs rellena Pages/ActiveTitle/ActiveURL de un master consultando
|
||||||
|
// sus targets CDP por HTTP. No devuelve error: cualquier fallo (sin CDP, puerto
|
||||||
|
// caído, timeout) deja los campos en su cero y el master se reporta igual.
|
||||||
|
func enrichMasterTabs(m *chromiumMaster) {
|
||||||
|
if m.CDPPort == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(m.CDPPort)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tabs, err := browser.CdpListTabs("localhost", port)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, t := range tabs {
|
||||||
|
if t.Type != "page" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.Pages++
|
||||||
|
if m.ActiveURL == "" {
|
||||||
|
m.ActiveTitle = t.Title
|
||||||
|
m.ActiveURL = t.URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- browser_launch_profile (MUTA) ----
|
||||||
|
|
||||||
|
type launchProfileArgs struct {
|
||||||
|
Profile string `json:"profile"`
|
||||||
|
UserDataDir string `json:"user_data_dir"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
CDP bool `json:"cdp"`
|
||||||
|
CDPPort int `json:"cdp_port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func browserLaunchProfileTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("browser_launch_profile",
|
||||||
|
mcp.WithDescription("Launch Chromium for a CONCRETE profile (e.g. \"Personal\", \"Work\") on the user's screen. Uses the REAL chromium binary (/usr/lib/chromium/chromium), bypassing the /usr/bin/chromium wrapper, so flags are controlled exactly. With cdp=false (default) NO remote-debugging flags are added — REQUIRED for human profiles where Google must keep the session (CDP makes Google treat the browser as automated and drop the login). With cdp=true adds --remote-debugging-port=<cdp_port> and --remote-allow-origins=*. Detects DISPLAY/XAUTHORITY from the XFCE session and launches DECOUPLED (setsid). If a master already owns the user_data_dir, Chromium forwards the open to it (note in the result). Returns {pid, profile, cdp, cdp_port[, note]}."),
|
||||||
|
mcp.WithString("profile", mcp.Required(), mcp.Description("Profile directory name to launch (--profile-directory value), e.g. \"Personal\", \"Default\", \"Automation\".")),
|
||||||
|
mcp.WithString("user_data_dir", mcp.Description("Chromium user-data-dir holding the profiles. Default ~/.config/chromium-cdp.")),
|
||||||
|
mcp.WithString("url", mcp.Description("Optional URL to open.")),
|
||||||
|
mcp.WithBoolean("cdp", mcp.Description("Enable CDP remote debugging. Default false. Leave false for human profiles (Google session-keeping). true only for automation.")),
|
||||||
|
mcp.WithNumber("cdp_port", mcp.Description("CDP port when cdp=true. Default 9222.")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handleBrowserLaunchProfile(_ context.Context, _ mcp.CallToolRequest, a launchProfileArgs) (*mcp.CallToolResult, error) {
|
||||||
|
if a.Profile == "" {
|
||||||
|
return mcp.NewToolResultError("profile is required"), nil
|
||||||
|
}
|
||||||
|
userDataDir := a.UserDataDir
|
||||||
|
if userDataDir == "" {
|
||||||
|
userDataDir = defaultProfileUserDataDir()
|
||||||
|
}
|
||||||
|
cdpPort := a.CDPPort
|
||||||
|
if cdpPort == 0 {
|
||||||
|
cdpPort = 9222
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect whether a master already owns this user-data-dir. If so, Chromium will
|
||||||
|
// forward the open to that master (it can't run two masters on one dir).
|
||||||
|
note := ""
|
||||||
|
if masters, err := listChromiumMasters(); err == nil {
|
||||||
|
for _, m := range masters {
|
||||||
|
if m.UserDataDir == userDataDir {
|
||||||
|
note = "forwarded to existing master"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"--user-data-dir=" + userDataDir,
|
||||||
|
"--profile-directory=" + a.Profile,
|
||||||
|
}
|
||||||
|
if a.CDP {
|
||||||
|
args = append(args,
|
||||||
|
fmt.Sprintf("--remote-debugging-port=%d", cdpPort),
|
||||||
|
"--remote-allow-origins=*",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if a.URL != "" {
|
||||||
|
args = append(args, a.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
display, xauthority := xSessionEnv()
|
||||||
|
|
||||||
|
cmd := exec.Command(realChromiumBin, args...)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"DISPLAY="+display,
|
||||||
|
"XAUTHORITY="+xauthority,
|
||||||
|
)
|
||||||
|
// Decouple from the MCP: new session leader (setsid) so the child survives the
|
||||||
|
// launcher dying, and no inherited stdio (avoids the exit-144 / SIGPIPE death
|
||||||
|
// when the parent's pipes close). We Release the process: never reaped here.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return mcp.NewToolResultError(fmt.Sprintf("launch chromium: %v", err)), nil
|
||||||
|
}
|
||||||
|
pid := cmd.Process.Pid
|
||||||
|
_ = cmd.Process.Release()
|
||||||
|
|
||||||
|
// Give Chromium a moment to come up. With CDP we poll the port instead of a
|
||||||
|
// blind 1s sleep: we return as soon as it responds (best-effort: a forwarded
|
||||||
|
// launch may not bind the port if the master had no CDP). Without CDP there's
|
||||||
|
// no port to poll, so we give the window a short margin to appear / forward.
|
||||||
|
if a.CDP && note == "" {
|
||||||
|
if !waitCDPPort(cdpPort, 5*time.Second) {
|
||||||
|
note = "cdp port not confirmed listening yet"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]any{
|
||||||
|
"pid": pid,
|
||||||
|
"profile": a.Profile,
|
||||||
|
"cdp": a.CDP,
|
||||||
|
"cdp_port": cdpPort,
|
||||||
|
}
|
||||||
|
if note != "" {
|
||||||
|
out["note"] = note
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(out, "", " ")
|
||||||
|
return mcp.NewToolResultText(string(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- browser_close (MUTA) ----
|
||||||
|
|
||||||
|
type browserCloseArgs struct {
|
||||||
|
Profile string `json:"profile"`
|
||||||
|
CDPPort int `json:"cdp_port"`
|
||||||
|
PID int `json:"pid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func browserCloseTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("browser_close",
|
||||||
|
mcp.WithDescription("Cleanly close a running Chromium master. Identify it by one of: profile (--profile-directory), cdp_port (--remote-debugging-port), or pid. Sends SIGTERM, waits up to 10s for it to die, then SIGKILL as a last resort (flagged in the result). Returns {closed, pid, method}."),
|
||||||
|
mcp.WithString("profile", mcp.Description("Match the master by --profile-directory value.")),
|
||||||
|
mcp.WithNumber("cdp_port", mcp.Description("Match the master by --remote-debugging-port value.")),
|
||||||
|
mcp.WithNumber("pid", mcp.Description("Match the master by exact PID.")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handleBrowserClose(_ context.Context, _ mcp.CallToolRequest, a browserCloseArgs) (*mcp.CallToolResult, error) {
|
||||||
|
if a.Profile == "" && a.CDPPort == 0 && a.PID == 0 {
|
||||||
|
return mcp.NewToolResultError("one of profile, cdp_port or pid is required"), nil
|
||||||
|
}
|
||||||
|
masters, err := listChromiumMasters()
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
target, found := matchMaster(masters, a)
|
||||||
|
if !found {
|
||||||
|
return mcp.NewToolResultError("no running Chromium master matched the given criteria"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
proc, err := os.FindProcess(target.PID)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError(fmt.Sprintf("find process %d: %v", target.PID, err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
method := "SIGTERM"
|
||||||
|
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||||
|
return mcp.NewToolResultError(fmt.Sprintf("SIGTERM pid=%d: %v", target.PID, err)), nil
|
||||||
|
}
|
||||||
|
// Wait up to ~10s for the process to die (poll /proc liveness).
|
||||||
|
if !waitProcessGone(target.PID, 10*time.Second) {
|
||||||
|
method = "SIGKILL"
|
||||||
|
_ = proc.Signal(syscall.SIGKILL)
|
||||||
|
waitProcessGone(target.PID, 3*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]any{
|
||||||
|
"closed": true,
|
||||||
|
"pid": target.PID,
|
||||||
|
"method": method,
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(out, "", " ")
|
||||||
|
return mcp.NewToolResultText(string(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchMaster picks the master matching the close criteria. PID is most specific,
|
||||||
|
// then cdp_port, then profile (first match wins for the latter two).
|
||||||
|
func matchMaster(masters []chromiumMaster, a browserCloseArgs) (chromiumMaster, bool) {
|
||||||
|
if a.PID != 0 {
|
||||||
|
for _, m := range masters {
|
||||||
|
if m.PID == a.PID {
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chromiumMaster{}, false
|
||||||
|
}
|
||||||
|
if a.CDPPort != 0 {
|
||||||
|
want := strconv.Itoa(a.CDPPort)
|
||||||
|
for _, m := range masters {
|
||||||
|
if m.CDPPort == want {
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chromiumMaster{}, false
|
||||||
|
}
|
||||||
|
for _, m := range masters {
|
||||||
|
if m.Profile == a.Profile {
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chromiumMaster{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitProcessGone polls until the PID no longer exists in /proc or the timeout
|
||||||
|
// elapses. Returns true if the process is gone.
|
||||||
|
func waitProcessGone(pid int, timeout time.Duration) bool {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if !processAlive(pid) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return !processAlive(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// processAlive reports whether /proc/<pid> still exists.
|
||||||
|
func processAlive(pid int) bool {
|
||||||
|
_, err := os.Stat(filepath.Join("/proc", strconv.Itoa(pid)))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitCDPPort polls the CDP port until it accepts a TCP connection or the timeout
|
||||||
|
// elapses. Replaces a blind sleep: returns as soon as Chromium binds the port.
|
||||||
|
func waitCDPPort(port int, timeout time.Duration) bool {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if cdpPortResponds(port) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return cdpPortResponds(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cdpPortResponds reports whether something is listening on the CDP port on
|
||||||
|
// 127.0.0.1. Single TCP dial with a short timeout; best-effort confirmation only.
|
||||||
|
func cdpPortResponds(port int) bool {
|
||||||
|
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestParseChromiumMaster cubre la deteccion de master: solo procesos chromium con
|
||||||
|
// --user-data-dir y SIN --type= cuentan; el resto (wrapper sin udd, children con
|
||||||
|
// --type=, no-chromium) se descartan. Tambien valida que profile/cdp_port se
|
||||||
|
// extraen y que has_cdp refleja la presencia del flag.
|
||||||
|
func TestParseChromiumMaster(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantOK bool
|
||||||
|
wantProfile string
|
||||||
|
wantUDD string
|
||||||
|
wantPort string
|
||||||
|
wantHasCDP bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "master con CDP y profile",
|
||||||
|
args: []string{
|
||||||
|
"/usr/lib/chromium/chromium",
|
||||||
|
"--user-data-dir=/home/u/.config/chromium-cdp",
|
||||||
|
"--profile-directory=Personal",
|
||||||
|
"--remote-debugging-port=9222",
|
||||||
|
"--remote-allow-origins=*",
|
||||||
|
},
|
||||||
|
wantOK: true,
|
||||||
|
wantProfile: "Personal",
|
||||||
|
wantUDD: "/home/u/.config/chromium-cdp",
|
||||||
|
wantPort: "9222",
|
||||||
|
wantHasCDP: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "master humano sin CDP",
|
||||||
|
args: []string{
|
||||||
|
"/usr/lib/chromium/chromium",
|
||||||
|
"--user-data-dir=/home/u/.config/chromium-cdp",
|
||||||
|
"--profile-directory=Default",
|
||||||
|
},
|
||||||
|
wantOK: true,
|
||||||
|
wantProfile: "Default",
|
||||||
|
wantUDD: "/home/u/.config/chromium-cdp",
|
||||||
|
wantPort: "",
|
||||||
|
wantHasCDP: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "child renderer con --type= se descarta",
|
||||||
|
args: []string{
|
||||||
|
"/usr/lib/chromium/chromium",
|
||||||
|
"--type=renderer",
|
||||||
|
"--user-data-dir=/home/u/.config/chromium-cdp",
|
||||||
|
},
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "child gpu-process con --type= se descarta",
|
||||||
|
args: []string{
|
||||||
|
"/usr/lib/chromium/chromium",
|
||||||
|
"--type=gpu-process",
|
||||||
|
"--user-data-dir=/home/u/.config/chromium-cdp",
|
||||||
|
"--profile-directory=Personal",
|
||||||
|
},
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chromium sin --user-data-dir se descarta",
|
||||||
|
args: []string{"/usr/lib/chromium/chromium", "--profile-directory=Personal"},
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "proceso no-chromium se descarta",
|
||||||
|
args: []string{"/usr/bin/firefox", "--user-data-dir=/x"},
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "argv vacio se descarta",
|
||||||
|
args: nil,
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
m, ok := parseChromiumMaster(1234, tc.args)
|
||||||
|
if ok != tc.wantOK {
|
||||||
|
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.PID != 1234 {
|
||||||
|
t.Errorf("PID = %d, want 1234", m.PID)
|
||||||
|
}
|
||||||
|
if m.Profile != tc.wantProfile {
|
||||||
|
t.Errorf("Profile = %q, want %q", m.Profile, tc.wantProfile)
|
||||||
|
}
|
||||||
|
if m.UserDataDir != tc.wantUDD {
|
||||||
|
t.Errorf("UserDataDir = %q, want %q", m.UserDataDir, tc.wantUDD)
|
||||||
|
}
|
||||||
|
if m.CDPPort != tc.wantPort {
|
||||||
|
t.Errorf("CDPPort = %q, want %q", m.CDPPort, tc.wantPort)
|
||||||
|
}
|
||||||
|
if m.HasCDP != tc.wantHasCDP {
|
||||||
|
t.Errorf("HasCDP = %v, want %v", m.HasCDP, tc.wantHasCDP)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFlagValue valida el parseo exacto de "--name=value".
|
||||||
|
func TestFlagValue(t *testing.T) {
|
||||||
|
args := []string{"--user-data-dir=/x/y", "--profile-directory=Work", "--flag-without-value"}
|
||||||
|
if v, ok := flagValue(args, "user-data-dir"); !ok || v != "/x/y" {
|
||||||
|
t.Errorf("user-data-dir = (%q,%v), want (/x/y,true)", v, ok)
|
||||||
|
}
|
||||||
|
if v, ok := flagValue(args, "profile-directory"); !ok || v != "Work" {
|
||||||
|
t.Errorf("profile-directory = (%q,%v), want (Work,true)", v, ok)
|
||||||
|
}
|
||||||
|
if _, ok := flagValue(args, "remote-debugging-port"); ok {
|
||||||
|
t.Errorf("remote-debugging-port should be absent")
|
||||||
|
}
|
||||||
|
// Prefijo no debe hacer match parcial: "user-data" != "user-data-dir".
|
||||||
|
if _, ok := flagValue(args, "user-data"); ok {
|
||||||
|
t.Errorf("partial prefix user-data should NOT match user-data-dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMatchMaster valida la prioridad pid > cdp_port > profile y el no-match.
|
||||||
|
func TestMatchMaster(t *testing.T) {
|
||||||
|
masters := []chromiumMaster{
|
||||||
|
{PID: 100, Profile: "Personal", CDPPort: ""},
|
||||||
|
{PID: 200, Profile: "Work", CDPPort: "9222"},
|
||||||
|
{PID: 300, Profile: "Personal", CDPPort: "9333"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if m, ok := matchMaster(masters, browserCloseArgs{PID: 200}); !ok || m.PID != 200 {
|
||||||
|
t.Errorf("by pid: got (%d,%v), want (200,true)", m.PID, ok)
|
||||||
|
}
|
||||||
|
if m, ok := matchMaster(masters, browserCloseArgs{CDPPort: 9333}); !ok || m.PID != 300 {
|
||||||
|
t.Errorf("by cdp_port: got (%d,%v), want (300,true)", m.PID, ok)
|
||||||
|
}
|
||||||
|
// profile "Personal" tiene dos: gana el primero (PID 100).
|
||||||
|
if m, ok := matchMaster(masters, browserCloseArgs{Profile: "Personal"}); !ok || m.PID != 100 {
|
||||||
|
t.Errorf("by profile: got (%d,%v), want (100,true)", m.PID, ok)
|
||||||
|
}
|
||||||
|
if _, ok := matchMaster(masters, browserCloseArgs{PID: 999}); ok {
|
||||||
|
t.Errorf("unknown pid should not match")
|
||||||
|
}
|
||||||
|
if _, ok := matchMaster(masters, browserCloseArgs{Profile: "Nope"}); ok {
|
||||||
|
t.Errorf("unknown profile should not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseCmdline cubre el parsing de /proc/<pid>/cmdline en sus dos formatos:
|
||||||
|
// el canonico separado por NUL y el colapsado por espacios que produce Chromium
|
||||||
|
// al reescribir su titulo de proceso in-place. El segundo caso es el que rompia
|
||||||
|
// browser_list (los flags quedaban dentro de un unico argv[0] gigante).
|
||||||
|
func TestParseCmdline(t *testing.T) {
|
||||||
|
// Caso canonico: argv separado por NUL (proceso normal).
|
||||||
|
nul := []byte("/usr/lib/chromium/chromium\x00--user-data-dir=/tmp/x\x00--remote-debugging-port=9333\x00")
|
||||||
|
got := parseCmdline(nul)
|
||||||
|
want := []string{"/usr/lib/chromium/chromium", "--user-data-dir=/tmp/x", "--remote-debugging-port=9333"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("NUL: got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("NUL[%d]: got %q, want %q", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caso Chromium: cmdline colapsado a una sola cadena separada por espacios.
|
||||||
|
collapsed := []byte("/usr/lib/chromium/chromium --remote-debugging-port=9333 --user-data-dir=/tmp/browser_mcp_userdata --no-first-run https://www.alsa.es/")
|
||||||
|
args := parseCmdline(collapsed)
|
||||||
|
if len(args) == 1 {
|
||||||
|
t.Fatalf("space-collapsed: parse devolvio un unico elemento gigante: %q", args[0])
|
||||||
|
}
|
||||||
|
if args[0] != "/usr/lib/chromium/chromium" {
|
||||||
|
t.Errorf("space-collapsed argv[0]: got %q, want chromium binary", args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// El master debe detectarse a partir del cmdline colapsado (regresion de browser_list).
|
||||||
|
m, ok := parseChromiumMaster(18148, args)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("space-collapsed: parseChromiumMaster no detecto el master")
|
||||||
|
}
|
||||||
|
if m.UserDataDir != "/tmp/browser_mcp_userdata" {
|
||||||
|
t.Errorf("space-collapsed udd: got %q, want /tmp/browser_mcp_userdata", m.UserDataDir)
|
||||||
|
}
|
||||||
|
if m.CDPPort != "9333" || !m.HasCDP {
|
||||||
|
t.Errorf("space-collapsed cdp: got port=%q hasCDP=%v, want 9333/true", m.CDPPort, m.HasCDP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parseCmdline([]byte("")) != nil || parseCmdline([]byte("\x00\x00")) != nil {
|
||||||
|
t.Errorf("cmdline vacio debe devolver nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsHeadless valida la deteccion de modo headless por el flag de lanzamiento:
|
||||||
|
// --headless, --headless=new y --headless=old cuentan; su ausencia es headed.
|
||||||
|
func TestIsHeadless(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"sin flag (headed)", []string{"/usr/lib/chromium/chromium", "--user-data-dir=/tmp/x"}, false},
|
||||||
|
{"--headless legacy", []string{"/usr/lib/chromium/chromium", "--headless", "--user-data-dir=/tmp/x"}, true},
|
||||||
|
{"--headless=new", []string{"/usr/lib/chromium/chromium", "--headless=new"}, true},
|
||||||
|
{"--headless=old", []string{"/usr/lib/chromium/chromium", "--headless=old"}, true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := isHeadless(c.args); got != c.want {
|
||||||
|
t.Errorf("isHeadless(%v) = %v, want %v", c.args, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// El master headed real (cmdline colapsado por espacios) debe reportar headless=false.
|
||||||
|
headed := parseCmdline([]byte("/usr/lib/chromium/chromium --remote-debugging-port=9333 --user-data-dir=/tmp/browser_mcp_userdata"))
|
||||||
|
if m, ok := parseChromiumMaster(1, headed); !ok || m.Headless {
|
||||||
|
t.Errorf("master headed: ok=%v headless=%v, want ok=true headless=false", ok, m.Headless)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
@@ -20,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 {
|
||||||
|
|||||||
+30
-1
@@ -12,13 +12,15 @@ 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
|
// maxLaunchedChromes es el tope duro de instancias Chrome que el MCP puede tener
|
||||||
@@ -142,3 +144,30 @@ func (d *deps) handleDisconnect(_ context.Context, _ mcp.CallToolRequest, a disc
|
|||||||
}
|
}
|
||||||
return mcp.NewToolResultText(msg), nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user