Compare commits

...

20 Commits

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

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

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

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

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

Total tools: 46 -> 50.

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

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

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

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

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

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

Doctrina invertida respecto a la anterior 'humanizado siempre': ahora rápido por defecto, sigiloso bajo demanda.
2026-06-13 14:27:56 +02:00
12 changed files with 662 additions and 35 deletions
+4
View File
@@ -1,2 +1,6 @@
/browser_mcp /browser_mcp
*.log *.log
# registry.db sólo existe en la raíz del repo (regla db_locations). Si un test o el
# binario lo crea aquí por un path relativo, es basura: ignorarlo evita trackearlo.
registry.db
operations.db*
+30 -4
View File
@@ -2,8 +2,8 @@
name: browser_mcp name: browser_mcp
lang: go lang: go
domain: infra domain: infra
version: 0.7.0 version: 0.8.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." 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,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 - Para adjuntarte deliberadamente al navegador diario, pasa `port: 9222` explícito en cada
tool. Hazlo solo con cuidado. tool. Hazlo solo con cuidado.
## Tools (45) ## 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`) ### Ciclo de vida por perfil (`tools_lifecycle.go`)
Gestionan los Chromium del USUARIO por perfil (`Personal`, `Work`, ...), distintos del Chrome Gestionan los Chromium del USUARIO por perfil (`Personal`, `Work`, ...), distintos del Chrome
@@ -286,6 +294,24 @@ 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 - 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`, 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 filtrando por `--user-data-dir` presente y `--type=` ausente), `browser_launch_profile` (lanza un
Executable
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Compila browser_mcp inyectando la versión declarada en app.md como única fuente
# de verdad. Evita el drift entre la constante del binario y app.md (bug 16/06/2026:
# serverInfo reportaba 0.7.0 mientras app.md ya iba por 0.8.0).
#
# Uso: ./build.sh
set -euo pipefail
cd "$(dirname "$0")"
version="$(grep -m1 '^version:' app.md | awk '{print $2}')"
if [ -z "${version}" ]; then
echo "build.sh: no pude leer 'version:' de app.md" >&2
exit 1
fi
CGO_ENABLED=0 go build -ldflags "-X main.version=${version}" -o browser_mcp .
echo "built browser_mcp version=${version}"
+5 -1
View File
@@ -15,7 +15,11 @@ import (
"fn-registry/functions/browser" "fn-registry/functions/browser"
) )
const version = "0.7.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
+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) 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.
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Instala los git hooks versionados de este repo en .git/hooks.
set -euo pipefail
cd "$(dirname "$0")/.."
ln -sf ../../scripts/pre-commit .git/hooks/pre-commit
echo "instalado .git/hooks/pre-commit -> scripts/pre-commit"
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Anti-stale binary guard. El .mcp.json ejecuta el binario ./browser_mcp; si se
# commitea un cambio en los .go sin recompilar, la sesión sirve código viejo
# (bug 16/06/2026). Este hook recompila en cada commit. Instálalo con
# scripts/install-hooks.sh.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
if ! ./build.sh >/tmp/browser_mcp_build.log 2>&1; then
echo "pre-commit: build.sh falló — commit abortado. Log:" >&2
cat /tmp/browser_mcp_build.log >&2
exit 1
fi
+275 -14
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"math/rand"
"time" "time"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
@@ -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 {
// 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.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
} }
+107 -14
View File
@@ -16,6 +16,8 @@ import (
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
"fn-registry/functions/browser"
) )
// registerLifecycleTools wires the per-profile Chromium lifecycle tools: // registerLifecycleTools wires the per-profile Chromium lifecycle tools:
@@ -55,16 +57,37 @@ type chromiumMaster struct {
UserDataDir string `json:"user_data_dir"` // value of --user-data-dir UserDataDir string `json:"user_data_dir"` // value of --user-data-dir
CDPPort string `json:"cdp_port"` // value of --remote-debugging-port ("" if none) CDPPort string `json:"cdp_port"` // value of --remote-debugging-port ("" if none)
HasCDP bool `json:"has_cdp"` 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
} }
// readProcCmdline reads /proc/<pid>/cmdline and splits it on NUL into argv. // parseCmdline turns the raw bytes of /proc/<pid>/cmdline into argv.
// Returns nil if the process is gone or unreadable. //
func readProcCmdline(pid int) []string { // Canonically the kernel separates arguments with NUL bytes. But Chromium (and
b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline")) // other programs that rewrite their process title in place) collapse the argv
if err != nil || len(b) == 0 { // 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 return nil
} }
raw := strings.Split(string(b), "\x00") var raw []string
if strings.Contains(s, "\x00") {
raw = strings.Split(s, "\x00")
} else {
raw = strings.Fields(s)
}
args := make([]string, 0, len(raw)) args := make([]string, 0, len(raw))
for _, a := range raw { for _, a := range raw {
if a != "" { if a != "" {
@@ -74,6 +97,16 @@ func readProcCmdline(pid int) []string {
return args 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 // 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. // was present. Matches the exact "--name=" prefix; the first occurrence wins.
func flagValue(args []string, name string) (string, bool) { func flagValue(args []string, name string) (string, bool) {
@@ -127,9 +160,23 @@ func parseChromiumMaster(pid int, args []string) (chromiumMaster, bool) {
UserDataDir: udd, UserDataDir: udd,
CDPPort: port, CDPPort: port,
HasCDP: hasCDP, HasCDP: hasCDP,
Headless: isHeadless(args),
}, true }, 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. // firstNonEmpty returns the flag value or "" if absent.
func firstNonEmpty(args []string, name string) string { func firstNonEmpty(args []string, name string) string {
v, _ := flagValue(args, name) v, _ := flagValue(args, name)
@@ -229,7 +276,7 @@ type browserListArgs struct{}
func browserListTool() mcp.Tool { func browserListTool() mcp.Tool {
return mcp.NewTool("browser_list", 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. Returns a JSON array. Read-only."), 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."),
) )
} }
@@ -241,10 +288,43 @@ func (d *deps) handleBrowserList(_ context.Context, _ mcp.CallToolRequest, _ bro
if masters == nil { if masters == nil {
masters = []chromiumMaster{} 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, "", " ") b, _ := json.MarshalIndent(masters, "", " ")
return mcp.NewToolResultText(string(b)), nil 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) ---- // ---- browser_launch_profile (MUTA) ----
type launchProfileArgs struct { type launchProfileArgs struct {
@@ -324,16 +404,16 @@ func (d *deps) handleBrowserLaunchProfile(_ context.Context, _ mcp.CallToolReque
pid := cmd.Process.Pid pid := cmd.Process.Pid
_ = cmd.Process.Release() _ = cmd.Process.Release()
// Give Chromium a moment to come up. If it forwarded to an existing master the // Give Chromium a moment to come up. With CDP we poll the port instead of a
// child exits fast; the launched pid is still informative. // blind 1s sleep: we return as soon as it responds (best-effort: a forwarded
time.Sleep(1 * time.Second) // 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.
// When cdp=true, opportunistically confirm the port responds (best-effort: a
// forwarded launch may not bind the port if the master had no CDP).
if a.CDP && note == "" { if a.CDP && note == "" {
if !cdpPortResponds(cdpPort) { if !waitCDPPort(cdpPort, 5*time.Second) {
note = "cdp port not confirmed listening yet" note = "cdp port not confirmed listening yet"
} }
} else {
time.Sleep(300 * time.Millisecond)
} }
out := map[string]any{ out := map[string]any{
@@ -452,6 +532,19 @@ func processAlive(pid int) bool {
return err == nil 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 // 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. // 127.0.0.1. Single TCP dial with a short timeout; best-effort confirmation only.
func cdpPortResponds(port int) bool { func cdpPortResponds(port int) bool {
+73
View File
@@ -150,3 +150,76 @@ func TestMatchMaster(t *testing.T) {
t.Errorf("unknown profile should not match") 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)
}
}
+83
View File
@@ -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
View File
@@ -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
}