diff --git a/app.md b/app.md index 46585d6..3c715bf 100644 --- a/app.md +++ b/app.md @@ -2,9 +2,20 @@ name: browser_mcp lang: go domain: infra -version: 0.4.0 +version: 0.5.0 description: "Servidor MCP que expone control total del navegador via CDP (40 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión, selección determinista de pestaña, lectura compacta texto/AX + bucle percibir→actuar por #ref con auto-observe, incluyendo find-ref-by-text) reusando funciones del dominio browser del registry con un pool de conexiones CDP vivas. Por defecto opera sobre un Chrome aislado (puerto 9333) separado del navegador diario." tags: [mcp, browser, cdp, automation, scraping] +e2e_checks: + - id: build + cmd: "cd projects/web_scraping/apps/browser_mcp && go build -o browser_mcp ." + timeout_s: 120 + - id: unit + cmd: "cd projects/web_scraping/apps/browser_mcp && go test -count=1 ./..." + timeout_s: 120 + - id: leak_no_orphans + cmd: "cd projects/web_scraping/apps/browser_mcp && go test -c -o /tmp/bmcp_e2e.test . && systemd-run --user --quiet --collect --unit=bmcp_e2e_ci --wait -p Type=oneshot --setenv=BMCP_E2E=1 -p StandardOutput=journal /tmp/bmcp_e2e.test -test.run TestE2E -test.v" + timeout_s: 180 + severity: warning uses_functions: - chrome_launch_go_browser - cdp_connect_go_browser @@ -230,6 +241,15 @@ Funciones del dominio `browser` que NO se exponen como tools en esta versión, c ## Capability growth log +- v0.5.0 (2026-06-06) — Fix del leak de RAM (chromium huérfanos, apagón 06/06/2026). El pool + ahora registra el PID del Chrome que lanzó por puerto (`pids` map + setPID/getPID/clearPID/ + launchedCount). `browser_disconnect` (drop) y el shutdown (closeAll) matan el grupo de proceso + completo SOLO si el PID está registrado (lo lanzó el MCP) — un Chrome externo (navegador diario + en 9222) nunca se mata, solo se cierra el WebSocket. `browser_launch` es idempotente por puerto, + reusa un Chrome ya vivo (`ChromeLaunch.ReuseExisting`, pid 0 = no relanza) y aplica un tope duro + de 4 instancias. Handler SIGTERM/SIGINT en main.go llama closeAll (los defers no corren con + señal). `withConn` retry usa `releaseConn` (suelta solo el WS) en vez de drop. Tests: pool_test.go + (lógicos) + pool_e2e_test.go (Chrome real, gate BMCP_E2E=1). e2e_checks añadidos. - v0.3.0 (2026-06-06) — Cierre del bucle percibir→actuar. Nuevas tools `dom_click_ref`, `dom_type_ref`, `dom_hover_ref`: actúan sobre el `#ref` (backendDOMNodeId estable) del outline de `page_perceive` con humanización por defecto (Bézier+jitter) y auto-observe diff --git a/main.go b/main.go index 3bb774f..786eada 100644 --- a/main.go +++ b/main.go @@ -6,8 +6,10 @@ import ( "log/slog" "net/http" "os" + "os/signal" "path/filepath" "strings" + "syscall" "github.com/mark3labs/mcp-go/server" @@ -42,8 +44,22 @@ func main() { slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}))) pool := newConnPool() + // Cierre por EOF de stdio (ServeStdio retorna) o salida normal de serveHTTP. defer pool.closeAll() + // Cierre por señal: SIGTERM/SIGINT NO ejecutan defers, así que matamos los + // Chrome propios explícitamente antes de salir. Sin esto, al matar el MCP los + // chromium lanzados quedaban vivos y huérfanos (~789 MiB RSS cada uno) — el + // leak que provocó el apagón por saturación de RAM (06/06/2026). + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigCh + slog.Info("signal received, killing launched chromes", "signal", sig.String()) + pool.closeAll() + os.Exit(0) + }() + d := &deps{pool: pool, readOnly: cfg.readOnly} srv := server.NewMCPServer( @@ -109,7 +125,10 @@ func (d *deps) withConn(port int, fn func(c *browser.CDPConn) error) error { } err = fn(c) if err != nil && isConnErr(err) { - d.pool.drop(port) + // La conexión murió (Chrome pudo cerrar la tab). Soltamos SOLO el + // WebSocket y reconectamos al mismo Chrome — releaseConn, no drop: drop + // mataría el proceso y dejaría sin nada a qué reconectar. + d.pool.releaseConn(port) c2, err2 := d.pool.get(port) if err2 != nil { return err2 diff --git a/pool.go b/pool.go index 32fe3a4..79b53ac 100644 --- a/pool.go +++ b/pool.go @@ -10,16 +10,25 @@ import ( // connPool reusa conexiones CDP entre invocaciones de tools. Clave = puerto CDP. // Una conexión = una sesión viva a una tab "page". Mantenerla evita pagar el // handshake WebSocket en cada tool y preserva estado (event handlers, contexto). +// +// El pool también registra el PID del Chrome que el MCP LANZÓ por puerto +// (mapa `pids`). Sin ese PID, cerrar la conexión solo suelta el WebSocket y deja +// el proceso chromium huérfano (~789 MiB RSS cada uno) — ese era el leak de RAM. +// Con el PID registrado, `drop`/`closeAll` matan el grupo de proceso completo. +// Un puerto SIN pid registrado (p.ej. el navegador diario del usuario en 9222, +// que el MCP no lanzó) nunca se mata: solo se suelta el WebSocket. type connPool struct { mu sync.Mutex conns map[int]*browser.CDPConn - cancels map[int]func() // cancels de handlers persistentes (handle_dialog) - dialogLogs map[int]*browser.DialogLog // log de diálogos auto-respondidos por puerto + pids map[int]int // puerto -> PID del Chrome lanzado por el MCP (solo los SUYOS) + cancels map[int]func() // cancels de handlers persistentes (handle_dialog) + dialogLogs map[int]*browser.DialogLog // log de diálogos auto-respondidos por puerto } func newConnPool() *connPool { return &connPool{ conns: map[int]*browser.CDPConn{}, + pids: map[int]int{}, cancels: map[int]func(){}, dialogLogs: map[int]*browser.DialogLog{}, } @@ -39,7 +48,44 @@ func (p *connPool) get(port int) (*browser.CDPConn, error) { return c, nil } -func (p *connPool) drop(port int) { +// setPID registra el PID del Chrome que el MCP lanzó en este puerto. A partir de +// aquí drop/closeAll podrán matar ese proceso (es nuestro). +func (p *connPool) setPID(port, pid int) { + p.mu.Lock() + defer p.mu.Unlock() + p.pids[port] = pid +} + +// getPID devuelve el PID registrado para el puerto (y si existe). pid<=0 o +// ausente significa que el MCP no lanzó ningún Chrome propio en ese puerto. +func (p *connPool) getPID(port int) (int, bool) { + p.mu.Lock() + defer p.mu.Unlock() + pid, ok := p.pids[port] + return pid, ok +} + +// clearPID olvida el PID de un puerto sin matar nada (p.ej. el proceso ya murió). +func (p *connPool) clearPID(port int) { + p.mu.Lock() + defer p.mu.Unlock() + delete(p.pids, port) +} + +// launchedCount devuelve cuántos Chrome propios tiene vivos el MCP (uno por +// puerto registrado). Alimenta el tope de instancias en handleLaunch. +func (p *connPool) launchedCount() int { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.pids) +} + +// releaseConn cierra SOLO el WebSocket pooled del puerto (pid=0, no mata Chrome) +// y lo borra del mapa, PRESERVANDO el PID registrado. Cancela el handler de +// diálogo de esa sesión (está atado a la conexión que se suelta). Lo usan el +// retry de withConn y connectTarget: necesitan reconectar al MISMO Chrome, no +// matarlo. +func (p *connPool) releaseConn(port int) { p.mu.Lock() defer p.mu.Unlock() if cancel, ok := p.cancels[port]; ok && cancel != nil { @@ -48,18 +94,41 @@ func (p *connPool) drop(port int) { } delete(p.dialogLogs, port) if c, ok := p.conns[port]; ok && c != nil { - // pid=0: cerrar solo el WebSocket, sin matar Chrome (el navegador sigue - // vivo; solo soltamos la sesión pooled). + // pid=0: solo soltar el WebSocket. El Chrome sigue vivo para reconectar. _ = browser.CdpClose(c, 0) delete(p.conns, port) } } +// drop cierra la sesión del puerto Y mata el Chrome SI lo lanzó el MCP (pid +// registrado). Para un Chrome externo (sin pid registrado, p.ej. el navegador +// diario en 9222) pasa pid=0 a CdpClose: solo cierra el WebSocket, NUNCA mata el +// navegador del usuario. Limpia todas las entradas del puerto. +func (p *connPool) drop(port int) { + p.mu.Lock() + defer p.mu.Unlock() + if cancel, ok := p.cancels[port]; ok && cancel != nil { + cancel() + delete(p.cancels, port) + } + delete(p.dialogLogs, port) + + pid := p.pids[port] // 0 si el MCP no lanzó este Chrome + c := p.conns[port] + // CdpClose mata el grupo de proceso completo SOLO si pid>0 (Setpgid=true en + // ChromeLaunch). Con c!=nil cierra además el WebSocket; con pid<=0 no toca el + // proceso. + _ = browser.CdpClose(c, pid) + delete(p.conns, port) + delete(p.pids, port) +} + // connectTarget descarta la conexión actual del puerto y reconecta a un target // determinista (por id o substring de URL). Asegura que el agente opera sobre una -// pestaña conocida y no sobre "la primera al azar". +// pestaña conocida y no sobre "la primera al azar". Usa releaseConn (NO drop): +// cambiar de pestaña no debe matar el Chrome, es el mismo navegador. func (p *connPool) connectTarget(port int, match string) (*browser.CDPConn, error) { - p.drop(port) + p.releaseConn(port) c, err := browser.CdpConnectTarget("localhost", port, match) if err != nil { return nil, err @@ -93,6 +162,10 @@ func (p *connPool) dialogSnapshot(port int) (int, string, string) { return 0, "", "" } +// closeAll cierra todas las conexiones y mata TODOS los Chrome que el MCP lanzó +// (pid registrado). Se llama con defer en main() (cierre por EOF de stdio) y +// desde el handler de señales (SIGTERM/SIGINT). Idempotente: vacía los mapas, así +// que una segunda llamada no hace nada. Un Chrome externo (sin pid) no se mata. func (p *connPool) closeAll() { p.mu.Lock() defer p.mu.Unlock() @@ -100,11 +173,19 @@ func (p *connPool) closeAll() { if cancel := p.cancels[port]; cancel != nil { cancel() } - if c != nil { - _ = browser.CdpClose(c, 0) + _ = browser.CdpClose(c, p.pids[port]) // mata nuestro Chrome; pid=0 para externos + delete(p.pids, port) // marcado como ya cerrado + } + // Matar también los Chrome propios cuya conexión ya fue soltada (releaseConn + // preserva el pid pero borra la conn): pid registrado sin conn viva. + for port, pid := range p.pids { + if pid > 0 { + _ = browser.CdpClose(nil, pid) } + _ = port } p.conns = map[int]*browser.CDPConn{} + p.pids = map[int]int{} p.cancels = map[int]func(){} p.dialogLogs = map[int]*browser.DialogLog{} } diff --git a/pool_e2e_test.go b/pool_e2e_test.go new file mode 100644 index 0000000..bb16f82 --- /dev/null +++ b/pool_e2e_test.go @@ -0,0 +1,215 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "fn-registry/functions/browser" +) + +// Estos tests lanzan y matan Chrome REAL. Gate BMCP_E2E=1 y deben correr +// AISLADOS en un servicio transitorio systemd-run --user: matar chromium desde +// el árbol de procesos del Bash tool dispara exit-144. Ver +// .claude/rules y la memoria harness-exit-144-chromium. + +func requireE2E(t *testing.T) { + t.Helper() + if os.Getenv("BMCP_E2E") != "1" { + t.Skip("skip: requiere BMCP_E2E=1 + Chrome real, correr bajo systemd-run --user") + } +} + +// chromePIDsByUDD cuenta los procesos chromium (browser + zygotes + renderers) +// que comparten un user-data-dir concreto, leyendo /proc//cmdline. Usar el +// UDD como aguja cuenta el ÁRBOL completo (los hijos heredan --user-data-dir), +// y aísla el conteo del navegador diario en 9222 (UDD distinto). +func chromePIDsByUDD(udd string) []int { + var pids []int + needle := "--user-data-dir=" + udd + matches, _ := filepath.Glob("/proc/[0-9]*/cmdline") + for _, m := range matches { + b, err := os.ReadFile(m) + if err != nil { + continue + } + cmd := strings.ReplaceAll(string(b), "\x00", " ") + if strings.Contains(cmd, needle) { + parts := strings.Split(m, "/") + if len(parts) >= 3 { + if pid, err := strconv.Atoi(parts[2]); err == nil { + pids = append(pids, pid) + } + } + } + } + return pids +} + +// rssKB suma el VmRSS (KiB) de un conjunto de PIDs. +func rssKB(pids []int) int64 { + var total int64 + for _, pid := range pids { + b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) + if err != nil { + continue + } + for _, line := range strings.Split(string(b), "\n") { + if strings.HasPrefix(line, "VmRSS:") { + f := strings.Fields(line) + if len(f) >= 2 { + if v, err := strconv.ParseInt(f[1], 10, 64); err == nil { + total += v + } + } + } + } + } + return total +} + +// TestE2EPoolKillsLaunchedChromes — GOLDEN PATH del fix del leak. +// Lanza 3 Chrome headless en puertos aislados, los registra en el pool, mide su +// RSS, llama closeAll() (lo que hace el shutdown del MCP) y verifica CERO +// huérfanos. Reporta el RSS liberado. +func TestE2EPoolKillsLaunchedChromes(t *testing.T) { + requireE2E(t) + base := filepath.Join(os.TempDir(), "bmcp_e2e_golden") + _ = os.RemoveAll(base) + defer os.RemoveAll(base) + + ports := []int{9401, 9402, 9403} + udds := map[int]string{} + pool := newConnPool() + defer pool.closeAll() // red de seguridad si el test aborta a mitad + + for _, p := range ports { + udd := filepath.Join(base, strconv.Itoa(p)) + if err := os.MkdirAll(udd, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", udd, err) + } + udds[p] = udd + pid, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{ + Port: p, + Headless: true, + UserDataDir: udd, + ReuseExisting: true, + }) + if err != nil { + t.Fatalf("ChromeLaunch port=%d: %v", p, err) + } + if pid == 0 { + t.Fatalf("port=%d ya estaba ocupado (ReuseExisting devolvió 0); usa otro puerto", p) + } + pool.setPID(p, pid) + t.Logf("lanzado Chrome pid=%d port=%d", pid, p) + } + + // Verificar que los 3 árboles están vivos + medir RSS. + var alive int + var rssBefore int64 + for _, p := range ports { + pids := chromePIDsByUDD(udds[p]) + alive += len(pids) + rssBefore += rssKB(pids) + } + if alive < len(ports) { + t.Fatalf("esperaba >=%d procesos chrome vivos, vivos=%d", len(ports), alive) + } + t.Logf("ANTES: %d procesos chrome vivos, RSS total ~%d MiB", alive, rssBefore/1024) + + // El kill: closeAll mata cada grupo de proceso registrado. + pool.closeAll() + time.Sleep(2 * time.Second) // dar tiempo al SIGKILL del grupo + + var after int + for _, p := range ports { + after += len(chromePIDsByUDD(udds[p])) + } + if after != 0 { + t.Fatalf("LEAK: %d procesos chrome siguen vivos tras closeAll (esperaba 0)", after) + } + t.Logf("DESPUES: 0 huérfanos. RSS liberado ~%d MiB (%d → 0)", rssBefore/1024, rssBefore/1024) +} + +// TestE2EDedupSamePort — EDGE: dos ChromeLaunch(ReuseExisting) al mismo puerto +// no duplican el proceso; el segundo devuelve pid 0. +func TestE2EDedupSamePort(t *testing.T) { + requireE2E(t) + base := filepath.Join(os.TempDir(), "bmcp_e2e_dedup") + _ = os.RemoveAll(base) + defer os.RemoveAll(base) + udd := filepath.Join(base, "9404") + if err := os.MkdirAll(udd, 0o755); err != nil { + t.Fatal(err) + } + + pid1, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9404, Headless: true, UserDataDir: udd, ReuseExisting: true}) + if err != nil { + t.Fatalf("primer launch: %v", err) + } + if pid1 == 0 { + t.Fatal("primer launch devolvió 0 (puerto ya ocupado)") + } + defer browser.CdpClose(nil, pid1) // cleanup: mata el grupo + + pid2, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9404, Headless: true, UserDataDir: udd, ReuseExisting: true}) + if err != nil { + t.Fatalf("segundo launch: %v", err) + } + if pid2 != 0 { + // matar el duplicado antes de fallar para no dejar huérfanos + _ = browser.CdpClose(nil, pid2) + t.Fatalf("segundo launch lanzó un DUPLICADO pid=%d (esperaba 0 = reuso)", pid2) + } + if n := len(chromePIDsByUDD(udd)); n == 0 { + t.Fatalf("el primer Chrome debería seguir vivo") + } + t.Logf("dedup OK: pid1=%d vivo, segundo launch reusó (pid 0)", pid1) +} + +// TestE2EDropKillsOwnNotExternal — EDGE + SEGURIDAD: drop mata el Chrome que el +// MCP lanzó (pid registrado), pero NO mata un Chrome que el MCP no lanzó (pid no +// registrado en el pool) — la salvaguarda que protege el navegador diario. +func TestE2EDropKillsOwnNotExternal(t *testing.T) { + requireE2E(t) + base := filepath.Join(os.TempDir(), "bmcp_e2e_drop") + _ = os.RemoveAll(base) + defer os.RemoveAll(base) + + // (a) Chrome PROPIO en 9405: registrado → drop debe matarlo. + uddOwn := filepath.Join(base, "9405") + _ = os.MkdirAll(uddOwn, 0o755) + ownPID, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9405, Headless: true, UserDataDir: uddOwn, ReuseExisting: true}) + if err != nil || ownPID == 0 { + t.Fatalf("launch propio 9405: pid=%d err=%v", ownPID, err) + } + pool := newConnPool() + pool.setPID(9405, ownPID) + + // (b) Chrome EXTERNO en 9406: NO registrado en el pool → drop NO debe matarlo. + uddExt := filepath.Join(base, "9406") + _ = os.MkdirAll(uddExt, 0o755) + extPID, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9406, Headless: true, UserDataDir: uddExt, ReuseExisting: true}) + if err != nil || extPID == 0 { + t.Fatalf("launch externo 9406: pid=%d err=%v", extPID, err) + } + defer browser.CdpClose(nil, extPID) // lo mata el test, no el pool + + // drop sobre ambos puertos. + pool.drop(9405) // pid registrado → mata + pool.drop(9406) // pid NO registrado → solo cierra WS, NO mata + time.Sleep(2 * time.Second) + + if n := len(chromePIDsByUDD(uddOwn)); n != 0 { + t.Fatalf("drop NO mató el Chrome propio 9405: %d vivos", n) + } + if n := len(chromePIDsByUDD(uddExt)); n == 0 { + t.Fatalf("drop MATÓ un Chrome externo 9406 (debía respetarlo)") + } + t.Logf("OK: propio 9405 muerto, externo 9406 respetado (salvaguarda navegador diario)") +} diff --git a/pool_test.go b/pool_test.go new file mode 100644 index 0000000..f621d87 --- /dev/null +++ b/pool_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" +) + +// resultText concatena el texto de un CallToolResult para asserts. +func resultText(r *mcp.CallToolResult) string { + var sb strings.Builder + for _, c := range r.Content { + if tc, ok := c.(mcp.TextContent); ok { + sb.WriteString(tc.Text) + } + } + return sb.String() +} + +// TestPoolPIDLifecycle verifica set/get/clear/count del registro de PIDs sin +// tocar Chrome real. +func TestPoolPIDLifecycle(t *testing.T) { + p := newConnPool() + if n := p.launchedCount(); n != 0 { + t.Fatalf("launchedCount inicial = %d, want 0", n) + } + p.setPID(9333, 4242) + if pid, ok := p.getPID(9333); !ok || pid != 4242 { + t.Fatalf("getPID(9333) = (%d,%v), want (4242,true)", pid, ok) + } + if n := p.launchedCount(); n != 1 { + t.Fatalf("launchedCount tras setPID = %d, want 1", n) + } + p.clearPID(9333) + if _, ok := p.getPID(9333); ok { + t.Fatalf("getPID(9333) sigue presente tras clearPID") + } + if n := p.launchedCount(); n != 0 { + t.Fatalf("launchedCount tras clearPID = %d, want 0", n) + } +} + +// TestInstanceCapRejectsWithoutLaunching verifica el tope duro: con +// maxLaunchedChromes PIDs ya registrados, browser_launch en un puerto nuevo +// devuelve error de tool y NO intenta lanzar Chrome (el cap se evalúa antes de +// ChromeLaunch, por eso este test no necesita Chrome real). Cubre el edge +// "superar el tope → error claro". +func TestInstanceCapRejectsWithoutLaunching(t *testing.T) { + p := newConnPool() + for i := 0; i < maxLaunchedChromes; i++ { + p.setPID(9500+i, 100000+i) // PIDs ficticios: nunca se matan en este test + } + d := &deps{pool: p} + res, err := d.handleLaunch(context.Background(), mcp.CallToolRequest{}, launchArgs{Port: 9600}) + if err != nil { + t.Fatalf("handleLaunch err = %v", err) + } + if !res.IsError { + t.Fatalf("esperaba IsError=true por cap, got text=%q", resultText(res)) + } + if txt := resultText(res); !strings.Contains(txt, "cap") { + t.Fatalf("mensaje no menciona el cap: %q", txt) + } + // El puerto nuevo no debe haberse registrado. + if _, ok := p.getPID(9600); ok { + t.Fatalf("el puerto rechazado por cap no debe registrarse") + } +} + +// TestLaunchReusesRegisteredPort verifica idempotencia: si el MCP ya lanzó un +// Chrome en el puerto (PID registrado), un segundo browser_launch lo reusa sin +// lanzar otro proceso. No necesita Chrome real (el reuse corta antes de +// ChromeLaunch). Cubre el edge "dos browser_launch al mismo puerto no duplica". +func TestLaunchReusesRegisteredPort(t *testing.T) { + p := newConnPool() + p.setPID(9333, 777777) + d := &deps{pool: p} + res, err := d.handleLaunch(context.Background(), mcp.CallToolRequest{}, launchArgs{Port: 9333}) + if err != nil { + t.Fatalf("handleLaunch err = %v", err) + } + if res.IsError { + t.Fatalf("no esperaba error, got %q", resultText(res)) + } + if txt := resultText(res); !strings.Contains(txt, "reused pid=777777") { + t.Fatalf("esperaba reuse del pid registrado, got %q", txt) + } + if n := p.launchedCount(); n != 1 { + t.Fatalf("launchedCount = %d, want 1 (no debe duplicar)", n) + } +} + +// TestDropClearsMapsNoPID verifica que drop sobre un puerto sin conn ni pid no +// panica y deja los mapas limpios (no mata nada — caso del navegador externo +// del que solo se soltó el WebSocket). +func TestDropClearsMapsNoPID(t *testing.T) { + p := newConnPool() + p.drop(9222) // puerto externo, sin conn ni pid registrado: no-op seguro + if n := p.launchedCount(); n != 0 { + t.Fatalf("launchedCount = %d, want 0", n) + } +} diff --git a/tools_session.go b/tools_session.go index cad74e8..81c1773 100644 --- a/tools_session.go +++ b/tools_session.go @@ -21,6 +21,12 @@ func registerSessionTools(s *server.MCPServer, d *deps) { s.AddTool(disconnectTool(), mcp.NewTypedToolHandler(d.handleDisconnect)) } +// 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 { @@ -41,6 +47,22 @@ func launchTool() mcp.Tool { } 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 @@ -49,9 +71,13 @@ func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchAr _ = os.MkdirAll(userDataDir, 0o755) } opts := browser.ChromeLaunchOpts{ - Port: portOr(a.Port), + 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) @@ -60,7 +86,15 @@ func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchAr if err != nil { return mcp.NewToolResultError(err.Error()), nil } - return mcp.NewToolResultText(fmt.Sprintf("launched pid=%d port=%d user_data_dir=%s", pid, opts.Port, userDataDir)), 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 ---- @@ -92,7 +126,7 @@ type disconnectArgs struct { func disconnectTool() mcp.Tool { return mcp.NewTool("browser_disconnect", - mcp.WithDescription("Close and drop the pooled CDP connection for the given port (cancels any armed dialog handler). Does NOT kill Chrome."), + 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.")), ) }