diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..dec7109 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,16 @@ +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) +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go new file mode 100644 index 0000000..7a1374b --- /dev/null +++ b/internal/mcp/tools.go @@ -0,0 +1,185 @@ +// 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 +} diff --git a/internal/mcp/tools_test.go b/internal/mcp/tools_test.go new file mode 100644 index 0000000..a229eae --- /dev/null +++ b/internal/mcp/tools_test.go @@ -0,0 +1,35 @@ +package mcpserver + +import ( + "context" + "testing" + + "github.com/lukaszraczylo/captcha-solver-mcp/internal/llm" +) + +func TestGridHandler(t *testing.T) { + h := New(&llm.Fake{VisionResp: `{"tiles":[1,2],"confidence":0.7,"recheck_recommended":false}`}, "anthropic", "claude-opus-4-8") + _, out, err := h.solveGrid(context.Background(), nil, SolveGridIn{ + Screenshot: pngB64(), Instruction: "buses", Rows: 3, Cols: 3, ImageWidth: 300, ImageHeight: 300, + }) + if err != nil { + t.Fatal(err) + } + if len(out.Tiles) != 2 || len(out.Centroids) != 2 { + t.Fatalf("out=%+v", out) + } +} + +func TestDetectHandler(t *testing.T) { + h := New(&llm.Fake{}, "anthropic", "claude-opus-4-8") + _, out, err := h.detect(context.Background(), nil, DetectIn{HTML: `
`}) + if err != nil { + t.Fatal(err) + } + if len(out.Detected) != 1 || out.Detected[0].Type != "recaptcha_v2" { + t.Fatalf("out=%+v", out) + } +} + +// pngB64 returns a 1px PNG as base64 (valid magic bytes are enough for decode). +func pngB64() string { return "iVBORw0KGgo=" } // "iVBORw..." decodes to PNG magic prefix