Compare commits
20 Commits
f0bfc3e300
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a681c79d96 | |||
| 3b68c02b25 | |||
| d687a501ba | |||
| 70ab1a4d30 | |||
| 9e9c690f06 | |||
| 8e421a3500 | |||
| 3ce9b12eab | |||
| cb587a7005 | |||
| 33358bca6c | |||
| 82993179dc | |||
| 6b7f71c39f | |||
| 15949bf4ed | |||
| 5706c84a15 | |||
| f2587d6fee | |||
| 91973ed6f9 | |||
| c56004da5c | |||
| c2470f4f67 | |||
| 1c5b81f711 | |||
| a48e262371 | |||
| fa1efe6fd5 |
@@ -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*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Compila browser_mcp inyectando la versión declarada en app.md como única fuente
|
||||||
|
# de verdad. Evita el drift entre la constante del binario y app.md (bug 16/06/2026:
|
||||||
|
# serverInfo reportaba 0.7.0 mientras app.md ya iba por 0.8.0).
|
||||||
|
#
|
||||||
|
# Uso: ./build.sh
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
version="$(grep -m1 '^version:' app.md | awk '{print $2}')"
|
||||||
|
if [ -z "${version}" ]; then
|
||||||
|
echo "build.sh: no pude leer 'version:' de app.md" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CGO_ENABLED=0 go build -ldflags "-X main.version=${version}" -o browser_mcp .
|
||||||
|
echo "built browser_mcp version=${version}"
|
||||||
@@ -15,7 +15,11 @@ import (
|
|||||||
"fn-registry/functions/browser"
|
"fn-registry/functions/browser"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "0.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
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type connPool struct {
|
|||||||
pids map[int]int // puerto -> PID del Chrome lanzado por el MCP (solo los SUYOS)
|
pids map[int]int // puerto -> PID del Chrome lanzado por el MCP (solo los SUYOS)
|
||||||
cancels map[int]func() // cancels de handlers persistentes (handle_dialog)
|
cancels map[int]func() // cancels de handlers persistentes (handle_dialog)
|
||||||
dialogLogs map[int]*browser.DialogLog // log de diálogos auto-respondidos por puerto
|
dialogLogs map[int]*browser.DialogLog // log de diálogos auto-respondidos por puerto
|
||||||
|
modes map[int]string // puerto -> modo de velocidad de sesión ("auto"|"human"|...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newConnPool() *connPool {
|
func newConnPool() *connPool {
|
||||||
@@ -31,9 +32,25 @@ func newConnPool() *connPool {
|
|||||||
pids: map[int]int{},
|
pids: map[int]int{},
|
||||||
cancels: map[int]func(){},
|
cancels: map[int]func(){},
|
||||||
dialogLogs: map[int]*browser.DialogLog{},
|
dialogLogs: map[int]*browser.DialogLog{},
|
||||||
|
modes: map[int]string{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setMode fija el modo de velocidad de sesión para un puerto (lo lee
|
||||||
|
// effectiveMode cuando una tool de acción no trae su propio arg `mode`).
|
||||||
|
func (p *connPool) setMode(port int, mode string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.modes[port] = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMode devuelve el modo de sesión del puerto ("" si no se fijó ninguno).
|
||||||
|
func (p *connPool) getMode(port int) string {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.modes[port]
|
||||||
|
}
|
||||||
|
|
||||||
func (p *connPool) get(port int) (*browser.CDPConn, error) {
|
func (p *connPool) get(port int) (*browser.CDPConn, error) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
@@ -121,6 +138,7 @@ func (p *connPool) drop(port int) {
|
|||||||
_ = browser.CdpClose(c, pid)
|
_ = browser.CdpClose(c, pid)
|
||||||
delete(p.conns, port)
|
delete(p.conns, port)
|
||||||
delete(p.pids, port)
|
delete(p.pids, port)
|
||||||
|
delete(p.modes, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
// connectTarget descarta la conexión actual del puerto y reconecta a un target
|
// connectTarget descarta la conexión actual del puerto y reconecta a un target
|
||||||
@@ -188,6 +206,7 @@ func (p *connPool) closeAll() {
|
|||||||
p.pids = map[int]int{}
|
p.pids = map[int]int{}
|
||||||
p.cancels = map[int]func(){}
|
p.cancels = map[int]func(){}
|
||||||
p.dialogLogs = map[int]*browser.DialogLog{}
|
p.dialogLogs = map[int]*browser.DialogLog{}
|
||||||
|
p.modes = map[int]string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isConnErr reconoce errores de conexión CDP muerta para reintentar UNA vez.
|
// isConnErr reconoce errores de conexión CDP muerta para reintentar UNA vez.
|
||||||
|
|||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Instala los git hooks versionados de este repo en .git/hooks.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
ln -sf ../../scripts/pre-commit .git/hooks/pre-commit
|
||||||
|
echo "instalado .git/hooks/pre-commit -> scripts/pre-commit"
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Anti-stale binary guard. El .mcp.json ejecuta el binario ./browser_mcp; si se
|
||||||
|
# commitea un cambio en los .go sin recompilar, la sesión sirve código viejo
|
||||||
|
# (bug 16/06/2026). Este hook recompila en cada commit. Instálalo con
|
||||||
|
# scripts/install-hooks.sh.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
if ! ./build.sh >/tmp/browser_mcp_build.log 2>&1; then
|
||||||
|
echo "pre-commit: build.sh falló — commit abortado. Log:" >&2
|
||||||
|
cat /tmp/browser_mcp_build.log >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+275
-14
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
@@ -20,12 +22,93 @@ func registerReadTools(s *server.MCPServer, d *deps) {
|
|||||||
s.AddTool(pageGetTextTool(), mcp.NewTypedToolHandler(d.handlePageGetText))
|
s.AddTool(pageGetTextTool(), mcp.NewTypedToolHandler(d.handlePageGetText))
|
||||||
s.AddTool(pagePerceiveTool(), mcp.NewTypedToolHandler(d.handlePagePerceive))
|
s.AddTool(pagePerceiveTool(), mcp.NewTypedToolHandler(d.handlePagePerceive))
|
||||||
s.AddTool(pageScreenshotTool(), mcp.NewTypedToolHandler(d.handlePageScreenshot))
|
s.AddTool(pageScreenshotTool(), mcp.NewTypedToolHandler(d.handlePageScreenshot))
|
||||||
|
s.AddTool(pageCollectConsoleTool(), mcp.NewTypedToolHandler(d.handlePageCollectConsole))
|
||||||
|
s.AddTool(pagePDFTool(), mcp.NewTypedToolHandler(d.handlePagePDF))
|
||||||
|
|
||||||
if !d.readOnly {
|
if !d.readOnly {
|
||||||
s.AddTool(pageEvalJSTool(), mcp.NewTypedToolHandler(d.handlePageEvalJS))
|
s.AddTool(pageEvalJSTool(), mcp.NewTypedToolHandler(d.handlePageEvalJS))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- page_collect_console ----
|
||||||
|
|
||||||
|
type pageCollectConsoleArgs struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
DurationMs int `json:"duration_ms"`
|
||||||
|
MaxEntries int `json:"max_entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageCollectConsoleTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("page_collect_console",
|
||||||
|
mcp.WithDescription("Capture the page's console output (console.log/info/warn/error), uncaught JS exceptions and browser log entries during a time window, and return them as JSON. It is a SNAPSHOT: it records only what happens during duration_ms AFTER the call starts (past backlog is discarded) — so trigger the action you want to observe (reload, click) right before or during the window. Capped at max_entries (default 200) to avoid flooding on verbose pages. Use this to debug why a page misbehaves without flying blind."),
|
||||||
|
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
|
||||||
|
mcp.WithNumber("duration_ms", mcp.Description("Capture window in milliseconds. Default 1500.")),
|
||||||
|
mcp.WithNumber("max_entries", mcp.Description("Max entries returned before truncating. Default 200.")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handlePageCollectConsole(_ context.Context, _ mcp.CallToolRequest, a pageCollectConsoleArgs) (*mcp.CallToolResult, error) {
|
||||||
|
var entries []browser.ConsoleEntry
|
||||||
|
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||||
|
var e error
|
||||||
|
entries, e = browser.CdpCollectConsole(c, a.DurationMs, a.MaxEntries)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []browser.ConsoleEntry{}
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(entries, "", " ")
|
||||||
|
return mcp.NewToolResultText(truncate(string(b), htmlMax)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- page_pdf ----
|
||||||
|
|
||||||
|
type pagePDFArgs struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Landscape bool `json:"landscape"`
|
||||||
|
PrintBackground bool `json:"print_background"`
|
||||||
|
Scale float64 `json:"scale"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func pagePDFTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("page_pdf",
|
||||||
|
mcp.WithDescription("Render the current page to a PDF (Page.printToPDF) and write it to a local file path. Use for archiving an article/invoice/report exactly as laid out, when a screenshot is not enough (multi-page, selectable text)."),
|
||||||
|
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
|
||||||
|
mcp.WithString("path", mcp.Required(), mcp.Description("Output .pdf file path.")),
|
||||||
|
mcp.WithBoolean("landscape", mcp.Description("Landscape orientation. Default false (portrait).")),
|
||||||
|
mcp.WithBoolean("print_background", mcp.Description("Include background graphics/colors. Default false.")),
|
||||||
|
mcp.WithNumber("scale", mcp.Description("Render scale. Default 1.0.")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handlePagePDF(_ context.Context, _ mcp.CallToolRequest, a pagePDFArgs) (*mcp.CallToolResult, error) {
|
||||||
|
if a.Path == "" {
|
||||||
|
return mcp.NewToolResultError("path is required"), nil
|
||||||
|
}
|
||||||
|
opts := browser.CdpPrintPDFOpts{
|
||||||
|
Landscape: a.Landscape,
|
||||||
|
PrintBackground: a.PrintBackground,
|
||||||
|
Scale: a.Scale,
|
||||||
|
}
|
||||||
|
var data []byte
|
||||||
|
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||||
|
var e error
|
||||||
|
data, e = browser.CdpPrintPDF(c, opts)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
if e := os.WriteFile(a.Path, data, 0o644); e != nil {
|
||||||
|
return mcp.NewToolResultError("saving pdf to " + a.Path + ": " + e.Error()), nil
|
||||||
|
}
|
||||||
|
return mcp.NewToolResultText(fmt.Sprintf("pdf saved to %s (%d bytes)", a.Path, len(data))), nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---- page_get_text ----
|
// ---- page_get_text ----
|
||||||
|
|
||||||
type pageGetTextArgs struct {
|
type pageGetTextArgs struct {
|
||||||
|
|||||||
+30
-1
@@ -12,13 +12,15 @@ import (
|
|||||||
"fn-registry/functions/browser"
|
"fn-registry/functions/browser"
|
||||||
)
|
)
|
||||||
|
|
||||||
// registerSessionTools wires browser_launch (MUTA), browser_connect, browser_disconnect.
|
// registerSessionTools wires browser_launch (MUTA), browser_connect, browser_disconnect,
|
||||||
|
// browser_set_mode.
|
||||||
func registerSessionTools(s *server.MCPServer, d *deps) {
|
func registerSessionTools(s *server.MCPServer, d *deps) {
|
||||||
if !d.readOnly {
|
if !d.readOnly {
|
||||||
s.AddTool(launchTool(), mcp.NewTypedToolHandler(d.handleLaunch))
|
s.AddTool(launchTool(), mcp.NewTypedToolHandler(d.handleLaunch))
|
||||||
}
|
}
|
||||||
s.AddTool(connectTool(), mcp.NewTypedToolHandler(d.handleConnect))
|
s.AddTool(connectTool(), mcp.NewTypedToolHandler(d.handleConnect))
|
||||||
s.AddTool(disconnectTool(), mcp.NewTypedToolHandler(d.handleDisconnect))
|
s.AddTool(disconnectTool(), mcp.NewTypedToolHandler(d.handleDisconnect))
|
||||||
|
s.AddTool(setModeTool(), mcp.NewTypedToolHandler(d.handleSetMode))
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxLaunchedChromes es el tope duro de instancias Chrome que el MCP puede tener
|
// maxLaunchedChromes es el tope duro de instancias Chrome que el MCP puede tener
|
||||||
@@ -142,3 +144,30 @@ func (d *deps) handleDisconnect(_ context.Context, _ mcp.CallToolRequest, a disc
|
|||||||
}
|
}
|
||||||
return mcp.NewToolResultText(msg), nil
|
return mcp.NewToolResultText(msg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- browser_set_mode ----
|
||||||
|
|
||||||
|
type setModeArgs struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func setModeTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("browser_set_mode",
|
||||||
|
mcp.WithDescription("Fija el modo de velocidad de SESIÓN de las acciones del navegador en este puerto. 'auto' (default del MCP) = rápido: movimiento de ratón mínimo, escritura en un solo evento (Input.insertText) y esperas breves — para scraping y automatización propia. 'human' = sigiloso anti-detección: trayectoria de ratón Bézier con jitter, escritura carácter a carácter y esperas ALEATORIAS entre acción y percepción — actívalo cuando un sitio aplique detección anti-bot fuerte. El arg 'mode' de cada tool de acción (dom_click_ref, dom_type_ref, dom_hover_ref, dom_click_xy) sigue ganando puntualmente sobre este ajuste de sesión."),
|
||||||
|
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
|
||||||
|
mcp.WithString("mode", mcp.Required(), mcp.Description("'auto' (rápido, default) o 'human' (sigiloso, anti-detección). También admite 'fast' (alias de auto) e 'instant' (sin movimiento de ratón) para casos puntuales.")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handleSetMode(_ context.Context, _ mcp.CallToolRequest, a setModeArgs) (*mcp.CallToolResult, error) {
|
||||||
|
switch a.Mode {
|
||||||
|
case "auto", "human", "fast", "instant":
|
||||||
|
// válido
|
||||||
|
default:
|
||||||
|
return mcp.NewToolResultError("mode debe ser 'auto' o 'human' (también 'fast'/'instant')"), nil
|
||||||
|
}
|
||||||
|
port := portOr(a.Port)
|
||||||
|
d.pool.setMode(port, a.Mode)
|
||||||
|
return mcp.NewToolResultText(fmt.Sprintf("session mode set to %q for port=%d (cada tool de acción puede overridearlo con su arg mode)", a.Mode, port)), nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user