Back to blog

How to build a SolveCaptcha MCP server for AI browser agents

AI browser agents can open pages, inspect the DOM, choose actions, fill forms, and extract data. Captcha is where many agent workflows become unreliable.

The wrong approach is to make the language model handle every captcha detail itself. That forces the agent to extract parameters, call a solver API, poll for a result, normalize the response, inject a token, and guess how the target page expects verification to continue.

A better design is to expose captcha solving as a dedicated Model Context Protocol (MCP) tool backed by the SolveCaptcha API.

This guide builds a separate project called SolveCaptcha MCP Browser Agent. It uses:

  • Playwright for browser automation;
  • the official solvecaptcha-python SDK;
  • FastMCP for the MCP server;
  • persistent browser sessions between tool calls;
  • structured tool results;
  • server-side token injection;
  • separate browser and captcha tools.

The example is intended only for websites you own or are authorized to test and automate.

Why captcha should be a dedicated MCP tool

An agent should control the workflow, not reproduce the internal mechanics of every integration.

A clean flow looks like this:

  1. The agent opens a page.
  2. It inspects the current browser state.
  3. It detects that reCAPTCHA v2 is blocking progress.
  4. It calls captcha_solve_recaptcha_v2.
  5. The MCP server sends the task to SolveCaptcha.
  6. The server injects the returned token directly into the current browser session.
  7. The agent continues with normal browser tools.
  8. The agent closes the session when the task is complete.

The agent should not need to:

  • build raw requests to in.php;
  • poll res.php;
  • keep the API key in the prompt;
  • copy tokens through model output;
  • manually locate every hidden response field;
  • mix vendor-specific captcha code with page navigation.

This gives the project a clear separation of responsibilities:

AI agent
   |
   v
MCP client
   |
   v
SolveCaptcha MCP server
   |
   +--> Playwright browser session
   |
   +--> SolveCaptcha Python SDK

The agent decides what to do. The MCP server decides how to execute it.

What makes this a separate SolveCaptcha project

This implementation is not a simple brand replacement. It uses a different stack and a different project model:

  • Playwright instead of Selenium;
  • one self-contained MCP server;
  • asynchronous MCP tools;
  • an internal Playwright session registry;
  • Solvecaptcha from the official SolveCaptcha Python SDK;
  • SOLVECAPTCHA_API_KEY as the only solver credential;
  • server-side response injection;
  • no raw captcha token passing through the LLM;
  • a deliberate MCP v1 pin to avoid accidental migration to an incompatible major version.

SolveCaptcha documents reCAPTCHA v2 as a token-based flow: the client sends the page URL and sitekey, receives a solution token, inserts it into g-recaptcha-response, and then continues the page workflow.

Project structure

The project is intentionally compact:

solvecaptcha-mcp-browser/
├── server.py
├── requirements.txt
├── .env.example
└── artifacts/
    ├── screenshots/
    └── results/

The full implementation fits into server.py, but the internal code is still divided into clear layers:

  • configuration;
  • result helpers;
  • browser session storage;
  • Playwright tools;
  • SolveCaptcha integration;
  • MCP tool declarations.

This keeps the example easy to run while preserving proper boundaries.

Requirements

Use Python 3.11 or newer.

Create requirements.txt:

mcp[cli]>=1.27,<2
playwright
python-dotenv
solvecaptcha-python==1.0.2

Install the dependencies:

python3 -m venv .venv
source .venv/bin/activate

python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m playwright install chromium

On Windows PowerShell:

python -m venv .venv
.venv\Scripts\Activate.ps1

python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m playwright install chromium

Environment variables

Create .env.example:

SOLVECAPTCHA_API_KEY=YOUR_API_KEY
BROWSER_HEADLESS=true
BROWSER_TIMEOUT_MS=30000
SCREENSHOT_DIR=artifacts/screenshots
RESULT_DIR=artifacts/results

Copy it to .env:

cp .env.example .env

On Windows:

Copy-Item .env.example .env

Replace YOUR_API_KEY with the API key from your SolveCaptcha account.

Do not expose this key to the agent, page JavaScript, tool arguments, or browser storage. The MCP server should read it only from the process environment.

Complete MCP server

Create server.py:

from __future__ import annotations

import asyncio
from dataclasses import asdict, dataclass, field
import json
import os
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
from uuid import uuid4

from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
from playwright.async_api import (
    Browser,
    BrowserContext,
    Page,
    Playwright,
    async_playwright,
)
from solvecaptcha import Solvecaptcha

load_dotenv()

def env_bool(name: str, default: bool) -> bool:
    raw_value = os.getenv(name)
    if raw_value is None:
        return default

    return raw_value.strip().lower() in {
        "1",
        "true",
        "yes",
        "on",
    }

@dataclass(frozen=True, slots=True)
class Settings:
    solvecaptcha_api_key: str
    browser_headless: bool
    browser_timeout_ms: int
    screenshot_dir: Path
    result_dir: Path

