Files
mcp-captcharesolver/docs/superpowers/plans/2026-06-07-captcha-solver-mcp.md

3315 lines
101 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/[email protected]
```
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", `<div class="g-recaptcha" data-sitekey="6Lc_abc"></div>`, "recaptcha_v2", true},
{"recaptcha v3", `<script src="https://www.google.com/recaptcha/api.js?render=6Lc_v3key"></script>`, "recaptcha_v3", false},
{"hcaptcha", `<div class="h-captcha" data-sitekey="hk_123"></div>`, "hcaptcha", true},
{"turnstile", `<div class="cf-turnstile" data-sitekey="0x4AAA"></div>`, "turnstile", false},
{"funcaptcha", `<div id="arkose" data-pkey="ABC"></div>`, "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":"<chars>","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":<bool>}.`,
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":<int>,"rotations":<int>,"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: `<div class="g-recaptcha" data-sitekey="k"></div>`})
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
<!doctype html>
<html>
<head><meta charset="utf-8"><title>fake captcha</title></head>
<body>
<div id="status">unsolved</div>
<input id="captcha-token" type="hidden" value="">
<div id="grid" data-instruction="select all buses"
style="display:grid;grid-template-columns:repeat(3,100px);width:300px">
<!-- 9 tiles; tiles 0,1,2 are the 'buses' -->
<div class="tile" data-idx="0" style="width:100px;height:100px;background:#3a7"></div>
<div class="tile" data-idx="1" style="width:100px;height:100px;background:#3a7"></div>
<div class="tile" data-idx="2" style="width:100px;height:100px;background:#3a7"></div>
<div class="tile" data-idx="3" style="width:100px;height:100px;background:#ccc"></div>
<div class="tile" data-idx="4" style="width:100px;height:100px;background:#ccc"></div>
<div class="tile" data-idx="5" style="width:100px;height:100px;background:#ccc"></div>
<div class="tile" data-idx="6" style="width:100px;height:100px;background:#ccc"></div>
<div class="tile" data-idx="7" style="width:100px;height:100px;background:#ccc"></div>
<div class="tile" data-idx="8" style="width:100px;height:100px;background:#ccc"></div>
</div>
<button id="verify">Verify</button>
<script>
const selected = new Set();
document.querySelectorAll('.tile').forEach(t => t.addEventListener('click', () => {
const i = t.dataset.idx; selected.has(i) ? selected.delete(i) : selected.add(i);
t.style.outline = selected.has(i) ? '3px solid red' : 'none';
}));
document.getElementById('verify').addEventListener('click', () => {
const want = new Set(['0','1','2']);
const ok = selected.size === want.size && [...want].every(x => selected.has(x));
if (ok) {
document.getElementById('captcha-token').value = 'FAKE_TOKEN_OK';
document.getElementById('status').textContent = 'solved';
}
});
// expose webdriver flag so stealth e2e can assert it is patched
window.__wd = navigator.webdriver;
</script>
</body>
</html>
```
- [ ] **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=<fake LLM>`, 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 = `<div class="g-recaptcha" data-sitekey="6Lc_e2e"></div>`;
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).