feat(browser_mcp): perceive nativo Go, datos de iframe, click XY y screenshot como imagen (v0.6.0)
Capacidades nuevas y cambios (40 -> 42 tools): - page_perceive ahora se genera de forma NATIVA en Go sobre la conexion CDP viva del pool (cdp_get_ax_outline_go_browser). Elimina el subprocess `fn run cdp_perceive_outline` (Python), el venv y la dependencia del binario `fn` en runtime (se borra resolveRoot/exec.Command). Respeta tab_select. - page_perceive acepta frame_id para percibir DENTRO de un iframe. El campo tab_id queda obsoleto (se ignora; usar tab_select) pero se conserva por compatibilidad. - frame_get_text (nueva, lectura): innerText de un iframe via cdp_get_text_in_frame_go_browser. Activa tambien bajo --read-only. - dom_click_xy (nueva, MUTA): click humanizado por coordenadas absolutas via cdp_click_xy_human_go_browser, con mode human/fast/instant y auto-observe. Fallback para actuar sobre lo que el LLM ve en page_screenshot. - page_screenshot devuelve la imagen como image content (cdp_screenshot_bytes_go_browser + mcp.NewToolResultImage) para que el LLM vea los pixeles; path pasa a ser opcional (si se da, ademas guarda a disco). - Auto-observe de las tools *_ref sube su truncado de 4000 a 8000 chars. - Fix de seguridad documental: todas las descripciones del parametro port que decian "Default 9222" (navegador diario del usuario) corregidas a "Default 9333" (Chrome aislado del MCP). El codigo ya usaba 9333; la doc era falsa y podia inducir al modelo a tocar pestanas de banca/correo. uses_functions del app.md: +cdp_get_ax_outline, +cdp_get_text_in_frame, +cdp_screenshot_bytes; -cdp_perceive_outline_py_pipelines. Verificacion: go build OK, go test OK (4 unit pass, 3 e2e skip gated BMCP_E2E=1), go vet OK, gofmt limpio, sin "Default 9222" en el codigo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+48
-65
@@ -2,10 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
@@ -67,14 +65,16 @@ func (d *deps) handlePageGetText(_ context.Context, _ mcp.CallToolRequest, a pag
|
||||
type pagePerceiveArgs struct {
|
||||
Port int `json:"port"`
|
||||
TabID string `json:"tab_id"`
|
||||
FrameID string `json:"frame_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.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. Generado de forma nativa en Go sobre la conexión CDP viva (sin subprocess ni Python). Para elegir la pestaña, usa tab_select ANTES de percibir (la conexión del pool ya está fijada a esa pestaña). Si frame_id se pasa, percibe DENTRO de ese iframe (obtén el id con frame_list)."),
|
||||
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.WithString("tab_id", mcp.Description("OBSOLETO: la conexión del pool ya está fijada a una pestaña vía tab_select. Para elegir pestaña usa tab_select primero; este campo se conserva por compatibilidad y se ignora.")),
|
||||
mcp.WithString("frame_id", mcp.Description("Frame ID (de frame_list) para percibir DENTRO de ese iframe. Vacío = página entera.")),
|
||||
mcp.WithNumber("max_chars", mcp.Description("Máximo de chars del outline. Default 20000.")),
|
||||
)
|
||||
}
|
||||
@@ -86,66 +86,36 @@ func (d *deps) handlePagePerceive(_ context.Context, _ mcp.CallToolRequest, a pa
|
||||
maxChars = 20000
|
||||
}
|
||||
|
||||
outline, err := d.perceiveOutlineTab(port, a.TabID, maxChars)
|
||||
outline, err := d.perceiveOutlineFrame(port, a.FrameID, maxChars)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(outline), nil
|
||||
}
|
||||
|
||||
// perceiveOutline genera el outline AX accionable de la pestaña (vía el pipeline
|
||||
// cdp_perceive_outline). Usa la primera pestaña 'page' del puerto.
|
||||
// perceiveOutline genera el outline AX accionable de la página entera sobre la
|
||||
// conexión viva del pool (sin subprocess). Lo usan los auto-observe de las tools
|
||||
// *_ref tras una acción.
|
||||
func (d *deps) perceiveOutline(port, maxChars int) (string, error) {
|
||||
return d.perceiveOutlineTab(port, "", maxChars)
|
||||
return d.perceiveOutlineFrame(port, "", maxChars)
|
||||
}
|
||||
|
||||
// perceiveOutlineTab genera el outline AX accionable de la pestaña indicada (vía
|
||||
// el pipeline cdp_perceive_outline). Si tabID es "", usa la primera pestaña 'page'.
|
||||
// Resuelve la raíz del registry para localizar el binario `fn` + el venv de Python
|
||||
// y ejecuta `<root>/fn run cdp_perceive_outline <port> <tabID> <maxChars>` por
|
||||
// subprocess, devolviendo su stdout truncado a htmlMax.
|
||||
func (d *deps) perceiveOutlineTab(port int, tabID string, maxChars int) (string, error) {
|
||||
root, err := resolveRoot()
|
||||
// perceiveOutlineFrame genera el outline AX accionable de forma NATIVA en Go,
|
||||
// reusando la conexión CDP viva del pool (browser.CdpGetAXOutline). Si frameID
|
||||
// != "", percibe DENTRO de ese iframe; frameID == "" = página entera. No lanza
|
||||
// subprocess `fn run` ni levanta el venv de Python — la lógica de poda y render
|
||||
// del árbol de accesibilidad vive en la función del registry.
|
||||
func (d *deps) perceiveOutlineFrame(port int, frameID string, maxChars int) (string, error) {
|
||||
var outline string
|
||||
err := d.withConn(port, func(c *browser.CDPConn) error {
|
||||
var e error
|
||||
outline, e = browser.CdpGetAXOutline(c, frameID, maxChars)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve registry root: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if tabID == "" {
|
||||
tabs, err := browser.CdpListTabs("localhost", port)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list tabs: %w", err)
|
||||
}
|
||||
for _, t := range tabs {
|
||||
if t.Type == "page" {
|
||||
tabID = t.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if tabID == "" {
|
||||
return "", fmt.Errorf("no 'page' tab found on port %d", port)
|
||||
}
|
||||
}
|
||||
|
||||
// `fn run` pasa los argumentos POSICIONALMENTE a la función del pipeline
|
||||
// (no como flags argparse): el orden debe coincidir con la firma
|
||||
// cdp_perceive_outline(debug_port, tab_id, max_chars).
|
||||
cmd := exec.Command(filepath.Join(root, "fn"), "run", "cdp_perceive_outline",
|
||||
fmt.Sprint(port),
|
||||
tabID,
|
||||
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 "", fmt.Errorf("cdp_perceive_outline failed: %s", msg)
|
||||
}
|
||||
return truncate(stdout.String(), htmlMax), nil
|
||||
return truncate(outline, htmlMax), nil
|
||||
}
|
||||
|
||||
// ---- page_get_html ----
|
||||
@@ -157,7 +127,7 @@ type pageGetHTMLArgs struct {
|
||||
func pageGetHTMLTool() mcp.Tool {
|
||||
return mcp.NewTool("page_get_html",
|
||||
mcp.WithDescription("Return the current page's full serialized HTML (outerHTML). Truncated to 200000 chars."),
|
||||
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.")),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -184,7 +154,7 @@ type pageEvalJSArgs struct {
|
||||
func pageEvalJSTool() mcp.Tool {
|
||||
return mcp.NewTool("page_eval_js",
|
||||
mcp.WithDescription("Evaluate a JavaScript expression in the page context via Runtime.evaluate. Returns the stringified result."),
|
||||
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.")),
|
||||
mcp.WithString("expression", mcp.Required(), mcp.Description("JavaScript expression to evaluate.")),
|
||||
)
|
||||
}
|
||||
@@ -215,23 +185,36 @@ type pageScreenshotArgs struct {
|
||||
|
||||
func pageScreenshotTool() mcp.Tool {
|
||||
return mcp.NewTool("page_screenshot",
|
||||
mcp.WithDescription("Capture a screenshot of the current page and write it to a local path (.png/.jpg)."),
|
||||
mcp.WithNumber("port", mcp.Description("CDP port. Default 9222.")),
|
||||
mcp.WithString("path", mcp.Required(), mcp.Description("Output file path (.png or .jpg).")),
|
||||
mcp.WithDescription("Capture a screenshot of the current page and return it as image content so the LLM can actually see the pixels. Optionally also writes it to a local path. Use this when the accessibility outline (page_perceive) is not enough — e.g. canvas/visual layouts — then act with dom_click_xy over what you see."),
|
||||
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("path", mcp.Description("Optional output file path (.png or .jpg). If given, the image is ALSO saved to disk; the image content is always returned regardless.")),
|
||||
mcp.WithBoolean("full_page", mcp.Description("Capture the full scroll height instead of just the viewport.")),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *deps) handlePageScreenshot(_ context.Context, _ mcp.CallToolRequest, a pageScreenshotArgs) (*mcp.CallToolResult, error) {
|
||||
if a.Path == "" {
|
||||
return mcp.NewToolResultError("path is required"), nil
|
||||
}
|
||||
opts := browser.CdpScreenshotOpts{FullPage: a.FullPage}
|
||||
var data []byte
|
||||
var mimeType string
|
||||
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||
return browser.CdpScreenshot(c, a.Path, opts)
|
||||
var e error
|
||||
data, mimeType, e = browser.CdpScreenshotBytes(c, opts)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText("screenshot saved to " + a.Path), nil
|
||||
|
||||
text := "screenshot captured"
|
||||
// Si se pidió un path, persistimos además los bytes capturados (mismo origen
|
||||
// que la imagen devuelta al LLM, así no se captura dos veces).
|
||||
if a.Path != "" {
|
||||
if e := os.WriteFile(a.Path, data, 0o644); e != nil {
|
||||
return mcp.NewToolResultError("saving screenshot to " + a.Path + ": " + e.Error()), nil
|
||||
}
|
||||
text = "screenshot saved to " + a.Path
|
||||
}
|
||||
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
return mcp.NewToolResultImage(text, b64, mimeType), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user