def get_settings() -> Settings:
    api_key = os.getenv("SOLVECAPTCHA_API_KEY", "").strip()
    if not api_key:
        raise RuntimeError(
            "SOLVECAPTCHA_API_KEY is not configured. "
            "Create a .env file and set the API key."
        )

    timeout_raw = os.getenv("BROWSER_TIMEOUT_MS", "30000").strip()

    try:
        browser_timeout_ms = int(timeout_raw)
    except ValueError as exc:
        raise RuntimeError(
            "BROWSER_TIMEOUT_MS must be an integer."
        ) from exc

    if browser_timeout_ms <= 0:
        raise RuntimeError(
            "BROWSER_TIMEOUT_MS must be greater than zero."
        )

    screenshot_dir = Path(
        os.getenv(
            "SCREENSHOT_DIR",
            "artifacts/screenshots",
        )
    ).expanduser()

    result_dir = Path(
        os.getenv(
            "RESULT_DIR",
            "artifacts/results",
        )
    ).expanduser()

    screenshot_dir.mkdir(
        parents=True,
        exist_ok=True,
    )
    result_dir.mkdir(
        parents=True,
        exist_ok=True,
    )

    return Settings(
        solvecaptcha_api_key=api_key,
        browser_headless=env_bool(
            "BROWSER_HEADLESS",
            True,
        ),
        browser_timeout_ms=browser_timeout_ms,
        screenshot_dir=screenshot_dir,
        result_dir=result_dir,
    )

@dataclass(slots=True)
class ToolResult:
    status: str
    tool: str
    message: str
    session_id: str | None = None
    page_url: str | None = None
    screenshot_path: str | None = None
    result_path: str | None = None
    details: dict[str, Any] = field(
        default_factory=dict
    )

    def to_dict(self) -> dict[str, Any]:
        return asdict(self)

@dataclass(slots=True)
class BrowserSession:
    session_id: str
    playwright: Playwright
    browser: Browser
    context: BrowserContext
    page: Page

class SessionStore:
    def __init__(self) -> None:
        self._sessions: dict[
            str,
            BrowserSession,
        ] = {}
        self._lock = asyncio.Lock()

    async def create(
        self,
        session: BrowserSession,
    ) -> None:
        async with self._lock:
            self._sessions[
                session.session_id
            ] = session

    async def get(
        self,
        session_id: str,
    ) -> BrowserSession:
        async with self._lock:
            session = self._sessions.get(
                session_id
            )

        if session is None:
            raise KeyError(
                f"Unknown browser session: "
                f"{session_id}"
            )

        return session

    async def remove(
        self,
        session_id: str,
    ) -> BrowserSession:
        async with self._lock:
            session = self._sessions.pop(
                session_id,
                None,
            )

        if session is None:
            raise KeyError(
                f"Unknown browser session: "
                f"{session_id}"
            )

        return session

    async def list_ids(
        self,
    ) -> list[str]:
        async with self._lock:
            return list(
                self._sessions.keys()
            )

settings = get_settings()
sessions = SessionStore()

mcp = FastMCP(
    "SolveCaptcha MCP Browser",
    instructions=(
        "Use browser tools to control one Playwright "
        "session. Use captcha_solve_recaptcha_v2 only "
        "when reCAPTCHA v2 blocks an authorized workflow. "
        "Always close the browser session after the task."
    ),
)

def safe_path(
    directory: Path,
    filename: str,
) -> Path:
    normalized = "".join(
        character
        for character in filename
        if character.isalnum()
        or character in {
            "-",
            "_",
            ".",
        }
    )

    if not normalized:
        normalized = uuid4().hex

    return directory / normalized

async def save_screenshot(
    session: BrowserSession,
    label: str,
) -> str:
    filename = (
        f"{session.session_id}-"
        f"{label}-{uuid4().hex[:8]}.png"
    )

    path = safe_path(
        settings.screenshot_dir,
        filename,
    )

    await session.page.screenshot(
        path=str(path),
        full_page=True,
    )

    return str(path.resolve())

def save_json_result(
    session_id: str,
    label: str,
    payload: dict[str, Any],
) -> str:
    filename = (
        f"{session_id}-"
        f"{label}-{uuid4().hex[:8]}.json"
    )

    path = safe_path(
        settings.result_dir,
        filename,
    )

    path.write_text(
        json.dumps(
            payload,
            ensure_ascii=False,
            indent=2,
        ),
        encoding="utf-8",
    )

    return str(path.resolve())

def normalize_solver_result(
    result: Any,
) -> tuple[str, str | None]:
    if isinstance(
        result,
        dict,
    ):
        token_candidates = [
            result.get("code"),
            result.get("token"),
            result.get("request"),
        ]

        token = next(
            (
                str(value)
                for value in token_candidates
                if value
            ),
            "",
        )

        task_id_candidates = [
            result.get("captchaId"),
            result.get("taskId"),
            result.get("id"),
        ]

        task_id = next(
            (
                str(value)
                for value in task_id_candidates
                if value is not None
            ),
            None,
        )
    else:
        token = str(result)
        task_id = None

    token = token.strip()

    if not token:
        raise RuntimeError(
            "SolveCaptcha returned an empty token."
        )

    return token, task_id

