Files
browser_mcp/tools_read.go
T
egutierrez fed245a738 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>
2026-06-06 17:35:33 +02:00

221 lines
8.6 KiB
Go

package main
import (
"context"
"encoding/base64"
"os"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"fn-registry/functions/browser"
)
const htmlMax = 200_000
// registerReadTools wires page_get_html, page_get_text, page_perceive,
// page_eval_js (MUTA), page_screenshot.
func registerReadTools(s *server.MCPServer, d *deps) {
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))
if !d.readOnly {
s.AddTool(pageEvalJSTool(), mcp.NewTypedToolHandler(d.handlePageEvalJS))
}
}
// ---- 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"`
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. 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("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.")),
)
}
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
}
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 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.perceiveOutlineFrame(port, "", maxChars)
}
// 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 "", err
}
return truncate(outline, htmlMax), nil
}
// ---- page_get_html ----
type pageGetHTMLArgs struct {
Port int `json:"port"`
}
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 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
)
}
func (d *deps) handlePageGetHTML(_ context.Context, _ mcp.CallToolRequest, a pageGetHTMLArgs) (*mcp.CallToolResult, error) {
var html string
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
var e error
html, e = browser.CdpGetHTML(c)
return e
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(truncate(html, htmlMax)), nil
}
// ---- page_eval_js (MUTA) ----
type pageEvalJSArgs struct {
Port int `json:"port"`
Expression string `json:"expression"`
}
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 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.")),
)
}
func (d *deps) handlePageEvalJS(_ context.Context, _ mcp.CallToolRequest, a pageEvalJSArgs) (*mcp.CallToolResult, error) {
if a.Expression == "" {
return mcp.NewToolResultError("expression is required"), nil
}
var res string
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
var e error
res, e = browser.CdpEvaluate(c, a.Expression)
return e
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(truncate(res, htmlMax)), nil
}
// ---- page_screenshot ----
type pageScreenshotArgs struct {
Port int `json:"port"`
Path string `json:"path"`
FullPage bool `json:"full_page"`
}
func pageScreenshotTool() mcp.Tool {
return mcp.NewTool("page_screenshot",
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) {
opts := browser.CdpScreenshotOpts{FullPage: a.FullPage}
var data []byte
var mimeType string
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
var e error
data, mimeType, e = browser.CdpScreenshotBytes(c, opts)
return e
})
if err != nil {
return mcp.NewToolResultError(err.Error()), 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
}