diff --git a/.gitignore b/.gitignore index 6eab868..60a3f83 100644 --- a/.gitignore +++ b/.gitignore @@ -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* diff --git a/app.md b/app.md index 784f6e6..680924c 100644 --- a/app.md +++ b/app.md @@ -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 diff --git a/pool.go b/pool.go index 79b53ac..2b0fa23 100644 --- a/pool.go +++ b/pool.go @@ -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. diff --git a/tools_dom.go b/tools_dom.go index 03666e2..970dcea 100644 --- a/tools_dom.go +++ b/tools_dom.go @@ -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 } diff --git a/tools_lifecycle.go b/tools_lifecycle.go index 3634222..30e8202 100644 --- a/tools_lifecycle.go +++ b/tools_lifecycle.go @@ -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 { diff --git a/tools_session.go b/tools_session.go index 81c1773..f947334 100644 --- a/tools_session.go +++ b/tools_session.go @@ -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 +}