async def detect_recaptcha_sitekey(
    page: Page,
) -> str:
    sitekey_locator = page.locator(
        "[data-sitekey]"
    )

    count = await sitekey_locator.count()

    for index in range(count):
        sitekey = await sitekey_locator.nth(
            index
        ).get_attribute(
            "data-sitekey"
        )

        if sitekey:
            return sitekey.strip()

    iframe_locator = page.locator(
        'iframe[src*="recaptcha"]'
    )

    iframe_count = await iframe_locator.count()

    for index in range(
        iframe_count
    ):
        src = await iframe_locator.nth(
            index
        ).get_attribute(
            "src"
        )

        if not src:
            continue

        query = parse_qs(
            urlparse(src).query
        )

        values = (
            query.get("k")
            or query.get("sitekey")
        )

        if values and values[0]:
            return values[0].strip()

    raise RuntimeError(
        "reCAPTCHA v2 sitekey was not found "
        "on the current page."
    )

async def inject_recaptcha_token(
    page: Page,
    token: str,
) -> dict[str, Any]:
    return await page.evaluate(
        """
        (token) => {
          const fields = [
            ...document.querySelectorAll(
              'textarea[name="g-recaptcha-response"],' +
              'textarea#g-recaptcha-response'
            )
          ];

          if (fields.length === 0) {
            const field = document.createElement(
              'textarea'
            );

            field.id = 'g-recaptcha-response';
            field.name = 'g-recaptcha-response';
            field.style.display = 'none';

            document.body.appendChild(field);
            fields.push(field);
          }

          for (const field of fields) {
            field.value = token;
            field.innerHTML = token;

            field.dispatchEvent(
              new Event(
                'input',
                { bubbles: true }
              )
            );

            field.dispatchEvent(
              new Event(
                'change',
                { bubbles: true }
              )
            );
          }

          const resolveFunction = (path) => {
            if (!path) {
              return null;
            }

            const parts = path.split('.');
            let value = window;

            for (const part of parts) {
              if (
                value === null ||
                value === undefined
              ) {
                return null;
              }

              value = value[part];
            }

            return typeof value === 'function'
              ? value
              : null;
          };

          let callbackCalled = false;

          const widgets = [
            ...document.querySelectorAll(
              '[data-callback]'
            )
          ];

          for (const widget of widgets) {
            const callbackName =
              widget.getAttribute(
                'data-callback'
              );

            const callback =
              resolveFunction(
                callbackName
              );

            if (callback) {
              callback(token);
              callbackCalled = true;
            }
          }

          return {
            injectedFields: fields.length,
            callbackCalled
          };
        }
        """,
        token,
    )

async def close_session_resources(
    session: BrowserSession,
) -> None:
    errors: list[str] = []

    try:
        await session.context.close()
    except Exception as exc:
        errors.append(
            f"context: {exc}"
        )

    try:
        await session.browser.close()
    except Exception as exc:
        errors.append(
            f"browser: {exc}"
        )

    try:
        await session.playwright.stop()
    except Exception as exc:
        errors.append(
            f"playwright: {exc}"
        )

    if errors:
        raise RuntimeError(
            "; ".join(errors)
        )

@mcp.tool()
async def healthcheck() -> dict[str, Any]:
    """Return MCP server and SolveCaptcha configuration status."""

    active_sessions = (
        await sessions.list_ids()
    )

    return {
        "status": "ok",
        "server": (
            "SolveCaptcha MCP Browser"
        ),
        "browser": "Playwright Chromium",
        "active_sessions": len(
            active_sessions
        ),
        "solvecaptcha_configured": bool(
            settings.solvecaptcha_api_key
        ),
    }

@mcp.tool()
async def browser_open_page(
    page_url: str,
) -> dict[str, Any]:
    """Open a URL in a new Playwright browser session."""

    if not page_url.startswith(
        (
            "http://",
            "https://",
        )
    ):
        return ToolResult(
            status="error",
            tool="browser_open_page",
            message=(
                "page_url must start with "
                "http:// or https://"
            ),
            details={
                "task_complete": False,
                "should_retry": False,
                "should_close_session": False,
            },
        ).to_dict()

    playwright = await (
        async_playwright().start()
    )

    browser: Browser | None = None
    context: BrowserContext | None = None

    try:
        browser = await (
            playwright.chromium.launch(
                headless=(
                    settings.browser_headless
                ),
            )
        )

        context = await (
            browser.new_context(
                viewport={
                    "width": 1440,
                    "height": 1000,
                },
            )
        )

        context.set_default_timeout(
            settings.browser_timeout_ms
        )

        page = await context.new_page()

        await page.goto(
            page_url,
            wait_until="domcontentloaded",
        )

        session = BrowserSession(
            session_id=uuid4().hex,
            playwright=playwright,
            browser=browser,
            context=context,
            page=page,
        )

        await sessions.create(
            session
        )

        screenshot_path = (
            await save_screenshot(
                session,
                "opened",
            )
        )

        return ToolResult(
            status="success",
            tool="browser_open_page",
            message="Page opened.",
            session_id=(
                session.session_id
            ),
            page_url=page.url,
            screenshot_path=(
                screenshot_path
            ),
            details={
                "title": await page.title(),
                "task_complete": False,
                "should_retry": False,
                "should_close_session": False,
            },
        ).to_dict()

    except Exception as exc:
        if context is not None:
            await context.close()

        if browser is not None:
            await browser.close()

        await playwright.stop()

        return ToolResult(
            status="error",
            tool="browser_open_page",
            message=str(exc),
            page_url=page_url,
            details={
                "task_complete": False,
                "should_retry": True,
                "should_close_session": False,
            },
        ).to_dict()

