Compare commits
5 Commits
54fe1b7f17
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c5b81f711 | |||
| a48e262371 | |||
| fa1efe6fd5 | |||
| f0bfc3e300 | |||
| 1fae6c1df9 |
@@ -1,2 +1,6 @@
|
|||||||
/browser_mcp
|
/browser_mcp
|
||||||
*.log
|
*.log
|
||||||
|
# registry.db sólo existe en la raíz del repo (regla db_locations). Si un test o el
|
||||||
|
# binario lo crea aquí por un path relativo, es basura: ignorarlo evita trackearlo.
|
||||||
|
registry.db
|
||||||
|
operations.db*
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ MCP server (Go) that exposes the registry's CDP browser-control functions
|
|||||||
Chrome DevTools Protocol: navigate, read the DOM, click, manage cookies, evaluate
|
Chrome DevTools Protocol: navigate, read the DOM, click, manage cookies, evaluate
|
||||||
JavaScript, operate iframes, and persist/restore session state.
|
JavaScript, operate iframes, and persist/restore session state.
|
||||||
|
|
||||||
36 tools total, grouped by domain. See `app.md` for the full per-tool reference and the
|
45 tools total, grouped by domain. See `app.md` for the full per-tool reference and the
|
||||||
"Omitido en v1" section.
|
"Omitido en v1" section.
|
||||||
|
|
||||||
|
Includes per-profile Chromium lifecycle tools (`browser_list`, `browser_launch_profile`,
|
||||||
|
`browser_close`) that manage the user's profiled Chromium windows (e.g. "Personal", "Work"),
|
||||||
|
separate from the MCP's own isolated automation Chrome on port 9333.
|
||||||
|
|
||||||
## Security: isolated Chrome by default (port 9333)
|
## Security: isolated Chrome by default (port 9333)
|
||||||
|
|
||||||
**By default the MCP operates on its OWN isolated Chrome, NOT the user's daily browser.**
|
**By default the MCP operates on its OWN isolated Chrome, NOT the user's daily browser.**
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
name: browser_mcp
|
name: browser_mcp
|
||||||
lang: go
|
lang: go
|
||||||
domain: infra
|
domain: infra
|
||||||
version: 0.6.0
|
version: 0.8.0
|
||||||
description: "Servidor MCP que expone control total del navegador via CDP (42 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión, selección determinista de pestaña, lectura compacta texto/AX nativa + bucle percibir→actuar por #ref con auto-observe, percepción y lectura de texto dentro de iframes, click por coordenadas, y screenshot devuelto como image content que el LLM ve) reusando funciones del dominio browser del registry con un pool de conexiones CDP vivas. Por defecto opera sobre un Chrome aislado (puerto 9333) separado del navegador diario."
|
description: "Servidor MCP que expone control total del navegador via CDP (46 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión, selección determinista de pestaña, modo de velocidad de sesión (browser_set_mode: 'auto' rápido por defecto / 'human' sigiloso anti-detección), lectura compacta texto/AX nativa + bucle percibir→actuar por #ref con auto-observe, percepción y lectura de texto dentro de iframes, click por coordenadas, screenshot devuelto como image content que el LLM ve, y gestión del ciclo de vida de Chromium por perfil: listar masters en ejecución, lanzar un perfil concreto con o sin CDP, y cerrar limpio) reusando funciones del dominio browser del registry con un pool de conexiones CDP vivas. Por defecto opera sobre un Chrome aislado (puerto 9333) separado del navegador diario."
|
||||||
tags: [mcp, browser, cdp, automation, scraping]
|
tags: [mcp, browser, cdp, automation, scraping]
|
||||||
e2e_checks:
|
e2e_checks:
|
||||||
- id: build
|
- id: build
|
||||||
@@ -118,12 +118,33 @@ podría manipular pestañas ajenas del usuario (banca, correo). Para evitarlo:
|
|||||||
- Para adjuntarte deliberadamente al navegador diario, pasa `port: 9222` explícito en cada
|
- Para adjuntarte deliberadamente al navegador diario, pasa `port: 9222` explícito en cada
|
||||||
tool. Hazlo solo con cuidado.
|
tool. Hazlo solo con cuidado.
|
||||||
|
|
||||||
## Tools (42)
|
## Tools (46)
|
||||||
|
|
||||||
### Sesión (`tools_session.go`)
|
### Sesión (`tools_session.go`)
|
||||||
- `browser_launch` (MUTA) — lanza Chrome con CDP. args: port, headless, user_data_dir, url.
|
- `browser_launch` (MUTA) — lanza Chrome con CDP. args: port, headless, user_data_dir, url.
|
||||||
- `browser_connect` — abre/poolea la conexión CDP del puerto. args: port.
|
- `browser_connect` — abre/poolea la conexión CDP del puerto. args: port.
|
||||||
- `browser_disconnect` — cierra y descarta la conexión del puerto (no mata Chrome). args: port.
|
- `browser_disconnect` — cierra y descarta la conexión del puerto (no mata Chrome). args: port.
|
||||||
|
- `browser_set_mode` — fija el modo de velocidad de sesión del puerto: `auto` (default, rápido) o `human` (sigiloso anti-detección). args: port, mode. Cada tool de acción puede overridearlo con su arg `mode`.
|
||||||
|
|
||||||
|
### Ciclo de vida por perfil (`tools_lifecycle.go`)
|
||||||
|
Gestionan los Chromium del USUARIO por perfil (`Personal`, `Work`, ...), distintos del Chrome
|
||||||
|
de automatización aislado de `browser_launch`. Las instancias lanzadas aquí NO se registran en el
|
||||||
|
pool: son de uso humano y sobreviven a la muerte del MCP; se cierran explícitamente con
|
||||||
|
`browser_close`.
|
||||||
|
- `browser_list` — lista los procesos MASTER de Chromium en ejecución (con `--user-data-dir`,
|
||||||
|
SIN `--type=`). Para cada uno: pid, profile, user_data_dir, cdp_port, has_cdp. Devuelve JSON
|
||||||
|
array. Read-only. args: (ninguno).
|
||||||
|
- `browser_launch_profile` (MUTA) — lanza Chromium para un perfil concreto en la pantalla del
|
||||||
|
usuario, usando el binario REAL `/usr/lib/chromium/chromium` (salta el wrapper). Con `cdp=false`
|
||||||
|
(default) NO añade flags de remote-debugging — necesario para perfiles humanos (Google mantiene
|
||||||
|
la sesión; con CDP la trata como automatizada y la tira). Con `cdp=true` añade
|
||||||
|
`--remote-debugging-port` + `--remote-allow-origins=*`. Detecta DISPLAY/XAUTHORITY de la sesión
|
||||||
|
XFCE y lanza DESACOPLADO (setsid). Si un master ya posee el user_data_dir, Chromium reenvía la
|
||||||
|
apertura a él (`note` en el resultado). args: profile (requerido), user_data_dir
|
||||||
|
(default `~/.config/chromium-cdp`), url, cdp (default false), cdp_port (default 9222).
|
||||||
|
- `browser_close` (MUTA) — cierra un master limpio. Lo localiza por `profile`, `cdp_port` o `pid`.
|
||||||
|
Envía SIGTERM, espera hasta 10s, y SIGKILL como último recurso (indicado en `method`). Devuelve
|
||||||
|
{closed, pid, method}. args: uno de profile, cdp_port o pid.
|
||||||
|
|
||||||
### Navegación + tabs (`tools_nav.go`)
|
### Navegación + tabs (`tools_nav.go`)
|
||||||
- `tab_navigate` (MUTA) — `Page.navigate`. args: port, url.
|
- `tab_navigate` (MUTA) — `Page.navigate`. args: port, url.
|
||||||
@@ -241,11 +262,11 @@ Transporte HTTP (Streamable HTTP):
|
|||||||
### Flag `--read-only`
|
### Flag `--read-only`
|
||||||
|
|
||||||
Con `--read-only`, el servidor NO registra las tools mutantes (marcadas MUTA arriba):
|
Con `--read-only`, el servidor NO registra las tools mutantes (marcadas MUTA arriba):
|
||||||
solo expone las 19 tools de lectura/control (`browser_connect`, `browser_disconnect`, `tab_list`,
|
solo expone las 20 tools de lectura/control (`browser_connect`, `browser_disconnect`, `browser_list`,
|
||||||
`tab_activate`, `tab_select`, `page_wait_load`, `page_wait_idle`, `page_get_html`, `page_get_text`,
|
`tab_list`, `tab_activate`, `tab_select`, `page_wait_load`, `page_wait_idle`, `page_get_html`,
|
||||||
`page_perceive`, `page_screenshot`, `dom_find_by_text`, `dom_find_ref_by_text`, `dom_wait_element`,
|
`page_get_text`, `page_perceive`, `page_screenshot`, `dom_find_by_text`, `dom_find_ref_by_text`,
|
||||||
`cookie_get`, `frame_list`, `frame_get_html`, `frame_get_text`, `storage_save`). Útil para sesiones
|
`dom_wait_element`, `cookie_get`, `frame_list`, `frame_get_html`, `frame_get_text`, `storage_save`).
|
||||||
de inspección sin riesgo de modificar el estado del navegador.
|
Útil para sesiones de inspección sin riesgo de modificar el estado del navegador.
|
||||||
|
|
||||||
## Omitido en v1
|
## Omitido en v1
|
||||||
|
|
||||||
@@ -266,6 +287,33 @@ Funciones del dominio `browser` que NO se exponen como tools en esta versión, c
|
|||||||
|
|
||||||
## Capability growth log
|
## Capability growth log
|
||||||
|
|
||||||
|
- v0.8.0 (2026-06-13) — Aceleración del manejo del navegador via CDP + flag de velocidad de
|
||||||
|
sesión. (1) Nueva tool `browser_set_mode` (45 → 46 tools): fija el modo de velocidad por puerto
|
||||||
|
en el pool — `auto` (default del MCP, rápido) vs `human` (sigiloso anti-detección). El modo se
|
||||||
|
resuelve por acción con `effectiveMode`: arg `mode` de la tool > modo de sesión > `auto`. (2) Settle
|
||||||
|
adaptativo: el sleep ciego fijo de 400ms tras cada acción mutante (`dom_click_ref`/`dom_type_ref`/
|
||||||
|
`dom_hover_ref`/`dom_click_xy`) pasa a `settleForMode` — 60ms en `auto`, aleatorio 250-650ms en
|
||||||
|
`human` (ritmo no-máquina), 0 en `instant`. (3) `dom_type_ref` ahora tiene arg `mode`: en `auto`
|
||||||
|
usa `CdpTypeRefFast` (`Input.insertText`, un solo round-trip) y en `human` teclea carácter a
|
||||||
|
carácter (`CdpTypeRef`) con pausas aleatorias. (4) `browser_launch_profile` reemplaza el `sleep(1s)`
|
||||||
|
ciego por un poll del puerto CDP (`waitCDPPort`). Cambios en el dominio `browser` del registry que
|
||||||
|
aprovecha el MCP: `Accessibility.enable`/`Network.enable`/`Page.enable` cacheados por conexión
|
||||||
|
(`ensureAX`/`ensureNetwork`/`ensurePage` en `CDPConn`) — se eliminan round-trips redundantes en cada
|
||||||
|
percepción/espera; `cdp_wait_load` pasa de polling de `document.readyState` cada 200ms a esperar el
|
||||||
|
evento `Page.loadEventFired` (fast path si ya está `complete`); `sendCDP` adquiere timeout
|
||||||
|
(`cdpCmdTimeout` 30s) para no colgar el tool indefinidamente; nuevas `CdpInsertText` y
|
||||||
|
`CdpTypeRefFast` (camino rápido de escritura); el modo `auto` se añade al perfil de ratón
|
||||||
|
(`MouseProfileForMode`) como alias rápido de `fast`. Smoke contra Chrome 9333: percepción #2 con
|
||||||
|
enable cacheado 1.7ms (vs 3.7ms la #1), `wait_load` fast-path 245µs (vs ≥200ms del polling previo).
|
||||||
|
- v0.7.0 (2026-06-10) — Ciclo de vida de Chromium por perfil (`tools_lifecycle.go`). Tres tools
|
||||||
|
nuevas: `browser_list` (enumera los procesos master de Chromium leyendo `/proc/*/cmdline`,
|
||||||
|
filtrando por `--user-data-dir` presente y `--type=` ausente), `browser_launch_profile` (lanza un
|
||||||
|
perfil concreto con el binario REAL `/usr/lib/chromium/chromium` para saltar el wrapper, con/sin
|
||||||
|
CDP — sin CDP por defecto para que Google mantenga la sesión de perfiles humanos; detecta
|
||||||
|
DISPLAY/XAUTHORITY de la sesión XFCE y lanza desacoplado con setsid) y `browser_close` (localiza el
|
||||||
|
master por profile/cdp_port/pid, SIGTERM con espera de 10s, SIGKILL como último recurso). Las
|
||||||
|
instancias por perfil NO se registran en el pool: son de uso humano y sobreviven a la muerte del
|
||||||
|
MCP. 42 → 45 tools.
|
||||||
- v0.6.0 (2026-06-06) — Percepción visual y de iframes + perceive nativo. (1) `page_perceive` se
|
- v0.6.0 (2026-06-06) — Percepción visual y de iframes + perceive nativo. (1) `page_perceive` se
|
||||||
generó hasta ahora por subprocess `fn run cdp_perceive_outline` (Python); ahora es **nativo en Go**
|
generó hasta ahora por subprocess `fn run cdp_perceive_outline` (Python); ahora es **nativo en Go**
|
||||||
sobre la conexión CDP viva del pool (`cdp_get_ax_outline_go_browser`) — mata el subprocess, el venv
|
sobre la conexión CDP viva del pool (`cdp_get_ax_outline_go_browser`) — mata el subprocess, el venv
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"fn-registry/functions/browser"
|
"fn-registry/functions/browser"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "0.6.0"
|
const version = "0.7.0"
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
httpAddr string
|
httpAddr string
|
||||||
@@ -92,6 +92,7 @@ func main() {
|
|||||||
// registerTools wires every tool group. Mutating tools are skipped under --read-only.
|
// registerTools wires every tool group. Mutating tools are skipped under --read-only.
|
||||||
func registerTools(s *server.MCPServer, d *deps) {
|
func registerTools(s *server.MCPServer, d *deps) {
|
||||||
registerSessionTools(s, d)
|
registerSessionTools(s, d)
|
||||||
|
registerLifecycleTools(s, d)
|
||||||
registerNavTools(s, d)
|
registerNavTools(s, d)
|
||||||
registerReadTools(s, d)
|
registerReadTools(s, d)
|
||||||
registerDomTools(s, d)
|
registerDomTools(s, d)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type connPool struct {
|
|||||||
pids map[int]int // puerto -> PID del Chrome lanzado por el MCP (solo los SUYOS)
|
pids map[int]int // puerto -> PID del Chrome lanzado por el MCP (solo los SUYOS)
|
||||||
cancels map[int]func() // cancels de handlers persistentes (handle_dialog)
|
cancels map[int]func() // cancels de handlers persistentes (handle_dialog)
|
||||||
dialogLogs map[int]*browser.DialogLog // log de diálogos auto-respondidos por puerto
|
dialogLogs map[int]*browser.DialogLog // log de diálogos auto-respondidos por puerto
|
||||||
|
modes map[int]string // puerto -> modo de velocidad de sesión ("auto"|"human"|...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newConnPool() *connPool {
|
func newConnPool() *connPool {
|
||||||
@@ -31,9 +32,25 @@ func newConnPool() *connPool {
|
|||||||
pids: map[int]int{},
|
pids: map[int]int{},
|
||||||
cancels: map[int]func(){},
|
cancels: map[int]func(){},
|
||||||
dialogLogs: map[int]*browser.DialogLog{},
|
dialogLogs: map[int]*browser.DialogLog{},
|
||||||
|
modes: map[int]string{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setMode fija el modo de velocidad de sesión para un puerto (lo lee
|
||||||
|
// effectiveMode cuando una tool de acción no trae su propio arg `mode`).
|
||||||
|
func (p *connPool) setMode(port int, mode string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.modes[port] = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMode devuelve el modo de sesión del puerto ("" si no se fijó ninguno).
|
||||||
|
func (p *connPool) getMode(port int) string {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.modes[port]
|
||||||
|
}
|
||||||
|
|
||||||
func (p *connPool) get(port int) (*browser.CDPConn, error) {
|
func (p *connPool) get(port int) (*browser.CDPConn, error) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
@@ -121,6 +138,7 @@ func (p *connPool) drop(port int) {
|
|||||||
_ = browser.CdpClose(c, pid)
|
_ = browser.CdpClose(c, pid)
|
||||||
delete(p.conns, port)
|
delete(p.conns, port)
|
||||||
delete(p.pids, port)
|
delete(p.pids, port)
|
||||||
|
delete(p.modes, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
// connectTarget descarta la conexión actual del puerto y reconecta a un target
|
// connectTarget descarta la conexión actual del puerto y reconecta a un target
|
||||||
@@ -188,6 +206,7 @@ func (p *connPool) closeAll() {
|
|||||||
p.pids = map[int]int{}
|
p.pids = map[int]int{}
|
||||||
p.cancels = map[int]func(){}
|
p.cancels = map[int]func(){}
|
||||||
p.dialogLogs = map[int]*browser.DialogLog{}
|
p.dialogLogs = map[int]*browser.DialogLog{}
|
||||||
|
p.modes = map[int]string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isConnErr reconoce errores de conexión CDP muerta para reintentar UNA vez.
|
// isConnErr reconoce errores de conexión CDP muerta para reintentar UNA vez.
|
||||||
|
|||||||
+65
-15
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
@@ -29,9 +30,40 @@ func registerDomTools(s *server.MCPServer, d *deps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// settleDelay es la espera breve tras una acción mutante antes de re-percibir,
|
// defaultMode es el modo de velocidad cuando ni la llamada ni la sesión fijan uno.
|
||||||
// dando tiempo a que el DOM se asiente (navegación, focus, repaint).
|
// "auto" = rápido (movimiento de ratón mínimo, escritura en un solo evento, settle
|
||||||
const settleDelay = 400 * time.Millisecond
|
// 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 +78,22 @@ func domClickRefTool() mcp.Tool {
|
|||||||
mcp.WithDescription("Click sobre el elemento por su #ref del outline de page_perceive (backendDOMNodeId estable). Devuelve el outline actualizado tras la acción (auto-observe)."),
|
mcp.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 +104,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 +113,7 @@ func domTypeRefTool() mcp.Tool {
|
|||||||
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
|
mcp.WithNumber("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 +122,21 @@ func (d *deps) handleDomTypeRef(_ context.Context, _ mcp.CallToolRequest, a domT
|
|||||||
return mcp.NewToolResultError("text is required"), nil
|
return mcp.NewToolResultError("text is required"), nil
|
||||||
}
|
}
|
||||||
port := portOr(a.Port)
|
port := portOr(a.Port)
|
||||||
// TODO: preset de humanización por sesión (human/fast/instant)
|
mode := d.effectiveMode(port, a.Mode)
|
||||||
err := d.withConn(port, func(c *browser.CDPConn) error {
|
err := d.withConn(port, func(c *browser.CDPConn) error {
|
||||||
return browser.CdpTypeRef(c, a.Ref, a.Text)
|
// human => teclea caracter a caracter (eventos de tecla reales + ritmo
|
||||||
|
// irregular). auto/fast/instant => inserta todo en un solo round-trip.
|
||||||
|
if mode == "human" {
|
||||||
|
return browser.CdpTypeRef(c, a.Ref, a.Text)
|
||||||
|
}
|
||||||
|
return browser.CdpTypeRefFast(c, a.Ref, a.Text)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return mcp.NewToolResultError(err.Error()), nil
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
}
|
}
|
||||||
time.Sleep(settleDelay)
|
if dl := settleForMode(mode); dl > 0 {
|
||||||
|
time.Sleep(dl)
|
||||||
|
}
|
||||||
outline, _ := d.perceiveOutline(port, 8000)
|
outline, _ := d.perceiveOutline(port, 8000)
|
||||||
return mcp.NewToolResultText("typed into ref " + fmt.Sprint(a.Ref) + "\n\n" + outline), nil
|
return mcp.NewToolResultText("typed into ref " + fmt.Sprint(a.Ref) + "\n\n" + outline), nil
|
||||||
}
|
}
|
||||||
@@ -110,19 +154,22 @@ func domHoverRefTool() mcp.Tool {
|
|||||||
mcp.WithDescription("Hover sobre el elemento por su #ref del outline de page_perceive (backendDOMNodeId estable). Devuelve el outline actualizado tras la acción (auto-observe)."),
|
mcp.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 +189,22 @@ func domClickXYTool() mcp.Tool {
|
|||||||
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
|
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
|
||||||
mcp.WithNumber("x", mcp.Required(), mcp.Description("Coordenada X absoluta en CSS pixels del viewport.")),
|
mcp.WithNumber("x", mcp.Required(), mcp.Description("Coordenada X absoluta en CSS pixels del viewport.")),
|
||||||
mcp.WithNumber("y", mcp.Required(), mcp.Description("Coordenada Y absoluta en CSS pixels del viewport.")),
|
mcp.WithNumber("y", mcp.Required(), mcp.Description("Coordenada Y absoluta en CSS pixels del viewport.")),
|
||||||
mcp.WithString("mode", mcp.Description("Velocidad: 'human' (default, Bézier+jitter anti-bot), 'fast' (movimiento reducido, scraping masivo), 'instant' (sin movimiento de ratón).")),
|
mcp.WithString("mode", mcp.Description("Velocidad: 'auto' (default de sesión: movimiento reducido, rápido), 'human' (Bézier+jitter+pausas aleatorias anti-bot), 'instant' (sin movimiento de ratón). Vacío = modo de sesión (browser_set_mode) o 'auto'.")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *deps) handleDomClickXY(_ context.Context, _ mcp.CallToolRequest, a domClickXYArgs) (*mcp.CallToolResult, error) {
|
func (d *deps) handleDomClickXY(_ context.Context, _ mcp.CallToolRequest, a domClickXYArgs) (*mcp.CallToolResult, error) {
|
||||||
port := portOr(a.Port)
|
port := portOr(a.Port)
|
||||||
|
mode := d.effectiveMode(port, a.Mode)
|
||||||
err := d.withConn(port, func(c *browser.CDPConn) error {
|
err := d.withConn(port, func(c *browser.CDPConn) error {
|
||||||
return browser.CdpClickXYHuman(c, a.X, a.Y, browser.MouseProfileForMode(a.Mode))
|
return browser.CdpClickXYHuman(c, a.X, a.Y, browser.MouseProfileForMode(mode))
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return mcp.NewToolResultError(err.Error()), nil
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
}
|
}
|
||||||
time.Sleep(settleDelay)
|
if dl := settleForMode(mode); dl > 0 {
|
||||||
|
time.Sleep(dl)
|
||||||
|
}
|
||||||
outline, _ := d.perceiveOutline(port, 8000)
|
outline, _ := d.perceiveOutline(port, 8000)
|
||||||
return mcp.NewToolResultText(fmt.Sprintf("clicked at (%g, %g)\n\n%s", a.X, a.Y, outline)), nil
|
return mcp.NewToolResultText(fmt.Sprintf("clicked at (%g, %g)\n\n%s", a.X, a.Y, outline)), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,478 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerLifecycleTools wires the per-profile Chromium lifecycle tools:
|
||||||
|
// - browser_list (read) — enumerate running Chromium master processes.
|
||||||
|
// - browser_launch_profile (MUTA) — launch Chromium for a concrete profile, with/without CDP.
|
||||||
|
// - browser_close (MUTA) — terminate a master process (SIGTERM, then SIGKILL).
|
||||||
|
//
|
||||||
|
// These manage the USER's Chromium instances by profile (e.g. "Personal", "Work"),
|
||||||
|
// distinct from browser_launch which spins the MCP's own isolated automation Chrome.
|
||||||
|
// Because the launched instances are user-facing (not driven by the MCP), they are
|
||||||
|
// NOT registered in the connection pool: the pool's shutdown-kill is reserved for
|
||||||
|
// automation Chromes the MCP owns, so a user's "Personal" window survives the MCP
|
||||||
|
// dying. Cleanup is explicit via browser_close.
|
||||||
|
func registerLifecycleTools(s *server.MCPServer, d *deps) {
|
||||||
|
s.AddTool(browserListTool(), mcp.NewTypedToolHandler(d.handleBrowserList))
|
||||||
|
if !d.readOnly {
|
||||||
|
s.AddTool(browserLaunchProfileTool(), mcp.NewTypedToolHandler(d.handleBrowserLaunchProfile))
|
||||||
|
s.AddTool(browserCloseTool(), mcp.NewTypedToolHandler(d.handleBrowserClose))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// realChromiumBin is the REAL Chromium binary, bypassing the /usr/bin/chromium
|
||||||
|
// wrapper. The wrapper sources /etc/chromium.d/* and injects global flags
|
||||||
|
// (--user-data-dir=$HOME/.config/chromium-cdp, --remote-debugging-port=9222,
|
||||||
|
// --remote-allow-origins=*). Launching the wrapper would force CDP on every
|
||||||
|
// instance, which breaks Google's session-keeping for human profiles. The real
|
||||||
|
// binary sources none of that, so we control the flags exactly.
|
||||||
|
const realChromiumBin = "/usr/lib/chromium/chromium"
|
||||||
|
|
||||||
|
// ---- master process discovery ----
|
||||||
|
|
||||||
|
// chromiumMaster describes one running Chromium master process (the top process
|
||||||
|
// that owns a user-data-dir, NOT a zygote/gpu/renderer child which carries --type=).
|
||||||
|
type chromiumMaster struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Profile string `json:"profile"` // value of --profile-directory ("" if absent)
|
||||||
|
UserDataDir string `json:"user_data_dir"` // value of --user-data-dir
|
||||||
|
CDPPort string `json:"cdp_port"` // value of --remote-debugging-port ("" if none)
|
||||||
|
HasCDP bool `json:"has_cdp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// readProcCmdline reads /proc/<pid>/cmdline and splits it on NUL 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
|
||||||
|
}
|
||||||
|
raw := strings.Split(string(b), "\x00")
|
||||||
|
args := make([]string, 0, len(raw))
|
||||||
|
for _, a := range raw {
|
||||||
|
if a != "" {
|
||||||
|
args = append(args, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// flagValue returns the value of a "--name=value" flag from argv, plus whether it
|
||||||
|
// was present. Matches the exact "--name=" prefix; the first occurrence wins.
|
||||||
|
func flagValue(args []string, name string) (string, bool) {
|
||||||
|
prefix := "--" + name + "="
|
||||||
|
for _, a := range args {
|
||||||
|
if strings.HasPrefix(a, prefix) {
|
||||||
|
return strings.TrimPrefix(a, prefix), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasFlagPrefix reports whether any arg starts with the given prefix (e.g. "--type=").
|
||||||
|
func hasFlagPrefix(args []string, prefix string) bool {
|
||||||
|
for _, a := range args {
|
||||||
|
if strings.HasPrefix(a, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isChromiumExe reports whether argv[0] looks like a chromium/chrome executable.
|
||||||
|
func isChromiumExe(args []string) bool {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
base := strings.ToLower(filepath.Base(args[0]))
|
||||||
|
return strings.Contains(base, "chromium") || strings.Contains(base, "chrome")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseChromiumMaster builds a chromiumMaster from argv if (and only if) the process
|
||||||
|
// is a Chromium MASTER: argv[0] is a chromium/chrome binary, it carries
|
||||||
|
// --user-data-dir, and it does NOT carry --type= (which all child processes have:
|
||||||
|
// zygote, gpu-process, renderer, utility...). Returns ok=false otherwise.
|
||||||
|
func parseChromiumMaster(pid int, args []string) (chromiumMaster, bool) {
|
||||||
|
if !isChromiumExe(args) {
|
||||||
|
return chromiumMaster{}, false
|
||||||
|
}
|
||||||
|
udd, hasUDD := flagValue(args, "user-data-dir")
|
||||||
|
if !hasUDD {
|
||||||
|
return chromiumMaster{}, false
|
||||||
|
}
|
||||||
|
if hasFlagPrefix(args, "--type=") {
|
||||||
|
return chromiumMaster{}, false // child process, not the master
|
||||||
|
}
|
||||||
|
port, hasCDP := flagValue(args, "remote-debugging-port")
|
||||||
|
return chromiumMaster{
|
||||||
|
PID: pid,
|
||||||
|
Profile: firstNonEmpty(args, "profile-directory"),
|
||||||
|
UserDataDir: udd,
|
||||||
|
CDPPort: port,
|
||||||
|
HasCDP: hasCDP,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstNonEmpty returns the flag value or "" if absent.
|
||||||
|
func firstNonEmpty(args []string, name string) string {
|
||||||
|
v, _ := flagValue(args, name)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// listChromiumMasters walks /proc and returns every running Chromium master process,
|
||||||
|
// sorted by PID for stable output.
|
||||||
|
func listChromiumMasters() ([]chromiumMaster, error) {
|
||||||
|
entries, err := os.ReadDir("/proc")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read /proc: %w", err)
|
||||||
|
}
|
||||||
|
var masters []chromiumMaster
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue // not a PID dir
|
||||||
|
}
|
||||||
|
args := readProcCmdline(pid)
|
||||||
|
if m, ok := parseChromiumMaster(pid, args); ok {
|
||||||
|
masters = append(masters, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(masters, func(i, j int) bool { return masters[i].PID < masters[j].PID })
|
||||||
|
return masters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- X session env detection ----
|
||||||
|
|
||||||
|
// xSessionEnv returns DISPLAY and XAUTHORITY scraped from a live XFCE session
|
||||||
|
// process. A decoupled Chromium launched from the MCP (no inherited X env) needs
|
||||||
|
// these to open a window on the user's screen. Falls back to :0 + ~/.Xauthority.
|
||||||
|
func xSessionEnv() (display, xauthority string) {
|
||||||
|
display = ":0"
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
xauthority = filepath.Join(home, ".Xauthority")
|
||||||
|
}
|
||||||
|
for _, proc := range []string{"xfwm4", "xfce4-session", "xfdesktop"} {
|
||||||
|
out, err := exec.Command("pgrep", "-x", proc).Output()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, line := range strings.Fields(string(out)) {
|
||||||
|
pid, err := strconv.Atoi(line)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d, x, ok := readProcEnviron(pid)
|
||||||
|
if ok {
|
||||||
|
if d != "" {
|
||||||
|
display = d
|
||||||
|
}
|
||||||
|
if x != "" {
|
||||||
|
xauthority = x
|
||||||
|
}
|
||||||
|
return display, xauthority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return display, xauthority
|
||||||
|
}
|
||||||
|
|
||||||
|
// readProcEnviron reads DISPLAY and XAUTHORITY from /proc/<pid>/environ (NUL-separated).
|
||||||
|
// ok is true if the environ was readable.
|
||||||
|
func readProcEnviron(pid int) (display, xauthority string, ok bool) {
|
||||||
|
b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "environ"))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
for _, kv := range strings.Split(string(b), "\x00") {
|
||||||
|
if v, found := strings.CutPrefix(kv, "DISPLAY="); found {
|
||||||
|
display = v
|
||||||
|
} else if v, found := strings.CutPrefix(kv, "XAUTHORITY="); found {
|
||||||
|
xauthority = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return display, xauthority, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultProfileUserDataDir is the user's daily Chromium user-data-dir where the
|
||||||
|
// named profiles (Automation, Default, Personal, "Profile 1", osint_01) live.
|
||||||
|
func defaultProfileUserDataDir() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ".config/chromium-cdp"
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".config", "chromium-cdp")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- browser_list ----
|
||||||
|
|
||||||
|
type browserListArgs struct{}
|
||||||
|
|
||||||
|
func browserListTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("browser_list",
|
||||||
|
mcp.WithDescription("List the running Chromium MASTER processes (one per user-data-dir master, NOT zygote/gpu/renderer children). For each: pid, profile (--profile-directory value), user_data_dir, cdp_port (--remote-debugging-port value, empty if none), has_cdp. Returns a JSON array. Read-only."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handleBrowserList(_ context.Context, _ mcp.CallToolRequest, _ browserListArgs) (*mcp.CallToolResult, error) {
|
||||||
|
masters, err := listChromiumMasters()
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
if masters == nil {
|
||||||
|
masters = []chromiumMaster{}
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(masters, "", " ")
|
||||||
|
return mcp.NewToolResultText(string(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- browser_launch_profile (MUTA) ----
|
||||||
|
|
||||||
|
type launchProfileArgs struct {
|
||||||
|
Profile string `json:"profile"`
|
||||||
|
UserDataDir string `json:"user_data_dir"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
CDP bool `json:"cdp"`
|
||||||
|
CDPPort int `json:"cdp_port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func browserLaunchProfileTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("browser_launch_profile",
|
||||||
|
mcp.WithDescription("Launch Chromium for a CONCRETE profile (e.g. \"Personal\", \"Work\") on the user's screen. Uses the REAL chromium binary (/usr/lib/chromium/chromium), bypassing the /usr/bin/chromium wrapper, so flags are controlled exactly. With cdp=false (default) NO remote-debugging flags are added — REQUIRED for human profiles where Google must keep the session (CDP makes Google treat the browser as automated and drop the login). With cdp=true adds --remote-debugging-port=<cdp_port> and --remote-allow-origins=*. Detects DISPLAY/XAUTHORITY from the XFCE session and launches DECOUPLED (setsid). If a master already owns the user_data_dir, Chromium forwards the open to it (note in the result). Returns {pid, profile, cdp, cdp_port[, note]}."),
|
||||||
|
mcp.WithString("profile", mcp.Required(), mcp.Description("Profile directory name to launch (--profile-directory value), e.g. \"Personal\", \"Default\", \"Automation\".")),
|
||||||
|
mcp.WithString("user_data_dir", mcp.Description("Chromium user-data-dir holding the profiles. Default ~/.config/chromium-cdp.")),
|
||||||
|
mcp.WithString("url", mcp.Description("Optional URL to open.")),
|
||||||
|
mcp.WithBoolean("cdp", mcp.Description("Enable CDP remote debugging. Default false. Leave false for human profiles (Google session-keeping). true only for automation.")),
|
||||||
|
mcp.WithNumber("cdp_port", mcp.Description("CDP port when cdp=true. Default 9222.")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handleBrowserLaunchProfile(_ context.Context, _ mcp.CallToolRequest, a launchProfileArgs) (*mcp.CallToolResult, error) {
|
||||||
|
if a.Profile == "" {
|
||||||
|
return mcp.NewToolResultError("profile is required"), nil
|
||||||
|
}
|
||||||
|
userDataDir := a.UserDataDir
|
||||||
|
if userDataDir == "" {
|
||||||
|
userDataDir = defaultProfileUserDataDir()
|
||||||
|
}
|
||||||
|
cdpPort := a.CDPPort
|
||||||
|
if cdpPort == 0 {
|
||||||
|
cdpPort = 9222
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect whether a master already owns this user-data-dir. If so, Chromium will
|
||||||
|
// forward the open to that master (it can't run two masters on one dir).
|
||||||
|
note := ""
|
||||||
|
if masters, err := listChromiumMasters(); err == nil {
|
||||||
|
for _, m := range masters {
|
||||||
|
if m.UserDataDir == userDataDir {
|
||||||
|
note = "forwarded to existing master"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"--user-data-dir=" + userDataDir,
|
||||||
|
"--profile-directory=" + a.Profile,
|
||||||
|
}
|
||||||
|
if a.CDP {
|
||||||
|
args = append(args,
|
||||||
|
fmt.Sprintf("--remote-debugging-port=%d", cdpPort),
|
||||||
|
"--remote-allow-origins=*",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if a.URL != "" {
|
||||||
|
args = append(args, a.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
display, xauthority := xSessionEnv()
|
||||||
|
|
||||||
|
cmd := exec.Command(realChromiumBin, args...)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"DISPLAY="+display,
|
||||||
|
"XAUTHORITY="+xauthority,
|
||||||
|
)
|
||||||
|
// Decouple from the MCP: new session leader (setsid) so the child survives the
|
||||||
|
// launcher dying, and no inherited stdio (avoids the exit-144 / SIGPIPE death
|
||||||
|
// when the parent's pipes close). We Release the process: never reaped here.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return mcp.NewToolResultError(fmt.Sprintf("launch chromium: %v", err)), nil
|
||||||
|
}
|
||||||
|
pid := cmd.Process.Pid
|
||||||
|
_ = cmd.Process.Release()
|
||||||
|
|
||||||
|
// Give Chromium a moment to come up. With CDP we poll the port instead of a
|
||||||
|
// blind 1s sleep: we return as soon as it responds (best-effort: a forwarded
|
||||||
|
// launch may not bind the port if the master had no CDP). Without CDP there's
|
||||||
|
// no port to poll, so we give the window a short margin to appear / forward.
|
||||||
|
if a.CDP && note == "" {
|
||||||
|
if !waitCDPPort(cdpPort, 5*time.Second) {
|
||||||
|
note = "cdp port not confirmed listening yet"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]any{
|
||||||
|
"pid": pid,
|
||||||
|
"profile": a.Profile,
|
||||||
|
"cdp": a.CDP,
|
||||||
|
"cdp_port": cdpPort,
|
||||||
|
}
|
||||||
|
if note != "" {
|
||||||
|
out["note"] = note
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(out, "", " ")
|
||||||
|
return mcp.NewToolResultText(string(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- browser_close (MUTA) ----
|
||||||
|
|
||||||
|
type browserCloseArgs struct {
|
||||||
|
Profile string `json:"profile"`
|
||||||
|
CDPPort int `json:"cdp_port"`
|
||||||
|
PID int `json:"pid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func browserCloseTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("browser_close",
|
||||||
|
mcp.WithDescription("Cleanly close a running Chromium master. Identify it by one of: profile (--profile-directory), cdp_port (--remote-debugging-port), or pid. Sends SIGTERM, waits up to 10s for it to die, then SIGKILL as a last resort (flagged in the result). Returns {closed, pid, method}."),
|
||||||
|
mcp.WithString("profile", mcp.Description("Match the master by --profile-directory value.")),
|
||||||
|
mcp.WithNumber("cdp_port", mcp.Description("Match the master by --remote-debugging-port value.")),
|
||||||
|
mcp.WithNumber("pid", mcp.Description("Match the master by exact PID.")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handleBrowserClose(_ context.Context, _ mcp.CallToolRequest, a browserCloseArgs) (*mcp.CallToolResult, error) {
|
||||||
|
if a.Profile == "" && a.CDPPort == 0 && a.PID == 0 {
|
||||||
|
return mcp.NewToolResultError("one of profile, cdp_port or pid is required"), nil
|
||||||
|
}
|
||||||
|
masters, err := listChromiumMasters()
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
target, found := matchMaster(masters, a)
|
||||||
|
if !found {
|
||||||
|
return mcp.NewToolResultError("no running Chromium master matched the given criteria"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
proc, err := os.FindProcess(target.PID)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError(fmt.Sprintf("find process %d: %v", target.PID, err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
method := "SIGTERM"
|
||||||
|
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||||
|
return mcp.NewToolResultError(fmt.Sprintf("SIGTERM pid=%d: %v", target.PID, err)), nil
|
||||||
|
}
|
||||||
|
// Wait up to ~10s for the process to die (poll /proc liveness).
|
||||||
|
if !waitProcessGone(target.PID, 10*time.Second) {
|
||||||
|
method = "SIGKILL"
|
||||||
|
_ = proc.Signal(syscall.SIGKILL)
|
||||||
|
waitProcessGone(target.PID, 3*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]any{
|
||||||
|
"closed": true,
|
||||||
|
"pid": target.PID,
|
||||||
|
"method": method,
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(out, "", " ")
|
||||||
|
return mcp.NewToolResultText(string(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchMaster picks the master matching the close criteria. PID is most specific,
|
||||||
|
// then cdp_port, then profile (first match wins for the latter two).
|
||||||
|
func matchMaster(masters []chromiumMaster, a browserCloseArgs) (chromiumMaster, bool) {
|
||||||
|
if a.PID != 0 {
|
||||||
|
for _, m := range masters {
|
||||||
|
if m.PID == a.PID {
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chromiumMaster{}, false
|
||||||
|
}
|
||||||
|
if a.CDPPort != 0 {
|
||||||
|
want := strconv.Itoa(a.CDPPort)
|
||||||
|
for _, m := range masters {
|
||||||
|
if m.CDPPort == want {
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chromiumMaster{}, false
|
||||||
|
}
|
||||||
|
for _, m := range masters {
|
||||||
|
if m.Profile == a.Profile {
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chromiumMaster{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitProcessGone polls until the PID no longer exists in /proc or the timeout
|
||||||
|
// elapses. Returns true if the process is gone.
|
||||||
|
func waitProcessGone(pid int, timeout time.Duration) bool {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if !processAlive(pid) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return !processAlive(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// processAlive reports whether /proc/<pid> still exists.
|
||||||
|
func processAlive(pid int) bool {
|
||||||
|
_, err := os.Stat(filepath.Join("/proc", strconv.Itoa(pid)))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitCDPPort polls the CDP port until it accepts a TCP connection or the timeout
|
||||||
|
// elapses. Replaces a blind sleep: returns as soon as Chromium binds the port.
|
||||||
|
func waitCDPPort(port int, timeout time.Duration) bool {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if cdpPortResponds(port) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return cdpPortResponds(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cdpPortResponds reports whether something is listening on the CDP port on
|
||||||
|
// 127.0.0.1. Single TCP dial with a short timeout; best-effort confirmation only.
|
||||||
|
func cdpPortResponds(port int) bool {
|
||||||
|
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestParseChromiumMaster cubre la deteccion de master: solo procesos chromium con
|
||||||
|
// --user-data-dir y SIN --type= cuentan; el resto (wrapper sin udd, children con
|
||||||
|
// --type=, no-chromium) se descartan. Tambien valida que profile/cdp_port se
|
||||||
|
// extraen y que has_cdp refleja la presencia del flag.
|
||||||
|
func TestParseChromiumMaster(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantOK bool
|
||||||
|
wantProfile string
|
||||||
|
wantUDD string
|
||||||
|
wantPort string
|
||||||
|
wantHasCDP bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "master con CDP y profile",
|
||||||
|
args: []string{
|
||||||
|
"/usr/lib/chromium/chromium",
|
||||||
|
"--user-data-dir=/home/u/.config/chromium-cdp",
|
||||||
|
"--profile-directory=Personal",
|
||||||
|
"--remote-debugging-port=9222",
|
||||||
|
"--remote-allow-origins=*",
|
||||||
|
},
|
||||||
|
wantOK: true,
|
||||||
|
wantProfile: "Personal",
|
||||||
|
wantUDD: "/home/u/.config/chromium-cdp",
|
||||||
|
wantPort: "9222",
|
||||||
|
wantHasCDP: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "master humano sin CDP",
|
||||||
|
args: []string{
|
||||||
|
"/usr/lib/chromium/chromium",
|
||||||
|
"--user-data-dir=/home/u/.config/chromium-cdp",
|
||||||
|
"--profile-directory=Default",
|
||||||
|
},
|
||||||
|
wantOK: true,
|
||||||
|
wantProfile: "Default",
|
||||||
|
wantUDD: "/home/u/.config/chromium-cdp",
|
||||||
|
wantPort: "",
|
||||||
|
wantHasCDP: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "child renderer con --type= se descarta",
|
||||||
|
args: []string{
|
||||||
|
"/usr/lib/chromium/chromium",
|
||||||
|
"--type=renderer",
|
||||||
|
"--user-data-dir=/home/u/.config/chromium-cdp",
|
||||||
|
},
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "child gpu-process con --type= se descarta",
|
||||||
|
args: []string{
|
||||||
|
"/usr/lib/chromium/chromium",
|
||||||
|
"--type=gpu-process",
|
||||||
|
"--user-data-dir=/home/u/.config/chromium-cdp",
|
||||||
|
"--profile-directory=Personal",
|
||||||
|
},
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chromium sin --user-data-dir se descarta",
|
||||||
|
args: []string{"/usr/lib/chromium/chromium", "--profile-directory=Personal"},
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "proceso no-chromium se descarta",
|
||||||
|
args: []string{"/usr/bin/firefox", "--user-data-dir=/x"},
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "argv vacio se descarta",
|
||||||
|
args: nil,
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
m, ok := parseChromiumMaster(1234, tc.args)
|
||||||
|
if ok != tc.wantOK {
|
||||||
|
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.PID != 1234 {
|
||||||
|
t.Errorf("PID = %d, want 1234", m.PID)
|
||||||
|
}
|
||||||
|
if m.Profile != tc.wantProfile {
|
||||||
|
t.Errorf("Profile = %q, want %q", m.Profile, tc.wantProfile)
|
||||||
|
}
|
||||||
|
if m.UserDataDir != tc.wantUDD {
|
||||||
|
t.Errorf("UserDataDir = %q, want %q", m.UserDataDir, tc.wantUDD)
|
||||||
|
}
|
||||||
|
if m.CDPPort != tc.wantPort {
|
||||||
|
t.Errorf("CDPPort = %q, want %q", m.CDPPort, tc.wantPort)
|
||||||
|
}
|
||||||
|
if m.HasCDP != tc.wantHasCDP {
|
||||||
|
t.Errorf("HasCDP = %v, want %v", m.HasCDP, tc.wantHasCDP)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFlagValue valida el parseo exacto de "--name=value".
|
||||||
|
func TestFlagValue(t *testing.T) {
|
||||||
|
args := []string{"--user-data-dir=/x/y", "--profile-directory=Work", "--flag-without-value"}
|
||||||
|
if v, ok := flagValue(args, "user-data-dir"); !ok || v != "/x/y" {
|
||||||
|
t.Errorf("user-data-dir = (%q,%v), want (/x/y,true)", v, ok)
|
||||||
|
}
|
||||||
|
if v, ok := flagValue(args, "profile-directory"); !ok || v != "Work" {
|
||||||
|
t.Errorf("profile-directory = (%q,%v), want (Work,true)", v, ok)
|
||||||
|
}
|
||||||
|
if _, ok := flagValue(args, "remote-debugging-port"); ok {
|
||||||
|
t.Errorf("remote-debugging-port should be absent")
|
||||||
|
}
|
||||||
|
// Prefijo no debe hacer match parcial: "user-data" != "user-data-dir".
|
||||||
|
if _, ok := flagValue(args, "user-data"); ok {
|
||||||
|
t.Errorf("partial prefix user-data should NOT match user-data-dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMatchMaster valida la prioridad pid > cdp_port > profile y el no-match.
|
||||||
|
func TestMatchMaster(t *testing.T) {
|
||||||
|
masters := []chromiumMaster{
|
||||||
|
{PID: 100, Profile: "Personal", CDPPort: ""},
|
||||||
|
{PID: 200, Profile: "Work", CDPPort: "9222"},
|
||||||
|
{PID: 300, Profile: "Personal", CDPPort: "9333"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if m, ok := matchMaster(masters, browserCloseArgs{PID: 200}); !ok || m.PID != 200 {
|
||||||
|
t.Errorf("by pid: got (%d,%v), want (200,true)", m.PID, ok)
|
||||||
|
}
|
||||||
|
if m, ok := matchMaster(masters, browserCloseArgs{CDPPort: 9333}); !ok || m.PID != 300 {
|
||||||
|
t.Errorf("by cdp_port: got (%d,%v), want (300,true)", m.PID, ok)
|
||||||
|
}
|
||||||
|
// profile "Personal" tiene dos: gana el primero (PID 100).
|
||||||
|
if m, ok := matchMaster(masters, browserCloseArgs{Profile: "Personal"}); !ok || m.PID != 100 {
|
||||||
|
t.Errorf("by profile: got (%d,%v), want (100,true)", m.PID, ok)
|
||||||
|
}
|
||||||
|
if _, ok := matchMaster(masters, browserCloseArgs{PID: 999}); ok {
|
||||||
|
t.Errorf("unknown pid should not match")
|
||||||
|
}
|
||||||
|
if _, ok := matchMaster(masters, browserCloseArgs{Profile: "Nope"}); ok {
|
||||||
|
t.Errorf("unknown profile should not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
+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