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

101 KiB
Raw Permalink Blame History

captcha-solver-mcp Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A Go MCP server that acts as a browser-agnostic vision/LLM "brain" for captcha solving and a stealth-artifact generator, fed by the caller's Playwright/Lightpanda.

Architecture: Solver-only/brain-only. Tools take images/audio/challenge-screenshots/page-context and return answers, tile-click decisions, and stealth artifacts (JS init scripts, fingerprints, humanized interaction plans). The LLM sits behind a llm.Client interface so every handler is unit-testable with a fake client. MCP over stdio via the official Go SDK.

Tech Stack: Go 1.26, github.com/modelcontextprotocol/go-sdk v1.2.0 (MCP), stdlib net/http for LLM providers (raw REST, no provider SDKs — uniform multi-provider layer), stdlib image for grid geometry. E2E: Go test harness driving the real server over stdio + Playwright (Chromium) and Lightpanda against a local fake-captcha page.

Grounded references (verified this session):

  • Go MCP SDK v1.2.0: mcp.NewServer(&mcp.Implementation{Name,Version}, nil); mcp.AddTool(server, &mcp.Tool{Name,Description}, handler) where handler is func(ctx, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error); server.Run(ctx, &mcp.StdioTransport{}). In/Out use json:"…" + jsonschema:"…" tags; return (nil, out, nil) and the SDK fills structured content.
  • OpenAI: POST {base}/v1/chat/completions with message content parts {"type":"text","text":…} and {"type":"image_url","image_url":{"url":"data:image/png;base64,…"}}; response .choices[0].message.content. Transcription: POST {base}/v1/audio/transcriptions multipart fields model + file; response .text.
  • Anthropic: POST {base}/v1/messages, headers x-api-key, anthropic-version: 2023-06-01; body {model,max_tokens,messages:[{role:"user",content:[{type:"text",text},{type:"image",source:{type:"base64",media_type,data}}]}]}; response .content[] first {type:"text"}. Do NOT send temperature/top_p (Opus 4.x → 400).

Quality gates (per project standards): golangci-lint run clean incl. fieldalignment; gosec applies to _test.go too; table-driven tests; build/test locally (no GitHub Actions).

Conventions used throughout this plan:

  • Module path: github.com/lukaszraczylo/captcha-solver-mcp.
  • All structs are written with fields ordered largest-alignment-first to satisfy fieldalignment (strings = 16 bytes, then float64/int64/int = 8, then bool = 1).
  • Commit after each task. Conventional-commit prefixes; keep bodies free of the words "breaking"/"feat" unless intended (semver-generator fuzzy-matches commit bodies).

Phase 0 — Scaffold

Task 0: Module, directories, tooling

Files:

  • Create: go.mod, .golangci.yml, Makefile, .env.example, README.md (skeleton)

  • Create dir markers via first real files in later tasks.

  • Step 1: Init module

Run:

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: geminiClient is implemented in Phase 11 (Task 19). Until then, comment out the gemini case or stub geminiClient so the package compiles. The cleanest order is to implement Task 19's gemini.go stub (struct + two methods returning fmt.Errorf("not implemented")) before this task, then fill it in Phase 11. To keep this task self-contained, add the stub now:

internal/llm/gemini.go (stub — replaced in Task 19):

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/indexOf reimplement strings.Contains. Use strings.Contains directly instead — the helpers above are shown only to keep the file dependency-free if desired. Prefer strings.Contains(html, sub) and delete contains/indexOf. (Reviewer: pick one; strings.Contains is the boring choice.)

  • Step 5: Run + commit

Run: go test ./internal/captcha/ -run TestDetectHTML → PASS.

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 extractJSON helper

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 by Seed; no math/rand global, no Date.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 pngB64 only needs to decode to bytes beginning with the PNG magic so imageutil.Decode sniffs image/png; the vision call is faked, so no real rendering happens.

  • Step 2: Run to verify fail → undefined: New.

  • Step 3: Write tools.go (In/Out structs + handlers)

// 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 generateContent request/response shape via context7 (resolve-library-id "Google Generative Language API" / "Gemini API", then query-docs for "generateContent inline_data base64 image request body; audio inline_data; response candidates parts text"). Confirm the body below matches; adjust field names if the API differs. Do not ship unverified.

Files:

  • Replace stub: internal/llm/gemini.go

  • Test: internal/llm/gemini_test.go

  • Step 1: Write the failing test (httptest; target shape below — reconcile with verified docs)

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 tag e2e) — an OpenAI-compatible httptest-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 tag e2e)