@mcp.tool()
async def browser_get_page_state(
    session_id: str,
) -> dict[str, Any]:
    """Inspect the current page without changing it."""

    try:
        session = await sessions.get(
            session_id
        )

        page = session.page

        state = await page.evaluate(
            """
            () => {
              const visibleText = (
                document.body?.innerText || ''
              ).slice(0, 12000);

              const links = [
                ...document.querySelectorAll(
                  'a[href]'
                )
              ].slice(0, 50).map(
                (element) => ({
                  text: (
                    element.innerText ||
                    element.textContent ||
                    ''
                  ).trim(),
                  href: element.href
                })
              );

              const buttons = [
                ...document.querySelectorAll(
                  'button, input[type="submit"],' +
                  ' input[type="button"]'
                )
              ].slice(0, 50).map(
                (element) => ({
                  text: (
                    element.innerText ||
                    element.value ||
                    element.getAttribute(
                      'aria-label'
                    ) ||
                    ''
                  ).trim(),
                  disabled: Boolean(
                    element.disabled
                  )
                })
              );

              return {
                visibleText,
                links,
                buttons,
                hasRecaptcha: Boolean(
                  document.querySelector(
                    '[data-sitekey],' +
                    'iframe[src*="recaptcha"],' +
                    'textarea[name=' +
                    '"g-recaptcha-response"]'
                  )
                )
              };
            }
            """
        )

        screenshot_path = (
            await save_screenshot(
                session,
                "state",
            )
        )

        return ToolResult(
            status="success",
            tool=(
                "browser_get_page_state"
            ),
            message=(
                "Current page state "
                "collected."
            ),
            session_id=session_id,
            page_url=page.url,
            screenshot_path=(
                screenshot_path
            ),
            details={
                **state,
                "title": await page.title(),
                "task_complete": False,
                "should_retry": False,
                "should_close_session": False,
            },
        ).to_dict()

    except Exception as exc:
        return ToolResult(
            status="error",
            tool=(
                "browser_get_page_state"
            ),
            message=str(exc),
            session_id=session_id,
            details={
                "task_complete": False,
                "should_retry": False,
                "should_close_session": True,
            },
        ).to_dict()

@mcp.tool()
async def browser_click(
    session_id: str,
    selector: str,
    index: int = 0,
) -> dict[str, Any]:
    """Click an element matching a Playwright selector."""

    try:
        session = await sessions.get(
            session_id
        )

        locator = session.page.locator(
            selector
        )

        count = await locator.count()

        if count == 0:
            raise RuntimeError(
                f"No elements matched: "
                f"{selector}"
            )

        if index < 0 or index >= count:
            raise IndexError(
                f"index must be between 0 "
                f"and {count - 1}"
            )

        await locator.nth(
            index
        ).click()

        await session.page.wait_for_timeout(
            500
        )

        screenshot_path = (
            await save_screenshot(
                session,
                "clicked",
            )
        )

        return ToolResult(
            status="success",
            tool="browser_click",
            message="Element clicked.",
            session_id=session_id,
            page_url=session.page.url,
            screenshot_path=(
                screenshot_path
            ),
            details={
                "selector": selector,
                "index": index,
                "matched_elements": count,
                "task_complete": False,
                "should_retry": False,
                "should_close_session": False,
            },
        ).to_dict()

    except Exception as exc:
        return ToolResult(
            status="error",
            tool="browser_click",
            message=str(exc),
            session_id=session_id,
            details={
                "selector": selector,
                "index": index,
                "task_complete": False,
                "should_retry": True,
                "should_close_session": False,
            },
        ).to_dict()

