feat: 4 tools nuevas + browser_list enriquecido
Tools nuevas (wrappers finos sobre funciones del registry functions/browser): - page_collect_console -> cdp_collect_console (console + exceptions + log, snapshot) - page_pdf -> cdp_print_pdf (Page.printToPDF a archivo) - dom_select_option -> cdp_select_option (<select> por value/texto + input/change) - dom_set_files -> cdp_set_file_input (subir archivos a <input type=file>) browser_list ahora enriquece cada master con CDP con pages (nº de page targets), active_title y active_url via GET /json (best-effort: si el puerto no responde los campos quedan a cero y el listado de procesos no falla). Total tools: 46 -> 50. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,9 +27,74 @@ func registerDomTools(s *server.MCPServer, d *deps) {
|
|||||||
s.AddTool(domTypeRefTool(), mcp.NewTypedToolHandler(d.handleDomTypeRef))
|
s.AddTool(domTypeRefTool(), mcp.NewTypedToolHandler(d.handleDomTypeRef))
|
||||||
s.AddTool(domHoverRefTool(), mcp.NewTypedToolHandler(d.handleDomHoverRef))
|
s.AddTool(domHoverRefTool(), mcp.NewTypedToolHandler(d.handleDomHoverRef))
|
||||||
s.AddTool(domClickXYTool(), mcp.NewTypedToolHandler(d.handleDomClickXY))
|
s.AddTool(domClickXYTool(), mcp.NewTypedToolHandler(d.handleDomClickXY))
|
||||||
|
s.AddTool(domSelectOptionTool(), mcp.NewTypedToolHandler(d.handleDomSelectOption))
|
||||||
|
s.AddTool(domSetFilesTool(), mcp.NewTypedToolHandler(d.handleDomSetFiles))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- dom_select_option (MUTA) ----
|
||||||
|
|
||||||
|
type domSelectOptionArgs struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
Selector string `json:"selector"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func domSelectOptionTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("dom_select_option",
|
||||||
|
mcp.WithDescription("Select an <option> in a native <select> element (by CSS selector), matching by option value first, then by visible text, and firing input/change events so React/Vue react. For custom (non-<select>) dropdowns use dom_click_ref on the trigger then on the option instead."),
|
||||||
|
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.Required(), mcp.Description("CSS selector of the <select> element.")),
|
||||||
|
mcp.WithString("value", mcp.Required(), mcp.Description("Option value (or visible text if no value matches).")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handleDomSelectOption(_ context.Context, _ mcp.CallToolRequest, a domSelectOptionArgs) (*mcp.CallToolResult, error) {
|
||||||
|
if a.Selector == "" || a.Value == "" {
|
||||||
|
return mcp.NewToolResultError("selector and value are required"), nil
|
||||||
|
}
|
||||||
|
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||||
|
return browser.CdpSelectOption(c, a.Selector, a.Value)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
return mcp.NewToolResultText(fmt.Sprintf("selected %q in %s", a.Value, a.Selector)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- dom_set_files (MUTA) ----
|
||||||
|
|
||||||
|
type domSetFilesArgs struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
Selector string `json:"selector"`
|
||||||
|
Paths []string `json:"paths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func domSetFilesTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("dom_set_files",
|
||||||
|
mcp.WithDescription("Upload files to an <input type=\"file\"> (by CSS selector) via DOM.setFileInputFiles, without driving the OS file picker. Paths must be absolute and readable by the Chrome process."),
|
||||||
|
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.Required(), mcp.Description("CSS selector of the file input element.")),
|
||||||
|
mcp.WithArray("paths", mcp.Required(), mcp.Description("Absolute file paths to attach."), mcp.Items(map[string]any{"type": "string"})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handleDomSetFiles(_ context.Context, _ mcp.CallToolRequest, a domSetFilesArgs) (*mcp.CallToolResult, error) {
|
||||||
|
if a.Selector == "" {
|
||||||
|
return mcp.NewToolResultError("selector is required"), nil
|
||||||
|
}
|
||||||
|
if len(a.Paths) == 0 {
|
||||||
|
return mcp.NewToolResultError("paths is required (at least one file)"), nil
|
||||||
|
}
|
||||||
|
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||||
|
return browser.CdpSetFileInput(c, a.Selector, a.Paths)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
return mcp.NewToolResultText(fmt.Sprintf("attached %d file(s) to %s", len(a.Paths), a.Selector)), nil
|
||||||
|
}
|
||||||
|
|
||||||
// defaultMode es el modo de velocidad cuando ni la llamada ni la sesión fijan uno.
|
// defaultMode es el modo de velocidad cuando ni la llamada ni la sesión fijan uno.
|
||||||
// "auto" = rápido (movimiento de ratón mínimo, escritura en un solo evento, settle
|
// "auto" = rápido (movimiento de ratón mínimo, escritura en un solo evento, settle
|
||||||
// breve) — el modo por defecto del MCP. "human" (Bézier + esperas aleatorias) se
|
// breve) — el modo por defecto del MCP. "human" (Bézier + esperas aleatorias) se
|
||||||
|
|||||||
+40
-2
@@ -16,6 +16,8 @@ import (
|
|||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
"github.com/mark3labs/mcp-go/server"
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
|
||||||
|
"fn-registry/functions/browser"
|
||||||
)
|
)
|
||||||
|
|
||||||
// registerLifecycleTools wires the per-profile Chromium lifecycle tools:
|
// registerLifecycleTools wires the per-profile Chromium lifecycle tools:
|
||||||
@@ -55,7 +57,10 @@ type chromiumMaster struct {
|
|||||||
UserDataDir string `json:"user_data_dir"` // value of --user-data-dir
|
UserDataDir string `json:"user_data_dir"` // value of --user-data-dir
|
||||||
CDPPort string `json:"cdp_port"` // value of --remote-debugging-port ("" if none)
|
CDPPort string `json:"cdp_port"` // value of --remote-debugging-port ("" if none)
|
||||||
HasCDP bool `json:"has_cdp"`
|
HasCDP bool `json:"has_cdp"`
|
||||||
Headless bool `json:"headless"` // true if launched with --headless / --headless=new / --headless=old
|
Headless bool `json:"headless"` // true if launched with --headless / --headless=new / --headless=old
|
||||||
|
Pages int `json:"pages"` // count of "page" targets (best-effort via GET /json; 0 if no CDP or unreachable)
|
||||||
|
ActiveTitle string `json:"active_title,omitempty"` // title of the first "page" target
|
||||||
|
ActiveURL string `json:"active_url,omitempty"` // URL of the first "page" target
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCmdline turns the raw bytes of /proc/<pid>/cmdline into argv.
|
// parseCmdline turns the raw bytes of /proc/<pid>/cmdline into argv.
|
||||||
@@ -271,7 +276,7 @@ type browserListArgs struct{}
|
|||||||
|
|
||||||
func browserListTool() mcp.Tool {
|
func browserListTool() mcp.Tool {
|
||||||
return mcp.NewTool("browser_list",
|
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, headless (true if launched with --headless). Returns a JSON array. Read-only."),
|
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, headless (true if launched with --headless), pages (count of open page targets via GET /json, best-effort), active_title/active_url (first open page). Returns a JSON array. Read-only."),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,10 +288,43 @@ func (d *deps) handleBrowserList(_ context.Context, _ mcp.CallToolRequest, _ bro
|
|||||||
if masters == nil {
|
if masters == nil {
|
||||||
masters = []chromiumMaster{}
|
masters = []chromiumMaster{}
|
||||||
}
|
}
|
||||||
|
// Enriquecer cada master con CDP con su nº de páginas y la primera página
|
||||||
|
// (título/URL) consultando GET /json. Best-effort: si el puerto no responde,
|
||||||
|
// se dejan los campos a cero — el listado de procesos nunca falla por esto.
|
||||||
|
for i := range masters {
|
||||||
|
enrichMasterTabs(&masters[i])
|
||||||
|
}
|
||||||
b, _ := json.MarshalIndent(masters, "", " ")
|
b, _ := json.MarshalIndent(masters, "", " ")
|
||||||
return mcp.NewToolResultText(string(b)), nil
|
return mcp.NewToolResultText(string(b)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enrichMasterTabs rellena Pages/ActiveTitle/ActiveURL de un master consultando
|
||||||
|
// sus targets CDP por HTTP. No devuelve error: cualquier fallo (sin CDP, puerto
|
||||||
|
// caído, timeout) deja los campos en su cero y el master se reporta igual.
|
||||||
|
func enrichMasterTabs(m *chromiumMaster) {
|
||||||
|
if m.CDPPort == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(m.CDPPort)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tabs, err := browser.CdpListTabs("localhost", port)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, t := range tabs {
|
||||||
|
if t.Type != "page" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.Pages++
|
||||||
|
if m.ActiveURL == "" {
|
||||||
|
m.ActiveTitle = t.Title
|
||||||
|
m.ActiveURL = t.URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- browser_launch_profile (MUTA) ----
|
// ---- browser_launch_profile (MUTA) ----
|
||||||
|
|
||||||
type launchProfileArgs struct {
|
type launchProfileArgs struct {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
@@ -20,12 +22,91 @@ func registerReadTools(s *server.MCPServer, d *deps) {
|
|||||||
s.AddTool(pageGetTextTool(), mcp.NewTypedToolHandler(d.handlePageGetText))
|
s.AddTool(pageGetTextTool(), mcp.NewTypedToolHandler(d.handlePageGetText))
|
||||||
s.AddTool(pagePerceiveTool(), mcp.NewTypedToolHandler(d.handlePagePerceive))
|
s.AddTool(pagePerceiveTool(), mcp.NewTypedToolHandler(d.handlePagePerceive))
|
||||||
s.AddTool(pageScreenshotTool(), mcp.NewTypedToolHandler(d.handlePageScreenshot))
|
s.AddTool(pageScreenshotTool(), mcp.NewTypedToolHandler(d.handlePageScreenshot))
|
||||||
|
s.AddTool(pageCollectConsoleTool(), mcp.NewTypedToolHandler(d.handlePageCollectConsole))
|
||||||
|
s.AddTool(pagePDFTool(), mcp.NewTypedToolHandler(d.handlePagePDF))
|
||||||
|
|
||||||
if !d.readOnly {
|
if !d.readOnly {
|
||||||
s.AddTool(pageEvalJSTool(), mcp.NewTypedToolHandler(d.handlePageEvalJS))
|
s.AddTool(pageEvalJSTool(), mcp.NewTypedToolHandler(d.handlePageEvalJS))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- page_collect_console ----
|
||||||
|
|
||||||
|
type pageCollectConsoleArgs struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
DurationMs int `json:"duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageCollectConsoleTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("page_collect_console",
|
||||||
|
mcp.WithDescription("Capture the page's console output (console.log/info/warn/error), uncaught JS exceptions and browser log entries during a time window, and return them as JSON. It is a SNAPSHOT: it records what happens during duration_ms AFTER the call starts, not past history — so trigger the action you want to observe (reload, click) right before or during the window. Use this to debug why a page misbehaves without flying blind."),
|
||||||
|
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
|
||||||
|
mcp.WithNumber("duration_ms", mcp.Description("Capture window in milliseconds. Default 1500.")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handlePageCollectConsole(_ context.Context, _ mcp.CallToolRequest, a pageCollectConsoleArgs) (*mcp.CallToolResult, error) {
|
||||||
|
var entries []browser.ConsoleEntry
|
||||||
|
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||||
|
var e error
|
||||||
|
entries, e = browser.CdpCollectConsole(c, a.DurationMs)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []browser.ConsoleEntry{}
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(entries, "", " ")
|
||||||
|
return mcp.NewToolResultText(truncate(string(b), htmlMax)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- page_pdf ----
|
||||||
|
|
||||||
|
type pagePDFArgs struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Landscape bool `json:"landscape"`
|
||||||
|
PrintBackground bool `json:"print_background"`
|
||||||
|
Scale float64 `json:"scale"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func pagePDFTool() mcp.Tool {
|
||||||
|
return mcp.NewTool("page_pdf",
|
||||||
|
mcp.WithDescription("Render the current page to a PDF (Page.printToPDF) and write it to a local file path. Use for archiving an article/invoice/report exactly as laid out, when a screenshot is not enough (multi-page, selectable text)."),
|
||||||
|
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.Required(), mcp.Description("Output .pdf file path.")),
|
||||||
|
mcp.WithBoolean("landscape", mcp.Description("Landscape orientation. Default false (portrait).")),
|
||||||
|
mcp.WithBoolean("print_background", mcp.Description("Include background graphics/colors. Default false.")),
|
||||||
|
mcp.WithNumber("scale", mcp.Description("Render scale. Default 1.0.")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *deps) handlePagePDF(_ context.Context, _ mcp.CallToolRequest, a pagePDFArgs) (*mcp.CallToolResult, error) {
|
||||||
|
if a.Path == "" {
|
||||||
|
return mcp.NewToolResultError("path is required"), nil
|
||||||
|
}
|
||||||
|
opts := browser.CdpPrintPDFOpts{
|
||||||
|
Landscape: a.Landscape,
|
||||||
|
PrintBackground: a.PrintBackground,
|
||||||
|
Scale: a.Scale,
|
||||||
|
}
|
||||||
|
var data []byte
|
||||||
|
err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error {
|
||||||
|
var e error
|
||||||
|
data, e = browser.CdpPrintPDF(c, opts)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
if e := os.WriteFile(a.Path, data, 0o644); e != nil {
|
||||||
|
return mcp.NewToolResultError("saving pdf to " + a.Path + ": " + e.Error()), nil
|
||||||
|
}
|
||||||
|
return mcp.NewToolResultText(fmt.Sprintf("pdf saved to %s (%d bytes)", a.Path, len(data))), nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---- page_get_text ----
|
// ---- page_get_text ----
|
||||||
|
|
||||||
type pageGetTextArgs struct {
|
type pageGetTextArgs struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user