mirror of
https://github.com/lukaszraczylo/mcp-readitall.git
synced 2026-10-12 00:45:33 +00:00
226 lines
7.5 KiB
Go
226 lines
7.5 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/chromedp/cdproto/cdp"
|
|
"github.com/chromedp/chromedp"
|
|
"github.com/lukaszraczylo/mcp-readitall/internal/browser"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// LoginInput is the schema for the login tool. The flow is:
|
|
//
|
|
// 1. A visible Chromium window opens and navigates to URL.
|
|
// 2. The human operator (you) authenticates by hand in that window.
|
|
// 3. The tool watches for SuccessURL (substring match against the
|
|
// address bar) or SuccessSelector (a CSS selector that becomes
|
|
// visible, e.g. an avatar dropdown) and, when matched, persists the
|
|
// browser's storage state to the canonical session path for the
|
|
// host.
|
|
// 4. Future read_url calls against this host will reuse the saved
|
|
// state.
|
|
//
|
|
// On a headless host (e.g. a remote MCP server) the visible-window
|
|
// approach will not work; the operator must run the MCP server on a
|
|
// machine with a display. We surface a clear error in that case so the
|
|
// caller knows the limitation is environmental, not a bug.
|
|
type LoginInput struct {
|
|
URL string `json:"url" jsonschema:"Absolute http(s) URL to open (typically the login page)"`
|
|
SuccessURL string `json:"success_url,omitempty" jsonschema:"Substring of the URL bar that indicates login succeeded (e.g. /dashboard). Polled every second."`
|
|
SuccessSelector string `json:"success_selector,omitempty" jsonschema:"CSS selector that becomes visible once login is complete (e.g. an avatar menu). Polled every second."`
|
|
TimeoutSeconds int `json:"timeout_seconds,omitempty" jsonschema:"How long to wait for a successful login in seconds (5-3600, default 300)"`
|
|
}
|
|
|
|
// LoginOutput reports what happened.
|
|
type LoginOutput struct {
|
|
Domain string `json:"domain"`
|
|
Saved bool `json:"saved"`
|
|
Path string `json:"path,omitempty"`
|
|
FinalURL string `json:"final_url"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// RegisterLogin wires the login tool to the server.
|
|
func RegisterLogin(s *mcp.Server, pw *browser.Browser, sess *browser.SessionStore, logger *log.Logger) {
|
|
mcp.AddTool(s, &mcp.Tool{
|
|
Name: "login",
|
|
Description: "Open a visible browser to a URL, wait for the human operator to authenticate, then persist the resulting browser storage state (cookies + localStorage) for the host. Subsequent read_url calls against this host will use the saved state automatically. Useful for sites that require login to expose their content.",
|
|
}, func(parentCtx context.Context, _ *mcp.CallToolRequest, in LoginInput) (*mcp.CallToolResult, LoginOutput, error) {
|
|
if in.URL == "" {
|
|
return nil, LoginOutput{}, fmt.Errorf("url is required")
|
|
}
|
|
if in.SuccessURL == "" && in.SuccessSelector == "" {
|
|
return nil, LoginOutput{}, fmt.Errorf("success_url or success_selector is required so the tool knows when login is complete")
|
|
}
|
|
timeout := time.Duration(in.TimeoutSeconds) * time.Second
|
|
if timeout == 0 {
|
|
timeout = 5 * time.Minute
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(parentCtx, timeout)
|
|
defer cancel()
|
|
|
|
browserCtx, releaseBrowser, err := pw.NewHeadedContext()
|
|
if err != nil {
|
|
return nil, LoginOutput{}, fmt.Errorf("launch headed browser: %w", err)
|
|
}
|
|
defer releaseBrowser()
|
|
|
|
// Start from a clean cookie jar so a stale session from a
|
|
// previous attempt cannot mask the new login's success.
|
|
if err := browser.ClearAllCookies(browserCtx); err != nil {
|
|
return nil, LoginOutput{}, fmt.Errorf("clear cookies: %w", err)
|
|
}
|
|
|
|
var title string
|
|
if err := chromedp.Run(browserCtx,
|
|
chromedp.Navigate(in.URL),
|
|
chromedp.WaitReady("body"),
|
|
chromedp.Title(&title),
|
|
); err != nil {
|
|
return nil, LoginOutput{}, fmt.Errorf("navigate: %w", err)
|
|
}
|
|
|
|
logger.Printf("login flow started for %s, waiting for success condition", in.URL)
|
|
|
|
if err := waitForLogin(ctx, browserCtx, in, timeout); err != nil {
|
|
return nil, LoginOutput{
|
|
Domain: hostOf(in.URL),
|
|
FinalURL: currentLocation(browserCtx),
|
|
Message: err.Error(),
|
|
}, fmt.Errorf("login did not complete: %w", err)
|
|
}
|
|
|
|
finalURL := currentLocation(browserCtx)
|
|
|
|
statePath, err := sess.StatePath(in.URL)
|
|
if err != nil {
|
|
return nil, LoginOutput{}, err
|
|
}
|
|
if err := browser.CaptureStorageState(browserCtx, statePath); err != nil {
|
|
return nil, LoginOutput{
|
|
Domain: hostOf(in.URL),
|
|
FinalURL: finalURL,
|
|
Message: "success condition matched but capturing storage state failed",
|
|
}, err
|
|
}
|
|
|
|
// Validate the captured state has at least one cookie or origin
|
|
// entry, otherwise we would happily save an "authenticated" file
|
|
// even when the success condition matched an unrelated selector.
|
|
if err := validateCapturedState(statePath); err != nil {
|
|
return nil, LoginOutput{
|
|
Domain: hostOf(in.URL),
|
|
FinalURL: finalURL,
|
|
Message: "success condition matched but storage state is empty; auth likely failed",
|
|
}, err
|
|
}
|
|
|
|
msg := fmt.Sprintf("session saved for %s", hostOf(in.URL))
|
|
return &mcp.CallToolResult{
|
|
Content: []mcp.Content{&mcp.TextContent{Text: msg}},
|
|
}, LoginOutput{
|
|
Domain: hostOf(in.URL),
|
|
Saved: true,
|
|
Path: statePath,
|
|
FinalURL: finalURL,
|
|
Message: msg,
|
|
}, nil
|
|
})
|
|
}
|
|
|
|
// waitForLogin polls the page once per second until the success
|
|
// condition is met or the timeout fires. It uses chromedp.Location and
|
|
// chromedp.Nodes (with a visibility check) for the two supported
|
|
// condition types.
|
|
func waitForLogin(ctx context.Context, pageCtx context.Context, in LoginInput, timeout time.Duration) error {
|
|
deadline := time.Now().Add(timeout)
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
|
|
check := func() (bool, error) {
|
|
if in.SuccessURL != "" {
|
|
var current string
|
|
if err := chromedp.Run(pageCtx, chromedp.Location(¤t)); err != nil {
|
|
return false, err
|
|
}
|
|
if strings.Contains(current, in.SuccessURL) {
|
|
return true, nil
|
|
}
|
|
}
|
|
if in.SuccessSelector != "" {
|
|
var nodes []*cdp.Node
|
|
if err := chromedp.Run(pageCtx, chromedp.Nodes(in.SuccessSelector, &nodes, chromedp.ByQuery)); err != nil {
|
|
return false, err
|
|
}
|
|
if len(nodes) > 0 {
|
|
var visible bool
|
|
if err := chromedp.Run(pageCtx, chromedp.Evaluate(
|
|
`(()=>{const e=document.querySelector(`+jsQuote(in.SuccessSelector)+`);return !!e && e.getBoundingClientRect().height>0;})()`,
|
|
&visible,
|
|
)); err != nil {
|
|
return false, err
|
|
}
|
|
if visible {
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// Immediate check so an already-authenticated session is captured
|
|
// without a 1-second wait.
|
|
if ok, err := check(); err != nil {
|
|
return err
|
|
} else if ok {
|
|
return nil
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-ticker.C:
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("timed out after %s waiting for login", timeout)
|
|
}
|
|
ok, err := check()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ok {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// currentLocation returns the page's current URL or "" if the query
|
|
// fails (e.g. the browser has already been torn down).
|
|
func currentLocation(ctx context.Context) string {
|
|
var s string
|
|
if err := chromedp.Run(ctx, chromedp.Location(&s)); err != nil {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|
|
|
|
// validateCapturedState reads the just-saved storage state file and
|
|
// ensures it has at least one cookie or one localStorage entry. This
|
|
// guards against "login success" false positives (e.g. selector matches
|
|
// a generic header element that happens to be visible).
|
|
func validateCapturedState(path string) error {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("read state: %w", err)
|
|
}
|
|
return browser.ValidateStorageState(raw)
|
|
}
|