mirror of
https://github.com/lukaszraczylo/mcp-readitall.git
synced 2026-10-12 00:45:33 +00:00
* ci(release): add goreleaser and semver configuration The release builds only cmd/readitall for Linux, macOS and Windows on amd64 and arm64. The checksum file is signed with cosign. The semver config uses regex matching. A major release needs an explicit trailer line. * ci: add shared pull request, release and autoupdate workflows The workflows call the shared workflows in lukaszraczylo/shared-actions. The release runs on push to main when Go files change. * docs: describe release binary installation The README now tells you to download a binary from the GitHub release page. * fix(browser): remove the unused mutex field of SessionStore Staticcheck reports U1000 for the field. No method locks it. The pull request checks fail until the field is removed.
248 lines
7.5 KiB
Go
248 lines
7.5 KiB
Go
package browser
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// SessionStore persists browser storage state per host. State files live
|
|
// under a configurable root directory (default ~/.mcp-readitall/sessions/<host>/
|
|
// state.json) and contain cookies + localStorage entries that authenticate
|
|
// subsequent browser contexts against the same host. The on-disk JSON shape
|
|
// is the same as Playwright's storage state so sessions are interchangeable.
|
|
type SessionStore struct {
|
|
root string
|
|
}
|
|
|
|
// SessionInfo describes a persisted session, returned to the MCP client by
|
|
// list_sessions.
|
|
type SessionInfo struct {
|
|
Domain string `json:"domain"`
|
|
Path string `json:"path"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
}
|
|
|
|
// NewSessionStore creates a store rooted at the default location
|
|
// ($HOME/.mcp-readitall/sessions) unless overridden via READITALL_SESSIONS_DIR.
|
|
func NewSessionStore() (*SessionStore, error) {
|
|
root := os.Getenv("READITALL_SESSIONS_DIR")
|
|
if root == "" {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("locate home dir: %w", err)
|
|
}
|
|
root = filepath.Join(home, ".mcp-readitall", "sessions")
|
|
}
|
|
if err := os.MkdirAll(root, 0o700); err != nil {
|
|
return nil, fmt.Errorf("create sessions root %q: %w", root, err)
|
|
}
|
|
return &SessionStore{root: root}, nil
|
|
}
|
|
|
|
// Root returns the directory where session files are stored.
|
|
func (s *SessionStore) Root() string { return s.root }
|
|
|
|
// HostKey normalises a URL to a safe directory name. It lower-cases the host
|
|
// and replaces any non-alphanumeric characters (except . and -) with _.
|
|
func HostKey(rawURL string) (string, error) {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse url: %w", err)
|
|
}
|
|
host := strings.ToLower(u.Host)
|
|
if host == "" {
|
|
return "", fmt.Errorf("url has no host: %q", rawURL)
|
|
}
|
|
// Strip credentials if present.
|
|
if i := strings.Index(host, "@"); i >= 0 {
|
|
host = host[i+1:]
|
|
}
|
|
// Use a separate var to satisfy go vet's "loop variable captured" check
|
|
// even though we only iterate once.
|
|
safe := make([]rune, 0, len(host))
|
|
for _, r := range host {
|
|
switch {
|
|
case r >= 'a' && r <= 'z',
|
|
r >= '0' && r <= '9',
|
|
r == '.', r == '-':
|
|
safe = append(safe, r)
|
|
default:
|
|
safe = append(safe, '_')
|
|
}
|
|
}
|
|
return string(safe), nil
|
|
}
|
|
|
|
// StatePath returns the path where storage state for the given URL's host is
|
|
// (or will be) stored. The file may not exist yet.
|
|
func (s *SessionStore) StatePath(rawURL string) (string, error) {
|
|
key, err := HostKey(rawURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(s.root, key, "state.json"), nil
|
|
}
|
|
|
|
// Exists reports whether a session is stored for the given URL's host.
|
|
func (s *SessionStore) Exists(rawURL string) (bool, error) {
|
|
path, err := s.StatePath(rawURL)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
_, err = os.Stat(path)
|
|
switch {
|
|
case err == nil:
|
|
return true, nil
|
|
case errors.Is(err, os.ErrNotExist):
|
|
return false, nil
|
|
default:
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
// Save writes the JSON-encoded storage state (cookies + origins) to the
|
|
// canonical path for the URL's host. The state value is expected to be the
|
|
// raw JSON object matching the standard {cookies, origins} storage state
|
|
// shape used by Playwright and chromedp.
|
|
func (s *SessionStore) Save(rawURL string, stateJSON []byte) (string, error) {
|
|
key, err := HostKey(rawURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dir := filepath.Join(s.root, key)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return "", fmt.Errorf("create session dir: %w", err)
|
|
}
|
|
path := filepath.Join(dir, "state.json")
|
|
if err := os.WriteFile(path, stateJSON, 0o600); err != nil {
|
|
return "", fmt.Errorf("write state: %w", err)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// List returns metadata for every stored session, sorted by host.
|
|
func (s *SessionStore) List() ([]SessionInfo, error) {
|
|
entries, err := os.ReadDir(s.root)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
var out []SessionInfo
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
path := filepath.Join(s.root, e.Name(), "state.json")
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, SessionInfo{
|
|
Domain: e.Name(),
|
|
Path: path,
|
|
UpdatedAt: info.ModTime(),
|
|
SizeBytes: info.Size(),
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain })
|
|
return out, nil
|
|
}
|
|
|
|
// Clear removes the saved session for the given host. It returns true when a
|
|
// file was deleted, false when nothing existed.
|
|
func (s *SessionStore) Clear(rawURL string) (bool, error) {
|
|
key, err := HostKey(rawURL)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
dir := filepath.Join(s.root, key)
|
|
// Stat first so we can report whether anything was actually there, then
|
|
// remove. We cannot rely on os.RemoveAll's return value to distinguish
|
|
// "missing" from "removed" — both succeed.
|
|
existed := false
|
|
if _, statErr := os.Stat(dir); statErr == nil {
|
|
existed = true
|
|
} else if !errors.Is(statErr, os.ErrNotExist) {
|
|
return false, fmt.Errorf("stat session: %w", statErr)
|
|
}
|
|
if !existed {
|
|
return false, nil
|
|
}
|
|
if err := os.RemoveAll(dir); err != nil {
|
|
return false, fmt.Errorf("remove session: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// Close releases any resources held by the store. Currently a no-op kept
|
|
// for symmetry with the Browser wrapper.
|
|
func (s *SessionStore) Close() error { return nil }
|
|
|
|
// PrettyPrint returns a human-readable summary of a session, used in tool
|
|
// responses.
|
|
func (s SessionInfo) PrettyPrint() string {
|
|
age := time.Since(s.UpdatedAt).Truncate(time.Second)
|
|
return fmt.Sprintf("%s (%d bytes, updated %s ago)", s.Domain, s.SizeBytes, age)
|
|
}
|
|
|
|
// storageStateShape is used only to validate that a value looks like a
|
|
// Playwright storage state blob before we persist it.
|
|
type storageStateShape struct {
|
|
Cookies []json.RawMessage `json:"cookies"`
|
|
Origins []json.RawMessage `json:"origins"`
|
|
}
|
|
|
|
// ValidateStorageState does a cheap structural check on the JSON so we do
|
|
// not silently store an empty or malformed blob.
|
|
func ValidateStorageState(stateJSON []byte) error {
|
|
var s storageStateShape
|
|
if err := json.Unmarshal(stateJSON, &s); err != nil {
|
|
return fmt.Errorf("storage state is not valid JSON: %w", err)
|
|
}
|
|
if len(s.Cookies) == 0 && len(s.Origins) == 0 {
|
|
return fmt.Errorf("storage state has no cookies or origins; auth likely failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateStorageStateStruct is the equivalent of ValidateStorageState for
|
|
// callers that already hold a parsed value (e.g. a slice of CDP
|
|
// *network.Cookie or a Playwright *StorageState). It round-trips through
|
|
// JSON and only inspects the cookies / origins fields, so the exact
|
|
// concrete type does not matter as long as the JSON shape is correct.
|
|
func ValidateStorageStateStruct(state any) error {
|
|
if state == nil {
|
|
return fmt.Errorf("storage state is nil; auth likely failed")
|
|
}
|
|
// We type-switch on the concrete shape because the input may be a
|
|
// third-party type that we cannot import directly; we only care about
|
|
// cookies and origins here.
|
|
type shaped struct {
|
|
Cookies []any `json:"cookies"`
|
|
Origins []any `json:"origins"`
|
|
}
|
|
raw, err := json.Marshal(state)
|
|
if err != nil {
|
|
return fmt.Errorf("encode storage state: %w", err)
|
|
}
|
|
var s shaped
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
return fmt.Errorf("storage state has unexpected shape: %w", err)
|
|
}
|
|
if len(s.Cookies) == 0 && len(s.Origins) == 0 {
|
|
return fmt.Errorf("storage state has no cookies or origins; auth likely failed")
|
|
}
|
|
return nil
|
|
}
|