@mcp.tool()
async def browser_fill(
    session_id: str,
    selector: str,
    value: str,
    index: int = 0,
) -> dict[str, Any]:
    """Fill an input or textarea in the current browser session."""

    try:
        session = await sessions.get(
            session_id
        )

        locator = session.page.locator(
            selector
        )

        count = await locator.count()

        if count == 0:
            raise RuntimeError(
                f"No elements matched: "
                f"{selector}"
            )

        if index < 0 or index >= count:
            raise IndexError(
                f"index must be between 0 "
                f"and {count - 1}"
            )

        await locator.nth(
            index
        ).fill(
            value
        )

        return ToolResult(
            status="success",
            tool="browser_fill",
            message="Field filled.",
            session_id=session_id,
            page_url=session.page.url,
            details={
                "selector": selector,
                "index": index,
                "matched_elements": count,
                "task_complete": False,
                "should_retry": False,
                "should_close_session": False,
            },
        ).to_dict()

    except Exception as exc:
        return ToolResult(
            status="error",
            tool="browser_fill",
            message=str(exc),
            session_id=session_id,
            details={
                "selector": selector,
                "index": index,
                "task_complete": False,
                "should_retry": True,
                "should_close_session": False,
            },
        ).to_dict()

@mcp.tool()
async def browser_extract_text(
    session_id: str,
    selector: str = "body",
    index: int = 0,
) -> dict[str, Any]:
    """Extract text from an element and save it as JSON."""

    try:
        session = await sessions.get(
            session_id
        )

        locator = session.page.locator(
            selector
        )

        count = await locator.count()

        if count == 0:
            raise RuntimeError(
                f"No elements matched: "
                f"{selector}"
            )

        if index < 0 or index >= count:
            raise IndexError(
                f"index must be between 0 "
                f"and {count - 1}"
            )

        text = await locator.nth(
            index
        ).inner_text()

        payload = {
            "page_url": session.page.url,
            "selector": selector,
            "index": index,
            "text": text,
        }

        result_path = save_json_result(
            session_id,
            "text",
            payload,
        )

        return ToolResult(
            status="success",
            tool="browser_extract_text",
            message="Text extracted.",
            session_id=session_id,
            page_url=session.page.url,
            result_path=result_path,
            details={
                "text": text,
                "task_complete": True,
                "should_retry": False,
                "should_close_session": True,
            },
        ).to_dict()

    except Exception as exc:
        return ToolResult(
            status="error",
            tool="browser_extract_text",
            message=str(exc),
            session_id=session_id,
            details={
                "selector": selector,
                "index": index,
                "task_complete": False,
                "should_retry": True,
                "should_close_session": False,
            },
        ).to_dict()

@mcp.tool()
async def captcha_solve_recaptcha_v2(
    session_id: str,
) -> dict[str, Any]:
    """
    Solve reCAPTCHA v2 on the current page with SolveCaptcha
    and inject the token into the active Playwright session.
    """

    try:
        session = await sessions.get(
            session_id
        )

        page = session.page

        sitekey = (
            await detect_recaptcha_sitekey(
                page
            )
        )

        page_url = page.url

        user_agent = await page.evaluate(
            "() => navigator.userAgent"
        )

        solver = Solvecaptcha(
            apiKey=(
                settings.solvecaptcha_api_key
            ),
            server="solvecaptcha.com",
            recaptchaTimeout=600,
            pollingInterval=10,
            extendedResponse=True,
        )

        solver_result = await asyncio.to_thread(
            solver.recaptcha,
            sitekey=sitekey,
            url=page_url,
            userAgent=user_agent,
        )

        token, task_id = (
            normalize_solver_result(
                solver_result
            )
        )

        injection = (
            await inject_recaptcha_token(
                page,
                token,
            )
        )

        screenshot_path = (
            await save_screenshot(
                session,
                "recaptcha-solved",
            )
        )

        safe_payload = {
            "provider": "SolveCaptcha",
            "captcha_type": (
                "reCAPTCHA v2"
            ),
            "sitekey": sitekey,
            "task_id": task_id,
            "token_length": len(token),
            "injected_fields": (
                injection.get(
                    "injectedFields",
                    0,
                )
            ),
            "callback_called": (
                injection.get(
                    "callbackCalled",
                    False,
                )
            ),
        }

        result_path = save_json_result(
            session_id,
            "recaptcha-v2",
            safe_payload,
        )

        return ToolResult(
            status="success",
            tool=(
                "captcha_solve_recaptcha_v2"
            ),
            message=(
                "reCAPTCHA v2 token was "
                "received from SolveCaptcha "
                "and injected into the page."
            ),
            session_id=session_id,
            page_url=page_url,
            screenshot_path=(
                screenshot_path
            ),
            result_path=result_path,
            details={
                **safe_payload,
                "task_complete": False,
                "should_retry": False,
                "should_close_session": False,
            },
        ).to_dict()

    except Exception as exc:
        return ToolResult(
            status="error",
            tool=(
                "captcha_solve_recaptcha_v2"
            ),
            message=str(exc),
            session_id=session_id,
            details={
                "provider": "SolveCaptcha",
                "captcha_type": (
                    "reCAPTCHA v2"
                ),
                "task_complete": False,
                "should_retry": True,
                "should_close_session": False,
            },
        ).to_dict()

