diff --git a/docs/superpowers/plans/2026-06-07-captcha-solver-mcp.md b/docs/superpowers/plans/2026-06-07-captcha-solver-mcp.md
new file mode 100644
index 0000000..2704472
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-07-captcha-solver-mcp.md
@@ -0,0 +1,3314 @@
+# captcha-solver-mcp Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A Go MCP server that acts as a browser-agnostic vision/LLM "brain" for captcha solving and a stealth-artifact generator, fed by the caller's Playwright/Lightpanda.
+
+**Architecture:** Solver-only/brain-only. Tools take images/audio/challenge-screenshots/page-context and return answers, tile-click decisions, and stealth artifacts (JS init scripts, fingerprints, humanized interaction plans). The LLM sits behind a `llm.Client` interface so every handler is unit-testable with a fake client. MCP over stdio via the official Go SDK.
+
+**Tech Stack:** Go 1.26, `github.com/modelcontextprotocol/go-sdk` v1.2.0 (MCP), stdlib `net/http` for LLM providers (raw REST, no provider SDKs — uniform multi-provider layer), stdlib `image` for grid geometry. E2E: Go test harness driving the real server over stdio + Playwright (Chromium) and Lightpanda against a local fake-captcha page.
+
+**Grounded references (verified this session):**
+- Go MCP SDK v1.2.0: `mcp.NewServer(&mcp.Implementation{Name,Version}, nil)`; `mcp.AddTool(server, &mcp.Tool{Name,Description}, handler)` where `handler` is `func(ctx, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error)`; `server.Run(ctx, &mcp.StdioTransport{})`. `In`/`Out` use `json:"…"` + `jsonschema:"…"` tags; return `(nil, out, nil)` and the SDK fills structured content.
+- OpenAI: `POST {base}/v1/chat/completions` with message content parts `{"type":"text","text":…}` and `{"type":"image_url","image_url":{"url":"data:image/png;base64,…"}}`; response `.choices[0].message.content`. Transcription: `POST {base}/v1/audio/transcriptions` multipart fields `model` + `file`; response `.text`.
+- Anthropic: `POST {base}/v1/messages`, headers `x-api-key`, `anthropic-version: 2023-06-01`; body `{model,max_tokens,messages:[{role:"user",content:[{type:"text",text},{type:"image",source:{type:"base64",media_type,data}}]}]}`; response `.content[]` first `{type:"text"}`. **Do NOT send `temperature`/`top_p`** (Opus 4.x → 400).
+
+**Quality gates (per project standards):** `golangci-lint run` clean incl. `fieldalignment`; `gosec` applies to `_test.go` too; table-driven tests; build/test locally (no GitHub Actions).
+
+**Conventions used throughout this plan:**
+- Module path: `github.com/lukaszraczylo/captcha-solver-mcp`.
+- All structs are written with fields ordered largest-alignment-first to satisfy `fieldalignment` (strings = 16 bytes, then `float64`/`int64`/`int` = 8, then `bool` = 1).
+- Commit after each task. Conventional-commit prefixes; keep bodies free of the words "breaking"/"feat" unless intended (semver-generator fuzzy-matches commit bodies).
+
+---
+
+## Phase 0 — Scaffold
+
+### Task 0: Module, directories, tooling
+
+**Files:**
+- Create: `go.mod`, `.golangci.yml`, `Makefile`, `.env.example`, `README.md` (skeleton)
+- Create dir markers via first real files in later tasks.
+
+- [ ] **Step 1: Init module**
+
+Run:
+```bash
+go mod init github.com/lukaszraczylo/captcha-solver-mcp
+go get github.com/modelcontextprotocol/go-sdk/mcp@v1.2.0
+```
+Expected: `go.mod` + `go.sum` created with the SDK dependency.
+
+- [ ] **Step 2: Write `.golangci.yml`**
+
+```yaml
+version: "2"
+linters:
+ enable:
+ - govet
+ - staticcheck
+ - errcheck
+ - ineffassign
+ - unused
+ - gosec
+linters-settings:
+ govet:
+ enable:
+ - fieldalignment
+issues:
+ exclude-rules: []
+```
+
+- [ ] **Step 3: Write `Makefile`**
+
+```makefile
+.PHONY: build test e2e e2e-live lint tidy
+build:
+ go build -o bin/captcha-solver-mcp ./cmd/captcha-solver-mcp
+test:
+ go test ./...
+e2e:
+ go test -tags e2e ./test/e2e/...
+e2e-live:
+ CAPTCHA_E2E_LIVE=1 go test -tags e2e,live ./test/e2e/...
+lint:
+ golangci-lint run
+tidy:
+ go mod tidy
+```
+
+- [ ] **Step 4: Write `.env.example`**
+
+```bash
+# Provider: anthropic | openai | openai-compatible | gemini
+CAPTCHA_LLM_PROVIDER=anthropic
+# Optional endpoint override (local/Ollama/OpenRouter). Empty = provider default.
+CAPTCHA_LLM_BASE_URL=
+CAPTCHA_LLM_API_KEY=sk-...
+# Vision-capable model id (e.g. claude-opus-4-8, gpt-4o, gemini-2.5-pro)
+CAPTCHA_LLM_MODEL=claude-opus-4-8
+# Optional separate audio/transcription model (e.g. whisper-1). Empty = use MODEL.
+CAPTCHA_LLM_AUDIO_MODEL=
+CAPTCHA_LLM_TEMPERATURE=0
+CAPTCHA_LLM_MAX_TOKENS=1024
+CAPTCHA_LLM_TIMEOUT=60s
+CAPTCHA_LOG_LEVEL=info
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add go.mod go.sum .golangci.yml Makefile .env.example
+git commit -m "chore: scaffold captcha-solver-mcp module and tooling"
+```
+
+---
+
+## Phase 1 — Config
+
+### Task 1: Env config parse + validate
+
+**Files:**
+- Create: `internal/config/config.go`
+- Test: `internal/config/config_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package config
+
+import (
+ "testing"
+ "time"
+)
+
+func TestLoad(t *testing.T) {
+ tests := []struct {
+ name string
+ env map[string]string
+ want Config
+ wantErr bool
+ }{
+ {
+ name: "valid anthropic",
+ env: map[string]string{
+ "CAPTCHA_LLM_PROVIDER": "anthropic",
+ "CAPTCHA_LLM_API_KEY": "k",
+ "CAPTCHA_LLM_MODEL": "claude-opus-4-8",
+ },
+ want: Config{
+ Provider: "anthropic", APIKey: "k", Model: "claude-opus-4-8",
+ LogLevel: "info", MaxTokens: 1024, Timeout: 60 * time.Second,
+ },
+ },
+ {
+ name: "missing model",
+ env: map[string]string{"CAPTCHA_LLM_PROVIDER": "openai", "CAPTCHA_LLM_API_KEY": "k"},
+ wantErr: true,
+ },
+ {
+ name: "bad provider",
+ env: map[string]string{"CAPTCHA_LLM_PROVIDER": "nope", "CAPTCHA_LLM_API_KEY": "k", "CAPTCHA_LLM_MODEL": "m"},
+ wantErr: true,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ for k, v := range tc.env {
+ t.Setenv(k, v)
+ }
+ got, err := Load()
+ if tc.wantErr {
+ if err == nil {
+ t.Fatal("want error, got nil")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != tc.want {
+ t.Fatalf("got %+v want %+v", got, tc.want)
+ }
+ })
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/config/...`
+Expected: FAIL — `undefined: Load` / `undefined: Config`.
+
+- [ ] **Step 3: Write the implementation**
+
+```go
+// Package config loads and validates the MCP server configuration from env vars.
+package config
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "time"
+)
+
+// Config is the validated runtime configuration. Fields are ordered
+// largest-alignment-first to satisfy the fieldalignment linter.
+type Config struct {
+ Provider string
+ BaseURL string
+ APIKey string
+ Model string
+ AudioModel string
+ LogLevel string
+ Temperature float64
+ Timeout time.Duration
+ MaxTokens int
+}
+
+var validProviders = map[string]bool{
+ "anthropic": true, "openai": true, "openai-compatible": true, "gemini": true,
+}
+
+// Load reads CAPTCHA_LLM_* env vars and returns a validated Config.
+func Load() (Config, error) {
+ c := Config{
+ Provider: os.Getenv("CAPTCHA_LLM_PROVIDER"),
+ BaseURL: os.Getenv("CAPTCHA_LLM_BASE_URL"),
+ APIKey: os.Getenv("CAPTCHA_LLM_API_KEY"),
+ Model: os.Getenv("CAPTCHA_LLM_MODEL"),
+ AudioModel: os.Getenv("CAPTCHA_LLM_AUDIO_MODEL"),
+ LogLevel: envOr("CAPTCHA_LOG_LEVEL", "info"),
+ MaxTokens: 1024,
+ Timeout: 60 * time.Second,
+ }
+ if !validProviders[c.Provider] {
+ return Config{}, fmt.Errorf("CAPTCHA_LLM_PROVIDER must be one of anthropic|openai|openai-compatible|gemini, got %q", c.Provider)
+ }
+ if c.APIKey == "" && c.BaseURL == "" {
+ return Config{}, fmt.Errorf("CAPTCHA_LLM_API_KEY is required (or set CAPTCHA_LLM_BASE_URL for a keyless local endpoint)")
+ }
+ if c.Model == "" {
+ return Config{}, fmt.Errorf("CAPTCHA_LLM_MODEL is required")
+ }
+ if v := os.Getenv("CAPTCHA_LLM_TEMPERATURE"); v != "" {
+ f, err := strconv.ParseFloat(v, 64)
+ if err != nil {
+ return Config{}, fmt.Errorf("CAPTCHA_LLM_TEMPERATURE: %w", err)
+ }
+ c.Temperature = f
+ }
+ if v := os.Getenv("CAPTCHA_LLM_MAX_TOKENS"); v != "" {
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ return Config{}, fmt.Errorf("CAPTCHA_LLM_MAX_TOKENS: %w", err)
+ }
+ c.MaxTokens = n
+ }
+ if v := os.Getenv("CAPTCHA_LLM_TIMEOUT"); v != "" {
+ d, err := time.ParseDuration(v)
+ if err != nil {
+ return Config{}, fmt.Errorf("CAPTCHA_LLM_TIMEOUT: %w", err)
+ }
+ c.Timeout = d
+ }
+ return c, nil
+}
+
+func envOr(key, def string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return def
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/config/...`
+Expected: PASS. (Note: `t.Setenv` isolates env per subtest.)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/config
+git commit -m "feat(config): env-driven config with validation"
+```
+
+---
+
+## Phase 2 — LLM provider layer
+
+### Task 2: Client interface + fake double
+
+**Files:**
+- Create: `internal/llm/client.go`
+- Create: `internal/llm/fake.go`
+- Test: `internal/llm/fake_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package llm
+
+import (
+ "context"
+ "testing"
+)
+
+func TestFakeClient(t *testing.T) {
+ f := &Fake{VisionResp: "hello", TranscribeResp: "spoken"}
+ got, err := f.Vision(context.Background(), "p", []Image{{MediaType: "image/png", Data: []byte{1}}}, Options{})
+ if err != nil || got != "hello" {
+ t.Fatalf("vision got %q err %v", got, err)
+ }
+ if len(f.VisionCalls) != 1 || f.VisionCalls[0].Prompt != "p" {
+ t.Fatalf("vision call not recorded: %+v", f.VisionCalls)
+ }
+ tr, err := f.Transcribe(context.Background(), []byte{1}, "audio.mp3", Options{Language: "en"})
+ if err != nil || tr != "spoken" {
+ t.Fatalf("transcribe got %q err %v", tr, err)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/llm/...`
+Expected: FAIL — `undefined: Fake` / `undefined: Image`.
+
+- [ ] **Step 3: Write `client.go`**
+
+```go
+// Package llm defines a provider-agnostic LLM client for vision and audio
+// transcription, with concrete HTTP implementations per provider.
+package llm
+
+import "context"
+
+// Image is a decoded image to send to a vision model.
+type Image struct {
+ MediaType string // e.g. "image/png", "image/jpeg"
+ Data []byte
+}
+
+// Options are per-call overrides. Zero values mean "use the client default".
+type Options struct {
+ Model string
+ Language string
+ Temperature float64
+ MaxTokens int
+}
+
+// Client is the abstraction every provider implements and every handler depends on.
+type Client interface {
+ // Vision sends a prompt plus images and returns the model's text answer.
+ Vision(ctx context.Context, prompt string, images []Image, opts Options) (string, error)
+ // Transcribe converts audio bytes to text. filename carries the extension
+ // for providers that infer the audio format from it. Returns ErrUnsupported
+ // if the provider has no audio capability.
+ Transcribe(ctx context.Context, audio []byte, filename string, opts Options) (string, error)
+}
+
+// ErrUnsupported is returned by Transcribe on providers without audio support.
+type ErrUnsupported struct{ Provider string }
+
+func (e ErrUnsupported) Error() string {
+ return "audio transcription unsupported by provider " + e.Provider
+}
+```
+
+- [ ] **Step 4: Write `fake.go`**
+
+```go
+package llm
+
+import "context"
+
+// Fake is a deterministic Client for tests.
+type Fake struct {
+ VisionResp string
+ TranscribeResp string
+ VisionErr error
+ TranscribeErr error
+ VisionCalls []VisionCall
+ TranscribeCalls []TranscribeCall
+}
+
+// VisionCall records a Vision invocation.
+type VisionCall struct {
+ Prompt string
+ Opts Options
+ Images []Image
+}
+
+// TranscribeCall records a Transcribe invocation.
+type TranscribeCall struct {
+ Filename string
+ Opts Options
+ Audio []byte
+}
+
+// Vision implements Client.
+func (f *Fake) Vision(_ context.Context, prompt string, images []Image, opts Options) (string, error) {
+ f.VisionCalls = append(f.VisionCalls, VisionCall{Prompt: prompt, Images: images, Opts: opts})
+ return f.VisionResp, f.VisionErr
+}
+
+// Transcribe implements Client.
+func (f *Fake) Transcribe(_ context.Context, audio []byte, filename string, opts Options) (string, error) {
+ f.TranscribeCalls = append(f.TranscribeCalls, TranscribeCall{Audio: audio, Filename: filename, Opts: opts})
+ return f.TranscribeResp, f.TranscribeErr
+}
+```
+
+- [ ] **Step 5: Run + commit**
+
+Run: `go test ./internal/llm/...` → PASS.
+```bash
+git add internal/llm
+git commit -m "feat(llm): client interface and fake test double"
+```
+
+### Task 3: OpenAI-compatible provider (vision + transcription)
+
+**Files:**
+- Create: `internal/llm/openai.go`
+- Test: `internal/llm/openai_test.go`
+
+- [ ] **Step 1: Write the failing test** (uses `httptest`, no network)
+
+```go
+package llm
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestOpenAIVision(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/chat/completions" {
+ t.Errorf("path %s", r.URL.Path)
+ }
+ if got := r.Header.Get("Authorization"); got != "Bearer key" {
+ t.Errorf("auth %q", got)
+ }
+ var body map[string]any
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ raw, _ := json.Marshal(body)
+ if !strings.Contains(string(raw), "data:image/png;base64,AQ==") {
+ t.Errorf("image not embedded: %s", raw)
+ }
+ _, _ = io.WriteString(w, `{"choices":[{"message":{"content":"ab12"}}]}`)
+ }))
+ defer srv.Close()
+
+ c := &openAIClient{http: &http.Client{Timeout: 5 * time.Second}, base: srv.URL, key: "key", model: "gpt-4o", maxTokens: 100}
+ got, err := c.Vision(context.Background(), "read", []Image{{MediaType: "image/png", Data: []byte{1}}}, Options{})
+ if err != nil || got != "ab12" {
+ t.Fatalf("got %q err %v", got, err)
+ }
+}
+
+func TestOpenAITranscribe(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/audio/transcriptions" {
+ t.Errorf("path %s", r.URL.Path)
+ }
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
+ t.Errorf("parse multipart: %v", err)
+ }
+ if r.FormValue("model") != "whisper-1" {
+ t.Errorf("model %q", r.FormValue("model"))
+ }
+ _, _ = io.WriteString(w, `{"text":"the secret is 42"}`)
+ }))
+ defer srv.Close()
+
+ c := &openAIClient{http: &http.Client{Timeout: 5 * time.Second}, base: srv.URL, key: "key", model: "gpt-4o", audioModel: "whisper-1"}
+ got, err := c.Transcribe(context.Background(), []byte("RIFF"), "a.wav", Options{})
+ if err != nil || got != "the secret is 42" {
+ t.Fatalf("got %q err %v", got, err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify fail**
+
+Run: `go test ./internal/llm/ -run OpenAI`
+Expected: FAIL — `undefined: openAIClient`.
+
+- [ ] **Step 3: Write `openai.go`**
+
+```go
+package llm
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+)
+
+type openAIClient struct {
+ http *http.Client
+ base string
+ key string
+ model string
+ audioModel string
+ maxTokens int
+}
+
+func (c *openAIClient) modelOr(o Options) string {
+ if o.Model != "" {
+ return o.Model
+ }
+ return c.model
+}
+
+func (c *openAIClient) Vision(ctx context.Context, prompt string, images []Image, opts Options) (string, error) {
+ content := []map[string]any{{"type": "text", "text": prompt}}
+ for _, img := range images {
+ url := fmt.Sprintf("data:%s;base64,%s", img.MediaType, base64.StdEncoding.EncodeToString(img.Data))
+ content = append(content, map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}})
+ }
+ maxTok := c.maxTokens
+ if opts.MaxTokens > 0 {
+ maxTok = opts.MaxTokens
+ }
+ reqBody := map[string]any{
+ "model": c.modelOr(opts),
+ "max_tokens": maxTok,
+ "temperature": opts.Temperature,
+ "messages": []map[string]any{{"role": "user", "content": content}},
+ }
+ var out struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ }
+ if err := c.postJSON(ctx, "/v1/chat/completions", reqBody, &out); err != nil {
+ return "", err
+ }
+ if len(out.Choices) == 0 {
+ return "", fmt.Errorf("openai: empty choices")
+ }
+ return out.Choices[0].Message.Content, nil
+}
+
+func (c *openAIClient) Transcribe(ctx context.Context, audio []byte, filename string, opts Options) (string, error) {
+ model := c.audioModel
+ if opts.Model != "" {
+ model = opts.Model
+ }
+ if model == "" {
+ model = c.model
+ }
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ _ = mw.WriteField("model", model)
+ if opts.Language != "" {
+ _ = mw.WriteField("language", opts.Language)
+ }
+ fw, err := mw.CreateFormFile("file", filename)
+ if err != nil {
+ return "", err
+ }
+ if _, err := fw.Write(audio); err != nil {
+ return "", err
+ }
+ if err := mw.Close(); err != nil {
+ return "", err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/v1/audio/transcriptions", &buf)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ if c.key != "" {
+ req.Header.Set("Authorization", "Bearer "+c.key)
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode >= 300 {
+ return "", fmt.Errorf("openai transcribe HTTP %d: %s", resp.StatusCode, string(body))
+ }
+ var out struct {
+ Text string `json:"text"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return "", err
+ }
+ return out.Text, nil
+}
+
+func (c *openAIClient) postJSON(ctx context.Context, path string, body, out any) error {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, bytes.NewReader(b))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if c.key != "" {
+ req.Header.Set("Authorization", "Bearer "+c.key)
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ raw, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode >= 300 {
+ return fmt.Errorf("openai HTTP %d: %s", resp.StatusCode, string(raw))
+ }
+ return json.Unmarshal(raw, out)
+}
+```
+
+- [ ] **Step 4: Run + commit**
+
+Run: `go test ./internal/llm/ -run OpenAI` → PASS.
+```bash
+git add internal/llm/openai.go internal/llm/openai_test.go
+git commit -m "feat(llm): openai-compatible vision and transcription provider"
+```
+
+### Task 4: Anthropic provider (vision; transcription unsupported)
+
+**Files:**
+- Create: `internal/llm/anthropic.go`
+- Test: `internal/llm/anthropic_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package llm
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestAnthropicVision(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/messages" {
+ t.Errorf("path %s", r.URL.Path)
+ }
+ if r.Header.Get("x-api-key") != "key" || r.Header.Get("anthropic-version") != "2023-06-01" {
+ t.Errorf("headers %v", r.Header)
+ }
+ raw, _ := io.ReadAll(r.Body)
+ if strings.Contains(string(raw), "temperature") {
+ t.Errorf("temperature must NOT be sent to anthropic: %s", raw)
+ }
+ if !strings.Contains(string(raw), `"type":"base64"`) {
+ t.Errorf("image block missing: %s", raw)
+ }
+ _, _ = io.WriteString(w, `{"content":[{"type":"text","text":"crosswalk"}]}`)
+ }))
+ defer srv.Close()
+
+ c := &anthropicClient{http: &http.Client{Timeout: 5 * time.Second}, base: srv.URL, key: "key", model: "claude-opus-4-8", maxTokens: 100}
+ got, err := c.Vision(context.Background(), "which tiles", []Image{{MediaType: "image/png", Data: []byte{2}}}, Options{Temperature: 0.7})
+ if err != nil || got != "crosswalk" {
+ t.Fatalf("got %q err %v", got, err)
+ }
+ _ = json.Marshal // keep import
+}
+
+func TestAnthropicTranscribeUnsupported(t *testing.T) {
+ c := &anthropicClient{model: "claude-opus-4-8"}
+ _, err := c.Transcribe(context.Background(), []byte{1}, "a.mp3", Options{})
+ var u ErrUnsupported
+ if !errors.As(err, &u) {
+ t.Fatalf("want ErrUnsupported, got %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify fail**
+
+Run: `go test ./internal/llm/ -run Anthropic`
+Expected: FAIL — `undefined: anthropicClient`.
+
+- [ ] **Step 3: Write `anthropic.go`**
+
+```go
+package llm
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+)
+
+type anthropicClient struct {
+ http *http.Client
+ base string
+ key string
+ model string
+ maxTokens int
+}
+
+func (c *anthropicClient) Vision(ctx context.Context, prompt string, images []Image, opts Options) (string, error) {
+ content := []map[string]any{}
+ for _, img := range images {
+ content = append(content, map[string]any{
+ "type": "image",
+ "source": map[string]any{
+ "type": "base64",
+ "media_type": img.MediaType,
+ "data": base64.StdEncoding.EncodeToString(img.Data),
+ },
+ })
+ }
+ content = append(content, map[string]any{"type": "text", "text": prompt})
+
+ model := c.model
+ if opts.Model != "" {
+ model = opts.Model
+ }
+ maxTok := c.maxTokens
+ if opts.MaxTokens > 0 {
+ maxTok = opts.MaxTokens
+ }
+ // NOTE: temperature/top_p are intentionally omitted — Opus 4.x rejects them (HTTP 400).
+ reqBody := map[string]any{
+ "model": model,
+ "max_tokens": maxTok,
+ "messages": []map[string]any{{"role": "user", "content": content}},
+ }
+ b, err := json.Marshal(reqBody)
+ if err != nil {
+ return "", err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/v1/messages", bytes.NewReader(b))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("x-api-key", c.key)
+ req.Header.Set("anthropic-version", "2023-06-01")
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ raw, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode >= 300 {
+ return "", fmt.Errorf("anthropic HTTP %d: %s", resp.StatusCode, string(raw))
+ }
+ var out struct {
+ Content []struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ } `json:"content"`
+ }
+ if err := json.Unmarshal(raw, &out); err != nil {
+ return "", err
+ }
+ for _, blk := range out.Content {
+ if blk.Type == "text" {
+ return blk.Text, nil
+ }
+ }
+ return "", fmt.Errorf("anthropic: no text block in response")
+}
+
+func (c *anthropicClient) Transcribe(_ context.Context, _ []byte, _ string, _ Options) (string, error) {
+ return "", ErrUnsupported{Provider: "anthropic"}
+}
+```
+
+- [ ] **Step 4: Run + commit**
+
+Run: `go test ./internal/llm/ -run Anthropic` → PASS.
+```bash
+git add internal/llm/anthropic.go internal/llm/anthropic_test.go
+git commit -m "feat(llm): anthropic vision provider (audio unsupported)"
+```
+
+### Task 5: Provider factory `New`
+
+**Files:**
+- Create: `internal/llm/new.go`
+- Test: `internal/llm/new_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package llm
+
+import (
+ "testing"
+
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/config"
+)
+
+func TestNew(t *testing.T) {
+ tests := []struct {
+ name string
+ provider string
+ wantBase string
+ wantErr bool
+ }{
+ {"anthropic default base", "anthropic", "https://api.anthropic.com", false},
+ {"openai default base", "openai", "https://api.openai.com", false},
+ {"compatible needs base", "openai-compatible", "", true},
+ {"unknown", "zzz", "", true},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg := config.Config{Provider: tc.provider, APIKey: "k", Model: "m"}
+ c, err := New(cfg)
+ if tc.wantErr {
+ if err == nil {
+ t.Fatal("want error")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if c == nil {
+ t.Fatal("nil client")
+ }
+ })
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify fail**
+
+Run: `go test ./internal/llm/ -run TestNew`
+Expected: FAIL — `undefined: New`.
+
+- [ ] **Step 3: Write `new.go`**
+
+```go
+package llm
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/config"
+)
+
+// New constructs the configured provider Client.
+func New(cfg config.Config) (Client, error) {
+ hc := &http.Client{Timeout: cfg.Timeout}
+ switch cfg.Provider {
+ case "anthropic":
+ base := cfg.BaseURL
+ if base == "" {
+ base = "https://api.anthropic.com"
+ }
+ return &anthropicClient{http: hc, base: base, key: cfg.APIKey, model: cfg.Model, maxTokens: cfg.MaxTokens}, nil
+ case "openai":
+ base := cfg.BaseURL
+ if base == "" {
+ base = "https://api.openai.com"
+ }
+ return &openAIClient{http: hc, base: base, key: cfg.APIKey, model: cfg.Model, audioModel: cfg.AudioModel, maxTokens: cfg.MaxTokens}, nil
+ case "openai-compatible":
+ if cfg.BaseURL == "" {
+ return nil, fmt.Errorf("openai-compatible requires CAPTCHA_LLM_BASE_URL")
+ }
+ return &openAIClient{http: hc, base: cfg.BaseURL, key: cfg.APIKey, model: cfg.Model, audioModel: cfg.AudioModel, maxTokens: cfg.MaxTokens}, nil
+ case "gemini":
+ base := cfg.BaseURL
+ if base == "" {
+ base = "https://generativelanguage.googleapis.com"
+ }
+ return &geminiClient{http: hc, base: base, key: cfg.APIKey, model: cfg.Model, audioModel: cfg.AudioModel, maxTokens: cfg.MaxTokens}, nil
+ default:
+ return nil, fmt.Errorf("unknown provider %q", cfg.Provider)
+ }
+}
+```
+
+> NOTE: `geminiClient` is implemented in Phase 11 (Task 19). Until then, comment out the `gemini` case or stub `geminiClient` so the package compiles. The cleanest order is to implement Task 19's `gemini.go` stub (struct + two methods returning `fmt.Errorf("not implemented")`) **before** this task, then fill it in Phase 11. To keep this task self-contained, add the stub now:
+
+`internal/llm/gemini.go` (stub — replaced in Task 19):
+```go
+package llm
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+)
+
+type geminiClient struct {
+ http *http.Client
+ base string
+ key string
+ model string
+ audioModel string
+ maxTokens int
+}
+
+func (c *geminiClient) Vision(_ context.Context, _ string, _ []Image, _ Options) (string, error) {
+ return "", fmt.Errorf("gemini provider not yet implemented")
+}
+
+func (c *geminiClient) Transcribe(_ context.Context, _ []byte, _ string, _ Options) (string, error) {
+ return "", fmt.Errorf("gemini provider not yet implemented")
+}
+```
+
+- [ ] **Step 4: Run + commit**
+
+Run: `go test ./internal/llm/...` → PASS.
+```bash
+git add internal/llm/new.go internal/llm/new_test.go internal/llm/gemini.go
+git commit -m "feat(llm): provider factory with per-provider default base URLs"
+```
+
+---
+
+## Phase 3 — Image utilities
+
+### Task 6: Input decoding + grid geometry
+
+**Files:**
+- Create: `internal/imageutil/imageutil.go`
+- Test: `internal/imageutil/imageutil_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package imageutil
+
+import (
+ "encoding/base64"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestDecode(t *testing.T) {
+ raw := []byte{0x89, 0x50, 0x4e, 0x47} // PNG magic prefix
+ b64 := base64.StdEncoding.EncodeToString(raw)
+
+ t.Run("data uri", func(t *testing.T) {
+ data, mt, err := Decode("data:image/png;base64," + b64)
+ if err != nil || mt != "image/png" || string(data) != string(raw) {
+ t.Fatalf("data=%v mt=%q err=%v", data, mt, err)
+ }
+ })
+ t.Run("bare base64 sniffs png", func(t *testing.T) {
+ data, mt, err := Decode(b64)
+ if err != nil || mt != "image/png" || string(data) != string(raw) {
+ t.Fatalf("data=%v mt=%q err=%v", data, mt, err)
+ }
+ })
+ t.Run("file path", func(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "x.png")
+ if err := os.WriteFile(p, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ data, mt, err := Decode(p)
+ if err != nil || mt != "image/png" || string(data) != string(raw) {
+ t.Fatalf("data=%v mt=%q err=%v", data, mt, err)
+ }
+ })
+}
+
+func TestGridCentroids(t *testing.T) {
+ pts := GridCentroids(300, 300, 3, 3)
+ if len(pts) != 9 {
+ t.Fatalf("want 9 centroids, got %d", len(pts))
+ }
+ // index 0 = top-left tile centre at (50,50)
+ if pts[0].X != 50 || pts[0].Y != 50 {
+ t.Fatalf("centroid[0]=%+v", pts[0])
+ }
+ // index 4 = centre tile at (150,150)
+ if pts[4].X != 150 || pts[4].Y != 150 {
+ t.Fatalf("centroid[4]=%+v", pts[4])
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify fail**
+
+Run: `go test ./internal/imageutil/...`
+Expected: FAIL — `undefined: Decode` / `undefined: GridCentroids`.
+
+- [ ] **Step 3: Write `imageutil.go`**
+
+```go
+// Package imageutil decodes image/audio inputs (base64, data-URI, file path,
+// http(s) URL) and computes grid geometry for tile-based captcha solving.
+package imageutil
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "regexp"
+ "strings"
+ "time"
+)
+
+var dataURIRe = regexp.MustCompile(`^data:([^;,]+)(;base64)?,(.*)$`)
+
+// Point is a pixel coordinate.
+type Point struct {
+ X int
+ Y int
+}
+
+// Decode resolves input (data-URI | bare base64 | file path | http(s) URL) to
+// raw bytes plus a best-effort media type sniffed from the content.
+func Decode(input string) ([]byte, string, error) {
+ input = strings.TrimSpace(input)
+ switch {
+ case strings.HasPrefix(input, "data:"):
+ m := dataURIRe.FindStringSubmatch(input)
+ if m == nil {
+ return nil, "", fmt.Errorf("malformed data URI")
+ }
+ var data []byte
+ var err error
+ if m[2] == ";base64" {
+ data, err = base64.StdEncoding.DecodeString(m[3])
+ } else {
+ data = []byte(m[3])
+ }
+ if err != nil {
+ return nil, "", err
+ }
+ return data, m[1], nil
+ case strings.HasPrefix(input, "http://"), strings.HasPrefix(input, "https://"):
+ return fetchURL(input)
+ default:
+ // File path if it exists; otherwise treat as bare base64.
+ if data, err := os.ReadFile(input); err == nil { //nolint:gosec // path is caller-provided automation input
+ return data, sniff(data), nil
+ }
+ data, err := base64.StdEncoding.DecodeString(input)
+ if err != nil {
+ return nil, "", fmt.Errorf("input is not a data-URI, URL, readable file, or base64: %w", err)
+ }
+ return data, sniff(data), nil
+ }
+}
+
+func fetchURL(url string) ([]byte, string, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, "", err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode >= 300 {
+ return nil, "", fmt.Errorf("fetch %s: HTTP %d", url, resp.StatusCode)
+ }
+ data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
+ if err != nil {
+ return nil, "", err
+ }
+ mt := resp.Header.Get("Content-Type")
+ if mt == "" {
+ mt = sniff(data)
+ }
+ return data, mt, nil
+}
+
+// sniff returns a media type from magic bytes, defaulting to image/png.
+func sniff(b []byte) string {
+ switch {
+ case len(b) >= 4 && b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4e && b[3] == 0x47:
+ return "image/png"
+ case len(b) >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff:
+ return "image/jpeg"
+ case len(b) >= 4 && b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F':
+ return "audio/wav"
+ case len(b) >= 6 && string(b[:3]) == "ID3":
+ return "audio/mpeg"
+ default:
+ return "image/png"
+ }
+}
+
+// GridCentroids returns the pixel centre of each tile for a rows x cols grid
+// laid over a width x height image, in row-major order (index 0 = top-left).
+func GridCentroids(width, height, rows, cols int) []Point {
+ pts := make([]Point, 0, rows*cols)
+ cw := float64(width) / float64(cols)
+ ch := float64(height) / float64(rows)
+ for r := 0; r < rows; r++ {
+ for c := 0; c < cols; c++ {
+ pts = append(pts, Point{
+ X: int(cw*float64(c) + cw/2),
+ Y: int(ch*float64(r) + ch/2),
+ })
+ }
+ }
+ return pts
+}
+```
+
+- [ ] **Step 4: Run + commit**
+
+Run: `go test ./internal/imageutil/...` → PASS.
+```bash
+git add internal/imageutil
+git commit -m "feat(imageutil): input decoding and grid centroid geometry"
+```
+
+---
+
+## Phase 4 — Captcha types + detection
+
+### Task 7: Shared types + deterministic detection
+
+**Files:**
+- Create: `internal/captcha/types.go`
+- Create: `internal/captcha/detect.go`
+- Test: `internal/captcha/detect_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package captcha
+
+import "testing"
+
+func TestDetectHTML(t *testing.T) {
+ tests := []struct {
+ name string
+ html string
+ wantType string
+ wantSolvable bool
+ }{
+ {"recaptcha v2", `
`, "recaptcha_v2", true},
+ {"recaptcha v3", ``, "recaptcha_v3", false},
+ {"hcaptcha", ``, "hcaptcha", true},
+ {"turnstile", ``, "turnstile", false},
+ {"funcaptcha", ``, "funcaptcha", true},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := DetectHTML(tc.html)
+ if len(got) == 0 {
+ t.Fatalf("no detection")
+ }
+ if got[0].Type != tc.wantType || got[0].LLMSolvable != tc.wantSolvable {
+ t.Fatalf("got %+v", got[0])
+ }
+ })
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify fail**
+
+Run: `go test ./internal/captcha/ -run TestDetectHTML`
+Expected: FAIL — `undefined: DetectHTML`.
+
+- [ ] **Step 3: Write `types.go`**
+
+```go
+// Package captcha provides deterministic detection and LLM-backed solving of
+// captcha challenges. It never drives a browser and never mints tokens.
+package captcha
+
+// Detection describes one captcha found on a page. Fields are ordered
+// largest-alignment-first for fieldalignment.
+type Detection struct {
+ Type string `json:"type"`
+ Sitekey string `json:"sitekey,omitempty"`
+ IframeURL string `json:"iframe_url,omitempty"`
+ Notes string `json:"notes,omitempty"`
+ LLMSolvable bool `json:"llm_solvable"`
+}
+```
+
+- [ ] **Step 4: Write `detect.go`**
+
+```go
+package captcha
+
+import "regexp"
+
+var (
+ reRecaptchaV3 = regexp.MustCompile(`recaptcha/api\.js\?render=([\w-]+)`)
+ reSitekey = regexp.MustCompile(`data-sitekey="([^"]+)"`)
+ rePkey = regexp.MustCompile(`data-pkey="([^"]+)"`)
+)
+
+// DetectHTML scans raw page HTML and returns deterministic detections for known
+// captcha providers. v3/Turnstile are reported with LLMSolvable=false.
+func DetectHTML(html string) []Detection {
+ var out []Detection
+ // reCAPTCHA v3 first (script render param). v3 has no visual challenge.
+ if m := reRecaptchaV3.FindStringSubmatch(html); m != nil {
+ out = append(out, Detection{
+ Type: "recaptcha_v3", Sitekey: m[1], LLMSolvable: false,
+ Notes: "score/behavioral — not LLM-solvable; needs browser reputation",
+ })
+ }
+ if contains(html, "g-recaptcha") || contains(html, "google.com/recaptcha/api2") {
+ out = append(out, Detection{Type: "recaptcha_v2", Sitekey: firstSitekey(html), LLMSolvable: true,
+ Notes: "image-grid; LLM returns tiles, caller clicks + harvests token"})
+ }
+ if contains(html, "h-captcha") || contains(html, "hcaptcha.com") {
+ out = append(out, Detection{Type: "hcaptcha", Sitekey: firstSitekey(html), LLMSolvable: true,
+ Notes: "image-grid; LLM returns tiles, caller clicks + harvests token"})
+ }
+ if contains(html, "cf-turnstile") || contains(html, "challenges.cloudflare.com") {
+ out = append(out, Detection{Type: "turnstile", Sitekey: firstSitekey(html), LLMSolvable: false,
+ Notes: "proof-of-work/behavioral — not LLM-solvable"})
+ }
+ if contains(html, "arkoselabs") || contains(html, "funcaptcha") || contains(html, "arkose") {
+ out = append(out, Detection{Type: "funcaptcha", Sitekey: firstPkey(html), LLMSolvable: true,
+ Notes: "rotation/visual — best-effort"})
+ }
+ return out
+}
+
+func firstSitekey(html string) string {
+ if m := reSitekey.FindStringSubmatch(html); m != nil {
+ return m[1]
+ }
+ return ""
+}
+
+func firstPkey(html string) string {
+ if m := rePkey.FindStringSubmatch(html); m != nil {
+ return m[1]
+ }
+ return ""
+}
+
+func contains(s, sub string) bool {
+ return len(sub) > 0 && len(s) >= len(sub) && indexOf(s, sub) >= 0
+}
+
+func indexOf(s, sub string) int {
+ for i := 0; i+len(sub) <= len(s); i++ {
+ if s[i:i+len(sub)] == sub {
+ return i
+ }
+ }
+ return -1
+}
+```
+
+> Simplification note: `contains`/`indexOf` reimplement `strings.Contains`. Use `strings.Contains` directly instead — the helpers above are shown only to keep the file dependency-free if desired. Prefer `strings.Contains(html, sub)` and delete `contains`/`indexOf`. (Reviewer: pick one; `strings.Contains` is the boring choice.)
+
+- [ ] **Step 5: Run + commit**
+
+Run: `go test ./internal/captcha/ -run TestDetectHTML` → PASS.
+```bash
+git add internal/captcha/types.go internal/captcha/detect.go internal/captcha/detect_test.go
+git commit -m "feat(captcha): deterministic HTML detection for known providers"
+```
+
+---
+
+## Phase 5 — Solve tools (LLM-backed)
+
+These functions take an `llm.Client` and return structured results. Each handler prompt is strict and asks for JSON we parse; on parse failure we retry once with a stricter instruction, then error (never fabricate). Results carry `Confidence`.
+
+### Task 8: Text-image OCR solve
+
+**Files:**
+- Create: `internal/captcha/text.go`
+- Test: `internal/captcha/text_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package captcha
+
+import (
+ "context"
+ "testing"
+
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/llm"
+)
+
+func TestSolveText(t *testing.T) {
+ f := &llm.Fake{VisionResp: `{"text":"ab12","confidence":0.9}`}
+ res, err := SolveText(context.Background(), f, []byte{1}, "image/png", TextOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Text != "ab12" || res.Confidence < 0.89 {
+ t.Fatalf("got %+v", res)
+ }
+ if len(f.VisionCalls) != 1 {
+ t.Fatalf("expected 1 vision call")
+ }
+}
+
+func TestSolveTextRetriesOnGarbage(t *testing.T) {
+ f := &llm.Fake{VisionResp: "I cannot read this"}
+ _, err := SolveText(context.Background(), f, []byte{1}, "image/png", TextOptions{})
+ if err == nil {
+ t.Fatal("want error on unparseable output")
+ }
+ if len(f.VisionCalls) != 2 {
+ t.Fatalf("expected 2 attempts (one retry), got %d", len(f.VisionCalls))
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify fail**
+
+Run: `go test ./internal/captcha/ -run SolveText`
+Expected: FAIL — `undefined: SolveText`.
+
+- [ ] **Step 3: Write `text.go`**
+
+```go
+package captcha
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/llm"
+)
+
+// TextOptions tunes text-image OCR.
+type TextOptions struct {
+ Hint string
+ Charset string
+ Length int
+ CaseSensitive bool
+}
+
+// TextResult is the OCR answer.
+type TextResult struct {
+ Text string `json:"text"`
+ Confidence float64 `json:"confidence"`
+}
+
+func textPrompt(o TextOptions, strict bool) string {
+ p := "You are solving an image text captcha. Read the exact characters shown. "
+ if o.Charset != "" {
+ p += "Allowed characters: " + o.Charset + ". "
+ }
+ if o.Length > 0 {
+ p += fmt.Sprintf("Expected length: %d. ", o.Length)
+ }
+ if !o.CaseSensitive {
+ p += "Case-insensitive. "
+ }
+ if o.Hint != "" {
+ p += "Hint: " + o.Hint + ". "
+ }
+ p += `Respond ONLY with compact JSON: {"text":"","confidence":<0..1>}.`
+ if strict {
+ p += " You MUST output valid JSON and nothing else. Do not explain."
+ }
+ return p
+}
+
+// SolveText runs OCR via the vision model with one stricter retry on parse failure.
+func SolveText(ctx context.Context, c llm.Client, img []byte, mediaType string, o TextOptions) (TextResult, error) {
+ images := []llm.Image{{MediaType: mediaType, Data: img}}
+ for attempt := 0; attempt < 2; attempt++ {
+ raw, err := c.Vision(ctx, textPrompt(o, attempt > 0), images, llm.Options{})
+ if err != nil {
+ return TextResult{}, err
+ }
+ var res TextResult
+ if err := json.Unmarshal([]byte(extractJSON(raw)), &res); err == nil && res.Text != "" {
+ return res, nil
+ }
+ }
+ return TextResult{}, fmt.Errorf("model did not return parseable captcha text")
+}
+```
+
+- [ ] **Step 4: Add `extractJSON` helper**
+
+Create `internal/captcha/json.go`:
+```go
+package captcha
+
+import "strings"
+
+// extractJSON returns the first {...} JSON object found in s, or s unchanged.
+func extractJSON(s string) string {
+ start := strings.Index(s, "{")
+ end := strings.LastIndex(s, "}")
+ if start >= 0 && end > start {
+ return s[start : end+1]
+ }
+ return s
+}
+```
+
+- [ ] **Step 5: Run + commit**
+
+Run: `go test ./internal/captcha/ -run SolveText` → PASS.
+```bash
+git add internal/captcha/text.go internal/captcha/json.go internal/captcha/text_test.go
+git commit -m "feat(captcha): LLM text-image OCR solver with strict-retry"
+```
+
+### Task 9: Audio solve
+
+**Files:**
+- Create: `internal/captcha/audio.go`
+- Test: `internal/captcha/audio_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package captcha
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/llm"
+)
+
+func TestSolveAudio(t *testing.T) {
+ f := &llm.Fake{TranscribeResp: " forty two "}
+ res, err := SolveAudio(context.Background(), f, []byte("RIFF"), "a.wav", "en")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Text != "forty two" {
+ t.Fatalf("got %q", res.Text)
+ }
+}
+
+func TestSolveAudioUnsupported(t *testing.T) {
+ f := &llm.Fake{TranscribeErr: llm.ErrUnsupported{Provider: "anthropic"}}
+ _, err := SolveAudio(context.Background(), f, []byte{1}, "a.wav", "")
+ var u llm.ErrUnsupported
+ if !errors.As(err, &u) {
+ t.Fatalf("want ErrUnsupported, got %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify fail** → `undefined: SolveAudio`.
+
+- [ ] **Step 3: Write `audio.go`**
+
+```go
+package captcha
+
+import (
+ "context"
+ "strings"
+
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/llm"
+)
+
+// AudioResult is the transcription answer.
+type AudioResult struct {
+ Text string `json:"text"`
+ Confidence float64 `json:"confidence"`
+}
+
+// SolveAudio transcribes an audio captcha. Confidence is fixed at 1.0 for
+// transcription providers (which do not return token-level confidence here);
+// the raw transcript is trimmed.
+func SolveAudio(ctx context.Context, c llm.Client, audio []byte, filename, language string) (AudioResult, error) {
+ txt, err := c.Transcribe(ctx, audio, filename, llm.Options{Language: language})
+ if err != nil {
+ return AudioResult{}, err
+ }
+ return AudioResult{Text: strings.TrimSpace(txt), Confidence: 1.0}, nil
+}
+```
+
+- [ ] **Step 4: Run + commit**
+
+Run: `go test ./internal/captcha/ -run SolveAudio` → PASS.
+```bash
+git add internal/captcha/audio.go internal/captcha/audio_test.go
+git commit -m "feat(captcha): audio captcha transcription solver"
+```
+
+### Task 10: Grid solve (reCAPTCHA v2 / hCaptcha)
+
+**Files:**
+- Create: `internal/captcha/grid.go`
+- Test: `internal/captcha/grid_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package captcha
+
+import (
+ "context"
+ "testing"
+
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/llm"
+)
+
+func TestSolveGrid(t *testing.T) {
+ f := &llm.Fake{VisionResp: `{"tiles":[0,4,8],"confidence":0.8,"recheck_recommended":true}`}
+ res, err := SolveGrid(context.Background(), f, []byte{1}, "image/png", GridOptions{
+ Instruction: "select all buses", Rows: 3, Cols: 3, ImageWidth: 300, ImageHeight: 300,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(res.Tiles) != 3 || res.Tiles[0] != 0 {
+ t.Fatalf("tiles %+v", res.Tiles)
+ }
+ if len(res.Centroids) != 3 || res.Centroids[1].X != 150 { // tile 4 centre
+ t.Fatalf("centroids %+v", res.Centroids)
+ }
+ if !res.RecheckRecommended {
+ t.Fatal("recheck flag lost")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify fail** → `undefined: SolveGrid`.
+
+- [ ] **Step 3: Write `grid.go`**
+
+```go
+package captcha
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/imageutil"
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/llm"
+)
+
+// GridOptions describes a reCAPTCHA v2 / hCaptcha tile grid challenge.
+type GridOptions struct {
+ Instruction string
+ CaptchaType string // recaptcha | hcaptcha (informational)
+ Rows int
+ Cols int
+ ImageWidth int
+ ImageHeight int
+}
+
+// GridResult lists tiles to click (row-major 0-based) plus optional pixel
+// centroids when grid dimensions are known.
+type GridResult struct {
+ Tiles []int `json:"tiles"`
+ Centroids []imageutil.Point `json:"centroids,omitempty"`
+ Confidence float64 `json:"confidence"`
+ RecheckRecommended bool `json:"recheck_recommended"`
+}
+
+func gridPrompt(o GridOptions, strict bool) string {
+ p := fmt.Sprintf(
+ "You are solving an image-grid captcha. The image is a %dx%d grid of tiles, "+
+ "numbered row-major starting at 0 (top-left). Instruction: %q. "+
+ "Return the 0-based indices of every tile that matches the instruction. "+
+ "If after selection more matching tiles would likely appear (dynamic grid), set recheck_recommended true. "+
+ `Respond ONLY with compact JSON: {"tiles":[..],"confidence":<0..1>,"recheck_recommended":}.`,
+ o.Rows, o.Cols, o.Instruction)
+ if strict {
+ p += " You MUST output valid JSON only. No prose."
+ }
+ return p
+}
+
+// SolveGrid asks the vision model which tiles to click and (when grid geometry
+// is supplied) maps them to pixel centroids for humanized clicking.
+func SolveGrid(ctx context.Context, c llm.Client, img []byte, mediaType string, o GridOptions) (GridResult, error) {
+ images := []llm.Image{{MediaType: mediaType, Data: img}}
+ for attempt := 0; attempt < 2; attempt++ {
+ raw, err := c.Vision(ctx, gridPrompt(o, attempt > 0), images, llm.Options{})
+ if err != nil {
+ return GridResult{}, err
+ }
+ var res GridResult
+ if err := json.Unmarshal([]byte(extractJSON(raw)), &res); err == nil && res.Tiles != nil {
+ res.Centroids = mapCentroids(res.Tiles, o)
+ return res, nil
+ }
+ }
+ return GridResult{}, fmt.Errorf("model did not return parseable tile selection")
+}
+
+func mapCentroids(tiles []int, o GridOptions) []imageutil.Point {
+ if o.Rows <= 0 || o.Cols <= 0 || o.ImageWidth <= 0 || o.ImageHeight <= 0 {
+ return nil
+ }
+ all := imageutil.GridCentroids(o.ImageWidth, o.ImageHeight, o.Rows, o.Cols)
+ out := make([]imageutil.Point, 0, len(tiles))
+ for _, idx := range tiles {
+ if idx >= 0 && idx < len(all) {
+ out = append(out, all[idx])
+ }
+ }
+ return out
+}
+```
+
+- [ ] **Step 4: Run + commit**
+
+Run: `go test ./internal/captcha/ -run SolveGrid` → PASS.
+```bash
+git add internal/captcha/grid.go internal/captcha/grid_test.go
+git commit -m "feat(captcha): image-grid tile solver with centroid mapping"
+```
+
+### Task 11: Rotation/FunCaptcha solve (best-effort)
+
+**Files:**
+- Create: `internal/captcha/rotation.go`
+- Test: `internal/captcha/rotation_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package captcha
+
+import (
+ "context"
+ "testing"
+
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/llm"
+)
+
+func TestSolveRotation(t *testing.T) {
+ f := &llm.Fake{VisionResp: `{"choice":3,"rotations":2,"confidence":0.6}`}
+ res, err := SolveRotation(context.Background(), f, []byte{1}, "image/png", "rotate the animal upright")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Choice != 3 || res.Rotations != 2 {
+ t.Fatalf("got %+v", res)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify fail** → `undefined: SolveRotation`.
+
+- [ ] **Step 3: Write `rotation.go`**
+
+```go
+package captcha
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/llm"
+)
+
+// RotationResult is a best-effort FunCaptcha/rotation answer.
+type RotationResult struct {
+ Choice int `json:"choice"`
+ Rotations int `json:"rotations"`
+ Confidence float64 `json:"confidence"`
+}
+
+// SolveRotation handles FunCaptcha-style rotation/choice puzzles (best-effort).
+func SolveRotation(ctx context.Context, c llm.Client, img []byte, mediaType, instruction string) (RotationResult, error) {
+ prompt := fmt.Sprintf(
+ "You are solving a rotation/choice puzzle captcha. Instruction: %q. "+
+ "If asked to pick one image, return its 0-based index as choice. "+
+ "If asked to rotate, return the number of clockwise steps as rotations. "+
+ `Respond ONLY with compact JSON: {"choice":,"rotations":,"confidence":<0..1>}.`,
+ instruction)
+ raw, err := c.Vision(ctx, prompt, []llm.Image{{MediaType: mediaType, Data: img}}, llm.Options{})
+ if err != nil {
+ return RotationResult{}, err
+ }
+ var res RotationResult
+ if err := json.Unmarshal([]byte(extractJSON(raw)), &res); err != nil {
+ return RotationResult{}, fmt.Errorf("model did not return parseable rotation answer")
+ }
+ return res, nil
+}
+```
+
+- [ ] **Step 4: Run + commit**
+
+Run: `go test ./internal/captcha/ -run SolveRotation` → PASS.
+```bash
+git add internal/captcha/rotation.go internal/captcha/rotation_test.go
+git commit -m "feat(captcha): best-effort rotation/funcaptcha solver"
+```
+
+---
+
+## Phase 6 — Stealth subsystem
+
+### Task 12: Fingerprint generator (coherent, seeded)
+
+**Files:**
+- Create: `internal/stealth/fingerprint.go`
+- Test: `internal/stealth/fingerprint_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package stealth
+
+import "testing"
+
+func TestGenerateFingerprintDeterministic(t *testing.T) {
+ a := GenerateFingerprint(FingerprintParams{OS: "windows", Browser: "chrome", Locale: "en-US", Seed: 42})
+ b := GenerateFingerprint(FingerprintParams{OS: "windows", Browser: "chrome", Locale: "en-US", Seed: 42})
+ if a != b {
+ t.Fatalf("same seed must be deterministic:\n%+v\n%+v", a, b)
+ }
+ if a.UserAgent == "" || a.Platform == "" || a.SecCHUA == "" {
+ t.Fatalf("incomplete fingerprint: %+v", a)
+ }
+ // internal consistency: windows UA must mention Windows and platform Win32
+ if a.Platform != "Win32" {
+ t.Fatalf("windows platform=%q", a.Platform)
+ }
+}
+
+func TestGenerateFingerprintVaries(t *testing.T) {
+ a := GenerateFingerprint(FingerprintParams{OS: "windows", Browser: "chrome", Seed: 1})
+ b := GenerateFingerprint(FingerprintParams{OS: "windows", Browser: "chrome", Seed: 2})
+ if a.HardwareConcurrency == b.HardwareConcurrency && a.UserAgent == b.UserAgent && a.Viewport == b.Viewport {
+ t.Fatal("different seeds should vary at least one attribute")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify fail** → `undefined: GenerateFingerprint`.
+
+- [ ] **Step 3: Write `fingerprint.go`** (deterministic PRNG seeded by `Seed`; no `math/rand` global, no `Date.now`)
+
+```go
+// Package stealth generates browser anti-bot artifacts (fingerprint profiles,
+// evasion init scripts, humanized interaction plans, header sets) that the
+// caller injects/applies. It never drives a browser.
+package stealth
+
+import (
+ "fmt"
+ "math/rand/v2"
+)
+
+// FingerprintParams selects an identity. Seed makes generation deterministic.
+type FingerprintParams struct {
+ OS string // windows | macos | linux
+ Browser string // chrome | firefox
+ Locale string // e.g. en-US
+ Seed uint64
+}
+
+// Fingerprint is a coherent, internally-consistent browser identity.
+type Fingerprint struct {
+ UserAgent string `json:"user_agent"`
+ SecCHUA string `json:"sec_ch_ua"`
+ SecCHUAPlatform string `json:"sec_ch_ua_platform"`
+ Platform string `json:"platform"`
+ Locale string `json:"locale"`
+ Timezone string `json:"timezone"`
+ Viewport string `json:"viewport"`
+ WebGLVendor string `json:"webgl_vendor"`
+ WebGLRenderer string `json:"webgl_renderer"`
+ HardwareConcurrency int `json:"hardware_concurrency"`
+ DeviceMemory int `json:"device_memory"`
+}
+
+func pick[T any](r *rand.Rand, xs []T) T { return xs[r.IntN(len(xs))] }
+
+// GenerateFingerprint returns a coherent identity. Same Seed → same output.
+func GenerateFingerprint(p FingerprintParams) Fingerprint {
+ if p.OS == "" {
+ p.OS = "windows"
+ }
+ if p.Browser == "" {
+ p.Browser = "chrome"
+ }
+ if p.Locale == "" {
+ p.Locale = "en-US"
+ }
+ r := rand.New(rand.NewPCG(p.Seed, p.Seed^0x9e3779b97f4a7c15))
+
+ chromeMajor := pick(r, []string{"131", "132", "133"})
+ var ua, platform, chPlatform, webglVendor, webglRenderer, tz string
+ switch p.OS {
+ case "macos":
+ platform, chPlatform = "MacIntel", `"macOS"`
+ ua = fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Safari/537.36", chromeMajor)
+ webglVendor, webglRenderer = "Google Inc. (Apple)", "ANGLE (Apple, Apple M2, OpenGL 4.1)"
+ tz = "America/Los_Angeles"
+ case "linux":
+ platform, chPlatform = "Linux x86_64", `"Linux"`
+ ua = fmt.Sprintf("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Safari/537.36", chromeMajor)
+ webglVendor, webglRenderer = "Google Inc. (Mesa)", "ANGLE (Mesa, llvmpipe, OpenGL 4.5)"
+ tz = "Europe/London"
+ default: // windows
+ platform, chPlatform = "Win32", `"Windows"`
+ ua = fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Safari/537.36", chromeMajor)
+ webglVendor, webglRenderer = "Google Inc. (NVIDIA)", "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11, D3D11)"
+ tz = "America/New_York"
+ }
+ secCHUA := fmt.Sprintf(`"Chromium";v="%s", "Google Chrome";v="%s", "Not?A_Brand";v="24"`, chromeMajor, chromeMajor)
+ viewport := pick(r, []string{"1920x1080", "1536x864", "1440x900", "1366x768"})
+ return Fingerprint{
+ UserAgent: ua,
+ SecCHUA: secCHUA,
+ SecCHUAPlatform: chPlatform,
+ Platform: platform,
+ Locale: p.Locale,
+ Timezone: tz,
+ Viewport: viewport,
+ WebGLVendor: webglVendor,
+ WebGLRenderer: webglRenderer,
+ HardwareConcurrency: pick(r, []int{4, 8, 12, 16}),
+ DeviceMemory: pick(r, []int{4, 8, 16}),
+ }
+}
+```
+
+- [ ] **Step 4: Run + commit**
+
+Run: `go test ./internal/stealth/ -run Fingerprint` → PASS.
+```bash
+git add internal/stealth/fingerprint.go internal/stealth/fingerprint_test.go
+git commit -m "feat(stealth): deterministic coherent fingerprint generator"
+```
+
+### Task 13: Evasion init script
+
+**Files:**
+- Create: `internal/stealth/script.go`
+- Create: `internal/stealth/assets/evasions.js`
+- Test: `internal/stealth/script_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package stealth
+
+import "testing"
+
+func TestStealthScript(t *testing.T) {
+ res := StealthScript(ScriptParams{Engine: "playwright"})
+ if res.Script == "" {
+ t.Fatal("empty script")
+ }
+ for _, want := range []string{"navigator.webdriver", "languages", "WebGL", "chrome"} {
+ if !contains(res.Script, want) {
+ t.Fatalf("script missing %q", want)
+ }
+ }
+ if res.Apply.How == "" || len(res.IncludedEvasions) == 0 {
+ t.Fatalf("missing apply metadata: %+v", res)
+ }
+}
+
+func contains(s, sub string) bool { return len(s) >= len(sub) && indexOf(s, sub) >= 0 }
+func indexOf(s, sub string) int {
+ for i := 0; i+len(sub) <= len(s); i++ {
+ if s[i:i+len(sub)] == sub {
+ return i
+ }
+ }
+ return -1
+}
+```
+
+- [ ] **Step 2: Run to verify fail** → `undefined: StealthScript`.
+
+- [ ] **Step 3: Write `assets/evasions.js`**
+
+> The init script patches the common bot-detection surfaces. This is a minimal, self-contained version (no external library). At implementation, cross-check against current open-source evasion sets (e.g. playwright-stealth / puppeteer-extra-plugin-stealth) and expand; record the source + license in `internal/stealth/assets/NOTICE`.
+
+```javascript
+(() => {
+ // navigator.webdriver
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
+ // languages
+ Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
+ // plugins (non-empty)
+ Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
+ // chrome runtime stub
+ window.chrome = window.chrome || { runtime: {} };
+ // permissions query consistency
+ const origQuery = window.navigator.permissions && window.navigator.permissions.query;
+ if (origQuery) {
+ window.navigator.permissions.query = (params) =>
+ params && params.name === 'notifications'
+ ? Promise.resolve({ state: Notification.permission })
+ : origQuery(params);
+ }
+ // WebGL vendor/renderer spoof
+ const getParam = WebGLRenderingContext.prototype.getParameter;
+ WebGLRenderingContext.prototype.getParameter = function (p) {
+ if (p === 37445) return 'Google Inc.'; // UNMASKED_VENDOR_WEBGL
+ if (p === 37446) return 'ANGLE (Generic GPU)'; // UNMASKED_RENDERER_WEBGL
+ return getParam.call(this, p);
+ };
+})();
+```
+
+- [ ] **Step 4: Write `script.go`** (embeds the asset)
+
+```go
+package stealth
+
+import _ "embed"
+
+//go:embed assets/evasions.js
+var evasionsJS string
+
+// ScriptParams selects the target engine for apply instructions.
+type ScriptParams struct {
+ Engine string // playwright | cdp | generic
+}
+
+// ApplyInfo tells the caller how to inject the script.
+type ApplyInfo struct {
+ How string `json:"how"`
+ API string `json:"api"`
+}
+
+// ScriptResult is the injectable evasion bundle plus metadata.
+type ScriptResult struct {
+ Script string `json:"script"`
+ Apply ApplyInfo `json:"apply"`
+ IncludedEvasions []string `json:"included_evasions"`
+}
+
+// StealthScript returns the evasion init script and engine-specific apply hints.
+func StealthScript(p ScriptParams) ScriptResult {
+ apply := ApplyInfo{How: "Inject before any page script runs.", API: "page.addInitScript(script)"}
+ switch p.Engine {
+ case "cdp":
+ apply.API = "Page.addScriptToEvaluateOnNewDocument({source: script})"
+ case "generic", "":
+ apply.API = "evaluate the script on every new document before navigation"
+ }
+ return ScriptResult{
+ Script: evasionsJS,
+ Apply: apply,
+ IncludedEvasions: []string{
+ "navigator.webdriver", "navigator.languages", "navigator.plugins",
+ "chrome.runtime", "permissions.query", "WebGL vendor/renderer",
+ },
+ }
+}
+```
+
+- [ ] **Step 5: Run + commit**
+
+Run: `go test ./internal/stealth/ -run StealthScript` → PASS.
+```bash
+git add internal/stealth/script.go internal/stealth/assets internal/stealth/script_test.go
+git commit -m "feat(stealth): embeddable evasion init script with apply hints"
+```
+
+### Task 14: Humanized interaction plan
+
+**Files:**
+- Create: `internal/stealth/interaction.go`
+- Test: `internal/stealth/interaction_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package stealth
+
+import "testing"
+
+func TestPlanInteraction(t *testing.T) {
+ plan := PlanInteraction(InteractionParams{
+ Actions: []Action{{Type: "click", X: 50, Y: 50}, {Type: "click", X: 150, Y: 150}},
+ Seed: 7,
+ })
+ if len(plan.Steps) == 0 {
+ t.Fatal("no steps")
+ }
+ // monotonic non-decreasing timing
+ last := -1.0
+ for _, s := range plan.Steps {
+ if s.AtMs < last {
+ t.Fatalf("timing not monotonic: %v then %v", last, s.AtMs)
+ }
+ last = s.AtMs
+ }
+ // last mouse step must land on the final target
+ var lastMove *Step
+ for i := range plan.Steps {
+ if plan.Steps[i].Type == "move" {
+ lastMove = &plan.Steps[i]
+ }
+ }
+ if lastMove == nil || lastMove.X != 150 || lastMove.Y != 150 {
+ t.Fatalf("final move did not reach target: %+v", lastMove)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify fail** → `undefined: PlanInteraction`.
+
+- [ ] **Step 3: Write `interaction.go`** (Bézier path + jitter + Fitts-law-ish timing, seeded)
+
+```go
+package stealth
+
+import (
+ "math"
+ "math/rand/v2"
+)
+
+// Action is a high-level target the caller wants to perform.
+type Action struct {
+ Type string `json:"type"` // click | type | scroll
+ Text string `json:"text,omitempty"`
+ X int `json:"x,omitempty"`
+ Y int `json:"y,omitempty"`
+}
+
+// InteractionParams is the input to PlanInteraction.
+type InteractionParams struct {
+ Actions []Action
+ Seed uint64
+}
+
+// Step is one low-level humanized event with a relative timestamp (ms).
+type Step struct {
+ Type string `json:"type"` // move | down | up | key
+ Key string `json:"key,omitempty"`
+ AtMs float64 `json:"at_ms"`
+ X int `json:"x,omitempty"`
+ Y int `json:"y,omitempty"`
+}
+
+// InteractionPlan is a humanized timeline the caller replays.
+type InteractionPlan struct {
+ Steps []Step `json:"steps"`
+}
+
+// PlanInteraction converts target actions into a humanized event timeline:
+// cubic-Bézier mouse paths with jitter and Fitts-law-derived durations, plus
+// per-key typing cadence. Deterministic for a given Seed.
+func PlanInteraction(p InteractionParams) InteractionPlan {
+ r := rand.New(rand.NewPCG(p.Seed, p.Seed^0xa0761d6478bd642f))
+ var steps []Step
+ t := 0.0
+ cx, cy := 0, 0
+ for _, a := range p.Actions {
+ switch a.Type {
+ case "click":
+ seg, dt := mousePath(r, cx, cy, a.X, a.Y, t)
+ steps = append(steps, seg...)
+ t += dt
+ steps = append(steps, Step{Type: "down", X: a.X, Y: a.Y, AtMs: t})
+ t += 40 + r.Float64()*60 // dwell
+ steps = append(steps, Step{Type: "up", X: a.X, Y: a.Y, AtMs: t})
+ t += 60 + r.Float64()*120
+ cx, cy = a.X, a.Y
+ case "type":
+ for _, ch := range a.Text {
+ t += 60 + r.Float64()*120 // inter-key cadence
+ steps = append(steps, Step{Type: "key", Key: string(ch), AtMs: t})
+ }
+ }
+ }
+ return InteractionPlan{Steps: steps}
+}
+
+func mousePath(r *rand.Rand, x0, y0, x1, y1 int, start float64) ([]Step, float64) {
+ dist := math.Hypot(float64(x1-x0), float64(y1-y0))
+ // Fitts-law-ish duration; bounded.
+ dur := 120 + 100*math.Log2(1+dist/40)
+ n := 12 + r.IntN(8)
+ // random control points around the straight line for a curved path
+ c1x := float64(x0) + (float64(x1-x0))*0.3 + (r.Float64()-0.5)*dist*0.3
+ c1y := float64(y0) + (float64(y1-y0))*0.3 + (r.Float64()-0.5)*dist*0.3
+ c2x := float64(x0) + (float64(x1-x0))*0.7 + (r.Float64()-0.5)*dist*0.3
+ c2y := float64(y0) + (float64(y1-y0))*0.7 + (r.Float64()-0.5)*dist*0.3
+ steps := make([]Step, 0, n)
+ for i := 1; i <= n; i++ {
+ u := float64(i) / float64(n)
+ bx := cubic(u, float64(x0), c1x, c2x, float64(x1))
+ by := cubic(u, float64(y0), c1y, c2y, float64(y1))
+ jitter := 0.0
+ if i < n { // no jitter on the final landing point
+ jitter = (r.Float64() - 0.5) * 2
+ }
+ steps = append(steps, Step{
+ Type: "move",
+ X: int(bx + jitter),
+ Y: int(by + jitter),
+ AtMs: start + dur*u,
+ })
+ }
+ return steps, dur
+}
+
+func cubic(u, p0, p1, p2, p3 float64) float64 {
+ v := 1 - u
+ return v*v*v*p0 + 3*v*v*u*p1 + 3*v*u*u*p2 + u*u*u*p3
+}
+```
+
+- [ ] **Step 4: Run + commit**
+
+Run: `go test ./internal/stealth/ -run PlanInteraction` → PASS.
+```bash
+git add internal/stealth/interaction.go internal/stealth/interaction_test.go
+git commit -m "feat(stealth): humanized interaction plan (bezier + cadence)"
+```
+
+### Task 15: Header / client-hint set
+
+**Files:**
+- Create: `internal/stealth/headers.go`
+- Test: `internal/stealth/headers_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package stealth
+
+import "testing"
+
+func TestHeadersFor(t *testing.T) {
+ fp := GenerateFingerprint(FingerprintParams{OS: "macos", Browser: "chrome", Locale: "en-GB", Seed: 5})
+ h := HeadersFor(fp)
+ if h.Headers["User-Agent"] != fp.UserAgent {
+ t.Fatalf("UA mismatch")
+ }
+ if h.Headers["Accept-Language"] == "" || h.Headers["Sec-CH-UA-Platform"] == "" {
+ t.Fatalf("missing client hints: %+v", h.Headers)
+ }
+ if len(h.Advisories) == 0 {
+ t.Fatal("expected TLS/JA3 advisory")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify fail** → `undefined: HeadersFor`.
+
+- [ ] **Step 3: Write `headers.go`**
+
+```go
+package stealth
+
+// HeaderSet is a consistent header bundle plus advisories the MCP cannot enforce.
+type HeaderSet struct {
+ Headers map[string]string `json:"headers"`
+ Advisories []string `json:"advisories"`
+}
+
+// HeadersFor builds an HTTP header set consistent with the given fingerprint.
+func HeadersFor(fp Fingerprint) HeaderSet {
+ return HeaderSet{
+ Headers: map[string]string{
+ "User-Agent": fp.UserAgent,
+ "Accept-Language": fp.Locale + ",en;q=0.9",
+ "Sec-CH-UA": fp.SecCHUA,
+ "Sec-CH-UA-Platform": fp.SecCHUAPlatform,
+ "Sec-CH-UA-Mobile": "?0",
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+ },
+ Advisories: []string{
+ "TLS/JA3 fingerprint is set by the browser's TLS stack and cannot be changed by this MCP — use a matching browser build or a TLS-rotating proxy.",
+ "HTTP/2 frame fingerprint is browser-controlled; header order here is advisory only.",
+ },
+ }
+}
+```
+
+- [ ] **Step 4: Run + commit**
+
+Run: `go test ./internal/stealth/...` → PASS.
+```bash
+git add internal/stealth/headers.go internal/stealth/headers_test.go
+git commit -m "feat(stealth): consistent header set with TLS/JA3 advisories"
+```
+
+---
+
+## Phase 7 — MCP server wiring
+
+### Task 16: Tool registration + handlers
+
+**Files:**
+- Create: `internal/mcp/server.go`
+- Create: `internal/mcp/tools.go`
+- Test: `internal/mcp/tools_test.go`
+
+The handlers use the SDK's typed signature: `func(ctx, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error)`. We unit-test the handler functions directly (not over a transport) with a `llm.Fake`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package mcpserver
+
+import (
+ "context"
+ "testing"
+
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/llm"
+)
+
+func TestGridHandler(t *testing.T) {
+ h := New(&llm.Fake{VisionResp: `{"tiles":[1,2],"confidence":0.7,"recheck_recommended":false}`}, "anthropic", "claude-opus-4-8")
+ _, out, err := h.solveGrid(context.Background(), nil, SolveGridIn{
+ Screenshot: pngB64(), Instruction: "buses", Rows: 3, Cols: 3, ImageWidth: 300, ImageHeight: 300,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(out.Tiles) != 2 || len(out.Centroids) != 2 {
+ t.Fatalf("out=%+v", out)
+ }
+}
+
+func TestDetectHandler(t *testing.T) {
+ h := New(&llm.Fake{}, "anthropic", "claude-opus-4-8")
+ _, out, err := h.detect(context.Background(), nil, DetectIn{HTML: ``})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(out.Detected) != 1 || out.Detected[0].Type != "recaptcha_v2" {
+ t.Fatalf("out=%+v", out)
+ }
+}
+
+// pngB64 returns a 1px PNG as base64 (valid magic bytes are enough for decode).
+func pngB64() string { return "iVBORw0KGgo=" } // "iVBORw..." decodes to PNG magic prefix
+```
+
+> Note: the base64 in `pngB64` only needs to decode to bytes beginning with the PNG magic so `imageutil.Decode` sniffs `image/png`; the vision call is faked, so no real rendering happens.
+
+- [ ] **Step 2: Run to verify fail** → `undefined: New`.
+
+- [ ] **Step 3: Write `tools.go`** (In/Out structs + handlers)
+
+```go
+// Package mcpserver wires captcha + stealth functions to MCP tools.
+package mcpserver
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/captcha"
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/imageutil"
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/llm"
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/stealth"
+)
+
+// Handlers holds dependencies shared by all tool handlers.
+type Handlers struct {
+ llm llm.Client
+ provider string
+ model string
+}
+
+// New constructs Handlers.
+func New(c llm.Client, provider, model string) *Handlers {
+ return &Handlers{llm: c, provider: provider, model: model}
+}
+
+// ---- detect_captcha ----
+
+type DetectIn struct {
+ HTML string `json:"html,omitempty" jsonschema:"page HTML to scan for captcha markers"`
+ URL string `json:"url,omitempty" jsonschema:"page URL (informational)"`
+ Screenshot string `json:"screenshot,omitempty" jsonschema:"base64/path/url screenshot, used only if HTML is inconclusive"`
+}
+type DetectOut struct {
+ Detected []captcha.Detection `json:"detected"`
+ Summary string `json:"summary"`
+}
+
+func (h *Handlers) detect(_ context.Context, _ *mcp.CallToolRequest, in DetectIn) (*mcp.CallToolResult, DetectOut, error) {
+ det := captcha.DetectHTML(in.HTML)
+ summary := fmt.Sprintf("%d captcha(s) detected", len(det))
+ return nil, DetectOut{Detected: det, Summary: summary}, nil
+}
+
+// ---- solve_text_captcha ----
+
+type SolveTextIn struct {
+ Image string `json:"image" jsonschema:"base64/path/url/data-uri of the text captcha image"`
+ Hint string `json:"hint,omitempty"`
+ Charset string `json:"charset,omitempty"`
+ Length int `json:"length,omitempty"`
+ CaseSensitive bool `json:"case_sensitive,omitempty"`
+}
+
+func (h *Handlers) solveText(ctx context.Context, _ *mcp.CallToolRequest, in SolveTextIn) (*mcp.CallToolResult, captcha.TextResult, error) {
+ data, mt, err := imageutil.Decode(in.Image)
+ if err != nil {
+ return nil, captcha.TextResult{}, err
+ }
+ res, err := captcha.SolveText(ctx, h.llm, data, mt, captcha.TextOptions{
+ Hint: in.Hint, Charset: in.Charset, Length: in.Length, CaseSensitive: in.CaseSensitive,
+ })
+ return nil, res, err
+}
+
+// ---- solve_audio_captcha ----
+
+type SolveAudioIn struct {
+ Audio string `json:"audio" jsonschema:"base64/path/url of the audio captcha"`
+ Language string `json:"language,omitempty"`
+}
+
+func (h *Handlers) solveAudio(ctx context.Context, _ *mcp.CallToolRequest, in SolveAudioIn) (*mcp.CallToolResult, captcha.AudioResult, error) {
+ data, _, err := imageutil.Decode(in.Audio)
+ if err != nil {
+ return nil, captcha.AudioResult{}, err
+ }
+ res, err := captcha.SolveAudio(ctx, h.llm, data, "audio.wav", in.Language)
+ var unsupported llm.ErrUnsupported
+ if errors.As(err, &unsupported) {
+ return nil, captcha.AudioResult{}, fmt.Errorf("audio captcha unsupported: configured provider %q has no transcription; set CAPTCHA_LLM_PROVIDER=openai|gemini and CAPTCHA_LLM_AUDIO_MODEL", h.provider)
+ }
+ return nil, res, err
+}
+
+// ---- solve_grid_captcha ----
+
+type SolveGridIn struct {
+ Screenshot string `json:"screenshot" jsonschema:"base64/path/url of the challenge grid image"`
+ Instruction string `json:"instruction" jsonschema:"the challenge text, e.g. 'select all buses'"`
+ CaptchaType string `json:"captcha_type,omitempty" jsonschema:"recaptcha | hcaptcha"`
+ Rows int `json:"rows" jsonschema:"grid rows (e.g. 3)"`
+ Cols int `json:"cols" jsonschema:"grid cols (e.g. 3)"`
+ ImageWidth int `json:"image_width,omitempty" jsonschema:"screenshot width in px, enables centroid output"`
+ ImageHeight int `json:"image_height,omitempty" jsonschema:"screenshot height in px, enables centroid output"`
+}
+
+func (h *Handlers) solveGrid(ctx context.Context, _ *mcp.CallToolRequest, in SolveGridIn) (*mcp.CallToolResult, captcha.GridResult, error) {
+ data, mt, err := imageutil.Decode(in.Screenshot)
+ if err != nil {
+ return nil, captcha.GridResult{}, err
+ }
+ res, err := captcha.SolveGrid(ctx, h.llm, data, mt, captcha.GridOptions{
+ Instruction: in.Instruction, CaptchaType: in.CaptchaType,
+ Rows: in.Rows, Cols: in.Cols, ImageWidth: in.ImageWidth, ImageHeight: in.ImageHeight,
+ })
+ return nil, res, err
+}
+
+// ---- solve_rotation_captcha ----
+
+type SolveRotationIn struct {
+ Screenshot string `json:"screenshot" jsonschema:"base64/path/url of the rotation/choice puzzle"`
+ Instruction string `json:"instruction,omitempty"`
+}
+
+func (h *Handlers) solveRotation(ctx context.Context, _ *mcp.CallToolRequest, in SolveRotationIn) (*mcp.CallToolResult, captcha.RotationResult, error) {
+ data, mt, err := imageutil.Decode(in.Screenshot)
+ if err != nil {
+ return nil, captcha.RotationResult{}, err
+ }
+ res, err := captcha.SolveRotation(ctx, h.llm, data, mt, in.Instruction)
+ return nil, res, err
+}
+
+// ---- stealth tools ----
+
+type StealthScriptIn struct {
+ Engine string `json:"engine,omitempty" jsonschema:"playwright | cdp | generic"`
+}
+
+func (h *Handlers) stealthScript(_ context.Context, _ *mcp.CallToolRequest, in StealthScriptIn) (*mcp.CallToolResult, stealth.ScriptResult, error) {
+ return nil, stealth.StealthScript(stealth.ScriptParams{Engine: in.Engine}), nil
+}
+
+type FingerprintIn struct {
+ OS string `json:"os,omitempty" jsonschema:"windows | macos | linux"`
+ Browser string `json:"browser,omitempty" jsonschema:"chrome | firefox"`
+ Locale string `json:"locale,omitempty"`
+ Seed uint64 `json:"seed,omitempty" jsonschema:"seed for a stable identity across runs"`
+}
+type FingerprintOut struct {
+ Fingerprint stealth.Fingerprint `json:"fingerprint"`
+ HeaderSet stealth.HeaderSet `json:"header_set"`
+}
+
+func (h *Handlers) fingerprint(_ context.Context, _ *mcp.CallToolRequest, in FingerprintIn) (*mcp.CallToolResult, FingerprintOut, error) {
+ fp := stealth.GenerateFingerprint(stealth.FingerprintParams{OS: in.OS, Browser: in.Browser, Locale: in.Locale, Seed: in.Seed})
+ return nil, FingerprintOut{Fingerprint: fp, HeaderSet: stealth.HeadersFor(fp)}, nil
+}
+
+type PlanInteractionIn struct {
+ Actions []stealth.Action `json:"actions" jsonschema:"clicks/types/scrolls to humanize"`
+ Seed uint64 `json:"seed,omitempty"`
+}
+
+func (h *Handlers) planInteraction(_ context.Context, _ *mcp.CallToolRequest, in PlanInteractionIn) (*mcp.CallToolResult, stealth.InteractionPlan, error) {
+ return nil, stealth.PlanInteraction(stealth.InteractionParams{Actions: in.Actions, Seed: in.Seed}), nil
+}
+
+// ---- solver_info ----
+
+type SolverInfoOut struct {
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ Capabilities map[string]bool `json:"capabilities"`
+ Notes map[string]string `json:"notes"`
+}
+
+func (h *Handlers) solverInfo(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, SolverInfoOut, error) {
+ audio := h.provider == "openai" || h.provider == "openai-compatible" || h.provider == "gemini"
+ return nil, SolverInfoOut{
+ Provider: h.provider, Model: h.model,
+ Capabilities: map[string]bool{
+ "text": true, "grid": true, "rotation": true, "audio": audio, "detect": true,
+ "stealth": true,
+ },
+ Notes: map[string]string{
+ "recaptcha_v3": "detect-only, not LLM-solvable",
+ "turnstile": "detect-only, not LLM-solvable",
+ },
+ }, nil
+}
+```
+
+- [ ] **Step 4: Write `server.go`** (registers tools on an `*mcp.Server`)
+
+```go
+package mcpserver
+
+import "github.com/modelcontextprotocol/go-sdk/mcp"
+
+// Register attaches all tools to the given MCP server.
+func (h *Handlers) Register(s *mcp.Server) {
+ mcp.AddTool(s, &mcp.Tool{Name: "detect_captcha", Description: "Detect captcha type/sitekey from page HTML or screenshot. Flags v3/Turnstile as not LLM-solvable."}, h.detect)
+ mcp.AddTool(s, &mcp.Tool{Name: "solve_text_captcha", Description: "OCR a distorted-text image captcha and return the characters."}, h.solveText)
+ mcp.AddTool(s, &mcp.Tool{Name: "solve_audio_captcha", Description: "Transcribe an audio captcha (requires an audio-capable provider)."}, h.solveAudio)
+ mcp.AddTool(s, &mcp.Tool{Name: "solve_grid_captcha", Description: "reCAPTCHA v2 / hCaptcha: return which grid tiles to click (and centroids). Caller clicks + harvests the token."}, h.solveGrid)
+ mcp.AddTool(s, &mcp.Tool{Name: "solve_rotation_captcha", Description: "Best-effort FunCaptcha/rotation puzzle solver."}, h.solveRotation)
+ mcp.AddTool(s, &mcp.Tool{Name: "get_stealth_script", Description: "Return an evasion JS init script the caller injects, plus apply instructions."}, h.stealthScript)
+ mcp.AddTool(s, &mcp.Tool{Name: "generate_fingerprint", Description: "Generate a coherent browser fingerprint + matching header set (optionally seeded)."}, h.fingerprint)
+ mcp.AddTool(s, &mcp.Tool{Name: "plan_interaction", Description: "Convert target clicks/typing into a humanized mouse/keyboard timeline."}, h.planInteraction)
+ mcp.AddTool(s, &mcp.Tool{Name: "solver_info", Description: "Report configured provider/model and per-type capabilities."}, h.solverInfo)
+}
+```
+
+- [ ] **Step 5: Run + commit**
+
+Run: `go test ./internal/mcp/...` → PASS.
+```bash
+git add internal/mcp
+git commit -m "feat(mcp): tool registration and handlers for solve+stealth+detect"
+```
+
+### Task 17: main entrypoint
+
+**Files:**
+- Create: `cmd/captcha-solver-mcp/main.go`
+
+- [ ] **Step 1: Write `main.go`**
+
+```go
+// Command captcha-solver-mcp runs the captcha solver MCP server over stdio.
+package main
+
+import (
+ "context"
+ "log"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/config"
+ "github.com/lukaszraczylo/captcha-solver-mcp/internal/llm"
+ mcpserver "github.com/lukaszraczylo/captcha-solver-mcp/internal/mcp"
+)
+
+func main() {
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatalf("config: %v", err)
+ }
+ client, err := llm.New(cfg)
+ if err != nil {
+ log.Fatalf("llm: %v", err)
+ }
+ server := mcp.NewServer(&mcp.Implementation{Name: "captcha-solver-mcp", Version: "v0.1.0"}, nil)
+ mcpserver.New(client, cfg.Provider, cfg.Model).Register(server)
+
+ if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
+ log.Fatalf("server: %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Build to verify it compiles + links**
+
+Run: `make build`
+Expected: `bin/captcha-solver-mcp` produced, exit 0.
+
+- [ ] **Step 3: Smoke-test config failure path**
+
+Run: `CAPTCHA_LLM_PROVIDER=bad ./bin/captcha-solver-mcp`
+Expected: exits non-zero with `config: CAPTCHA_LLM_PROVIDER must be one of ...`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cmd/captcha-solver-mcp/main.go
+git commit -m "feat(cmd): stdio entrypoint wiring config+llm+mcp"
+```
+
+---
+
+## Phase 8 — Lint pass + full unit gate
+
+### Task 18: golangci clean + fieldalignment
+
+- [ ] **Step 1: Run the linter**
+
+Run: `make lint`
+Expected: zero issues. If `fieldalignment` reports a struct, fix by reordering fields (do **not** disable the linter). Re-run.
+
+- [ ] **Step 2: Run all unit tests + vet**
+
+Run: `go vet ./... && go test ./...`
+Expected: all PASS.
+
+- [ ] **Step 3: Commit any fixes**
+
+```bash
+git add -A
+git commit -m "chore: golangci clean (fieldalignment, vet)"
+```
+
+---
+
+## Phase 9 — Gemini provider (deferred verification)
+
+### Task 19: Implement gemini provider
+
+> **Grounding gate:** Before writing code, fetch the current `generateContent` request/response shape via context7 (`resolve-library-id` "Google Generative Language API" / "Gemini API", then `query-docs` for "generateContent inline_data base64 image request body; audio inline_data; response candidates parts text"). Confirm the body below matches; adjust field names if the API differs. Do not ship unverified.
+
+**Files:**
+- Replace stub: `internal/llm/gemini.go`
+- Test: `internal/llm/gemini_test.go`
+
+- [ ] **Step 1: Write the failing test** (httptest; target shape below — reconcile with verified docs)
+
+```go
+package llm
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestGeminiVision(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !strings.Contains(r.URL.Path, ":generateContent") {
+ t.Errorf("path %s", r.URL.Path)
+ }
+ if r.URL.Query().Get("key") != "key" {
+ t.Errorf("missing key query")
+ }
+ raw, _ := io.ReadAll(r.Body)
+ if !strings.Contains(string(raw), "inline_data") {
+ t.Errorf("inline_data missing: %s", raw)
+ }
+ _, _ = io.WriteString(w, `{"candidates":[{"content":{"parts":[{"text":"motorcycle"}]}}]}`)
+ }))
+ defer srv.Close()
+
+ c := &geminiClient{http: &http.Client{Timeout: 5 * time.Second}, base: srv.URL, key: "key", model: "gemini-2.5-pro", maxTokens: 100}
+ got, err := c.Vision(context.Background(), "which", []Image{{MediaType: "image/png", Data: []byte{1}}}, Options{})
+ if err != nil || got != "motorcycle" {
+ t.Fatalf("got %q err %v", got, err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify fail** (stub returns "not implemented").
+
+- [ ] **Step 3: Replace `gemini.go`** (reconcile with verified docs first)
+
+```go
+package llm
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+)
+
+type geminiClient struct {
+ http *http.Client
+ base string
+ key string
+ model string
+ audioModel string
+ maxTokens int
+}
+
+func (c *geminiClient) Vision(ctx context.Context, prompt string, images []Image, opts Options) (string, error) {
+ parts := []map[string]any{{"text": prompt}}
+ for _, img := range images {
+ parts = append(parts, map[string]any{
+ "inline_data": map[string]any{
+ "mime_type": img.MediaType,
+ "data": base64.StdEncoding.EncodeToString(img.Data),
+ },
+ })
+ }
+ model := c.model
+ if opts.Model != "" {
+ model = opts.Model
+ }
+ body := map[string]any{"contents": []map[string]any{{"parts": parts}}}
+ url := fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s", c.base, model, c.key)
+ b, err := json.Marshal(body)
+ if err != nil {
+ return "", err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ raw, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode >= 300 {
+ return "", fmt.Errorf("gemini HTTP %d: %s", resp.StatusCode, string(raw))
+ }
+ var out struct {
+ Candidates []struct {
+ Content struct {
+ Parts []struct {
+ Text string `json:"text"`
+ } `json:"parts"`
+ } `json:"content"`
+ } `json:"candidates"`
+ }
+ if err := json.Unmarshal(raw, &out); err != nil {
+ return "", err
+ }
+ if len(out.Candidates) == 0 || len(out.Candidates[0].Content.Parts) == 0 {
+ return "", fmt.Errorf("gemini: empty candidates")
+ }
+ return out.Candidates[0].Content.Parts[0].Text, nil
+}
+
+func (c *geminiClient) Transcribe(ctx context.Context, audio []byte, _ string, opts Options) (string, error) {
+ // Gemini transcribes via generateContent with audio inline_data.
+ model := c.audioModel
+ if opts.Model != "" {
+ model = opts.Model
+ }
+ if model == "" {
+ model = c.model
+ }
+ parts := []map[string]any{
+ {"text": "Transcribe the spoken characters in this audio captcha. Output only the transcription."},
+ {"inline_data": map[string]any{"mime_type": "audio/wav", "data": base64.StdEncoding.EncodeToString(audio)}},
+ }
+ body := map[string]any{"contents": []map[string]any{{"parts": parts}}}
+ url := fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s", c.base, model, c.key)
+ b, _ := json.Marshal(body)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ raw, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode >= 300 {
+ return "", fmt.Errorf("gemini HTTP %d: %s", resp.StatusCode, string(raw))
+ }
+ var out struct {
+ Candidates []struct {
+ Content struct {
+ Parts []struct {
+ Text string `json:"text"`
+ } `json:"parts"`
+ } `json:"content"`
+ } `json:"candidates"`
+ }
+ if err := json.Unmarshal(raw, &out); err != nil {
+ return "", err
+ }
+ if len(out.Candidates) == 0 || len(out.Candidates[0].Content.Parts) == 0 {
+ return "", fmt.Errorf("gemini: empty candidates")
+ }
+ return out.Candidates[0].Content.Parts[0].Text, nil
+}
+```
+
+- [ ] **Step 4: Run + commit**
+
+Run: `go test ./internal/llm/ -run Gemini && make lint` → PASS/clean.
+```bash
+git add internal/llm/gemini.go internal/llm/gemini_test.go
+git commit -m "feat(llm): gemini vision+audio provider via generateContent"
+```
+
+---
+
+## Phase 10 — Deterministic e2e (both engines, fake LLM, local page)
+
+The e2e suite (build tag `e2e`) launches the **real server binary over stdio** using a minimal MCP client, points the server at a **fake LLM HTTP server**, and drives a **local fake-captcha page** with Playwright (Chromium) and Lightpanda.
+
+### Task 20: Local fake-captcha test page
+
+**Files:**
+- Create: `test/e2e/testpage/index.html`
+- Create: `test/e2e/testpage/grid.png` (a 3x3 image with one obvious row of "buses"; generated by a tiny Go gen or committed asset)
+
+- [ ] **Step 1: Write `index.html`** (a deterministic fake widget: 3x3 tile grid + a hidden token field set on correct selection)
+
+```html
+
+
+fake captcha
+
+ unsolved
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add test/e2e/testpage
+git commit -m "test(e2e): local fake-captcha page with hidden token field"
+```
+
+### Task 21: Fake LLM HTTP server for e2e
+
+**Files:**
+- Create: `test/e2e/fakellm_test.go` (build tag `e2e`) — an OpenAI-compatible `httptest`-style server returning a fixed grid selection `[0,1,2]`.
+
+- [ ] **Step 1: Write the fake LLM**
+
+```go
+//go:build e2e
+
+package e2e
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+)
+
+// startFakeLLM returns an OpenAI-compatible endpoint that always selects tiles 0,1,2.
+func startFakeLLM() *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/v1/chat/completions":
+ _, _ = io.WriteString(w, `{"choices":[{"message":{"content":"{\"tiles\":[0,1,2],\"confidence\":0.95,\"recheck_recommended\":false}"}}]}`)
+ case "/v1/audio/transcriptions":
+ _, _ = io.WriteString(w, `{"text":"fortytwo"}`)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add test/e2e/fakellm_test.go
+git commit -m "test(e2e): openai-compatible fake LLM returning fixed tiles"
+```
+
+### Task 22: MCP stdio client + server-launch e2e (no browser)
+
+**Files:**
+- Create: `test/e2e/server_test.go` (build tag `e2e`)
+
+This launches the built binary configured with `provider=openai-compatible` + `base_url=`, connects an MCP client over the child's stdio, calls `solve_grid_captcha`, and asserts tiles `[0,1,2]`.
+
+- [ ] **Step 1: Write the test** (uses the SDK's client over a command transport)
+
+```go
+//go:build e2e
+
+package e2e
+
+import (
+ "context"
+ "os/exec"
+ "testing"
+ "time"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+func TestSolveGridOverStdio(t *testing.T) {
+ llmSrv := startFakeLLM()
+ defer llmSrv.Close()
+
+ // Build once (Makefile target or go build in TestMain); assume bin exists.
+ cmd := exec.Command("../../bin/captcha-solver-mcp")
+ cmd.Env = append(cmd.Environ(),
+ "CAPTCHA_LLM_PROVIDER=openai-compatible",
+ "CAPTCHA_LLM_BASE_URL="+llmSrv.URL,
+ "CAPTCHA_LLM_API_KEY=test",
+ "CAPTCHA_LLM_MODEL=fake",
+ )
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ client := mcp.NewClient(&mcp.Implementation{Name: "e2e", Version: "v0"}, nil)
+ session, err := client.Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil)
+ if err != nil {
+ t.Fatalf("connect: %v", err)
+ }
+ defer func() { _ = session.Close() }()
+
+ res, err := session.CallTool(ctx, &mcp.CallToolParams{
+ Name: "solve_grid_captcha",
+ Arguments: map[string]any{
+ "screenshot": "iVBORw0KGgo=", // PNG magic; vision is faked
+ "instruction": "select all buses",
+ "rows": 3,
+ "cols": 3,
+ "image_width": 300,
+ "image_height": 300,
+ },
+ })
+ if err != nil {
+ t.Fatalf("call: %v", err)
+ }
+ // StructuredContent carries the typed Out; assert tiles via the JSON map.
+ assertTiles(t, res, []int{0, 1, 2})
+}
+```
+
+> Note: the exact client API surface (`mcp.NewClient`, `client.Connect`, `mcp.CommandTransport`, reading `res.StructuredContent`) must be confirmed against the go-sdk v1.2.0 client docs at implementation (query-docs: "go-sdk client Connect CommandTransport CallTool read structured result"). `assertTiles` is a small helper that unmarshals `res.StructuredContent` and compares. Implement `assertTiles` in the same file.
+
+- [ ] **Step 2: Add `TestMain` to build the binary**
+
+```go
+//go:build e2e
+
+package e2e
+
+import (
+ "os"
+ "os/exec"
+ "testing"
+)
+
+func TestMain(m *testing.M) {
+ build := exec.Command("go", "build", "-o", "../../bin/captcha-solver-mcp", "../../cmd/captcha-solver-mcp")
+ build.Stdout, build.Stderr = os.Stdout, os.Stderr
+ if err := build.Run(); err != nil {
+ os.Exit(1)
+ }
+ os.Exit(m.Run())
+}
+```
+
+- [ ] **Step 3: Run + commit**
+
+Run: `make e2e`
+Expected: `TestSolveGridOverStdio` PASS — server launched, fake LLM served, tiles `[0,1,2]` returned through the real MCP transport.
+```bash
+git add test/e2e/server_test.go test/e2e/main_test.go
+git commit -m "test(e2e): solve_grid over real MCP stdio with fake LLM"
+```
+
+### Task 23: Browser-loop e2e — Playwright (Chromium)
+
+**Files:**
+- Create: `test/e2e/playwright/run.mjs` (Node script: serve testpage, inject stealth script from MCP, screenshot grid, call MCP, click tiles via humanized plan, assert token)
+- Create: `test/e2e/playwright_test.go` (build tag `e2e`; shells out to the Node script, asserts exit 0)
+- Update: `package.json` (devDeps: `playwright`)
+
+- [ ] **Step 1: Write `run.mjs`**
+
+The script: (1) starts a static file server for `testpage/`, (2) launches the MCP server as a child and speaks MCP over stdio via a tiny JSON-RPC client (or via `@modelcontextprotocol/sdk` if added), (3) `get_stealth_script` → `page.addInitScript`, (4) navigate, screenshot `#grid`, (5) `solve_grid_captcha` with width/height 300, (6) `plan_interaction` with the returned centroids, (7) replay clicks, click Verify, (8) assert `#captcha-token` == `FAKE_TOKEN_OK` and `window.__wd === undefined`.
+
+```javascript
+import { chromium } from 'playwright';
+import http from 'node:http';
+import fs from 'node:fs';
+import path from 'node:path';
+import { spawn } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const pageDir = path.join(__dirname, '..', 'testpage');
+
+// 1. static server
+const server = http.createServer((req, res) => {
+ const f = path.join(pageDir, req.url === '/' ? 'index.html' : req.url);
+ fs.readFile(f, (err, data) => {
+ if (err) { res.writeHead(404); res.end(); return; }
+ res.end(data);
+ });
+}).listen(0);
+const port = server.address().port;
+
+// 2. minimal MCP stdio client (newline-delimited JSON-RPC)
+const mcp = spawn(path.join(__dirname, '..', '..', '..', 'bin', 'captcha-solver-mcp'), {
+ env: { ...process.env,
+ CAPTCHA_LLM_PROVIDER: 'openai-compatible',
+ CAPTCHA_LLM_BASE_URL: process.env.FAKE_LLM_URL,
+ CAPTCHA_LLM_API_KEY: 'test', CAPTCHA_LLM_MODEL: 'fake' },
+ stdio: ['pipe', 'pipe', 'inherit'],
+});
+let idc = 0; const pending = new Map(); let buf = '';
+mcp.stdout.on('data', (d) => {
+ buf += d.toString();
+ let nl;
+ while ((nl = buf.indexOf('\n')) >= 0) {
+ const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
+ if (!line.trim()) continue;
+ const msg = JSON.parse(line);
+ if (msg.id !== undefined && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
+ }
+});
+function rpc(method, params) {
+ const id = ++idc;
+ return new Promise((resolve) => {
+ pending.set(id, resolve);
+ mcp.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
+ });
+}
+async function callTool(name, args) {
+ const r = await rpc('tools/call', { name, arguments: args });
+ return r.result.structuredContent;
+}
+
+await rpc('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'e2e', version: '0' } });
+mcp.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
+
+// 3. stealth script
+const stealth = await callTool('get_stealth_script', { engine: 'playwright' });
+
+const browser = await chromium.launch();
+const page = await browser.newPage();
+await page.addInitScript(stealth.script);
+await page.goto(`http://localhost:${port}/`);
+
+const grid = page.locator('#grid');
+const shot = (await grid.screenshot()).toString('base64');
+const instruction = await grid.getAttribute('data-instruction');
+
+const sol = await callTool('solve_grid_captcha', {
+ screenshot: shot, instruction, rows: 3, cols: 3, image_width: 300, image_height: 300,
+});
+
+const plan = await callTool('plan_interaction', {
+ actions: sol.centroids.map((c) => ({ type: 'click', x: c.x, y: c.y })),
+});
+
+const box = await grid.boundingBox();
+// replay: for each 'down' step, click the tile at that grid-relative point
+for (const step of plan.steps) {
+ if (step.type === 'down') {
+ await page.mouse.click(box.x + step.x, box.y + step.y);
+ }
+}
+await page.click('#verify');
+
+const token = await page.inputValue('#captcha-token');
+const wd = await page.evaluate(() => window.__wd);
+await browser.close(); mcp.kill(); server.close();
+
+if (token !== 'FAKE_TOKEN_OK') { console.error('token mismatch:', token); process.exit(1); }
+if (wd !== undefined) { console.error('webdriver not patched:', wd); process.exit(1); }
+console.log('PASS');
+```
+
+- [ ] **Step 2: Write `playwright_test.go`**
+
+```go
+//go:build e2e
+
+package e2e
+
+import (
+ "os"
+ "os/exec"
+ "testing"
+)
+
+func TestPlaywrightGridLoop(t *testing.T) {
+ if _, err := exec.LookPath("node"); err != nil {
+ t.Skip("node not installed")
+ }
+ llm := startFakeLLM()
+ defer llm.Close()
+
+ cmd := exec.Command("node", "playwright/run.mjs")
+ cmd.Env = append(cmd.Environ(), "FAKE_LLM_URL="+llm.URL)
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("playwright loop failed: %v", err)
+ }
+}
+```
+
+- [ ] **Step 3: Install Playwright + browsers**
+
+Run:
+```bash
+npm init -y
+npm install -D playwright
+npx playwright install chromium
+```
+
+- [ ] **Step 4: Run + commit**
+
+Run: `make e2e`
+Expected: `TestPlaywrightGridLoop` PASS — full screenshot→solve→humanized-click→token loop offline; stealth script verified (`navigator.webdriver === undefined`).
+```bash
+git add test/e2e/playwright test/e2e/playwright_test.go package.json package-lock.json
+git commit -m "test(e2e): playwright grid-loop with stealth + humanized clicks"
+```
+
+### Task 24: Browser-loop e2e — Lightpanda (detect/DOM)
+
+**Files:**
+- Create: `test/e2e/lightpanda/run.mjs` (drives Lightpanda via CDP: load page, read DOM HTML, call `detect_captcha`, assert detection)
+- Create: `test/e2e/lightpanda_test.go` (build tag `e2e`; skips if `lightpanda` binary absent)
+
+- [ ] **Step 1: Write `run.mjs`** (Lightpanda is CDP-compatible; use Playwright's `chromium.connectOverCDP` or puppeteer-core to attach; this path tests detection from DOM, not canvas screenshots)
+
+```javascript
+import { chromium } from 'playwright';
+import http from 'node:http';
+import fs from 'node:fs';
+import path from 'node:path';
+import { spawn } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const pageDir = path.join(__dirname, '..', 'testpage');
+// Serve a recaptcha-marker page for detection
+const html = ``;
+const server = http.createServer((_, res) => res.end(html)).listen(0);
+const port = server.address().port;
+
+// Launch Lightpanda in CDP server mode (adjust flag per installed version).
+const lp = spawn('lightpanda', ['serve', '--host', '127.0.0.1', '--port', '9222'], { stdio: 'inherit' });
+await new Promise((r) => setTimeout(r, 800));
+
+// MCP client (same minimal RPC as playwright/run.mjs — factor into a shared module at impl time)
+// ... initialize MCP, then:
+const browser = await chromium.connectOverCDP('http://127.0.0.1:9222');
+const page = await browser.newPage();
+await page.goto(`http://localhost:${port}/`);
+const dom = await page.content();
+const det = await callTool('detect_captcha', { html: dom });
+await browser.close(); lp.kill(); server.close();
+
+if (!det.detected.some((d) => d.type === 'recaptcha_v2')) { console.error('no detection', det); process.exit(1); }
+console.log('PASS');
+```
+
+> Implementation note: the MCP client boilerplate is identical to Task 23 — extract `test/e2e/mcpclient.mjs` and import it in both `run.mjs` scripts (DRY). Confirm the exact Lightpanda CDP launch flags against the installed binary's `--help` at implementation.
+
+- [ ] **Step 2: Write `lightpanda_test.go`**
+
+```go
+//go:build e2e
+
+package e2e
+
+import (
+ "os"
+ "os/exec"
+ "testing"
+)
+
+func TestLightpandaDetect(t *testing.T) {
+ if _, err := exec.LookPath("lightpanda"); err != nil {
+ t.Skip("lightpanda not installed")
+ }
+ llm := startFakeLLM()
+ defer llm.Close()
+ cmd := exec.Command("node", "lightpanda/run.mjs")
+ cmd.Env = append(cmd.Environ(), "FAKE_LLM_URL="+llm.URL)
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("lightpanda detect failed: %v", err)
+ }
+}
+```
+
+- [ ] **Step 3: Run + commit**
+
+Run: `make e2e` (Lightpanda test skips cleanly if the binary is absent).
+```bash
+git add test/e2e/lightpanda test/e2e/lightpanda_test.go test/e2e/mcpclient.mjs
+git commit -m "test(e2e): lightpanda DOM detection path"
+```
+
+---
+
+## Phase 11 — Gated live e2e (opt-in)
+
+### Task 25: Live tier against real demo pages
+
+**Files:**
+- Create: `test/e2e/live_test.go` (build tags `e2e,live`)
+
+- [ ] **Step 1: Write the live test** (skips unless `CAPTCHA_E2E_LIVE=1` and real creds present)
+
+```go
+//go:build e2e && live
+
+package e2e
+
+import (
+ "os"
+ "os/exec"
+ "testing"
+)
+
+func TestLiveRecaptchaDemo(t *testing.T) {
+ if os.Getenv("CAPTCHA_E2E_LIVE") != "1" {
+ t.Skip("set CAPTCHA_E2E_LIVE=1 to run live tests")
+ }
+ if os.Getenv("CAPTCHA_LLM_API_KEY") == "" || os.Getenv("CAPTCHA_LLM_MODEL") == "" {
+ t.Skip("live test needs real CAPTCHA_LLM_* creds")
+ }
+ if _, err := exec.LookPath("node"); err != nil {
+ t.Skip("node not installed")
+ }
+ // Drives the real Google reCAPTCHA v2 demo page with a real LLM + Chromium.
+ // Best-effort: token captchas are stochastic; treat a single failure as
+ // non-fatal unless CAPTCHA_E2E_LIVE_STRICT=1.
+ cmd := exec.Command("node", "playwright/run-live.mjs")
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ err := cmd.Run()
+ if err != nil && os.Getenv("CAPTCHA_E2E_LIVE_STRICT") == "1" {
+ t.Fatalf("live recaptcha demo failed: %v", err)
+ }
+ if err != nil {
+ t.Logf("live demo did not solve this run (expected occasionally): %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Write `playwright/run-live.mjs`**
+
+Adapt `run.mjs` to: navigate to the public reCAPTCHA v2 demo, click the checkbox, when the image challenge appears screenshot the challenge `iframe`, loop `solve_grid_captcha` (handling `recheck_recommended` by re-screenshotting), use `generate_fingerprint` + `get_stealth_script` + `plan_interaction`, and read `textarea[name="g-recaptcha-response"]`. Real LLM via the server's configured provider (do NOT point at the fake LLM). Exit 0 if a token appears.
+
+- [ ] **Step 3: Document running it**
+
+Run (manual): `CAPTCHA_E2E_LIVE=1 CAPTCHA_LLM_PROVIDER=anthropic CAPTCHA_LLM_API_KEY=... CAPTCHA_LLM_MODEL=claude-opus-4-8 make e2e-live`
+Expected: skipped by default; when enabled, attempts a real solve (best-effort).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add test/e2e/live_test.go test/e2e/playwright/run-live.mjs
+git commit -m "test(e2e): gated live tier against real recaptcha demo"
+```
+
+---
+
+## Phase 12 — Examples + docs
+
+### Task 26: Runnable examples + README
+
+**Files:**
+- Create: `examples/playwright/solve-recaptcha.mjs` (annotated end-to-end recipe)
+- Create: `examples/lightpanda/detect.mjs`
+- Rewrite: `README.md`
+
+- [ ] **Step 1: Write `examples/playwright/solve-recaptcha.mjs`**
+
+A documented version of the Task-23 loop, parameterized by env, with comments explaining the brain/hands split, the dynamic-grid re-screenshot loop, and where the token is read. No assertions — it's a copy-paste starting point.
+
+- [ ] **Step 2: Write `examples/lightpanda/detect.mjs`**
+
+A short script: connect to Lightpanda over CDP, load a URL, call `detect_captcha`, print the result.
+
+- [ ] **Step 3: Rewrite `README.md`**
+
+Cover: what it is / isn't (brain-only, no token minting, v3/Turnstile detect-only); **authorized-use statement**; install/build; env vars table; each tool's input/output; the Playwright/Lightpanda recipe; testing (`make test` / `make e2e` / `make e2e-live`); the TLS/JA3 advisory limitation.
+
+- [ ] **Step 4: Final full verification**
+
+Run:
+```bash
+make tidy && make lint && go test ./... && make build && make e2e
+```
+Expected: tidy clean, lint clean, unit PASS, build OK, deterministic e2e PASS (Lightpanda test skips if absent).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add examples README.md
+git commit -m "docs: runnable examples and README with authorized-use scope"
+```
+
+---
+
+## Acceptance criteria (maps to spec §)
+
+- §3/§1 brain-only/solver-only: server imports no browser driver; all tools take inputs and return data (Tasks 16–17). ✓
+- §5 env-driven providers incl. fail-fast: Task 1 + Task 5. ✓
+- §6 nine tools (detect, text, audio, grid, rotation, solver_info, get_stealth_script, generate_fingerprint, plan_interaction): Tasks 7–16. ✓
+- §6 input formats base64/path/url/data-uri: Task 6. ✓
+- §6.4 grid returns tiles + centroids + recheck: Task 10. ✓
+- §7 v3/Turnstile detect-only (`llm_solvable:false`): Task 7. ✓
+- §8 four stealth capabilities: Tasks 12–15. ✓
+- §6.3 audio "unsupported" honest error on anthropic: Tasks 4, 16. ✓
+- §10 strict-retry, no fabricated answers, confidence returned: Tasks 8, 10. ✓
+- §11 unit (fake LLM) + deterministic e2e (both engines, local page) + gated live: Tasks 2–24 (unit), 20–24 (deterministic e2e), 25 (live). ✓
+- §12 golangci clean incl. fieldalignment, gosec on tests, no GitHub Actions: Tasks 0, 18; Makefile local-only. ✓
+- §9 Playwright + Lightpanda recipes shipped: Task 26. ✓
+
+## Notes for the implementer
+- **Verify two SDK surfaces against go-sdk v1.2.0 docs before coding the tasks that use them:** (a) the **client** API in Task 22 (`mcp.NewClient` / `Connect` / `CommandTransport` / reading `StructuredContent`) and (b) any `mcp.AddTool` handler-signature nuance in Task 16. The server/`AddTool`/`StdioTransport` shapes are already grounded.
+- **Gemini (Task 19) is the one provider with an unverified wire format** — its grounding gate is mandatory; do not ship without the context7 check.
+- **Evasion JS (Task 13)** is a minimal in-repo set; before release, reconcile with a current open-source evasion bundle and record source + license in `internal/stealth/assets/NOTICE`.
+- Keep commit bodies free of "breaking"/"feat" wording beyond the conventional-commit type prefix (semver-generator fuzzy-matches bodies).