merge: quick/cdp-speed-modes — modo de velocidad de sesión + aceleraciones CDP (v0.8.0)

This commit is contained in:
2026-06-13 14:28:20 +02:00
6 changed files with 160 additions and 26 deletions
+4
View File
@@ -1,2 +1,6 @@
/browser_mcp
*.log
# registry.db sólo existe en la raíz del repo (regla db_locations). Si un test o el
# binario lo crea aquí por un path relativo, es basura: ignorarlo evita trackearlo.
registry.db
operations.db*
+22 -3
View File
@@ -2,8 +2,8 @@
name: browser_mcp
lang: go
domain: infra
version: 0.7.0
description: "Servidor MCP que expone control total del navegador via CDP (45 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, 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."
version: 0.8.0
description: "Servidor MCP que expone control total del navegador via CDP (46 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión, selección determinista de pestaña, modo de velocidad de sesión (browser_set_mode: 'auto' rápido por defecto / 'human' sigiloso anti-detección), lectura compacta texto/AX nativa + bucle percibir→actuar por #ref con auto-observe, percepción y lectura de texto dentro de iframes, click por coordenadas, screenshot devuelto como image content que el LLM ve, y gestión del ciclo de vida de Chromium por perfil: listar masters en ejecución, lanzar un perfil concreto con o sin CDP, y cerrar limpio) reusando funciones del dominio browser del registry con un pool de conexiones CDP vivas. Por defecto opera sobre un Chrome aislado (puerto 9333) separado del navegador diario."
tags: [mcp, browser, cdp, automation, scraping]
e2e_checks:
- id: build
@@ -118,12 +118,13 @@ podría manipular pestañas ajenas del usuario (banca, correo). Para evitarlo:
- Para adjuntarte deliberadamente al navegador diario, pasa `port: 9222` explícito en cada
tool. Hazlo solo con cuidado.
## Tools (45)
## Tools (46)
### Sesión (`tools_session.go`)
- `browser_launch` (MUTA) — lanza Chrome con CDP. args: port, headless, user_data_dir, url.
- `browser_connect` — abre/poolea la conexión CDP del puerto. args: port.
- `browser_disconnect` — cierra y descarta la conexión del puerto (no mata Chrome). args: port.
- `browser_set_mode` — fija el modo de velocidad de sesión del puerto: `auto` (default, rápido) o `human` (sigiloso anti-detección). args: port, mode. Cada tool de acción puede overridearlo con su arg `mode`.
### Ciclo de vida por perfil (`tools_lifecycle.go`)
Gestionan los Chromium del USUARIO por perfil (`Personal`, `Work`, ...), distintos del Chrome
@@ -286,6 +287,24 @@ Funciones del dominio `browser` que NO se exponen como tools en esta versión, c
## Capability growth log
- v0.8.0 (2026-06-13) — Aceleración del manejo del navegador via CDP + flag de velocidad de
sesión. (1) Nueva tool `browser_set_mode` (45 → 46 tools): fija el modo de velocidad por puerto
en el pool — `auto` (default del MCP, rápido) vs `human` (sigiloso anti-detección). El modo se
resuelve por acción con `effectiveMode`: arg `mode` de la tool > modo de sesión > `auto`. (2) Settle
adaptativo: el sleep ciego fijo de 400ms tras cada acción mutante (`dom_click_ref`/`dom_type_ref`/
`dom_hover_ref`/`dom_click_xy`) pasa a `settleForMode` — 60ms en `auto`, aleatorio 250-650ms en
`human` (ritmo no-máquina), 0 en `instant`. (3) `dom_type_ref` ahora tiene arg `mode`: en `auto`
usa `CdpTypeRefFast` (`Input.insertText`, un solo round-trip) y en `human` teclea carácter a
carácter (`CdpTypeRef`) con pausas aleatorias. (4) `browser_launch_profile` reemplaza el `sleep(1s)`
ciego por un poll del puerto CDP (`waitCDPPort`). Cambios en el dominio `browser` del registry que
aprovecha el MCP: `Accessibility.enable`/`Network.enable`/`Page.enable` cacheados por conexión
(`ensureAX`/`ensureNetwork`/`ensurePage` en `CDPConn`) — se eliminan round-trips redundantes en cada
percepción/espera; `cdp_wait_load` pasa de polling de `document.readyState` cada 200ms a esperar el
evento `Page.loadEventFired` (fast path si ya está `complete`); `sendCDP` adquiere timeout
(`cdpCmdTimeout` 30s) para no colgar el tool indefinidamente; nuevas `CdpInsertText` y
`CdpTypeRefFast` (camino rápido de escritura); el modo `auto` se añade al perfil de ratón
(`MouseProfileForMode`) como alias rápido de `fast`. Smoke contra Chrome 9333: percepción #2 con
enable cacheado 1.7ms (vs 3.7ms la #1), `wait_load` fast-path 245µs (vs ≥200ms del polling previo).
- v0.7.0 (2026-06-10) — Ciclo de vida de Chromium por perfil (`tools_lifecycle.go`). Tres tools
nuevas: `browser_list` (enumera los procesos master de Chromium leyendo `/proc/*/cmdline`,
filtrando por `--user-data-dir` presente y `--type=` ausente), `browser_launch_profile` (lanza un
+19
View File
@@ -23,6 +23,7 @@ type connPool struct {
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 {
@@ -31,9 +32,25 @@ func newConnPool() *connPool {
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) {
p.mu.Lock()
defer p.mu.Unlock()
@@ -121,6 +138,7 @@ func (p *connPool) drop(port int) {
_ = 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
@@ -188,6 +206,7 @@ func (p *connPool) closeAll() {
p.pids = map[int]int{}
p.cancels = map[int]func(){}
p.dialogLogs = map[int]*browser.DialogLog{}
p.modes = map[int]string{}
}
// isConnErr reconoce errores de conexión CDP muerta para reintentar UNA vez.
+65 -15
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"math/rand"
"time"
"github.com/mark3labs/mcp-go/mcp"
@@ -29,9 +30,40 @@ func registerDomTools(s *server.MCPServer, d *deps) {
}
}
// settleDelay es la espera breve tras una acción mutante antes de re-percibir,
// dando tiempo a que el DOM se asiente (navegación, focus, repaint).
const settleDelay = 400 * time.Millisecond
// 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 ----
@@ -46,19 +78,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.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
mcp.WithNumber("ref", mcp.Required(), mcp.Description("#ref del elemento (backendDOMNodeId) leído del outline de page_perceive.")),
mcp.WithString("mode", mcp.Description("Velocidad: 'human' (default, Bézier+jitter anti-bot), 'fast' (movimiento reducido, scraping masivo), 'instant' (element.click() JS, sin eventos de ratón; también fallback si el elemento no tiene geometría).")),
mcp.WithString("mode", mcp.Description("Velocidad: 'auto' (default de sesión: movimiento de ratón reducido, rápido), 'human' (Bézier+jitter+pausas aleatorias anti-bot, para detección fuerte), 'instant' (element.click() JS, sin eventos de ratón; también fallback si el elemento no tiene geometría). Vacío = modo de sesión (browser_set_mode) o 'auto'.")),
)
}
func (d *deps) handleDomClickRef(_ context.Context, _ mcp.CallToolRequest, a domClickRefArgs) (*mcp.CallToolResult, error) {
port := portOr(a.Port)
mode := d.effectiveMode(port, a.Mode)
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 {
return mcp.NewToolResultError(err.Error()), nil
}
time.Sleep(settleDelay)
if dl := settleForMode(mode); dl > 0 {
time.Sleep(dl)
}
outline, _ := d.perceiveOutline(port, 8000)
return mcp.NewToolResultText("clicked ref " + fmt.Sprint(a.Ref) + "\n\n" + outline), nil
}
@@ -69,6 +104,7 @@ type domTypeRefArgs struct {
Port int `json:"port"`
Ref int `json:"ref"`
Text string `json:"text"`
Mode string `json:"mode"`
}
func domTypeRefTool() mcp.Tool {
@@ -77,6 +113,7 @@ func domTypeRefTool() mcp.Tool {
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
mcp.WithNumber("ref", mcp.Required(), mcp.Description("#ref del elemento (backendDOMNodeId) leído del outline de page_perceive.")),
mcp.WithString("text", mcp.Required(), mcp.Description("Texto a escribir en el elemento.")),
mcp.WithString("mode", mcp.Description("Velocidad: 'auto' (default de sesión, escribe en un solo evento Input.insertText — rápido) o 'human' (caracter a caracter con pausas aleatorias, anti-detección). Vacío = modo de sesión (browser_set_mode) o 'auto'.")),
)
}
@@ -85,14 +122,21 @@ func (d *deps) handleDomTypeRef(_ context.Context, _ mcp.CallToolRequest, a domT
return mcp.NewToolResultError("text is required"), nil
}
port := portOr(a.Port)
// TODO: preset de humanización por sesión (human/fast/instant)
mode := d.effectiveMode(port, a.Mode)
err := d.withConn(port, func(c *browser.CDPConn) error {
return browser.CdpTypeRef(c, a.Ref, a.Text)
// human => teclea caracter a caracter (eventos de tecla reales + ritmo
// irregular). auto/fast/instant => inserta todo en un solo round-trip.
if mode == "human" {
return browser.CdpTypeRef(c, a.Ref, a.Text)
}
return browser.CdpTypeRefFast(c, a.Ref, a.Text)
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
time.Sleep(settleDelay)
if dl := settleForMode(mode); dl > 0 {
time.Sleep(dl)
}
outline, _ := d.perceiveOutline(port, 8000)
return mcp.NewToolResultText("typed into ref " + fmt.Sprint(a.Ref) + "\n\n" + outline), nil
}
@@ -110,19 +154,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.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
mcp.WithNumber("ref", mcp.Required(), mcp.Description("#ref del elemento (backendDOMNodeId) leído del outline de page_perceive.")),
mcp.WithString("mode", mcp.Description("Velocidad: 'human' (default, Bézier+jitter), 'fast' (movimiento reducido), 'instant' (sin movimiento de ratón).")),
mcp.WithString("mode", mcp.Description("Velocidad: 'auto' (default de sesión: movimiento reducido, rápido), 'human' (Bézier+jitter+pausas aleatorias anti-bot), 'instant' (sin movimiento de ratón). Vacío = modo de sesión (browser_set_mode) o 'auto'.")),
)
}
func (d *deps) handleDomHoverRef(_ context.Context, _ mcp.CallToolRequest, a domHoverRefArgs) (*mcp.CallToolResult, error) {
port := portOr(a.Port)
mode := d.effectiveMode(port, a.Mode)
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 {
return mcp.NewToolResultError(err.Error()), nil
}
time.Sleep(settleDelay)
if dl := settleForMode(mode); dl > 0 {
time.Sleep(dl)
}
outline, _ := d.perceiveOutline(port, 8000)
return mcp.NewToolResultText("hovered ref " + fmt.Sprint(a.Ref) + "\n\n" + outline), nil
}
@@ -142,19 +189,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("x", mcp.Required(), mcp.Description("Coordenada X absoluta en CSS pixels del viewport.")),
mcp.WithNumber("y", mcp.Required(), mcp.Description("Coordenada Y absoluta en CSS pixels del viewport.")),
mcp.WithString("mode", mcp.Description("Velocidad: 'human' (default, Bézier+jitter anti-bot), 'fast' (movimiento reducido, scraping masivo), 'instant' (sin movimiento de ratón).")),
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(a.Mode))
return browser.CdpClickXYHuman(c, a.X, a.Y, browser.MouseProfileForMode(mode))
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
time.Sleep(settleDelay)
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
}
+20 -7
View File
@@ -324,16 +324,16 @@ func (d *deps) handleBrowserLaunchProfile(_ context.Context, _ mcp.CallToolReque
pid := cmd.Process.Pid
_ = cmd.Process.Release()
// Give Chromium a moment to come up. If it forwarded to an existing master the
// child exits fast; the launched pid is still informative.
time.Sleep(1 * time.Second)
// When cdp=true, opportunistically confirm the port responds (best-effort: a
// forwarded launch may not bind the port if the master had no CDP).
// 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 !cdpPortResponds(cdpPort) {
if !waitCDPPort(cdpPort, 5*time.Second) {
note = "cdp port not confirmed listening yet"
}
} else {
time.Sleep(300 * time.Millisecond)
}
out := map[string]any{
@@ -452,6 +452,19 @@ func processAlive(pid int) bool {
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 {
+30 -1
View File
@@ -12,13 +12,15 @@ import (
"fn-registry/functions/browser"
)
// registerSessionTools wires browser_launch (MUTA), browser_connect, browser_disconnect.
// registerSessionTools wires browser_launch (MUTA), browser_connect, browser_disconnect,
// browser_set_mode.
func registerSessionTools(s *server.MCPServer, d *deps) {
if !d.readOnly {
s.AddTool(launchTool(), mcp.NewTypedToolHandler(d.handleLaunch))
}
s.AddTool(connectTool(), mcp.NewTypedToolHandler(d.handleConnect))
s.AddTool(disconnectTool(), mcp.NewTypedToolHandler(d.handleDisconnect))
s.AddTool(setModeTool(), mcp.NewTypedToolHandler(d.handleSetMode))
}
// maxLaunchedChromes es el tope duro de instancias Chrome que el MCP puede tener
@@ -142,3 +144,30 @@ func (d *deps) handleDisconnect(_ context.Context, _ mcp.CallToolRequest, a disc
}
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
}