diff --git a/app.md b/app.md index 27687e7..46585d6 100644 --- a/app.md +++ b/app.md @@ -2,8 +2,8 @@ name: browser_mcp lang: go domain: infra -version: 0.3.0 -description: "Servidor MCP que expone control total del navegador via CDP (39 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión, selección determinista de pestaña, lectura compacta texto/AX + bucle percibir→actuar por #ref con auto-observe) 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." +version: 0.4.0 +description: "Servidor MCP que expone control total del navegador via CDP (40 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión, selección determinista de pestaña, lectura compacta texto/AX + bucle percibir→actuar por #ref con auto-observe, incluyendo find-ref-by-text) 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] uses_functions: - chrome_launch_go_browser @@ -26,6 +26,7 @@ uses_functions: - cdp_click_text_go_browser - cdp_type_text_go_browser - cdp_find_by_text_go_browser + - cdp_find_ref_by_text_go_browser - cdp_wait_element_go_browser - cdp_press_key_go_browser - cdp_scroll_go_browser diff --git a/pool.go b/pool.go index a2accce..0a5b6f2 100644 --- a/pool.go +++ b/pool.go @@ -11,13 +11,18 @@ import ( // Una conexión = una sesión viva a una tab "page". Mantenerla evita pagar el // handshake WebSocket en cada tool y preserva estado (event handlers, contexto). type connPool struct { - mu sync.Mutex - conns map[int]*browser.CDPConn - cancels map[int]func() // cancels de handlers persistentes (handle_dialog) + mu sync.Mutex + conns map[int]*browser.CDPConn + cancels map[int]func() // cancels de handlers persistentes (handle_dialog) + dialogLogs map[int]*browser.DialogLog // log de diálogos auto-respondidos por puerto } func newConnPool() *connPool { - return &connPool{conns: map[int]*browser.CDPConn{}, cancels: map[int]func(){}} + return &connPool{ + conns: map[int]*browser.CDPConn{}, + cancels: map[int]func(){}, + dialogLogs: map[int]*browser.DialogLog{}, + } } func (p *connPool) get(port int) (*browser.CDPConn, error) { @@ -41,8 +46,11 @@ func (p *connPool) drop(port int) { cancel() delete(p.cancels, port) } + delete(p.dialogLogs, port) if c, ok := p.conns[port]; ok && c != nil { - _ = browser.CdpClose(c, 0) + // CdpDisconnect = cerrar el WebSocket sin matar Chrome (el navegador + // sigue vivo; solo soltamos la sesión pooled). + _ = browser.CdpDisconnect(c) delete(p.conns, port) } } @@ -62,13 +70,27 @@ func (p *connPool) connectTarget(port int, match string) (*browser.CDPConn, erro return c, nil } -func (p *connPool) setCancel(port int, cancel func()) { +// setDialog guarda el cancel y el DialogLog del auto-handler de diálogos del +// puerto. Si ya había uno armado, lo cancela primero. +func (p *connPool) setDialog(port int, cancel func(), dlog *browser.DialogLog) { p.mu.Lock() defer p.mu.Unlock() if old := p.cancels[port]; old != nil { old() } p.cancels[port] = cancel + p.dialogLogs[port] = dlog +} + +// dialogSnapshot devuelve el estado del log de diálogos del puerto (0,"","" si +// no hay handler armado). +func (p *connPool) dialogSnapshot(port int) (int, string, string) { + p.mu.Lock() + defer p.mu.Unlock() + if dl := p.dialogLogs[port]; dl != nil { + return dl.Snapshot() + } + return 0, "", "" } func (p *connPool) closeAll() { @@ -79,11 +101,12 @@ func (p *connPool) closeAll() { cancel() } if c != nil { - _ = browser.CdpClose(c, 0) + _ = browser.CdpDisconnect(c) } } p.conns = map[int]*browser.CDPConn{} p.cancels = map[int]func(){} + p.dialogLogs = map[int]*browser.DialogLog{} } // isConnErr reconoce errores de conexión CDP muerta para reintentar UNA vez. diff --git a/tools_dom.go b/tools_dom.go index f68fba5..9dec50d 100644 --- a/tools_dom.go +++ b/tools_dom.go @@ -14,6 +14,7 @@ import ( // registerDomTools wires DOM interaction tools. find/wait stay on under --read-only. func registerDomTools(s *server.MCPServer, d *deps) { s.AddTool(domFindByTextTool(), mcp.NewTypedToolHandler(d.handleDomFindByText)) + s.AddTool(domFindRefByTextTool(), mcp.NewTypedToolHandler(d.handleDomFindRefByText)) s.AddTool(domWaitElementTool(), mcp.NewTypedToolHandler(d.handleDomWaitElement)) if !d.readOnly { @@ -34,23 +35,24 @@ const settleDelay = 400 * time.Millisecond // ---- dom_click_ref (MUTA) — bucle percibir→actuar ---- type domClickRefArgs struct { - Port int `json:"port"` - Ref int `json:"ref"` + Port int `json:"port"` + Ref int `json:"ref"` + Mode string `json:"mode"` } func domClickRefTool() mcp.Tool { return mcp.NewTool("dom_click_ref", - mcp.WithDescription("Click humanizado sobre el elemento por su #ref del outline de page_perceive (backendDOMNodeId estable). Usa humanización por defecto (Bézier+jitter). Devuelve el outline actualizado tras la acción (auto-observe)."), + mcp.WithDescription("Click sobre el elemento por su #ref del outline de page_perceive (backendDOMNodeId estable). Devuelve el outline actualizado tras la acción (auto-observe)."), 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("ref", mcp.Required(), mcp.Description("#ref del elemento (backendDOMNodeId) leído del outline de page_perceive.")), + mcp.WithString("mode", mcp.Description("Velocidad: 'human' (default, Bézier+jitter anti-bot), 'fast' (movimiento reducido, scraping masivo), 'instant' (element.click() JS, sin eventos de ratón; también fallback si el elemento no tiene geometría).")), ) } func (d *deps) handleDomClickRef(_ context.Context, _ mcp.CallToolRequest, a domClickRefArgs) (*mcp.CallToolResult, error) { port := portOr(a.Port) - // TODO: preset de humanización por sesión (human/fast/instant) err := d.withConn(port, func(c *browser.CDPConn) error { - return browser.CdpClickRef(c, a.Ref, browser.MouseHumanOpts{}) + return browser.CdpClickRef(c, a.Ref, browser.MouseProfileForMode(a.Mode)) }) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -97,23 +99,24 @@ func (d *deps) handleDomTypeRef(_ context.Context, _ mcp.CallToolRequest, a domT // ---- dom_hover_ref (MUTA) — bucle percibir→actuar ---- type domHoverRefArgs struct { - Port int `json:"port"` - Ref int `json:"ref"` + Port int `json:"port"` + Ref int `json:"ref"` + Mode string `json:"mode"` } func domHoverRefTool() mcp.Tool { return mcp.NewTool("dom_hover_ref", - mcp.WithDescription("Hover humanizado sobre el elemento por su #ref del outline de page_perceive (backendDOMNodeId estable). Usa humanización por defecto (Bézier+jitter). Devuelve el outline actualizado tras la acción (auto-observe)."), + mcp.WithDescription("Hover sobre el elemento por su #ref del outline de page_perceive (backendDOMNodeId estable). Devuelve el outline actualizado tras la acción (auto-observe)."), 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("ref", mcp.Required(), mcp.Description("#ref del elemento (backendDOMNodeId) leído del outline de page_perceive.")), + mcp.WithString("mode", mcp.Description("Velocidad: 'human' (default, Bézier+jitter), 'fast' (movimiento reducido), 'instant' (sin movimiento de ratón).")), ) } func (d *deps) handleDomHoverRef(_ context.Context, _ mcp.CallToolRequest, a domHoverRefArgs) (*mcp.CallToolResult, error) { port := portOr(a.Port) - // TODO: preset de humanización por sesión (human/fast/instant) err := d.withConn(port, func(c *browser.CDPConn) error { - return browser.CdpHoverRef(c, a.Ref, browser.MouseHumanOpts{}) + return browser.CdpHoverRef(c, a.Ref, browser.MouseProfileForMode(a.Mode)) }) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -266,6 +269,41 @@ func (d *deps) handleDomFindByText(_ context.Context, _ mcp.CallToolRequest, a d return mcp.NewToolResultText(sel), nil } +// ---- dom_find_ref_by_text ---- + +type domFindRefByTextArgs struct { + Port int `json:"port"` + Text string `json:"text"` +} + +func domFindRefByTextTool() mcp.Tool { + return mcp.NewTool("dom_find_ref_by_text", + mcp.WithDescription("Find the first element whose visible text matches and return its #ref (backendDOMNodeId) ready for dom_click_ref/dom_hover_ref — no fragile CSS selector. Also reports how many elements match (count>1 = ambiguous)."), + 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("text", mcp.Required(), mcp.Description("Visible text to match (substring).")), + ) +} + +func (d *deps) handleDomFindRefByText(_ context.Context, _ mcp.CallToolRequest, a domFindRefByTextArgs) (*mcp.CallToolResult, error) { + if a.Text == "" { + return mcp.NewToolResultError("text is required"), nil + } + var ref, count int + err := d.withConn(portOr(a.Port), func(c *browser.CDPConn) error { + var e error + ref, count, e = browser.CdpFindRefByText(c, a.Text, browser.FindByTextOpts{}) + return e + }) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + msg := fmt.Sprintf("ref=%d count=%d", ref, count) + if count > 1 { + msg += " (ambiguous: returning the first match; refine the text to disambiguate)" + } + return mcp.NewToolResultText(msg), nil +} + // ---- dom_wait_element ---- type domWaitElementArgs struct { diff --git a/tools_input.go b/tools_input.go index 27df385..f9a74d2 100644 --- a/tools_input.go +++ b/tools_input.go @@ -101,10 +101,12 @@ func (d *deps) handleHandleDialog(_ context.Context, _ mcp.CallToolRequest, a ha if err != nil { return mcp.NewToolResultError(err.Error()), nil } - cancel, err := browser.CdpHandleDialog(c, a.Accept, a.PromptText) + cancel, dlog, err := browser.CdpHandleDialog(c, a.Accept, a.PromptText) if err != nil { return mcp.NewToolResultError(err.Error()), nil } - d.pool.setCancel(port, cancel) + // Guardamos el DialogLog junto al cancel para que browser_disconnect pueda + // reportar cuántos diálogos se auto-respondieron y cuál fue el último. + d.pool.setDialog(port, cancel, dlog) return mcp.NewToolResultText("dialog auto-handler armed"), nil } diff --git a/tools_session.go b/tools_session.go index 2bac431..cad74e8 100644 --- a/tools_session.go +++ b/tools_session.go @@ -99,6 +99,12 @@ func disconnectTool() mcp.Tool { func (d *deps) handleDisconnect(_ context.Context, _ mcp.CallToolRequest, a disconnectArgs) (*mcp.CallToolResult, error) { port := portOr(a.Port) + // Leer el log de diálogos ANTES de drop (drop lo limpia). + count, lastType, lastMsg := d.pool.dialogSnapshot(port) d.pool.drop(port) - return mcp.NewToolResultText(fmt.Sprintf("disconnected port=%d", port)), nil + msg := fmt.Sprintf("disconnected port=%d", port) + if count > 0 { + msg += fmt.Sprintf(" (dialogs auto-handled: %d, last %s: %q)", count, lastType, lastMsg) + } + return mcp.NewToolResultText(msg), nil }