feat: P0 LLM-readiness — Chrome aislado (9333), tab_select determinista, page_get_text, page_perceive
This commit is contained in:
@@ -5,9 +5,23 @@ 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.
|
||||||
|
|
||||||
33 tools total, grouped by domain. See `app.md` for the full per-tool reference and the
|
36 tools total, grouped by domain. See `app.md` for the full per-tool reference and the
|
||||||
"Omitido en v1" section.
|
"Omitido en v1" section.
|
||||||
|
|
||||||
|
## Security: isolated Chrome by default (port 9333)
|
||||||
|
|
||||||
|
**By default the MCP operates on its OWN isolated Chrome, NOT the user's daily browser.**
|
||||||
|
|
||||||
|
In this ecosystem the user's daily chromium has CDP enabled globally on port **9222** (via
|
||||||
|
`/etc/chromium.d/cdp`). If the MCP defaulted there, the agent could drive the user's own
|
||||||
|
tabs (banking, email). To prevent that:
|
||||||
|
|
||||||
|
- The default CDP port is **9333** (the MCP's dedicated Chrome), not 9222.
|
||||||
|
- `browser_launch` without `user_data_dir` uses a dedicated isolated profile
|
||||||
|
(`<tmp>/browser_mcp_userdata`) on port 9333.
|
||||||
|
- **Port 9222 = the daily browser.** Pass `port: 9222` explicitly, with care, only when you
|
||||||
|
deliberately want to attach to it.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -54,22 +68,21 @@ For an inspection-only session that cannot mutate browser state, pass `"args": [
|
|||||||
|
|
||||||
## Example session
|
## Example session
|
||||||
|
|
||||||
Assuming a Chrome already running with `--remote-debugging-port=9222` (or call
|
The default port is **9333** (the MCP's isolated Chrome). A typical LLM-readiness agent
|
||||||
`browser_launch` first), a typical agent flow:
|
flow — launch isolated Chrome, pick the right tab, perceive the page, act, read result:
|
||||||
|
|
||||||
```
|
```
|
||||||
browser_launch { "port": 9222, "url": "https://example.com" } # -> "launched pid=... port=9222"
|
browser_launch { "url": "https://example.com" } # -> "launched pid=... port=9333 user_data_dir=<tmp>/browser_mcp_userdata"
|
||||||
browser_connect { "port": 9222 } # -> "connected port=9222"
|
tab_list { } # -> JSON list of targets (id, type, url, title)
|
||||||
tab_navigate { "port": 9222, "url": "https://example.com" }
|
tab_select { "match": "example.com" } # -> "selected target matching: example.com" (deterministic, by id or URL substring)
|
||||||
page_wait_load { "port": 9222, "timeout_ms": 10000 }
|
page_perceive { } # -> indented accessibility outline (roles, names, #ref) — the LLM "sees" the page compactly
|
||||||
page_get_html { "port": 9222 } # -> serialized HTML (truncated 200k)
|
dom_click { "selector": "a" } # act on what you perceived
|
||||||
dom_find_by_text { "port": 9222, "text": "More information" } # -> "a" / "#id" selector
|
page_get_text { "selector": "body", "max_bytes": 20000 } # -> visible innerText, compact (does NOT blow up the context like page_get_html)
|
||||||
dom_click { "port": 9222, "selector": "a" }
|
browser_disconnect{ }
|
||||||
page_eval_js { "port": 9222, "expression": "document.title" } # -> page title
|
|
||||||
page_screenshot { "port": 9222, "path": "/tmp/example.png", "full_page": true }
|
|
||||||
browser_disconnect{ "port": 9222 }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To attach to the daily browser instead, pass `port: 9222` explicitly in each call (with care).
|
||||||
|
|
||||||
Cookies, iframes (`frame_list` -> `frame_eval`/`frame_get_html`), keyboard/scroll
|
Cookies, iframes (`frame_list` -> `frame_eval`/`frame_get_html`), keyboard/scroll
|
||||||
(`press_key`, `scroll`), JS dialogs (`handle_dialog`), and session persistence
|
(`press_key`, `scroll`), JS dialogs (`handle_dialog`), and session persistence
|
||||||
(`storage_save` / `storage_load`) follow the same per-port pattern.
|
(`storage_save` / `storage_load`) follow the same per-port pattern.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
name: browser_mcp
|
name: browser_mcp
|
||||||
lang: go
|
lang: go
|
||||||
domain: infra
|
domain: infra
|
||||||
version: 0.1.0
|
version: 0.2.0
|
||||||
description: "Servidor MCP que expone control total del navegador via CDP (33 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión) reusando funciones del dominio browser del registry con un pool de conexiones CDP vivas."
|
description: "Servidor MCP que expone control total del navegador via CDP (36 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión, selección determinista de pestaña + lectura compacta texto/AX) 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]
|
||||||
uses_functions:
|
uses_functions:
|
||||||
- chrome_launch_go_browser
|
- chrome_launch_go_browser
|
||||||
@@ -39,6 +39,9 @@ uses_functions:
|
|||||||
- cdp_get_frame_html_go_browser
|
- cdp_get_frame_html_go_browser
|
||||||
- cdp_save_storage_state_go_browser
|
- cdp_save_storage_state_go_browser
|
||||||
- cdp_load_storage_state_go_browser
|
- cdp_load_storage_state_go_browser
|
||||||
|
- cdp_get_text_go_browser
|
||||||
|
- cdp_connect_target_go_browser
|
||||||
|
- cdp_perceive_outline_py_pipelines
|
||||||
uses_types: []
|
uses_types: []
|
||||||
framework: ""
|
framework: ""
|
||||||
entry_point: "main.go"
|
entry_point: "main.go"
|
||||||
@@ -71,17 +74,33 @@ Por eso reusamos la conexión por puerto.
|
|||||||
|
|
||||||
- `connPool.get(port)` devuelve la conexión cacheada o abre una nueva.
|
- `connPool.get(port)` devuelve la conexión cacheada o abre una nueva.
|
||||||
- `connPool.drop(port)` cancela el handler de diálogo (si lo hay) y cierra la conexión.
|
- `connPool.drop(port)` cancela el handler de diálogo (si lo hay) y cierra la conexión.
|
||||||
|
- `connPool.connectTarget(port, match)` descarta la conexión actual y reconecta a un target
|
||||||
|
determinista (por id o substring de URL). Es lo que usa `tab_select` para fijar la pestaña.
|
||||||
- `connPool.setCancel(port, cancel)` registra el cancel del auto-handler de `handle_dialog`.
|
- `connPool.setCancel(port, cancel)` registra el cancel del auto-handler de `handle_dialog`.
|
||||||
- `connPool.closeAll()` se ejecuta con `defer` en `main()`.
|
- `connPool.closeAll()` se ejecuta con `defer` en `main()`.
|
||||||
- `deps.withConn(port, fn)` ejecuta `fn` con la conexión del pool y, si el error indica
|
- `deps.withConn(port, fn)` ejecuta `fn` con la conexión del pool y, si el error indica
|
||||||
conexión muerta (`isConnErr`: connection close, broken pipe, use of closed, ws read, EOF),
|
conexión muerta (`isConnErr`: connection close, broken pipe, use of closed, ws read, EOF),
|
||||||
descarta la conexión y reintenta UNA vez (Chrome pudo cerrar la tab entre tools).
|
descarta la conexión y reintenta UNA vez (Chrome pudo cerrar la tab entre tools).
|
||||||
|
|
||||||
Toda tool con argumento `port` usa `portOr(a.Port)` (default 9222). Las tools de tabs
|
Toda tool con argumento `port` usa `portOr(a.Port)` (default 9333). Las tools de tabs
|
||||||
(`tab_list`, `tab_new`, `tab_close`, `tab_activate`) usan el endpoint HTTP `/json` de CDP
|
(`tab_list`, `tab_new`, `tab_close`, `tab_activate`, `tab_select`) usan el endpoint HTTP `/json`
|
||||||
directamente (host `localhost`), no el pool, porque no requieren una sesión WebSocket viva.
|
de CDP directamente (host `localhost`), no el pool, porque no requieren una sesión WebSocket viva.
|
||||||
|
|
||||||
## Tools (33)
|
## Seguridad: Chrome aislado por defecto (puerto 9333)
|
||||||
|
|
||||||
|
**El default del MCP es operar sobre su PROPIO Chrome aislado, no sobre el navegador diario.**
|
||||||
|
|
||||||
|
En este ecosistema el chromium diario del usuario tiene CDP habilitado globalmente en el
|
||||||
|
puerto **9222** (via `/etc/chromium.d/cdp`). Si el MCP usara 9222 por defecto, el agente
|
||||||
|
podría manipular pestañas ajenas del usuario (banca, correo). Para evitarlo:
|
||||||
|
|
||||||
|
- `portOr` devuelve **9333** por defecto (no 9222) — el Chrome dedicado del MCP.
|
||||||
|
- `browser_launch` sin `user_data_dir` usa un perfil DEDICADO y aislado:
|
||||||
|
`<tmp>/browser_mcp_userdata` (se crea si hace falta) en el puerto 9333.
|
||||||
|
- Para adjuntarte deliberadamente al navegador diario, pasa `port: 9222` explícito en cada
|
||||||
|
tool. Hazlo solo con cuidado.
|
||||||
|
|
||||||
|
## Tools (36)
|
||||||
|
|
||||||
### 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.
|
||||||
@@ -94,6 +113,9 @@ directamente (host `localhost`), no el pool, porque no requieren una sesión Web
|
|||||||
- `tab_new` (MUTA) — abre tab via `PUT /json/new`. args: port, url.
|
- `tab_new` (MUTA) — abre tab via `PUT /json/new`. args: port, url.
|
||||||
- `tab_close` (MUTA) — cierra tab por ID. args: port, tab_id.
|
- `tab_close` (MUTA) — cierra tab por ID. args: port, tab_id.
|
||||||
- `tab_activate` — pone tab en foreground. args: port, tab_id.
|
- `tab_activate` — pone tab en foreground. args: port, tab_id.
|
||||||
|
- `tab_select` — fija la pestaña sobre la que operan las siguientes tools, eligiéndola por id
|
||||||
|
o por substring de su URL (determinista). Usar tras `tab_list` para no operar sobre la
|
||||||
|
pestaña equivocada. args: port, match.
|
||||||
- `nav_back` (MUTA) — atrás en el historial. args: port.
|
- `nav_back` (MUTA) — atrás en el historial. args: port.
|
||||||
- `nav_forward` (MUTA) — adelante en el historial. args: port.
|
- `nav_forward` (MUTA) — adelante en el historial. args: port.
|
||||||
- `page_wait_load` — espera el evento load. args: port, timeout_ms (default 10000).
|
- `page_wait_load` — espera el evento load. args: port, timeout_ms (default 10000).
|
||||||
@@ -101,6 +123,14 @@ directamente (host `localhost`), no el pool, porque no requieren una sesión Web
|
|||||||
|
|
||||||
### Lectura (`tools_read.go`)
|
### Lectura (`tools_read.go`)
|
||||||
- `page_get_html` — HTML serializado (truncado a 200000 chars). args: port.
|
- `page_get_html` — HTML serializado (truncado a 200000 chars). args: port.
|
||||||
|
- `page_get_text` — texto visible (innerText) de la página o de un elemento (selector CSS),
|
||||||
|
truncado a `max_bytes`. Preferir sobre `page_get_html` cuando solo necesitas leer contenido
|
||||||
|
— no revienta el contexto. args: port, selector (opcional), max_bytes (default 20000).
|
||||||
|
- `page_perceive` — outline indentado y accionable del árbol de accesibilidad (roles, nombres,
|
||||||
|
`#ref`): la forma compacta de que el agente "perciba" la página sin reventar el contexto.
|
||||||
|
Implementado por subprocess (`fn run cdp_perceive_outline`). Si `tab_id` se omite, usa la
|
||||||
|
primera pestaña page. args: port, tab_id (opcional), max_chars (default 20000).
|
||||||
|
**Gotcha:** requiere el binario `fn` y el venv de Python del registry disponibles en runtime.
|
||||||
- `page_eval_js` (MUTA) — `Runtime.evaluate`. args: port, expression.
|
- `page_eval_js` (MUTA) — `Runtime.evaluate`. args: port, expression.
|
||||||
- `page_screenshot` — captura a archivo. args: port, path, full_page.
|
- `page_screenshot` — captura a archivo. args: port, path, full_page.
|
||||||
|
|
||||||
@@ -152,11 +182,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 14 tools de lectura (`browser_connect`, `browser_disconnect`, `tab_list`,
|
solo expone las 17 tools de lectura/control (`browser_connect`, `browser_disconnect`, `tab_list`,
|
||||||
`tab_activate`, `page_wait_load`, `page_wait_idle`, `page_get_html`, `page_screenshot`,
|
`tab_activate`, `tab_select`, `page_wait_load`, `page_wait_idle`, `page_get_html`, `page_get_text`,
|
||||||
`dom_find_by_text`, `dom_wait_element`, `cookie_get`, `frame_list`, `frame_get_html`,
|
`page_perceive`, `page_screenshot`, `dom_find_by_text`, `dom_wait_element`, `cookie_get`,
|
||||||
`storage_save`). Útil para sesiones de inspección sin riesgo de modificar el estado del
|
`frame_list`, `frame_get_html`, `storage_save`). Útil para sesiones de inspección sin riesgo de
|
||||||
navegador.
|
modificar el estado del navegador.
|
||||||
|
|
||||||
## Omitido en v1
|
## Omitido en v1
|
||||||
|
|
||||||
@@ -166,9 +196,17 @@ Funciones del dominio `browser` que NO se exponen como tools en esta versión, c
|
|||||||
larga duración (registrar handlers + un punto de "stop" que devuelve los datos
|
larga duración (registrar handlers + un punto de "stop" que devuelve los datos
|
||||||
acumulados); no encaja en el modelo request/response de una tool MCP simple. Pendiente
|
acumulados); no encaja en el modelo request/response de una tool MCP simple. Pendiente
|
||||||
de un diseño con tool de start + tool de stop.
|
de un diseño con tool de start + tool de stop.
|
||||||
- **`cdp_get_ax_tree`** — el árbol de accesibilidad se obtiene hoy via un pipeline Python;
|
- **`cdp_get_ax_tree`** — ya expuesto desde v0.2.0 via la tool `page_perceive`, que invoca
|
||||||
futuro a exponer via `fn run` en vez de duplicar la lógica aquí.
|
el pipeline `cdp_perceive_outline` por subprocess (`fn run`) en vez de duplicar la lógica aquí.
|
||||||
- **Funciones de perfiles Chrome (Bash: create/delete/appearance/reset)** — requieren que
|
- **Funciones de perfiles Chrome (Bash: create/delete/appearance/reset)** — requieren que
|
||||||
Chrome esté CERRADO para modificar el `Local State` / `Preferences` del perfil; son
|
Chrome esté CERRADO para modificar el `Local State` / `Preferences` del perfil; son
|
||||||
incompatibles con un MCP cuyo propósito es controlar un Chrome vivo. Quedan disponibles
|
incompatibles con un MCP cuyo propósito es controlar un Chrome vivo. Quedan disponibles
|
||||||
como `fn run` aparte.
|
como `fn run` aparte.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v0.2.0 (2026-06-06) — P0 LLM-readiness. Seguridad: Chrome aislado por defecto (puerto 9333
|
||||||
|
+ perfil dedicado `<tmp>/browser_mcp_userdata`), separado del navegador diario en 9222.
|
||||||
|
Nuevas tools: `tab_select` (selección determinista de pestaña por id/URL), `page_get_text`
|
||||||
|
(lectura compacta de innerText), `page_perceive` (outline AX via `fn run cdp_perceive_outline`).
|
||||||
|
33 → 36 tools.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/server"
|
"github.com/mark3labs/mcp-go/server"
|
||||||
@@ -85,10 +86,16 @@ func registerTools(s *server.MCPServer, d *deps) {
|
|||||||
registerStorageTools(s, d)
|
registerStorageTools(s, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// portOr returns the CDP port, defaulting to 9222 when zero.
|
// portOr returns the CDP port, defaulting to 9333 when zero.
|
||||||
|
//
|
||||||
|
// SECURITY (P0.3): the default is 9333 — the MCP's OWN isolated Chrome — NOT
|
||||||
|
// 9222. Port 9222 is the user's daily chromium (CDP enabled globally via
|
||||||
|
// /etc/chromium.d/cdp). Defaulting there would let the agent drive the user's
|
||||||
|
// banking/email tabs. The MCP operates on its dedicated browser by default;
|
||||||
|
// pass port=9222 explicitly only to deliberately attach to the daily browser.
|
||||||
func portOr(p int) int {
|
func portOr(p int) int {
|
||||||
if p == 0 {
|
if p == 0 {
|
||||||
return 9222
|
return 9333
|
||||||
}
|
}
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
@@ -172,3 +179,28 @@ func truncate(s string, n int) string {
|
|||||||
}
|
}
|
||||||
return s[:n] + "\n... [truncated]"
|
return s[:n] + "\n... [truncated]"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveRoot finds the fn_registry root so we can locate the `fn` binary and
|
||||||
|
// the Python venv at runtime. Mirrors registry_mcp's resolveRoot: honors
|
||||||
|
// FN_REGISTRY_ROOT, otherwise walks up from cwd looking for registry.db.
|
||||||
|
func resolveRoot() (string, error) {
|
||||||
|
if env := os.Getenv("FN_REGISTRY_ROOT"); env != "" {
|
||||||
|
return filepath.Abs(env)
|
||||||
|
}
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir := cwd
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "registry.db")); err == nil {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("registry.db not found upward from %s", cwd)
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,21 @@ func (p *connPool) drop(port int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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".
|
||||||
|
func (p *connPool) connectTarget(port int, match string) (*browser.CDPConn, error) {
|
||||||
|
p.drop(port)
|
||||||
|
c, err := browser.CdpConnectTarget("localhost", port, match)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
p.conns[port] = c
|
||||||
|
p.mu.Unlock()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *connPool) setCancel(port int, cancel func()) {
|
func (p *connPool) setCancel(port int, cancel func()) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func registerNavTools(s *server.MCPServer, d *deps) {
|
|||||||
// Tab tools use HTTP /json directly (no pool) — list/activate are read-only.
|
// Tab tools use HTTP /json directly (no pool) — list/activate are read-only.
|
||||||
s.AddTool(tabListTool(), mcp.NewTypedToolHandler(d.handleTabList))
|
s.AddTool(tabListTool(), mcp.NewTypedToolHandler(d.handleTabList))
|
||||||
s.AddTool(tabActivateTool(), mcp.NewTypedToolHandler(d.handleTabActivate))
|
s.AddTool(tabActivateTool(), mcp.NewTypedToolHandler(d.handleTabActivate))
|
||||||
|
s.AddTool(tabSelectTool(), mcp.NewTypedToolHandler(d.handleTabSelect))
|
||||||
s.AddTool(pageWaitLoadTool(), mcp.NewTypedToolHandler(d.handlePageWaitLoad))
|
s.AddTool(pageWaitLoadTool(), mcp.NewTypedToolHandler(d.handlePageWaitLoad))
|
||||||
s.AddTool(pageWaitIdleTool(), mcp.NewTypedToolHandler(d.handlePageWaitIdle))
|
s.AddTool(pageWaitIdleTool(), mcp.NewTypedToolHandler(d.handlePageWaitIdle))
|
||||||
|
|
||||||
@@ -152,6 +153,28 @@ func (d *deps) handleTabActivate(_ context.Context, _ mcp.CallToolRequest, a tab
|
|||||||
return mcp.NewToolResultText("activated tab " + a.TabID), nil
|
return mcp.NewToolResultText("activated tab " + a.TabID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- tab_select ----
|
||||||
|
|
||||||
|
type tabSelectArgs struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
Match string `json:"match"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func tabSelectTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("tab_select",
|
||||||
|
mcp.WithDescription("Fija la pestaña sobre la que operan las siguientes tools, eligiéndola por id o por substring de su URL (determinista). Úsala tras tab_list para no operar sobre la pestaña equivocada."),
|
||||||
|
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("match", mcp.Description("Target id exacto o substring de la URL de la pestaña. Vacío = primera page.")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handleTabSelect(_ context.Context, _ mcp.CallToolRequest, a tabSelectArgs) (*mcp.CallToolResult, error) {
|
||||||
|
if _, err := d.pool.connectTarget(portOr(a.Port), a.Match); err != nil {
|
||||||
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
return mcp.NewToolResultText("selected target matching: " + a.Match), nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---- nav_back (MUTA) ----
|
// ---- nav_back (MUTA) ----
|
||||||
|
|
||||||
type navBackArgs struct {
|
type navBackArgs struct {
|
||||||
|
|||||||
+107
-1
@@ -2,6 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
"github.com/mark3labs/mcp-go/server"
|
"github.com/mark3labs/mcp-go/server"
|
||||||
@@ -11,9 +15,12 @@ import (
|
|||||||
|
|
||||||
const htmlMax = 200_000
|
const htmlMax = 200_000
|
||||||
|
|
||||||
// registerReadTools wires page_get_html, page_eval_js (MUTA), page_screenshot.
|
// registerReadTools wires page_get_html, page_get_text, page_perceive,
|
||||||
|
// page_eval_js (MUTA), page_screenshot.
|
||||||
func registerReadTools(s *server.MCPServer, d *deps) {
|
func registerReadTools(s *server.MCPServer, d *deps) {
|
||||||
s.AddTool(pageGetHTMLTool(), mcp.NewTypedToolHandler(d.handlePageGetHTML))
|
s.AddTool(pageGetHTMLTool(), mcp.NewTypedToolHandler(d.handlePageGetHTML))
|
||||||
|
s.AddTool(pageGetTextTool(), mcp.NewTypedToolHandler(d.handlePageGetText))
|
||||||
|
s.AddTool(pagePerceiveTool(), mcp.NewTypedToolHandler(d.handlePagePerceive))
|
||||||
s.AddTool(pageScreenshotTool(), mcp.NewTypedToolHandler(d.handlePageScreenshot))
|
s.AddTool(pageScreenshotTool(), mcp.NewTypedToolHandler(d.handlePageScreenshot))
|
||||||
|
|
||||||
if !d.readOnly {
|
if !d.readOnly {
|
||||||
@@ -21,6 +28,105 @@ func registerReadTools(s *server.MCPServer, d *deps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- page_get_text ----
|
||||||
|
|
||||||
|
type pageGetTextArgs struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
Selector string `json:"selector"`
|
||||||
|
MaxBytes int `json:"max_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageGetTextTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("page_get_text",
|
||||||
|
mcp.WithDescription("Devuelve el texto visible (innerText) de la página o de un elemento (selector CSS), truncado a max_bytes. Preferir sobre page_get_html cuando solo necesitas leer contenido — no revienta el contexto."),
|
||||||
|
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
|
||||||
|
mcp.WithString("selector", mcp.Description("Selector CSS opcional. Vacío = body (toda la página).")),
|
||||||
|
mcp.WithNumber("max_bytes", mcp.Description("Máximo de bytes a devolver. Default 20000. 0 = sin límite.")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handlePageGetText(_ context.Context, _ mcp.CallToolRequest, a pageGetTextArgs) (*mcp.CallToolResult, error) {
|
||||||
|
maxBytes := a.MaxBytes
|
||||||
|
if maxBytes == 0 {
|
||||||
|
maxBytes = 20000
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||||
|
var e error
|
||||||
|
text, e = browser.CdpGetText(c, a.Selector, maxBytes)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
return mcp.NewToolResultText(text), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- page_perceive ----
|
||||||
|
|
||||||
|
type pagePerceiveArgs struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
TabID string `json:"tab_id"`
|
||||||
|
MaxChars int `json:"max_chars"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func pagePerceiveTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("page_perceive",
|
||||||
|
mcp.WithDescription("Devuelve un outline indentado y accionable del árbol de accesibilidad (roles, nombres, #ref) — la forma compacta de que el agente 'perciba' la página sin reventar el contexto. Si tab_id se omite, usa la primera pestaña page. Gotcha: requiere el binario `fn` y el venv de Python del registry disponibles en runtime."),
|
||||||
|
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("tab_id", mcp.Description("Target id de la pestaña. Vacío = primera pestaña page.")),
|
||||||
|
mcp.WithNumber("max_chars", mcp.Description("Máximo de chars del outline. Default 20000.")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handlePagePerceive(_ context.Context, _ mcp.CallToolRequest, a pagePerceiveArgs) (*mcp.CallToolResult, error) {
|
||||||
|
port := portOr(a.Port)
|
||||||
|
maxChars := a.MaxChars
|
||||||
|
if maxChars == 0 {
|
||||||
|
maxChars = 20000
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := resolveRoot()
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError("resolve registry root: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tabID := a.TabID
|
||||||
|
if tabID == "" {
|
||||||
|
tabs, err := browser.CdpListTabs("localhost", port)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError("list tabs: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
for _, t := range tabs {
|
||||||
|
if t.Type == "page" {
|
||||||
|
tabID = t.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tabID == "" {
|
||||||
|
return mcp.NewToolResultError("no 'page' tab found on port " + fmt.Sprint(port)), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(filepath.Join(root, "fn"), "run", "cdp_perceive_outline",
|
||||||
|
"--debug-port", fmt.Sprint(port),
|
||||||
|
"--tab-id", tabID,
|
||||||
|
"--max-chars", fmt.Sprint(maxChars),
|
||||||
|
)
|
||||||
|
cmd.Dir = root
|
||||||
|
var stdout, stderr strings.Builder
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
msg := strings.TrimSpace(stderr.String())
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
return mcp.NewToolResultError("cdp_perceive_outline failed: " + msg), nil
|
||||||
|
}
|
||||||
|
return mcp.NewToolResultText(truncate(stdout.String(), htmlMax)), nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---- page_get_html ----
|
// ---- page_get_html ----
|
||||||
|
|
||||||
type pageGetHTMLArgs struct {
|
type pageGetHTMLArgs struct {
|
||||||
|
|||||||
+16
-7
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
"github.com/mark3labs/mcp-go/server"
|
"github.com/mark3labs/mcp-go/server"
|
||||||
@@ -30,19 +32,26 @@ type launchArgs struct {
|
|||||||
|
|
||||||
func launchTool() mcp.Tool {
|
func launchTool() mcp.Tool {
|
||||||
return mcp.NewTool("browser_launch",
|
return mcp.NewTool("browser_launch",
|
||||||
mcp.WithDescription("Launch a Chrome/Chromium instance with CDP remote debugging enabled. Returns the launched PID. Waits up to 15s for the CDP port to be ready."),
|
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 9222.")),
|
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.WithBoolean("headless", mcp.Description("Run headless (--headless=new). Default false.")),
|
||||||
mcp.WithString("user_data_dir", mcp.Description("Chrome profile directory. Empty = /tmp/chrome-cdp-profile.")),
|
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.")),
|
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) {
|
func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchArgs) (*mcp.CallToolResult, error) {
|
||||||
|
// 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{
|
opts := browser.ChromeLaunchOpts{
|
||||||
Port: portOr(a.Port),
|
Port: portOr(a.Port),
|
||||||
Headless: a.Headless,
|
Headless: a.Headless,
|
||||||
UserDataDir: a.UserDataDir,
|
UserDataDir: userDataDir,
|
||||||
}
|
}
|
||||||
if a.URL != "" {
|
if a.URL != "" {
|
||||||
opts.ExtraArgs = append(opts.ExtraArgs, a.URL)
|
opts.ExtraArgs = append(opts.ExtraArgs, a.URL)
|
||||||
@@ -51,7 +60,7 @@ func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchAr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return mcp.NewToolResultError(err.Error()), nil
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
}
|
}
|
||||||
return mcp.NewToolResultText(fmt.Sprintf("launched pid=%d port=%d", pid, opts.Port)), nil
|
return mcp.NewToolResultText(fmt.Sprintf("launched pid=%d port=%d user_data_dir=%s", pid, opts.Port, userDataDir)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- browser_connect ----
|
// ---- browser_connect ----
|
||||||
@@ -63,7 +72,7 @@ type connectArgs struct {
|
|||||||
func connectTool() mcp.Tool {
|
func connectTool() mcp.Tool {
|
||||||
return mcp.NewTool("browser_connect",
|
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.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 9222.")),
|
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +93,7 @@ type disconnectArgs struct {
|
|||||||
func disconnectTool() mcp.Tool {
|
func disconnectTool() mcp.Tool {
|
||||||
return mcp.NewTool("browser_disconnect",
|
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 and drop the pooled CDP connection for the given port (cancels any armed dialog handler). Does NOT kill Chrome."),
|
||||||
mcp.WithNumber("port", mcp.Description("CDP port. Default 9222.")),
|
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user