fed245a738
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>
221 lines
8.6 KiB
Go
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
|
|
}
|