Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,22 @@ Choose **"Skip — I'll send the emails myself"** in the setup wizard (or `optio

---

## Let a local AI agent handle follow-ups

`eraser mcp` connects Eraser to Codex over local STDIO. Ask **“Handle my pending
removal tasks”**, review the proposed batch, and approve it once. The agent can
open confirmation links, fill forms, inspect receipts, and save notes and
screenshots in the existing **Tasks** page. Email sending uses your existing
settings, templates, limits, and history. Codex supplies the AI; Eraser needs no
separate AI API key.

Chrome/Chromium is required for forms. CAPTCHA, login, identity checks, uncertain
submissions, and unexpected destinations need your intervention. A completed form
means the request was accepted, not that the broker has erased your data.

See **[MCP setup and the fake-data demo](docs/mcp.md)** for the Codex configuration,
required batch approval settings, and supported workflows.

## For Developers: CLI Usage

If you prefer the command line, Eraser has a full CLI.
Expand Down
109 changes: 109 additions & 0 deletions cmd/eraser/cmd_mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package main

import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"

"github.com/drumandbytes/eraser/internal/history"
"github.com/drumandbytes/eraser/internal/mcpserver"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/spf13/cobra"
)

func mcpCmd() *cobra.Command {
var headless, demo bool
var chromePath string
cmd := &cobra.Command{
Use: "mcp", Short: "Expose Eraser tasks to a local AI agent over MCP (experimental)",
Long: "Start a local MCP server using the same configuration and history as the web UI.\nBatches and retries start only after you approve them: on the Automation page in the web UI, or with `eraser mcp approve <id>` in a terminal.\nChrome/Chromium is required for browser actions. Use --demo to try fake local forms.",
RunE: func(cmd *cobra.Command, _ []string) error {
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
var s *mcpserver.Service
var cleanup func()
var err error
if demo {
s, cleanup, err = mcpserver.NewDemo(headless, chromePath)
} else {
s, err = mcpserver.New(mcpserver.Options{ConfigPath: resolveConfigPath(), BrokerPath: brokerFile, ProfileID: profileFlag, Headless: headless, ChromePath: chromePath})
}
if err != nil {
return err
}
if cleanup != nil {
defer cleanup()
}
defer func() { _ = s.Close() }()
fmt.Fprintln(os.Stderr, "Eraser MCP (experimental) ready. Waiting for a local client; logs use stderr.")
err = s.MCP.Run(ctx, &mcp.StdioTransport{})
if ctx.Err() != nil {
return context.Cause(ctx)
}
return err
},
}
cmd.AddCommand(mcpApproveCmd(), mcpPendingCmd())
cmd.Flags().BoolVar(&headless, "headless", false, "Hide Chrome (visible by default for verification handoff)")
cmd.Flags().BoolVar(&demo, "demo", false, "Use isolated fake data and local sample forms; never access real email or brokers")
cmd.Flags().StringVar(&chromePath, "chrome-path", "", "Optional path to Chrome/Chromium")
return cmd
}

// stdinIsTerminal is a var so tests can stand in for a person at a terminal.
var stdinIsTerminal = func() bool {
fi, err := os.Stdin.Stat()
return err == nil && fi.Mode()&os.ModeCharDevice != 0
}

func mcpApproveCmd() *cobra.Command {
return &cobra.Command{
Use: "approve <plan_id>", Short: "Approve a previewed MCP batch (once, for 15 minutes)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// an agent with a shell runs commands without a terminal
if !stdinIsTerminal() {
return fmt.Errorf("approve must be run by a person in a terminal; use the Automation page in the web UI otherwise")
}
store, err := openHistoryStore(history.DBPathFor(resolveConfigPath()))
if err != nil {
return err
}
defer func() { _ = store.Close() }()
if err := store.ApproveBatch(args[0], "cli"); err != nil {
return fmt.Errorf("approve %s: %w", args[0], err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Approved batch %s. The agent can begin it once, within 15 minutes.\n", args[0])
return nil
},
}
}

func mcpPendingCmd() *cobra.Command {
return &cobra.Command{
Use: "pending", Short: "List MCP batches waiting for approval",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
store, err := openHistoryStore(history.DBPathFor(resolveConfigPath()))
if err != nil {
return err
}
defer func() { _ = store.Close() }()
pending, err := store.PendingBatchApprovals()
if err != nil {
return err
}
if len(pending) == 0 {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No batches waiting for approval.")
return nil
}
for _, a := range pending {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s profile=%s expires=%s\n%s\n\n", a.PlanID, a.ProfileID, a.ExpiresAt.Format(time.RFC3339), a.Preview)
}
return nil
},
}
}
53 changes: 53 additions & 0 deletions cmd/eraser/cmd_mcp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package main

import (
"testing"
"time"

"github.com/drumandbytes/eraser/internal/history"
)

func TestMCPApproveAndPending(t *testing.T) {
orig := stdinIsTerminal
t.Cleanup(func() { stdinIsTerminal = orig })
stdinIsTerminal = func() bool { return true }
e := newCLIEnv(t, manualConfig)
store, err := history.NewStore(history.DBPathFor(e.cfgPath))
if err != nil {
t.Fatal(err)
}
if err := store.CreateBatchApproval(history.BatchApproval{PlanID: "plan-1", ProfileID: "default", Digest: "d", Preview: `{"emails":[]}`, ExpiresAt: time.Now().Add(time.Minute)}); err != nil {
t.Fatal(err)
}
_ = store.Close()

out, err := e.run(t, "", "mcp", "pending")
if err != nil {
t.Fatalf("pending: %v\n%s", err, out)
}
mustContain(t, out, "plan-1", "profile=default")

stdinIsTerminal = func() bool { return false }
if _, err := e.run(t, "", "mcp", "approve", "plan-1"); err == nil {
t.Fatal("approved without a terminal")
}
stdinIsTerminal = func() bool { return true }
out, err = e.run(t, "", "mcp", "approve", "plan-1")
if err != nil {
t.Fatalf("approve: %v\n%s", err, out)
}
mustContain(t, out, "Approved batch plan-1")

out, err = e.run(t, "", "mcp", "pending")
if err != nil {
t.Fatalf("pending after approve: %v\n%s", err, out)
}
mustContain(t, out, "No batches waiting for approval.")

if _, err := e.run(t, "", "mcp", "approve", "plan-1"); err == nil {
t.Fatal("approved the same batch twice")
}
if _, err := e.run(t, "", "mcp", "approve", "unknown"); err == nil {
t.Fatal("approved a batch that does not exist")
}
}
1 change: 1 addition & 0 deletions cmd/eraser/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ send through any SMTP provider (Gmail, Proton Mail Bridge, Fastmail, ...).`,
rootCmd.AddCommand(initCmd())
rootCmd.AddCommand(sendCmd())
rootCmd.AddCommand(serveCmd())
rootCmd.AddCommand(mcpCmd())
rootCmd.AddCommand(listBrokersCmd())
rootCmd.AddCommand(statusCmd())
rootCmd.AddCommand(addBrokerCmd())
Expand Down
24 changes: 24 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- **Database**: SQLite (for history tracking via `modernc.org/sqlite`)
- **Config**: YAML (`gopkg.in/yaml.v3`)
- **Browser automation**: chromedp, for `fill`/`confirm` commands and CAPTCHA detection
- **Local agent connection**: official Go MCP SDK, STDIO transport (`eraser mcp`)

## Project Structure

Expand Down Expand Up @@ -36,6 +37,7 @@ eraser/
│ ├── inbox/ # IMAP scan (scan.go: ScanAndStore/RecordReply) + reply classification (success/form-required/
│ │ # confirmation/rejection/pending/bounced)
│ ├── schedule/ # unattended cycles: shared lock + state file, launchd/systemd install
│ ├── mcpserver/ # profile-scoped MCP tools, reviewed batches, persistent browser sessions
│ ├── template/
│ │ ├── template.go # Template rendering engine
│ │ └── templates/ # Embedded: gdpr.tmpl, ccpa.tmpl, generic.tmpl
Expand All @@ -61,6 +63,28 @@ CI (`.github/workflows/ci.yml`) runs `go build`/`go vet`/`go test -race` and `go

## Key Concepts

### MCP automation

`cmd_mcp.go` exposes `internal/mcpserver` through the official Go MCP SDK. The
server is local and serves one client session. The client supplies the AI and
must prompt before `begin_batch` and `retry_action`; the server enforces the
resulting profile/task/broker scope. Changing settings invalidates that scope.

Browser tools use `internal/browser/automation.go`, retaining Chrome between
calls and referencing controls from fresh snapshots in an isolated JavaScript
world. Destinations derive from the selected broker's catalog entries. Request
interception checks redirects and form traffic; a cumulative document CSP
blocks popups, workers, and out-of-scope connections. This deliberately limits
compatibility with embedded forms and external dependencies.

`internal/history/automation.go` adds an action journal without changing existing
tables. An action is claimed before external work; interrupted submissions
remain unknown and cannot be automatically replayed. Notes, screenshots, and
completion use existing `pending_tasks`, and accepted requests remain awaiting
a response in the pipeline. Email uses `email.SendRemoval` and the existing
profile-scoped limits; inbox replies use the existing classifier with an
explicitly resolved owner. See [mcp.md](mcp.md) for operational limits.

### Broker
`data/brokers.yaml` is compiled into the binary (`data/embed.go`, `//go:embed`).
`broker.Load(override)` resolves it from `--brokers`, then `~/.eraser/brokers.yaml`
Expand Down
7 changes: 7 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ go test ./...
./eraser confirm # click confirmation links from broker emails
./eraser fill # browser-automate opt-out forms
./eraser serve [-p 3000] # web UI
./eraser mcp [--demo] [--headless] [--chrome-path /path/to/chrome] # local MCP over stdio
./eraser profile list # list configured profiles
./eraser profile add # add a second/third named profile
```
Expand All @@ -48,6 +49,12 @@ Web UI equivalents for the per-profile commands: `export` → History → Export

## Configuration

`mcp` shares the web UI's configuration, selected broker list, profiles, and
history database. Its browser stays visible by default so a user can complete
verification. `--demo` uses an isolated temporary profile and five local forms;
it never accesses real mail or broker sites. See [mcp.md](mcp.md) for the required
client approval settings and tool reference.

Which of these keys and commands are covered by the 1.x compatibility promise: [stability.md](stability.md).

User config is stored at `~/.eraser/config.yaml` (see `config.example.yaml` for the full schema). Key sections:
Expand Down
Loading
Loading