101 KiB
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)wherehandlerisfunc(ctx, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error);server.Run(ctx, &mcp.StdioTransport{}).In/Outusejson:"…"+jsonschema:"…"tags; return(nil, out, nil)and the SDK fills structured content. - OpenAI:
POST {base}/v1/chat/completionswith 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/transcriptionsmultipart fieldsmodel+file; response.text. - Anthropic:
POST {base}/v1/messages, headersx-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 sendtemperature/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, thenfloat64/int64/int= 8, thenbool= 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:
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
version: "2"
linters:
enable:
- govet
- staticcheck
- errcheck
- ineffassign
- unused
- gosec
linters-settings:
govet:
enable:
- fieldalignment
issues:
exclude-rules: []
- Step 3: Write
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
# 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
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
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
// 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
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
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
// 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
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.
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)
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
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.
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
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
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.
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
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
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:
geminiClientis implemented in Phase 11 (Task 19). Until then, comment out thegeminicase or stubgeminiClientso the package compiles. The cleanest order is to implement Task 19'sgemini.gostub (struct + two methods returningfmt.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):
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.
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
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
// 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.
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
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
// 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
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/indexOfreimplementstrings.Contains. Usestrings.Containsdirectly instead — the helpers above are shown only to keep the file dependency-free if desired. Preferstrings.Contains(html, sub)and deletecontains/indexOf. (Reviewer: pick one;strings.Containsis the boring choice.)
- Step 5: Run + commit
Run: go test ./internal/captcha/ -run TestDetectHTML → PASS.
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
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
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
extractJSONhelper
Create internal/captcha/json.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.
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
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
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.
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
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
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.
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
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
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.
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
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 bySeed; nomath/randglobal, noDate.now)
// 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.
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
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.
(() => {
// 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)
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.
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
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)
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.
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
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
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.
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
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
pngB64only needs to decode to bytes beginning with the PNG magic soimageutil.Decodesniffsimage/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)
// 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)
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.
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
// 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
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
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
generateContentrequest/response shape via context7 (resolve-library-id"Google Generative Language API" / "Gemini API", thenquery-docsfor "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)
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)
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.
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)
<!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
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 tage2e) — an OpenAI-compatiblehttptest-style server returning a fixed grid selection[0,1,2]. -
Step 1: Write the fake LLM
//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
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 tage2e)
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: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, readingres.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").assertTilesis a small helper that unmarshalsres.StructuredContentand compares. ImplementassertTilesin the same file.
- Step 2: Add
TestMainto build the binary
//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.
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 tage2e; 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.
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: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:
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).
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, calldetect_captcha, assert detection) -
Create:
test/e2e/lightpanda_test.go(build tage2e; skips iflightpandabinary absent) -
Step 1: Write
run.mjs(Lightpanda is CDP-compatible; use Playwright'schromium.connectOverCDPor puppeteer-core to attach; this path tests detection from DOM, not canvas screenshots)
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.mjsand import it in bothrun.mjsscripts (DRY). Confirm the exact Lightpanda CDP launch flags against the installed binary's--helpat implementation.
- Step 2: Write
lightpanda_test.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).
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 tagse2e,live) -
Step 1: Write the live test (skips unless
CAPTCHA_E2E_LIVE=1and real creds present)
//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
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:
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
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/ readingStructuredContent) and (b) anymcp.AddToolhandler-signature nuance in Task 16. The server/AddTool/StdioTransportshapes 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).