@mcp.tool()
async def browser_close(
    session_id: str,
) -> dict[str, Any]:
    """Close the browser and remove the stored session."""

    try:
        session = await sessions.remove(
            session_id
        )

        await close_session_resources(
            session
        )

        return ToolResult(
            status="success",
            tool="browser_close",
            message=(
                "Browser session closed."
            ),
            session_id=session_id,
            details={
                "task_complete": True,
                "should_retry": False,
                "should_close_session": False,
            },
        ).to_dict()

    except Exception as exc:
        return ToolResult(
            status="error",
            tool="browser_close",
            message=str(exc),
            session_id=session_id,
            details={
                "task_complete": False,
                "should_retry": False,
                "should_close_session": True,
            },
        ).to_dict()

def main() -> None:
    mcp.run(
        transport="stdio"
    )

if __name__ == "__main__":
    main()

Why the server injects the token itself

The SolveCaptcha token should not be returned to the language model and then passed back into another browser tool.

That design creates unnecessary risks:

  • the model may wrap the token in Markdown;
  • the token may be truncated;
  • the token may be logged in chat history;
  • the agent may insert it into the wrong field;
  • the token may expire while the model plans the next action.

The server therefore performs the complete specialized operation:

  1. Find the sitekey.
  2. Read the current page URL.
  3. Read the browser user agent.
  4. Send the task to SolveCaptcha.
  5. Wait for the SDK result.
  6. normalize the response;
  7. inject the token into g-recaptcha-response;
  8. invoke a declared data-callback, when present;
  9. return only safe metadata to the agent.

The tool result contains the task ID and token length, but not the token itself.

How sitekey detection works

The example checks two common locations.

First, it looks for a data-sitekey attribute:

<div
  class="g-recaptcha"
  data-sitekey="SITE_KEY"
></div>

If no element contains data-sitekey, it inspects reCAPTCHA iframe URLs and extracts the k query parameter.

This covers many standard reCAPTCHA v2 implementations. Some sites load the widget through custom JavaScript and hide the configuration deeper in runtime objects. Those cases require target-specific extraction logic and should be implemented as separate, explicitly named workflows rather than hidden inside a generic solver.

Why the SolveCaptcha SDK runs in a worker thread

Playwright and FastMCP tools are asynchronous. The current SolveCaptcha Python SDK exposes synchronous solver methods.

Calling a synchronous method directly inside an async MCP tool would block the event loop while the SDK waits for the result.

The server avoids that with:

solver_result = await asyncio.to_thread(
    solver.recaptcha,
    sitekey=sitekey,
    url=page_url,
    userAgent=user_agent,
)

This keeps the MCP server responsive while SolveCaptcha processes the task.

How browser sessions are preserved

An agent rarely completes a browser workflow in one tool call.

A realistic chain is:

browser_open_page
browser_get_page_state
captcha_solve_recaptcha_v2
browser_click
browser_extract_text
browser_close

Every call receives the same session_id.

The SessionStore keeps:

  • the Playwright runtime;
  • the Chromium process;
  • the browser context;
  • the active page;
  • the session identifier.

Without state preservation, every MCP tool would open a new browser and lose:

  • cookies;
  • local storage;
  • page state;
  • authentication;
  • captcha context;
  • navigation history.

Connect the server to Claude Desktop

Claude Desktop can launch a local MCP server over STDIO.

macOS configuration

Open:

~/Library/Application Support/Claude/claude_desktop_config.json

Add:

{
  "mcpServers": {
    "solvecaptcha-browser": {
      "command": "/ABSOLUTE/PATH/solvecaptcha-mcp-browser/.venv/bin/python",
      "args": [
        "/ABSOLUTE/PATH/solvecaptcha-mcp-browser/server.py"
      ],
      "env": {
        "SOLVECAPTCHA_API_KEY": "YOUR_API_KEY",
        "BROWSER_HEADLESS": "true",
        "BROWSER_TIMEOUT_MS": "30000",
        "SCREENSHOT_DIR": "/ABSOLUTE/PATH/solvecaptcha-mcp-browser/artifacts/screenshots",
        "RESULT_DIR": "/ABSOLUTE/PATH/solvecaptcha-mcp-browser/artifacts/results"
      }
    }
  }
}

Replace every path with an absolute local path.

Windows configuration

Use the Python executable inside the virtual environment:

{
  "mcpServers": {
    "solvecaptcha-browser": {
      "command": "C:\\ABSOLUTE\\PATH\\solvecaptcha-mcp-browser\\.venv\\Scripts\\python.exe",
      "args": [
        "C:\\ABSOLUTE\\PATH\\solvecaptcha-mcp-browser\\server.py"
      ],
      "env": {
        "SOLVECAPTCHA_API_KEY": "YOUR_API_KEY",
        "BROWSER_HEADLESS": "true",
        "BROWSER_TIMEOUT_MS": "30000",
        "SCREENSHOT_DIR": "C:\\ABSOLUTE\\PATH\\solvecaptcha-mcp-browser\\artifacts\\screenshots",
        "RESULT_DIR": "C:\\ABSOLUTE\\PATH\\solvecaptcha-mcp-browser\\artifacts\\results"
      }
    }
  }
}

