fa1efe6fd5
Añade un flag de velocidad por sesión para que el manejo del navegador sea muy rápido por defecto, conservando un modo sigiloso para cuando haya detección anti-bot fuerte. - Nueva tool browser_set_mode (tools_session.go): fija el modo de la sesión por puerto en el pool. 'auto' (default del MCP) = rápido; 'human' = sigiloso anti-detección; también admite 'fast'/'instant'. Cada tool de acción puede overridearlo con su arg mode. - pool.go: estado de modo por puerto (modes map + setMode/getMode), limpiado en drop y closeAll. - tools_dom.go: effectiveMode resuelve el modo (arg de la llamada > modo de sesión > 'auto'). settleForMode reemplaza el sleep ciego fijo de 400ms tras cada acción mutante: 60ms en auto/fast, aleatorio 250-650ms en human (ritmo no-máquina), 0 en instant. dom_type_ref gana arg mode y rutea a CdpTypeRefFast (insertText, un round-trip) en auto o CdpTypeRef (carácter a carácter) en human. Descripciones del arg mode actualizadas (el default ya no es human). - tools_lifecycle.go: browser_launch_profile reemplaza el sleep(1s) ciego por un poll del puerto CDP (waitCDPPort). - .gitignore: ignora registry.db/operations.db (no deben vivir en la app; regla db_locations). Doctrina invertida respecto a la anterior 'humanizado siempre': ahora rápido por defecto, sigiloso bajo demanda.
174 lines
8.1 KiB
Go
174 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
|
|
"fn-registry/functions/browser"
|
|
)
|
|
|
|
// registerSessionTools wires browser_launch (MUTA), browser_connect, browser_disconnect,
|
|
// browser_set_mode.
|
|
func registerSessionTools(s *server.MCPServer, d *deps) {
|
|
if !d.readOnly {
|
|
s.AddTool(launchTool(), mcp.NewTypedToolHandler(d.handleLaunch))
|
|
}
|
|
s.AddTool(connectTool(), mcp.NewTypedToolHandler(d.handleConnect))
|
|
s.AddTool(disconnectTool(), mcp.NewTypedToolHandler(d.handleDisconnect))
|
|
s.AddTool(setModeTool(), mcp.NewTypedToolHandler(d.handleSetMode))
|
|
}
|
|
|
|
// maxLaunchedChromes es el tope duro de instancias Chrome que el MCP puede tener
|
|
// vivas a la vez (una por puerto). Cada chromium ocioso pesa ~789 MiB RSS; sin
|
|
// tope, llamadas repetidas a browser_launch saturan la RAM (apagón 06/06/2026).
|
|
// Al superarlo, browser_launch devuelve un error de tool en vez de lanzar más.
|
|
const maxLaunchedChromes = 4
|
|
|
|
// ---- browser_launch (MUTA) ----
|
|
|
|
type launchArgs struct {
|
|
Port int `json:"port"`
|
|
Headless bool `json:"headless"`
|
|
UserDataDir string `json:"user_data_dir"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
func launchTool() mcp.Tool {
|
|
return mcp.NewTool("browser_launch",
|
|
mcp.WithDescription("Launch a Chrome/Chromium instance with CDP remote debugging enabled. By default launches a Chrome ISOLATED from the user's daily browser: port 9333 (default) and a dedicated user_data_dir under the temp dir. This keeps the agent off the daily chromium on 9222 (banking, email). Returns the launched PID. Waits up to 15s for the CDP port to be ready."),
|
|
mcp.WithNumber("port", mcp.Description("CDP remote debugging port. Default 9333 (the MCP's isolated Chrome). Pass 9222 only to attach to the daily browser.")),
|
|
mcp.WithBoolean("headless", mcp.Description("Run headless (--headless=new). Default false.")),
|
|
mcp.WithString("user_data_dir", mcp.Description("Chrome profile directory. Empty = a dedicated isolated dir (<tmp>/browser_mcp_userdata), kept separate from the daily browser profile.")),
|
|
mcp.WithString("url", mcp.Description("Optional initial URL to open on launch.")),
|
|
)
|
|
}
|
|
|
|
func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchArgs) (*mcp.CallToolResult, error) {
|
|
port := portOr(a.Port)
|
|
|
|
// (1) Idempotente: si el MCP ya lanzó un Chrome en este puerto, reusarlo en
|
|
// vez de duplicar el proceso. (Si el proceso hubiera muerto, withConn/connect
|
|
// fallará y el usuario puede browser_disconnect + relanzar.)
|
|
if pid, ok := d.pool.getPID(port); ok && pid > 0 {
|
|
return mcp.NewToolResultText(fmt.Sprintf("reused pid=%d port=%d (already launched by this MCP)", pid, port)), nil
|
|
}
|
|
|
|
// (2) Tope duro de instancias propias. Cada chromium ocioso ~789 MiB RSS.
|
|
if d.pool.launchedCount() >= maxLaunchedChromes {
|
|
return mcp.NewToolResultError(fmt.Sprintf(
|
|
"instance cap reached: the MCP already launched %d Chrome instances (max %d); browser_disconnect one before launching another",
|
|
d.pool.launchedCount(), maxLaunchedChromes)), nil
|
|
}
|
|
|
|
// SECURITY (P0.3): default to an isolated user-data-dir so the MCP never
|
|
// reuses the user's daily browser profile. Created on demand.
|
|
userDataDir := a.UserDataDir
|
|
if userDataDir == "" {
|
|
userDataDir = filepath.Join(os.TempDir(), "browser_mcp_userdata")
|
|
_ = os.MkdirAll(userDataDir, 0o755)
|
|
}
|
|
opts := browser.ChromeLaunchOpts{
|
|
Port: port,
|
|
Headless: a.Headless,
|
|
UserDataDir: userDataDir,
|
|
// (3) Anti-duplicado: si ya hay un Chrome vivo en el puerto (incluido el
|
|
// navegador diario externo en 9222), ChromeLaunch NO lanza otro y devuelve
|
|
// pid 0 — nos adjuntamos al existente sin registrarlo como nuestro.
|
|
ReuseExisting: true,
|
|
}
|
|
if a.URL != "" {
|
|
opts.ExtraArgs = append(opts.ExtraArgs, a.URL)
|
|
}
|
|
pid, err := browser.ChromeLaunch(opts)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
if pid == 0 {
|
|
// Había un Chrome externo en el puerto: lo reusamos pero NO lo registramos
|
|
// (no es nuestro → browser_disconnect no debe matarlo).
|
|
return mcp.NewToolResultText(fmt.Sprintf("reused existing chrome on port=%d (external, not killed by the MCP)", port)), nil
|
|
}
|
|
// (4) Registrar el PID: a partir de aquí el MCP puede matar este Chrome en
|
|
// browser_disconnect / shutdown. Esto es lo que cierra el leak de RAM.
|
|
d.pool.setPID(port, pid)
|
|
return mcp.NewToolResultText(fmt.Sprintf("launched pid=%d port=%d user_data_dir=%s", pid, port, userDataDir)), nil
|
|
}
|
|
|
|
// ---- browser_connect ----
|
|
|
|
type connectArgs struct {
|
|
Port int `json:"port"`
|
|
}
|
|
|
|
func connectTool() mcp.Tool {
|
|
return mcp.NewTool("browser_connect",
|
|
mcp.WithDescription("Open (and pool) a CDP WebSocket connection to a running Chrome's first 'page' tab on the given port. Subsequent tools reuse this live session."),
|
|
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
|
|
)
|
|
}
|
|
|
|
func (d *deps) handleConnect(_ context.Context, _ mcp.CallToolRequest, a connectArgs) (*mcp.CallToolResult, error) {
|
|
port := portOr(a.Port)
|
|
if _, err := d.pool.get(port); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
return mcp.NewToolResultText(fmt.Sprintf("connected port=%d", port)), nil
|
|
}
|
|
|
|
// ---- browser_disconnect ----
|
|
|
|
type disconnectArgs struct {
|
|
Port int `json:"port"`
|
|
}
|
|
|
|
func disconnectTool() mcp.Tool {
|
|
return mcp.NewTool("browser_disconnect",
|
|
mcp.WithDescription("Close the pooled CDP connection for the given port (cancels any armed dialog handler). If the MCP LAUNCHED the Chrome on that port (via browser_launch), it also KILLS that Chrome process group, freeing its RAM. A Chrome the MCP did not launch (e.g. the user's daily browser on 9222) is never killed — only the WebSocket is closed."),
|
|
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
|
|
)
|
|
}
|
|
|
|
func (d *deps) handleDisconnect(_ context.Context, _ mcp.CallToolRequest, a disconnectArgs) (*mcp.CallToolResult, error) {
|
|
port := portOr(a.Port)
|
|
// Leer el log de diálogos ANTES de drop (drop lo limpia).
|
|
count, lastType, lastMsg := d.pool.dialogSnapshot(port)
|
|
d.pool.drop(port)
|
|
msg := fmt.Sprintf("disconnected port=%d", port)
|
|
if count > 0 {
|
|
msg += fmt.Sprintf(" (dialogs auto-handled: %d, last %s: %q)", count, lastType, lastMsg)
|
|
}
|
|
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
|
|
}
|