feat(browser_mcp): add browser_list/launch_profile/close lifecycle tools
Three MCP tools to manage the user's Chromium instances by profile, distinct from browser_launch's isolated automation Chrome: - browser_list: enumerate running Chromium master processes by scanning /proc/*/cmdline (has --user-data-dir, no --type=). Returns pid, profile, user_data_dir, cdp_port, has_cdp as a JSON array. - browser_launch_profile: launch a concrete profile using the REAL binary /usr/lib/chromium/chromium (bypassing the /usr/bin/chromium wrapper). No CDP by default so Google keeps the session for human profiles; cdp=true adds --remote-debugging-port + --remote-allow-origins=*. Detects DISPLAY/XAUTHORITY from the XFCE session and launches decoupled via setsid. - browser_close: locate a master by profile/cdp_port/pid, SIGTERM with a 10s wait, then SIGKILL as a last resort. Per-profile instances are NOT registered in the connection pool: they are user-facing and survive the MCP dying; cleanup is explicit via browser_close. Unit tests for cmdline master detection, flag parsing, and close-target matching. Bumps version 0.6.0 -> 0.7.0 (42 -> 45 tools). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
**By default the MCP operates on its OWN isolated Chrome, NOT the user's daily browser.**
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
name: browser_mcp
|
||||
lang: go
|
||||
domain: infra
|
||||
version: 0.6.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."
|
||||
version: 0.7.0
|
||||
description: "Servidor MCP que expone control total del navegador via CDP (45 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión, selección determinista de pestaña, lectura compacta texto/AX nativa + bucle percibir→actuar por #ref con auto-observe, percepción y lectura de texto dentro de iframes, click por coordenadas, screenshot devuelto como image content que el LLM ve, y gestión del ciclo de vida de Chromium por perfil: listar masters en ejecución, lanzar un perfil concreto con o sin CDP, y cerrar limpio) reusando funciones del dominio browser del registry con un pool de conexiones CDP vivas. Por defecto opera sobre un Chrome aislado (puerto 9333) separado del navegador diario."
|
||||
tags: [mcp, browser, cdp, automation, scraping]
|
||||
e2e_checks:
|
||||
- id: build
|
||||
@@ -118,13 +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
|
||||
tool. Hazlo solo con cuidado.
|
||||
|
||||
## Tools (42)
|
||||
## Tools (45)
|
||||
|
||||
### Sesión (`tools_session.go`)
|
||||
- `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_disconnect` — cierra y descarta la conexión del puerto (no mata Chrome). args: port.
|
||||
|
||||
### 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`)
|
||||
- `tab_navigate` (MUTA) — `Page.navigate`. args: port, url.
|
||||
- `tab_list` — lista targets via `GET /json`. args: port.
|
||||
@@ -241,11 +261,11 @@ Transporte HTTP (Streamable HTTP):
|
||||
### Flag `--read-only`
|
||||
|
||||
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`,
|
||||
`tab_activate`, `tab_select`, `page_wait_load`, `page_wait_idle`, `page_get_html`, `page_get_text`,
|
||||
`page_perceive`, `page_screenshot`, `dom_find_by_text`, `dom_find_ref_by_text`, `dom_wait_element`,
|
||||
`cookie_get`, `frame_list`, `frame_get_html`, `frame_get_text`, `storage_save`). Útil para sesiones
|
||||
de inspección sin riesgo de modificar el estado del navegador.
|
||||
solo expone las 20 tools de lectura/control (`browser_connect`, `browser_disconnect`, `browser_list`,
|
||||
`tab_list`, `tab_activate`, `tab_select`, `page_wait_load`, `page_wait_idle`, `page_get_html`,
|
||||
`page_get_text`, `page_perceive`, `page_screenshot`, `dom_find_by_text`, `dom_find_ref_by_text`,
|
||||
`dom_wait_element`, `cookie_get`, `frame_list`, `frame_get_html`, `frame_get_text`, `storage_save`).
|
||||
Útil para sesiones de inspección sin riesgo de modificar el estado del navegador.
|
||||
|
||||
## Omitido en v1
|
||||
|
||||
@@ -266,6 +286,15 @@ Funciones del dominio `browser` que NO se exponen como tools en esta versión, c
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- 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
|
||||
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
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"fn-registry/functions/browser"
|
||||
)
|
||||
|
||||
const version = "0.6.0"
|
||||
const version = "0.7.0"
|
||||
|
||||
type config struct {
|
||||
httpAddr string
|
||||
@@ -92,6 +92,7 @@ func main() {
|
||||
// registerTools wires every tool group. Mutating tools are skipped under --read-only.
|
||||
func registerTools(s *server.MCPServer, d *deps) {
|
||||
registerSessionTools(s, d)
|
||||
registerLifecycleTools(s, d)
|
||||
registerNavTools(s, d)
|
||||
registerReadTools(s, d)
|
||||
registerDomTools(s, d)
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
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. If it forwarded to an existing master the
|
||||
// child exits fast; the launched pid is still informative.
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// When cdp=true, opportunistically confirm the port responds (best-effort: a
|
||||
// forwarded launch may not bind the port if the master had no CDP).
|
||||
if a.CDP && note == "" {
|
||||
if !cdpPortResponds(cdpPort) {
|
||||
note = "cdp port not confirmed listening yet"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user