33358bca6c
build.sh inyecta la version de app.md por -ldflags (-X main.version), haciendo de app.md la unica fuente de verdad — el binario ya no puede quedar por detras (drift 0.7.0 vs 0.8.0 del 16/06/2026). main.go pasa la version de const a var para permitir el override por ldflags. scripts/pre-commit recompila en cada commit para que el binario que sirve el .mcp.json nunca quede stale respecto a los .go commiteados (la causa raiz del mismo bug). scripts/install-hooks.sh lo instala via symlink.
205 lines
5.5 KiB
Go
205 lines
5.5 KiB
Go
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/mark3labs/mcp-go/server"
|
|
|
|
"fn-registry/functions/browser"
|
|
)
|
|
|
|
// version is the server version reported in serverInfo. The literal here is a
|
|
// fallback for `go build` with no flags; build.sh overrides it via
|
|
// -ldflags "-X main.version=<app.md version>" so app.md stays the single source
|
|
// of truth and the binary can never drift behind it (see build.sh).
|
|
var version = "0.8.0"
|
|
|
|
type config struct {
|
|
httpAddr string
|
|
bind string
|
|
readOnly bool
|
|
logLevel string
|
|
}
|
|
|
|
// deps carries shared state into tool handlers.
|
|
type deps struct {
|
|
pool *connPool
|
|
readOnly bool
|
|
}
|
|
|
|
func main() {
|
|
var cfg config
|
|
flag.StringVar(&cfg.httpAddr, "http", "", "Listen on HTTP address (e.g. :7740). Empty = stdio.")
|
|
flag.StringVar(&cfg.bind, "bind", "127.0.0.1", "HTTP bind address. Use 0.0.0.0 only with REGISTRY_API_TOKEN set.")
|
|
flag.BoolVar(&cfg.readOnly, "read-only", false, "Register only read tools (no mutating browser actions).")
|
|
flag.StringVar(&cfg.logLevel, "log-level", "info", "Log level: debug, info, warn, error.")
|
|
flag.Parse()
|
|
|
|
// Slog → stderr (stdio JSON-RPC owns stdout).
|
|
lvl := parseLevel(cfg.logLevel)
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})))
|
|
|
|
pool := newConnPool()
|
|
// Cierre por EOF de stdio (ServeStdio retorna) o salida normal de serveHTTP.
|
|
defer pool.closeAll()
|
|
|
|
// Cierre por señal: SIGTERM/SIGINT NO ejecutan defers, así que matamos los
|
|
// Chrome propios explícitamente antes de salir. Sin esto, al matar el MCP los
|
|
// chromium lanzados quedaban vivos y huérfanos (~789 MiB RSS cada uno) — el
|
|
// leak que provocó el apagón por saturación de RAM (06/06/2026).
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
go func() {
|
|
sig := <-sigCh
|
|
slog.Info("signal received, killing launched chromes", "signal", sig.String())
|
|
pool.closeAll()
|
|
os.Exit(0)
|
|
}()
|
|
|
|
d := &deps{pool: pool, readOnly: cfg.readOnly}
|
|
|
|
srv := server.NewMCPServer(
|
|
"browser_mcp",
|
|
version,
|
|
server.WithToolCapabilities(true),
|
|
)
|
|
|
|
registerTools(srv, d)
|
|
|
|
slog.Info("starting browser_mcp",
|
|
"version", version,
|
|
"transport", transportLabel(cfg),
|
|
"read_only", cfg.readOnly,
|
|
)
|
|
|
|
if cfg.httpAddr == "" {
|
|
if err := server.ServeStdio(srv); err != nil {
|
|
slog.Error("stdio server", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := serveHTTP(srv, cfg); err != nil {
|
|
slog.Error("http server", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// registerTools wires every tool group. Mutating tools are skipped under --read-only.
|
|
func registerTools(s *server.MCPServer, d *deps) {
|
|
registerSessionTools(s, d)
|
|
registerLifecycleTools(s, d)
|
|
registerNavTools(s, d)
|
|
registerReadTools(s, d)
|
|
registerDomTools(s, d)
|
|
registerInputTools(s, d)
|
|
registerCookieTools(s, d)
|
|
registerFrameTools(s, d)
|
|
registerStorageTools(s, d)
|
|
}
|
|
|
|
// portOr returns the CDP port, defaulting to 9333 when zero.
|
|
//
|
|
// SECURITY (P0.3): the default is 9333 — the MCP's OWN isolated Chrome — NOT
|
|
// 9222. Port 9222 is the user's daily chromium (CDP enabled globally via
|
|
// /etc/chromium.d/cdp). Defaulting there would let the agent drive the user's
|
|
// banking/email tabs. The MCP operates on its dedicated browser by default;
|
|
// pass port=9222 explicitly only to deliberately attach to the daily browser.
|
|
func portOr(p int) int {
|
|
if p == 0 {
|
|
return 9333
|
|
}
|
|
return p
|
|
}
|
|
|
|
// withConn obtiene la conexión del puerto y ejecuta fn. Si falla con error de
|
|
// conexión muerta, descarta y reintenta UNA vez (Chrome pudo cerrar la tab).
|
|
func (d *deps) withConn(port int, fn func(c *browser.CDPConn) error) error {
|
|
c, err := d.pool.get(port)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = fn(c)
|
|
if err != nil && isConnErr(err) {
|
|
// La conexión murió (Chrome pudo cerrar la tab). Soltamos SOLO el
|
|
// WebSocket y reconectamos al mismo Chrome — releaseConn, no drop: drop
|
|
// mataría el proceso y dejaría sin nada a qué reconectar.
|
|
d.pool.releaseConn(port)
|
|
c2, err2 := d.pool.get(port)
|
|
if err2 != nil {
|
|
return err2
|
|
}
|
|
return fn(c2)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// serveHTTP hosts the MCP server over Streamable HTTP with optional bearer auth.
|
|
func serveHTTP(s *server.MCPServer, cfg config) error {
|
|
addr := cfg.bind + cfg.httpAddr
|
|
|
|
httpSrv := server.NewStreamableHTTPServer(s)
|
|
|
|
token := os.Getenv("REGISTRY_API_TOKEN")
|
|
if cfg.bind == "0.0.0.0" && token == "" {
|
|
return fmt.Errorf("--bind 0.0.0.0 requires REGISTRY_API_TOKEN")
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
if token != "" {
|
|
mux.Handle("/", authMiddleware(token, httpSrv))
|
|
} else {
|
|
mux.Handle("/", httpSrv)
|
|
}
|
|
|
|
slog.Info("listening http", "addr", addr)
|
|
return http.ListenAndServe(addr, mux)
|
|
}
|
|
|
|
func authMiddleware(token string, next http.Handler) http.Handler {
|
|
expected := "Bearer " + token
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") != expected {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func transportLabel(cfg config) string {
|
|
if cfg.httpAddr == "" {
|
|
return "stdio"
|
|
}
|
|
return fmt.Sprintf("http %s%s", cfg.bind, cfg.httpAddr)
|
|
}
|
|
|
|
func parseLevel(s string) slog.Level {
|
|
switch strings.ToLower(s) {
|
|
case "debug":
|
|
return slog.LevelDebug
|
|
case "warn":
|
|
return slog.LevelWarn
|
|
case "error":
|
|
return slog.LevelError
|
|
default:
|
|
return slog.LevelInfo
|
|
}
|
|
}
|
|
|
|
// truncate caps a string at n chars, appending a marker when cut.
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "\n... [truncated]"
|
|
}
|