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).