After editing the configuration:

  1. Fully close Claude Desktop.
  2. Start Claude Desktop again.
  3. Open the developer or MCP settings.
  4. Confirm that solvecaptcha-browser connects.
  5. Call healthcheck.

Test the project

Use the SolveCaptcha reCAPTCHA v2 demo page:

https://solvecaptcha.com/demo/recaptcha-v2

A suitable agent prompt is:

Open https://solvecaptcha.com/demo/recaptcha-v2.

Use one browser session for the entire task.

Inspect the page. If reCAPTCHA v2 blocks progress, use the SolveCaptcha reCAPTCHA v2 tool. After the token is injected, continue with the browser tools and complete the demo verification.

Do not create another browser session unless the current session has been closed.

Always close the browser at the end.

In the final response, return only:
- final status
- result_path
- screenshot_path

This prompt gives the agent a goal instead of a sequence of Selenium or Playwright commands.

Tool responsibilities

browser_open_page

Creates a new Chromium session and returns a session_id.

It does not solve captcha and does not attempt to complete the target workflow.

browser_get_page_state

Returns:

  • page title;
  • visible text;
  • links;
  • buttons;
  • whether common reCAPTCHA elements are present;
  • a screenshot.

This gives the agent enough context to choose the next tool.

captcha_solve_recaptcha_v2

Performs one specialized operation:

  • detects the sitekey;
  • sends the task to SolveCaptcha;
  • waits for the result;
  • injects the token;
  • calls a declared callback when available.

It does not click the final submit button unless the page callback handles that automatically.

browser_click

Continues the page workflow after the captcha tool finishes.

browser_extract_text

Extracts the final result and stores it in a JSON artifact.

browser_close

Closes Chromium, the browser context, and the Playwright runtime.

Error handling

The server returns structured errors instead of raising raw exceptions through the MCP boundary.

Each result contains:

{
  "status": "error",
  "tool": "captcha_solve_recaptcha_v2",
  "message": "Description of the failure",
  "session_id": "SESSION_ID",
  "details": {
    "task_complete": false,
    "should_retry": true,
    "should_close_session": false
  }
}

This allows the agent to distinguish between:

  • a retryable SolveCaptcha error;
  • a missing sitekey;
  • an invalid selector;
  • a closed browser session;
  • a permanent configuration error.

The production version should classify SolveCaptcha SDK exceptions more precisely and map them to stable internal error codes.

Reporting incorrect solutions

The SolveCaptcha SDK supports reporting whether a result was correct:

solver.report(
    captcha_id,
    True,
)

or:

solver.report(
    captcha_id,
    False,
)

A production implementation should add a separate MCP tool such as:

captcha_report_result

The tool should accept:

  • the SolveCaptcha task ID;
  • whether the result was accepted by the target page;
  • the browser session ID;
  • optional diagnostic metadata.

Do not automatically mark every failed page flow as an incorrect captcha result. A valid token can still fail because of:

  • expired session state;
  • wrong proxy context;
  • navigation after solving;
  • an invalid callback;
  • mismatched user agent;
  • an unrelated form validation error.

Report a bad result only when the captcha solution itself is known to be incorrect.

Proxy-aware solving

SolveCaptcha supports proxy parameters for captcha types where the solver context should match the browser context.

The SDK accepts a proxy object in supported solver methods:

proxy = {
    "type": "HTTPS",
    "uri": "login:password@IP_ADDRESS:PORT",
}

result = solver.recaptcha(
    sitekey=sitekey,
    url=page_url,
    userAgent=user_agent,
    proxy=proxy,
)

For proxy-sensitive targets, the browser and SolveCaptcha task should use consistent:

  • IP address;
  • country;
  • proxy type;
  • user agent;
  • cookies where supported;
  • session timing.

A correct token is only one part of the complete browser verification context.

Do not hardcode proxy credentials in source code. Read them from environment variables or a secret manager.

Extending the project to other captcha types

The same MCP design can support additional SolveCaptcha methods.

reCAPTCHA v3

The SDK uses the same recaptcha method with version-specific parameters:

result = solver.recaptcha(
    sitekey=sitekey,
    url=page_url,
    version="v3",
    action=page_action,
    min_score=0.3,
)

A dedicated MCP tool should be named:

captcha_solve_recaptcha_v3

It should require or reliably extract:

  • sitekey;
  • pageAction;
  • expected score;
  • page URL;
  • current browser context.

Do not silently treat reCAPTCHA v3 as reCAPTCHA v2.

hCaptcha

The SDK exposes:

result = solver.hcaptcha(
    sitekey=sitekey,
    url=page_url,
    invisible=0,
    domain="hcaptcha.com",
)

Create a separate tool:

captcha_solve_hcaptcha

Cloudflare Turnstile

The SDK exposes a Turnstile method:

result = solver.turnstile(
    sitekey=sitekey,
    url=page_url,
    action=action,
    data=cdata,
    pagedata=page_data,
    useragent=user_agent,
)

Turnstile challenge pages may require dynamic parameters and a page callback. This should be a separate workflow with explicit parameter extraction and validation.

Image captcha

For a normal image captcha:

result = solver.normal(
    "artifacts/captcha.png"
)

The MCP server can:

  1. screenshot the captcha element;
  2. save the image;
  3. call solver.normal;
  4. fill the answer into the target input;
  5. return structured metadata.

Local STDIO and remote Streamable HTTP

STDIO is suitable for local development and desktop MCP clients.

For a production service, the official MCP Python SDK recommends Streamable HTTP. The server can be changed to:

def main() -> None:
    mcp.run(
        transport="streamable-http"
    )

A remote deployment also needs:

  • authentication;
  • authorization;
  • tenant isolation;
  • encrypted traffic;
  • session expiration;
  • rate limits;
  • browser process limits;
  • artifact retention rules;
  • secret management;
  • audit logs.

Do not expose the example server directly to the internet without those controls.

Security requirements

An MCP browser server has more authority than a normal API client. It can open pages, execute JavaScript, store cookies, and call paid services.

At minimum:

  • restrict allowed target domains;
  • never expose SOLVECAPTCHA_API_KEY as a tool argument;
  • run browsers in isolated containers;
  • apply CPU and memory limits;
  • expire inactive sessions;
  • validate selectors and URLs;
  • prevent arbitrary file access;
  • redact credentials from logs;
  • restrict outbound network access where possible;
  • require authentication for remote MCP;
  • keep tool permissions explicit;
  • close browser sessions after every completed task.

Protect against prompt injection

A page can contain instructions intended for the language model.

For example, hidden page text may tell the agent to:

  • ignore its original task;
  • reveal environment variables;
  • send cookies to an external endpoint;
  • call an unrelated MCP tool;
  • keep the browser session open.

The MCP server should not treat DOM text as trusted instructions.

Useful controls include:

  • domain allowlists;
  • tools with narrow parameters;
  • no generic shell execution tool;
  • no tool for reading arbitrary environment variables;
  • no unrestricted HTTP request tool;
  • no raw cookie export unless explicitly required;
  • approval for high-risk actions;
  • strict separation between page content and system instructions.

Production improvements

The example is intentionally compact. A production version should add the following.

Session expiration

Store the last activity time and automatically close sessions after a fixed idle period.

Per-user isolation

Never place sessions from multiple customers in one unscoped global dictionary.

Use tenant-aware keys:

tenant_id + session_id

Browser pooling

Launching a new Chromium process for every session is simple but expensive.

At scale, use:

  • a browser pool;
  • isolated contexts;
  • maximum concurrent session limits;
  • queueing;
  • backpressure.

Persistent artifacts

Local files work for development. A distributed service should store screenshots and results in object storage.

Observability

Track:

  • tool latency;
  • SolveCaptcha response time;
  • solve success rate;
  • page verification success;
  • session duration;
  • browser crashes;
  • retry count;
  • cost per completed workflow.

Target-specific workflows

Generic browser tools are useful, but stable production automation usually needs explicit workflows for important targets.

For example:

workflow_open_report
workflow_solve_verification
workflow_download_result

The agent can still choose tools, but the low-level page behavior remains deterministic and testable.

Common implementation mistakes

Passing the API key to the agent

Bad:

captcha_solve(
  api_key,
  sitekey,
  page_url
)

Correct:

captcha_solve_recaptcha_v2(
  session_id
)

The server already owns the API key.

Returning the raw token to the model

The token should move directly from SolveCaptcha to the browser page.

Opening a new browser after solving

The solution belongs to the current page context. Reopening the page can invalidate the workflow.

Polling too aggressively

The SolveCaptcha SDK manages polling. When calling the API manually, follow the documented polling interval and do not repeatedly query the result endpoint without delay.

Using one universal captcha tool

Different captcha types require different parameters and completion logic. Use separate tools with explicit schemas.

Treating token injection as task completion

Captcha removal is only one step. The agent may still need to:

  • submit the form;
  • wait for navigation;
  • extract data;
  • verify success;
  • close the session.

Final architecture

The completed project follows this sequence:

User request
   |
   v
AI agent
   |
   v
browser_open_page
   |
   v
browser_get_page_state
   |
   v
captcha_solve_recaptcha_v2
   |
   +--> detect sitekey
   +--> call SolveCaptcha SDK
   +--> receive token
   +--> inject token
   +--> call page callback
   |
   v
browser_click
   |
   v
browser_extract_text
   |
   v
browser_close

This is the main design principle:

The agent manages the task. SolveCaptcha handles captcha solving. Playwright controls the browser. MCP provides the boundary between them.

That separation makes the workflow easier to test, safer to operate, and simpler to extend than a monolithic browser script or a prompt filled with low-level captcha instructions.