This launches the built binary configured with provider=openai-compatible + base_url=<fake LLM>, connects an MCP client over the child's stdio, calls solve_grid_captcha, and asserts tiles [0,1,2].

  • Step 1: Write the test (uses the SDK's client over a command transport)
//go:build e2e

package e2e

import (
	"context"
	"os/exec"
	"testing"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

func TestSolveGridOverStdio(t *testing.T) {
	llmSrv := startFakeLLM()
	defer llmSrv.Close()

	// Build once (Makefile target or go build in TestMain); assume bin exists.
	cmd := exec.Command("../../bin/captcha-solver-mcp")
	cmd.Env = append(cmd.Environ(),
		"CAPTCHA_LLM_PROVIDER=openai-compatible",
		"CAPTCHA_LLM_BASE_URL="+llmSrv.URL,
		"CAPTCHA_LLM_API_KEY=test",
		"CAPTCHA_LLM_MODEL=fake",
	)

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	client := mcp.NewClient(&mcp.Implementation{Name: "e2e", Version: "v0"}, nil)
	session, err := client.Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil)
	if err != nil {
		t.Fatalf("connect: %v", err)
	}
	defer func() { _ = session.Close() }()

	res, err := session.CallTool(ctx, &mcp.CallToolParams{
		Name: "solve_grid_captcha",
		Arguments: map[string]any{
			"screenshot":   "iVBORw0KGgo=", // PNG magic; vision is faked
			"instruction":  "select all buses",
			"rows":         3,
			"cols":         3,
			"image_width":  300,
			"image_height": 300,
		},
	})
	if err != nil {
		t.Fatalf("call: %v", err)
	}
	// StructuredContent carries the typed Out; assert tiles via the JSON map.
	assertTiles(t, res, []int{0, 1, 2})
}

Note: the exact client API surface (mcp.NewClient, client.Connect, mcp.CommandTransport, reading res.StructuredContent) must be confirmed against the go-sdk v1.2.0 client docs at implementation (query-docs: "go-sdk client Connect CommandTransport CallTool read structured result"). assertTiles is a small helper that unmarshals res.StructuredContent and compares. Implement assertTiles in the same file.

  • Step 2: Add TestMain to build the binary
//go: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 tag e2e; shells out to the Node script, asserts exit 0)

  • Update: package.json (devDeps: playwright)

  • Step 1: Write run.mjs

The script: (1) starts a static file server for testpage/, (2) launches the MCP server as a child and speaks MCP over stdio via a tiny JSON-RPC client (or via @modelcontextprotocol/sdk if added), (3) get_stealth_script → page.addInitScript, (4) navigate, screenshot #grid, (5) solve_grid_captcha with width/height 300, (6) plan_interaction with the returned centroids, (7) replay clicks, click Verify, (8) assert #captcha-token == FAKE_TOKEN_OK and window.__wd === undefined.

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, call detect_captcha, assert detection)

  • Create: test/e2e/lightpanda_test.go (build tag e2e; skips if lightpanda binary absent)

  • Step 1: Write run.mjs (Lightpanda is CDP-compatible; use Playwright's chromium.connectOverCDP or puppeteer-core to attach; this path tests detection from DOM, not canvas screenshots)

import { chromium } from 'playwright';
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const pageDir = path.join(__dirname, '..', 'testpage');
// Serve a recaptcha-marker page for detection
const html = `<div class="g-recaptcha" data-sitekey="6Lc_e2e"></div>`;
const server = http.createServer((_, res) => res.end(html)).listen(0);
const port = server.address().port;

// Launch Lightpanda in CDP server mode (adjust flag per installed version).
const lp = spawn('lightpanda', ['serve', '--host', '127.0.0.1', '--port', '9222'], { stdio: 'inherit' });
await new Promise((r) => setTimeout(r, 800));

// MCP client (same minimal RPC as playwright/run.mjs — factor into a shared module at impl time)
// ... initialize MCP, then:
const browser = await chromium.connectOverCDP('http://127.0.0.1:9222');
const page = await browser.newPage();
await page.goto(`http://localhost:${port}/`);
const dom = await page.content();
const det = await callTool('detect_captcha', { html: dom });
await browser.close(); lp.kill(); server.close();

if (!det.detected.some((d) => d.type === 'recaptcha_v2')) { console.error('no detection', det); process.exit(1); }
console.log('PASS');

Implementation note: the MCP client boilerplate is identical to Task 23 — extract test/e2e/mcpclient.mjs and import it in both run.mjs scripts (DRY). Confirm the exact Lightpanda CDP launch flags against the installed binary's --help at implementation.

  • Step 2: Write lightpanda_test.go
//go: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 tags e2e,live)

  • Step 1: Write the live test (skips unless CAPTCHA_E2E_LIVE=1 and 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 / reading StructuredContent) and (b) any mcp.AddTool handler-signature nuance in Task 16. The server/AddTool/StdioTransport shapes are already grounded.
  • Gemini (Task 19) is the one provider with an unverified wire format — its grounding gate is mandatory; do not ship without the context7 check.
  • Evasion JS (Task 13) is a minimal in-repo set; before release, reconcile with a current open-source evasion bundle and record source + license in internal/stealth/assets/NOTICE.
  • Keep commit bodies free of "breaking"/"feat" wording beyond the conventional-commit type prefix (semver-generator fuzzy-matches bodies).