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-pythonSDK; - 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:
- The agent opens a page.
- It inspects the current browser state.
- It detects that reCAPTCHA v2 is blocking progress.
- It calls
captcha_solve_recaptcha_v2. - The MCP server sends the task to SolveCaptcha.
- The server injects the returned token directly into the current browser session.
- The agent continues with normal browser tools.
- 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;
Solvecaptchafrom the official SolveCaptcha Python SDK;SOLVECAPTCHA_API_KEYas 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:
- Find the sitekey.
- Read the current page URL.
- Read the browser user agent.
- Send the task to SolveCaptcha.
- Wait for the SDK result.
- normalize the response;
- inject the token into
g-recaptcha-response; - invoke a declared
data-callback, when present; - 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:
- Fully close Claude Desktop.
- Start Claude Desktop again.
- Open the developer or MCP settings.
- Confirm that
solvecaptcha-browserconnects. - 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:
- screenshot the captcha element;
- save the image;
- call
solver.normal; - fill the answer into the target input;
- 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_KEYas 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.