clearcote-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clearcote_mcp-0.1.0/PKG-INFO +110 -0
- clearcote_mcp-0.1.0/README.md +97 -0
- clearcote_mcp-0.1.0/clearcote_mcp/__init__.py +5 -0
- clearcote_mcp-0.1.0/clearcote_mcp/__main__.py +4 -0
- clearcote_mcp-0.1.0/clearcote_mcp/_facade.py +247 -0
- clearcote_mcp-0.1.0/clearcote_mcp/server.py +366 -0
- clearcote_mcp-0.1.0/clearcote_mcp.egg-info/PKG-INFO +110 -0
- clearcote_mcp-0.1.0/clearcote_mcp.egg-info/SOURCES.txt +13 -0
- clearcote_mcp-0.1.0/clearcote_mcp.egg-info/dependency_links.txt +1 -0
- clearcote_mcp-0.1.0/clearcote_mcp.egg-info/entry_points.txt +2 -0
- clearcote_mcp-0.1.0/clearcote_mcp.egg-info/requires.txt +3 -0
- clearcote_mcp-0.1.0/clearcote_mcp.egg-info/top_level.txt +1 -0
- clearcote_mcp-0.1.0/pyproject.toml +26 -0
- clearcote_mcp-0.1.0/setup.cfg +4 -0
- clearcote_mcp-0.1.0/tests/test_server.py +73 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clearcote-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Clearcote — drive the open-source stealth Chromium from Claude Desktop, Cursor, Cline, and any MCP client.
|
|
5
|
+
License: BSD-3-Clause
|
|
6
|
+
Project-URL: Homepage, https://github.com/clearcotelabs/clearcote-browser
|
|
7
|
+
Project-URL: Source, https://github.com/clearcotelabs/clearcote-browser/tree/main/mcp
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: clearcote>=0.13
|
|
11
|
+
Requires-Dist: mcp[cli]>=1.2
|
|
12
|
+
Requires-Dist: markdownify>=0.11
|
|
13
|
+
|
|
14
|
+
# Clearcote MCP server
|
|
15
|
+
|
|
16
|
+
Drive the open-source **Clearcote stealth Chromium** from any MCP client — Claude Desktop, Cursor,
|
|
17
|
+
Cline, Continue, or your own agent. One shared, coherent stealth browser; ~20 tools to navigate,
|
|
18
|
+
read, extract, click, fill, screenshot, and persist sessions — plus `get_cdp_endpoint`, which hands
|
|
19
|
+
the **same** stealth browser to any Playwright / Puppeteer / browser-use / Crawl4AI client.
|
|
20
|
+
|
|
21
|
+
The fingerprint is corrected in Chromium's **C++**, so you do **not** add `puppeteer-stealth` /
|
|
22
|
+
`undetected-chromedriver` / any JS patching — those self-reveal and undo it. Driving over CDP adds
|
|
23
|
+
no automation flags, so `navigator.webdriver` stays `false` and the persona is intact end to end.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Run it
|
|
28
|
+
|
|
29
|
+
**Node (no install):**
|
|
30
|
+
```bash
|
|
31
|
+
npx clearcote-mcp
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Python:**
|
|
35
|
+
```bash
|
|
36
|
+
pip install clearcote-mcp
|
|
37
|
+
clearcote-mcp # stdio server
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Both auto-download + SHA-256-verify the right Clearcote binary per OS on first use (native Windows
|
|
41
|
+
x64 + Linux x64).
|
|
42
|
+
|
|
43
|
+
## Add it to a client
|
|
44
|
+
|
|
45
|
+
Claude Desktop / Cursor / Cline `mcpServers` config:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"clearcote": {
|
|
51
|
+
"command": "npx",
|
|
52
|
+
"args": ["-y", "clearcote-mcp"],
|
|
53
|
+
"env": {
|
|
54
|
+
"CLEARCOTE_FINGERPRINT": "acct-1",
|
|
55
|
+
"CLEARCOTE_PLATFORM": "windows",
|
|
56
|
+
"CLEARCOTE_PROXY": "http://user:pass@host:port",
|
|
57
|
+
"CLEARCOTE_GEOIP": "1"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The **persona lives in the environment**, so the tool surface stays clean:
|
|
65
|
+
|
|
66
|
+
| env var | meaning |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `CLEARCOTE_FINGERPRINT` | seed → one stable, coherent identity (same seed = same machine across runs) |
|
|
69
|
+
| `CLEARCOTE_PLATFORM` | `windows` \| `linux` \| `macos` \| `android` |
|
|
70
|
+
| `CLEARCOTE_BRAND` | `Chrome` \| `Edge` \| `Opera` \| `Vivaldi` |
|
|
71
|
+
| `CLEARCOTE_PROXY` | `http://user:pass@host:port` (routes all traffic) |
|
|
72
|
+
| `CLEARCOTE_GEOIP` | `1` → derive timezone/locale/WebRTC IP from the proxy exit IP |
|
|
73
|
+
| `CLEARCOTE_TIMEZONE` / `CLEARCOTE_ACCEPT_LANGUAGE` | explicit overrides |
|
|
74
|
+
| `CLEARCOTE_HEADLESS` | `0` for a visible window (default headless) |
|
|
75
|
+
| `CLEARCOTE_BINARY` | path to a specific Clearcote binary (optional) |
|
|
76
|
+
|
|
77
|
+
Hardening knobs: `CLEARCOTE_MCP_TOOL_TIMEOUT` (s), `CLEARCOTE_MCP_WRITE_DIR` (sandbox for file
|
|
78
|
+
writes), `CLEARCOTE_MCP_ALLOW_ANY_PATH=1`, `CLEARCOTE_ALLOW_PRIVATE_EGRESS=1` (allow localhost /
|
|
79
|
+
private targets), `CLEARCOTE_MCP_PREWARM=0`, `CLEARCOTE_SERVE_PORT`.
|
|
80
|
+
|
|
81
|
+
## Tools
|
|
82
|
+
|
|
83
|
+
**Read** · `read_page` (text + Markdown) · `get_page_html` · `page_elements` (interactive elements +
|
|
84
|
+
selectors) · `evaluate_js` · `wait_for` · `current_page` · `get_cookies` · `list_tabs`
|
|
85
|
+
**Act** · `navigate` · `click` (selector or visible text) · `fill_field` (selector/label/placeholder/name)
|
|
86
|
+
· `press_key` · `new_tab` · `close_tab`
|
|
87
|
+
**Capture** · `screenshot_page` · `save_page_pdf`
|
|
88
|
+
**Session** · `save_profile` / `load_profile` (cookies + storage)
|
|
89
|
+
**Stealth / infra** · `get_egress_info` (public IP + active persona) · **`get_cdp_endpoint`** (attach any
|
|
90
|
+
other CDP client to the same stealth browser)
|
|
91
|
+
|
|
92
|
+
## Guardrails (built in)
|
|
93
|
+
|
|
94
|
+
- Every tool has a wall-clock timeout and returns a **structured error** instead of crashing the server.
|
|
95
|
+
- URL args are **SSRF-checked** — localhost / private / cloud-metadata are refused unless you opt in.
|
|
96
|
+
- File writes are **confined** to a sandbox dir (no path traversal).
|
|
97
|
+
- Oversized text is **capped** so a response never floods the agent's context.
|
|
98
|
+
- The shared browser is **rebuilt** automatically if it dies.
|
|
99
|
+
|
|
100
|
+
## Just want the raw endpoint?
|
|
101
|
+
|
|
102
|
+
If you don't need the tools, run the browser as a standing CDP endpoint and attach your existing code:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
clearcote-serve --port 9222 --fingerprint acct-1 --platform windows
|
|
106
|
+
# → prints http://127.0.0.1:9222 ; then: connect_over_cdp / puppeteer.connect({browserURL})
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
See [USAGE.md](USAGE.md) for per-client examples. Part of
|
|
110
|
+
[clearcote-browser](https://github.com/clearcotelabs/clearcote-browser) (BSD-3).
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Clearcote MCP server
|
|
2
|
+
|
|
3
|
+
Drive the open-source **Clearcote stealth Chromium** from any MCP client — Claude Desktop, Cursor,
|
|
4
|
+
Cline, Continue, or your own agent. One shared, coherent stealth browser; ~20 tools to navigate,
|
|
5
|
+
read, extract, click, fill, screenshot, and persist sessions — plus `get_cdp_endpoint`, which hands
|
|
6
|
+
the **same** stealth browser to any Playwright / Puppeteer / browser-use / Crawl4AI client.
|
|
7
|
+
|
|
8
|
+
The fingerprint is corrected in Chromium's **C++**, so you do **not** add `puppeteer-stealth` /
|
|
9
|
+
`undetected-chromedriver` / any JS patching — those self-reveal and undo it. Driving over CDP adds
|
|
10
|
+
no automation flags, so `navigator.webdriver` stays `false` and the persona is intact end to end.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Run it
|
|
15
|
+
|
|
16
|
+
**Node (no install):**
|
|
17
|
+
```bash
|
|
18
|
+
npx clearcote-mcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**Python:**
|
|
22
|
+
```bash
|
|
23
|
+
pip install clearcote-mcp
|
|
24
|
+
clearcote-mcp # stdio server
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Both auto-download + SHA-256-verify the right Clearcote binary per OS on first use (native Windows
|
|
28
|
+
x64 + Linux x64).
|
|
29
|
+
|
|
30
|
+
## Add it to a client
|
|
31
|
+
|
|
32
|
+
Claude Desktop / Cursor / Cline `mcpServers` config:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"clearcote": {
|
|
38
|
+
"command": "npx",
|
|
39
|
+
"args": ["-y", "clearcote-mcp"],
|
|
40
|
+
"env": {
|
|
41
|
+
"CLEARCOTE_FINGERPRINT": "acct-1",
|
|
42
|
+
"CLEARCOTE_PLATFORM": "windows",
|
|
43
|
+
"CLEARCOTE_PROXY": "http://user:pass@host:port",
|
|
44
|
+
"CLEARCOTE_GEOIP": "1"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The **persona lives in the environment**, so the tool surface stays clean:
|
|
52
|
+
|
|
53
|
+
| env var | meaning |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `CLEARCOTE_FINGERPRINT` | seed → one stable, coherent identity (same seed = same machine across runs) |
|
|
56
|
+
| `CLEARCOTE_PLATFORM` | `windows` \| `linux` \| `macos` \| `android` |
|
|
57
|
+
| `CLEARCOTE_BRAND` | `Chrome` \| `Edge` \| `Opera` \| `Vivaldi` |
|
|
58
|
+
| `CLEARCOTE_PROXY` | `http://user:pass@host:port` (routes all traffic) |
|
|
59
|
+
| `CLEARCOTE_GEOIP` | `1` → derive timezone/locale/WebRTC IP from the proxy exit IP |
|
|
60
|
+
| `CLEARCOTE_TIMEZONE` / `CLEARCOTE_ACCEPT_LANGUAGE` | explicit overrides |
|
|
61
|
+
| `CLEARCOTE_HEADLESS` | `0` for a visible window (default headless) |
|
|
62
|
+
| `CLEARCOTE_BINARY` | path to a specific Clearcote binary (optional) |
|
|
63
|
+
|
|
64
|
+
Hardening knobs: `CLEARCOTE_MCP_TOOL_TIMEOUT` (s), `CLEARCOTE_MCP_WRITE_DIR` (sandbox for file
|
|
65
|
+
writes), `CLEARCOTE_MCP_ALLOW_ANY_PATH=1`, `CLEARCOTE_ALLOW_PRIVATE_EGRESS=1` (allow localhost /
|
|
66
|
+
private targets), `CLEARCOTE_MCP_PREWARM=0`, `CLEARCOTE_SERVE_PORT`.
|
|
67
|
+
|
|
68
|
+
## Tools
|
|
69
|
+
|
|
70
|
+
**Read** · `read_page` (text + Markdown) · `get_page_html` · `page_elements` (interactive elements +
|
|
71
|
+
selectors) · `evaluate_js` · `wait_for` · `current_page` · `get_cookies` · `list_tabs`
|
|
72
|
+
**Act** · `navigate` · `click` (selector or visible text) · `fill_field` (selector/label/placeholder/name)
|
|
73
|
+
· `press_key` · `new_tab` · `close_tab`
|
|
74
|
+
**Capture** · `screenshot_page` · `save_page_pdf`
|
|
75
|
+
**Session** · `save_profile` / `load_profile` (cookies + storage)
|
|
76
|
+
**Stealth / infra** · `get_egress_info` (public IP + active persona) · **`get_cdp_endpoint`** (attach any
|
|
77
|
+
other CDP client to the same stealth browser)
|
|
78
|
+
|
|
79
|
+
## Guardrails (built in)
|
|
80
|
+
|
|
81
|
+
- Every tool has a wall-clock timeout and returns a **structured error** instead of crashing the server.
|
|
82
|
+
- URL args are **SSRF-checked** — localhost / private / cloud-metadata are refused unless you opt in.
|
|
83
|
+
- File writes are **confined** to a sandbox dir (no path traversal).
|
|
84
|
+
- Oversized text is **capped** so a response never floods the agent's context.
|
|
85
|
+
- The shared browser is **rebuilt** automatically if it dies.
|
|
86
|
+
|
|
87
|
+
## Just want the raw endpoint?
|
|
88
|
+
|
|
89
|
+
If you don't need the tools, run the browser as a standing CDP endpoint and attach your existing code:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
clearcote-serve --port 9222 --fingerprint acct-1 --platform windows
|
|
93
|
+
# → prints http://127.0.0.1:9222 ; then: connect_over_cdp / puppeteer.connect({browserURL})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
See [USAGE.md](USAGE.md) for per-client examples. Part of
|
|
97
|
+
[clearcote-browser](https://github.com/clearcotelabs/clearcote-browser) (BSD-3).
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Async browser facade the MCP tools drive.
|
|
2
|
+
|
|
3
|
+
It launches ONE clearcote stealth browser via ``clearcote.serve()`` (a raw CDP endpoint) and
|
|
4
|
+
attaches to it with Playwright's async ``connect_over_cdp`` — so the exact same browser the tools
|
|
5
|
+
drive is ALSO the endpoint any other client can attach to (``get_cdp_endpoint``). Attaching over
|
|
6
|
+
CDP adds no launch flags, so the served persona (``navigator.webdriver=false``, Windows UA, etc.)
|
|
7
|
+
is preserved end to end.
|
|
8
|
+
|
|
9
|
+
The facade owns a "current page" (the active tab) so an agent can call tools without repeating a
|
|
10
|
+
URL; pass ``url`` to any read/act tool to navigate first.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _md(html: str) -> str:
|
|
20
|
+
"""HTML -> Markdown, best-effort. Uses markdownify if present, else falls back to a light strip."""
|
|
21
|
+
try:
|
|
22
|
+
from markdownify import markdownify
|
|
23
|
+
return markdownify(html, heading_style="ATX", strip=["script", "style", "noscript"])
|
|
24
|
+
except Exception:
|
|
25
|
+
import re
|
|
26
|
+
text = re.sub(r"(?is)<(script|style|noscript)[^>]*>.*?</\1>", " ", html)
|
|
27
|
+
text = re.sub(r"(?s)<[^>]+>", " ", text)
|
|
28
|
+
return re.sub(r"[ \t\r\f\v]+\n", "\n", re.sub(r"[ \t]{2,}", " ", text)).strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# In-page collector for interactive elements (links, buttons, inputs) with a stable-ish selector.
|
|
32
|
+
_ELEMENTS_JS = r"""() => {
|
|
33
|
+
const out = [];
|
|
34
|
+
const sel = (el) => {
|
|
35
|
+
if (el.id) return '#' + CSS.escape(el.id);
|
|
36
|
+
const nm = el.getAttribute('name'); if (nm) return el.tagName.toLowerCase() + '[name="' + nm + '"]';
|
|
37
|
+
const aria = el.getAttribute('aria-label'); if (aria) return el.tagName.toLowerCase() + '[aria-label="' + aria + '"]';
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
for (const el of document.querySelectorAll('a[href], button, input, textarea, select, [role=button], [onclick]')) {
|
|
42
|
+
const r = el.getBoundingClientRect();
|
|
43
|
+
if (r.width === 0 && r.height === 0) continue; // skip hidden
|
|
44
|
+
const txt = (el.innerText || el.value || el.getAttribute('aria-label') || el.getAttribute('placeholder') || '').trim().slice(0, 120);
|
|
45
|
+
const item = { tag: el.tagName.toLowerCase(), text: txt,
|
|
46
|
+
type: el.getAttribute('type') || null,
|
|
47
|
+
href: el.getAttribute('href') || null, selector: sel(el) };
|
|
48
|
+
const key = JSON.stringify(item);
|
|
49
|
+
if (seen.has(key)) continue; seen.add(key);
|
|
50
|
+
out.push(item);
|
|
51
|
+
if (out.length >= 200) break;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ClearcoteBrowser:
|
|
58
|
+
"""One shared, stealth clearcote browser + its Playwright attachment."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, persona: dict | None = None):
|
|
61
|
+
self._persona = persona or {}
|
|
62
|
+
self._srv = None # clearcote._serve.Server
|
|
63
|
+
self._pw = None # playwright async context manager
|
|
64
|
+
self._browser = None # playwright Browser (over CDP)
|
|
65
|
+
self._ctx = None # BrowserContext
|
|
66
|
+
self._page = None # current page
|
|
67
|
+
|
|
68
|
+
# ── lifecycle ────────────────────────────────────────────────────────────
|
|
69
|
+
async def start(self):
|
|
70
|
+
import clearcote
|
|
71
|
+
from playwright.async_api import async_playwright
|
|
72
|
+
loop = asyncio.get_running_loop()
|
|
73
|
+
# serve() is a blocking subprocess launch — off the event loop.
|
|
74
|
+
self._srv = await loop.run_in_executor(None, lambda: clearcote.serve(quiet=True, **self._persona))
|
|
75
|
+
self._pw = await async_playwright().start()
|
|
76
|
+
self._browser = await self._pw.chromium.connect_over_cdp(self._srv.cdp_url)
|
|
77
|
+
self._ctx = self._browser.contexts[0] if self._browser.contexts else await self._browser.new_context()
|
|
78
|
+
self._page = self._ctx.pages[0] if self._ctx.pages else await self._ctx.new_page()
|
|
79
|
+
|
|
80
|
+
def is_healthy(self) -> bool:
|
|
81
|
+
try:
|
|
82
|
+
return bool(self._srv and self._srv.is_alive() and self._browser and self._browser.is_connected())
|
|
83
|
+
except Exception:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
async def close(self):
|
|
87
|
+
for step in (
|
|
88
|
+
lambda: self._browser.close() if self._browser else None,
|
|
89
|
+
lambda: self._pw.stop() if self._pw else None,
|
|
90
|
+
):
|
|
91
|
+
try:
|
|
92
|
+
r = step()
|
|
93
|
+
if asyncio.iscoroutine(r):
|
|
94
|
+
await r
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
try:
|
|
98
|
+
if self._srv:
|
|
99
|
+
self._srv.close()
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
self._srv = self._pw = self._browser = self._ctx = self._page = None
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def cdp_url(self) -> str:
|
|
106
|
+
return self._srv.cdp_url if self._srv else ""
|
|
107
|
+
|
|
108
|
+
async def _pg(self, url: str | None):
|
|
109
|
+
if url:
|
|
110
|
+
await self._page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
|
111
|
+
return self._page
|
|
112
|
+
|
|
113
|
+
# ── read ─────────────────────────────────────────────────────────────────
|
|
114
|
+
async def navigate(self, url: str) -> dict:
|
|
115
|
+
pg = await self._pg(url)
|
|
116
|
+
return {"status": "ok", "url": pg.url, "title": await pg.title()}
|
|
117
|
+
|
|
118
|
+
async def read_page(self, url: str | None = None) -> dict:
|
|
119
|
+
pg = await self._pg(url)
|
|
120
|
+
html = await pg.content()
|
|
121
|
+
return {"status": "ok", "url": pg.url, "title": await pg.title(),
|
|
122
|
+
"text": (await pg.inner_text("body"))[:200000], "markdown": _md(html)[:200000]}
|
|
123
|
+
|
|
124
|
+
async def get_html(self, url: str | None = None) -> dict:
|
|
125
|
+
pg = await self._pg(url)
|
|
126
|
+
return {"status": "ok", "url": pg.url, "html": await pg.content()}
|
|
127
|
+
|
|
128
|
+
async def page_elements(self, url: str | None = None) -> dict:
|
|
129
|
+
pg = await self._pg(url)
|
|
130
|
+
return {"status": "ok", "url": pg.url, "elements": await pg.evaluate(_ELEMENTS_JS)}
|
|
131
|
+
|
|
132
|
+
async def evaluate(self, expression: str, url: str | None = None) -> dict:
|
|
133
|
+
pg = await self._pg(url)
|
|
134
|
+
return {"status": "ok", "url": pg.url, "result": await pg.evaluate(expression)}
|
|
135
|
+
|
|
136
|
+
async def wait_for(self, selector: str, url: str | None = None, timeout_ms: int = 10000) -> dict:
|
|
137
|
+
pg = await self._pg(url)
|
|
138
|
+
await pg.wait_for_selector(selector, timeout=timeout_ms)
|
|
139
|
+
return {"status": "ok", "url": pg.url, "found": selector}
|
|
140
|
+
|
|
141
|
+
async def current_page(self) -> dict:
|
|
142
|
+
pg = self._page
|
|
143
|
+
return {"status": "ok", "url": pg.url, "title": await pg.title()}
|
|
144
|
+
|
|
145
|
+
# ── act ──────────────────────────────────────────────────────────────────
|
|
146
|
+
async def click(self, target: str, url: str | None = None) -> dict:
|
|
147
|
+
pg = await self._pg(url)
|
|
148
|
+
# try as a CSS selector first, then fall back to visible text
|
|
149
|
+
try:
|
|
150
|
+
await pg.click(target, timeout=6000)
|
|
151
|
+
except Exception:
|
|
152
|
+
await pg.get_by_text(target, exact=False).first.click(timeout=6000)
|
|
153
|
+
await pg.wait_for_load_state("domcontentloaded")
|
|
154
|
+
return {"status": "ok", "url": pg.url, "clicked": target}
|
|
155
|
+
|
|
156
|
+
async def fill(self, field: str, value: str, url: str | None = None) -> dict:
|
|
157
|
+
pg = await self._pg(url)
|
|
158
|
+
loc = None
|
|
159
|
+
for attempt in (
|
|
160
|
+
lambda: pg.locator(field),
|
|
161
|
+
lambda: pg.get_by_label(field),
|
|
162
|
+
lambda: pg.get_by_placeholder(field),
|
|
163
|
+
lambda: pg.locator("[name='%s']" % field),
|
|
164
|
+
):
|
|
165
|
+
try:
|
|
166
|
+
cand = attempt()
|
|
167
|
+
await cand.first.fill(value, timeout=4000)
|
|
168
|
+
loc = field
|
|
169
|
+
break
|
|
170
|
+
except Exception:
|
|
171
|
+
continue
|
|
172
|
+
if loc is None:
|
|
173
|
+
raise ValueError("no fillable field matched %r" % field)
|
|
174
|
+
return {"status": "ok", "url": pg.url, "filled": field}
|
|
175
|
+
|
|
176
|
+
async def press(self, key: str) -> dict:
|
|
177
|
+
await self._page.keyboard.press(key)
|
|
178
|
+
return {"status": "ok", "pressed": key}
|
|
179
|
+
|
|
180
|
+
# ── capture ──────────────────────────────────────────────────────────────
|
|
181
|
+
async def screenshot(self, path: str, url: str | None = None, full_page: bool = True) -> dict:
|
|
182
|
+
pg = await self._pg(url)
|
|
183
|
+
await pg.screenshot(path=path, full_page=full_page)
|
|
184
|
+
return {"status": "ok", "url": pg.url, "path": path}
|
|
185
|
+
|
|
186
|
+
async def save_pdf(self, path: str, url: str | None = None) -> dict:
|
|
187
|
+
pg = await self._pg(url)
|
|
188
|
+
await pg.pdf(path=path) # headless only
|
|
189
|
+
return {"status": "ok", "url": pg.url, "path": path}
|
|
190
|
+
|
|
191
|
+
# ── state ────────────────────────────────────────────────────────────────
|
|
192
|
+
async def cookies(self, url: str | None = None) -> dict:
|
|
193
|
+
c = await self._ctx.cookies(url) if url else await self._ctx.cookies()
|
|
194
|
+
return {"status": "ok", "cookies": c}
|
|
195
|
+
|
|
196
|
+
async def list_tabs(self) -> dict:
|
|
197
|
+
tabs = []
|
|
198
|
+
for i, p in enumerate(self._ctx.pages):
|
|
199
|
+
tabs.append({"index": i, "url": p.url, "title": await p.title(), "current": p is self._page})
|
|
200
|
+
return {"status": "ok", "tabs": tabs}
|
|
201
|
+
|
|
202
|
+
async def new_tab(self, url: str | None = None) -> dict:
|
|
203
|
+
self._page = await self._ctx.new_page()
|
|
204
|
+
if url:
|
|
205
|
+
await self._page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
|
206
|
+
return {"status": "ok", "url": self._page.url, "index": len(self._ctx.pages) - 1}
|
|
207
|
+
|
|
208
|
+
async def close_tab(self, index: int) -> dict:
|
|
209
|
+
pages = self._ctx.pages
|
|
210
|
+
if not (0 <= index < len(pages)):
|
|
211
|
+
raise ValueError("tab index %d out of range (0..%d)" % (index, len(pages) - 1))
|
|
212
|
+
target = pages[index]
|
|
213
|
+
await target.close()
|
|
214
|
+
if target is self._page:
|
|
215
|
+
self._page = self._ctx.pages[-1] if self._ctx.pages else await self._ctx.new_page()
|
|
216
|
+
return {"status": "ok", "closed": index}
|
|
217
|
+
|
|
218
|
+
async def save_profile(self, path: str) -> dict:
|
|
219
|
+
state = await self._ctx.storage_state()
|
|
220
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
221
|
+
json.dump(state, fh)
|
|
222
|
+
return {"status": "ok", "path": path,
|
|
223
|
+
"cookies": len(state.get("cookies", [])), "origins": len(state.get("origins", []))}
|
|
224
|
+
|
|
225
|
+
async def load_profile(self, path: str) -> dict:
|
|
226
|
+
with open(path, encoding="utf-8") as fh:
|
|
227
|
+
state = json.load(fh)
|
|
228
|
+
if state.get("cookies"):
|
|
229
|
+
await self._ctx.add_cookies(state["cookies"])
|
|
230
|
+
return {"status": "ok", "path": path, "cookies": len(state.get("cookies", []))}
|
|
231
|
+
|
|
232
|
+
async def egress_info(self) -> dict:
|
|
233
|
+
pg = self._page
|
|
234
|
+
prev = pg.url
|
|
235
|
+
try:
|
|
236
|
+
info = await pg.evaluate(
|
|
237
|
+
"async () => { const r = await fetch('https://api.ipify.org?format=json',{cache:'no-store'});"
|
|
238
|
+
" return await r.json(); }")
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
info = {"error": str(exc)}
|
|
241
|
+
try:
|
|
242
|
+
if prev and prev != "about:blank":
|
|
243
|
+
await pg.goto(prev, wait_until="domcontentloaded", timeout=15000)
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
return {"status": "ok", "public_ip": info.get("ip") if isinstance(info, dict) else None,
|
|
247
|
+
"persona": {k: self._persona.get(k) for k in ("fingerprint", "platform", "brand", "proxy") if self._persona.get(k)}}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Clearcote MCP server — drive the open-source stealth Chromium from any MCP client
|
|
2
|
+
(Claude Desktop, Cursor, Cline, ...).
|
|
3
|
+
|
|
4
|
+
One shared stealth browser, launched via ``clearcote.serve()`` (a raw CDP endpoint the tools
|
|
5
|
+
attach to over Playwright, and that ``get_cdp_endpoint`` hands to any other client). The persona
|
|
6
|
+
(seed / platform / proxy / geoip) is read from the environment so the tool surface stays clean.
|
|
7
|
+
|
|
8
|
+
Hardening (ported from the Fortress MCP design): every tool has a wall-clock timeout and returns a
|
|
9
|
+
STRUCTURED error instead of crashing the server; URL args are SSRF-checked (no localhost / private /
|
|
10
|
+
cloud-metadata unless opted in); file writes are confined to a sandbox dir; oversized text fields
|
|
11
|
+
are capped so a response never floods the agent's context; the shared browser is guarded by an
|
|
12
|
+
asyncio lock and rebuilt if it dies.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import functools
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from contextlib import asynccontextmanager
|
|
21
|
+
|
|
22
|
+
from mcp.server.fastmcp import FastMCP
|
|
23
|
+
from mcp.types import ToolAnnotations
|
|
24
|
+
|
|
25
|
+
from ._facade import ClearcoteBrowser
|
|
26
|
+
|
|
27
|
+
# ── tool annotations ─────────────────────────────────────────────────────────
|
|
28
|
+
_READ = ToolAnnotations(readOnlyHint=True, openWorldHint=True)
|
|
29
|
+
_LOCAL = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
|
|
30
|
+
_WRITE = ToolAnnotations(readOnlyHint=False, openWorldHint=True)
|
|
31
|
+
|
|
32
|
+
_TOOL_TIMEOUT = float(os.environ.get("CLEARCOTE_MCP_TOOL_TIMEOUT", "90"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _env(name: str, default: str | None = None) -> str | None:
|
|
36
|
+
v = os.environ.get("CLEARCOTE_" + name)
|
|
37
|
+
return v if v is not None else default
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── error boundary ───────────────────────────────────────────────────────────
|
|
41
|
+
def _safe(fn):
|
|
42
|
+
"""Per-tool wall-clock timeout + structured error, so a bad/slow call never wedges the server."""
|
|
43
|
+
@functools.wraps(fn)
|
|
44
|
+
async def wrap(*args, **kwargs):
|
|
45
|
+
try:
|
|
46
|
+
return await asyncio.wait_for(fn(*args, **kwargs), timeout=_TOOL_TIMEOUT)
|
|
47
|
+
except asyncio.TimeoutError:
|
|
48
|
+
return {"status": "error",
|
|
49
|
+
"error": f"tool timed out after {_TOOL_TIMEOUT:.0f}s "
|
|
50
|
+
f"(raise CLEARCOTE_MCP_TOOL_TIMEOUT for slow pages)"}
|
|
51
|
+
except Exception as exc: # noqa: BLE001 — deliberate catch-all at the tool boundary
|
|
52
|
+
return {"status": "error", "error": f"{type(exc).__name__}: {exc}"}
|
|
53
|
+
return wrap
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── SSRF guard ───────────────────────────────────────────────────────────────
|
|
57
|
+
def _ip_blocked(ip_str: str) -> bool:
|
|
58
|
+
import ipaddress
|
|
59
|
+
try:
|
|
60
|
+
ip = ipaddress.ip_address(ip_str)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return False
|
|
63
|
+
if getattr(ip, "ipv4_mapped", None) is not None:
|
|
64
|
+
ip = ip.ipv4_mapped
|
|
65
|
+
return (ip.is_private or ip.is_loopback or ip.is_link_local
|
|
66
|
+
or ip.is_reserved or ip.is_multicast or ip.is_unspecified)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _check_url(url: str | None) -> None:
|
|
70
|
+
"""Refuse localhost / private / cloud-metadata targets unless CLEARCOTE_ALLOW_PRIVATE_EGRESS=1."""
|
|
71
|
+
if url is None:
|
|
72
|
+
return
|
|
73
|
+
if _env("ALLOW_PRIVATE_EGRESS", "0") == "1":
|
|
74
|
+
return
|
|
75
|
+
from urllib.parse import urlparse
|
|
76
|
+
host = (urlparse(url).hostname or "").strip("[]")
|
|
77
|
+
if not host:
|
|
78
|
+
return
|
|
79
|
+
if host.lower() in ("localhost", "metadata.google.internal"):
|
|
80
|
+
raise ValueError(f"refused private/metadata host {host!r} (set CLEARCOTE_ALLOW_PRIVATE_EGRESS=1 to allow)")
|
|
81
|
+
if _ip_blocked(host):
|
|
82
|
+
raise ValueError(f"refused private/internal address {host!r} (set CLEARCOTE_ALLOW_PRIVATE_EGRESS=1 to allow)")
|
|
83
|
+
try:
|
|
84
|
+
loop = asyncio.get_running_loop()
|
|
85
|
+
infos = await loop.getaddrinfo(host, None)
|
|
86
|
+
except Exception:
|
|
87
|
+
return
|
|
88
|
+
for info in infos:
|
|
89
|
+
if _ip_blocked(info[4][0]):
|
|
90
|
+
raise ValueError(f"refused private/internal address for {host!r} (set CLEARCOTE_ALLOW_PRIVATE_EGRESS=1 to allow)")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── write-path sandbox ───────────────────────────────────────────────────────
|
|
94
|
+
def _write_root() -> str:
|
|
95
|
+
import tempfile
|
|
96
|
+
root = _env("MCP_WRITE_DIR") or os.path.join(tempfile.gettempdir(), "clearcote-mcp")
|
|
97
|
+
os.makedirs(root, exist_ok=True)
|
|
98
|
+
return os.path.abspath(root)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _confine_path(path: str | None, suffix: str) -> str:
|
|
102
|
+
import tempfile
|
|
103
|
+
root = _write_root()
|
|
104
|
+
if _env("MCP_ALLOW_ANY_PATH", "0") == "1" and path:
|
|
105
|
+
return path
|
|
106
|
+
if not path:
|
|
107
|
+
fd, p = tempfile.mkstemp(suffix=suffix, prefix="clearcote_", dir=root)
|
|
108
|
+
os.close(fd)
|
|
109
|
+
return p
|
|
110
|
+
name = os.path.basename(path) or ("out" + suffix)
|
|
111
|
+
if not os.path.splitext(name)[1]:
|
|
112
|
+
name += suffix
|
|
113
|
+
return os.path.join(root, name)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _cap(d: dict, limits: dict[str, int]) -> dict:
|
|
117
|
+
out = dict(d)
|
|
118
|
+
for field, n in limits.items():
|
|
119
|
+
v = out.get(field)
|
|
120
|
+
if isinstance(v, str) and len(v) > n:
|
|
121
|
+
out[field] = v[:n]
|
|
122
|
+
out[f"{field}_truncated"] = True
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── shared browser ───────────────────────────────────────────────────────────
|
|
127
|
+
def _persona_from_env() -> dict:
|
|
128
|
+
p: dict = {}
|
|
129
|
+
for env_key, opt in (("FINGERPRINT", "fingerprint"), ("PLATFORM", "platform"),
|
|
130
|
+
("BRAND", "brand"), ("TIMEZONE", "timezone"),
|
|
131
|
+
("ACCEPT_LANGUAGE", "accept_language")):
|
|
132
|
+
v = _env(env_key)
|
|
133
|
+
if v:
|
|
134
|
+
p[opt] = v
|
|
135
|
+
if _env("GEOIP", "0") == "1":
|
|
136
|
+
p["geoip"] = True
|
|
137
|
+
if _env("BINARY"):
|
|
138
|
+
p["executable_path"] = _env("BINARY")
|
|
139
|
+
proxy = _env("PROXY")
|
|
140
|
+
if proxy:
|
|
141
|
+
from clearcote._serve import _parse_proxy
|
|
142
|
+
p["proxy"] = _parse_proxy(proxy)
|
|
143
|
+
port = _env("SERVE_PORT")
|
|
144
|
+
if port:
|
|
145
|
+
p["port"] = int(port)
|
|
146
|
+
p["headless"] = _env("HEADLESS", "1") != "0"
|
|
147
|
+
return p
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_browser: ClearcoteBrowser | None = None
|
|
151
|
+
_lock: asyncio.Lock | None = None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def _b() -> ClearcoteBrowser:
|
|
155
|
+
"""Shared stealth browser; started on first use, rebuilt if it dies. Concurrency-safe."""
|
|
156
|
+
global _browser, _lock
|
|
157
|
+
if _lock is None:
|
|
158
|
+
_lock = asyncio.Lock()
|
|
159
|
+
async with _lock:
|
|
160
|
+
if _browser is not None and not _browser.is_healthy():
|
|
161
|
+
try:
|
|
162
|
+
await _browser.close()
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
_browser = None
|
|
166
|
+
if _browser is None:
|
|
167
|
+
inst = ClearcoteBrowser(_persona_from_env())
|
|
168
|
+
await inst.start()
|
|
169
|
+
_browser = inst
|
|
170
|
+
return _browser
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@asynccontextmanager
|
|
174
|
+
async def _lifespan(_server):
|
|
175
|
+
"""Pre-warm the browser so the agent's first tool call is warm, not a cold launch."""
|
|
176
|
+
if _env("MCP_PREWARM", "1") != "0":
|
|
177
|
+
try:
|
|
178
|
+
await _b()
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
try:
|
|
182
|
+
yield
|
|
183
|
+
finally:
|
|
184
|
+
global _browser
|
|
185
|
+
if _browser is not None:
|
|
186
|
+
try:
|
|
187
|
+
await _browser.close()
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
_browser = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
mcp = FastMCP("Clearcote Stealth Browser", lifespan=_lifespan)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── tools ─────────────────────────────────────────────────────────────────────
|
|
197
|
+
@mcp.tool(annotations=_WRITE)
|
|
198
|
+
@_safe
|
|
199
|
+
async def navigate(url: str) -> dict:
|
|
200
|
+
"""Navigate the current tab to a URL. Returns the resolved url + title."""
|
|
201
|
+
await _check_url(url)
|
|
202
|
+
return await (await _b()).navigate(url)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@mcp.tool(annotations=_READ)
|
|
206
|
+
@_safe
|
|
207
|
+
async def read_page(url: str | None = None) -> dict:
|
|
208
|
+
"""Read the current page (or navigate to `url` first) as clean text + Markdown."""
|
|
209
|
+
await _check_url(url)
|
|
210
|
+
return _cap(await (await _b()).read_page(url), {"text": 20000, "markdown": 40000})
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@mcp.tool(annotations=_READ)
|
|
214
|
+
@_safe
|
|
215
|
+
async def get_page_html(url: str | None = None) -> dict:
|
|
216
|
+
"""Get the raw HTML of the current page (or navigate to `url` first)."""
|
|
217
|
+
await _check_url(url)
|
|
218
|
+
return _cap(await (await _b()).get_html(url), {"html": 80000})
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@mcp.tool(annotations=_READ)
|
|
222
|
+
@_safe
|
|
223
|
+
async def page_elements(url: str | None = None) -> dict:
|
|
224
|
+
"""List the interactive elements (links, buttons, inputs) on the page, each with a selector."""
|
|
225
|
+
await _check_url(url)
|
|
226
|
+
return await (await _b()).page_elements(url)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@mcp.tool(annotations=_WRITE)
|
|
230
|
+
@_safe
|
|
231
|
+
async def click(target: str, url: str | None = None) -> dict:
|
|
232
|
+
"""Click an element by CSS selector or by visible text (navigate to `url` first if given)."""
|
|
233
|
+
await _check_url(url)
|
|
234
|
+
return await (await _b()).click(target, url)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@mcp.tool(annotations=_WRITE)
|
|
238
|
+
@_safe
|
|
239
|
+
async def fill_field(field: str, value: str, url: str | None = None) -> dict:
|
|
240
|
+
"""Fill an input matched by selector, label, placeholder, or name."""
|
|
241
|
+
await _check_url(url)
|
|
242
|
+
return await (await _b()).fill(field, value, url)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@mcp.tool(annotations=_WRITE)
|
|
246
|
+
@_safe
|
|
247
|
+
async def press_key(key: str) -> dict:
|
|
248
|
+
"""Press a key on the current page, e.g. 'Enter', 'Tab', 'Control+A'."""
|
|
249
|
+
return await (await _b()).press(key)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@mcp.tool(annotations=_READ)
|
|
253
|
+
@_safe
|
|
254
|
+
async def evaluate_js(expression: str, url: str | None = None) -> dict:
|
|
255
|
+
"""Evaluate a JavaScript expression in the page and return the (JSON-serializable) result."""
|
|
256
|
+
await _check_url(url)
|
|
257
|
+
return await (await _b()).evaluate(expression, url)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@mcp.tool(annotations=_READ)
|
|
261
|
+
@_safe
|
|
262
|
+
async def wait_for(selector: str, url: str | None = None, timeout_ms: int = 10000) -> dict:
|
|
263
|
+
"""Wait until a selector appears (up to timeout_ms)."""
|
|
264
|
+
await _check_url(url)
|
|
265
|
+
return await (await _b()).wait_for(selector, url, timeout_ms)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@mcp.tool(annotations=_READ)
|
|
269
|
+
@_safe
|
|
270
|
+
async def current_page() -> dict:
|
|
271
|
+
"""Return the current tab's url + title."""
|
|
272
|
+
return await (await _b()).current_page()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@mcp.tool(annotations=_WRITE)
|
|
276
|
+
@_safe
|
|
277
|
+
async def screenshot_page(url: str | None = None, path: str | None = None) -> dict:
|
|
278
|
+
"""Screenshot the current page (or navigate to `url` first). Saved under the sandbox dir."""
|
|
279
|
+
await _check_url(url)
|
|
280
|
+
return await (await _b()).screenshot(_confine_path(path, ".png"), url)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@mcp.tool(annotations=_WRITE)
|
|
284
|
+
@_safe
|
|
285
|
+
async def save_page_pdf(url: str | None = None, path: str | None = None) -> dict:
|
|
286
|
+
"""Save the current page as a PDF (headless only). Saved under the sandbox dir."""
|
|
287
|
+
await _check_url(url)
|
|
288
|
+
return await (await _b()).save_pdf(_confine_path(path, ".pdf"), url)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@mcp.tool(annotations=_READ)
|
|
292
|
+
@_safe
|
|
293
|
+
async def get_cookies(url: str | None = None) -> dict:
|
|
294
|
+
"""Get cookies for the current context (optionally filtered to `url`)."""
|
|
295
|
+
await _check_url(url)
|
|
296
|
+
return await (await _b()).cookies(url)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@mcp.tool(annotations=_LOCAL)
|
|
300
|
+
@_safe
|
|
301
|
+
async def list_tabs() -> dict:
|
|
302
|
+
"""List open tabs (index, url, title, which is current)."""
|
|
303
|
+
return await (await _b()).list_tabs()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@mcp.tool(annotations=_WRITE)
|
|
307
|
+
@_safe
|
|
308
|
+
async def new_tab(url: str | None = None) -> dict:
|
|
309
|
+
"""Open a new tab (optionally navigate it) and make it current."""
|
|
310
|
+
await _check_url(url)
|
|
311
|
+
return await (await _b()).new_tab(url)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@mcp.tool(annotations=_WRITE)
|
|
315
|
+
@_safe
|
|
316
|
+
async def close_tab(index: int) -> dict:
|
|
317
|
+
"""Close the tab at `index` (from list_tabs)."""
|
|
318
|
+
return await (await _b()).close_tab(index)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@mcp.tool(annotations=_WRITE)
|
|
322
|
+
@_safe
|
|
323
|
+
async def save_profile(name: str = "session") -> dict:
|
|
324
|
+
"""Save cookies + storage state to a named profile under the sandbox dir."""
|
|
325
|
+
return await (await _b()).save_profile(_confine_path(name, ".json"))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@mcp.tool(annotations=_WRITE)
|
|
329
|
+
@_safe
|
|
330
|
+
async def load_profile(name: str = "session") -> dict:
|
|
331
|
+
"""Restore cookies + storage from a named profile (see save_profile)."""
|
|
332
|
+
return await (await _b()).load_profile(_confine_path(name, ".json"))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@mcp.tool(annotations=_LOCAL)
|
|
336
|
+
@_safe
|
|
337
|
+
async def get_egress_info() -> dict:
|
|
338
|
+
"""Report the browser's public egress IP (through any configured proxy) + the active persona."""
|
|
339
|
+
return await (await _b()).egress_info()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@mcp.tool(annotations=_LOCAL)
|
|
343
|
+
@_safe
|
|
344
|
+
async def get_cdp_endpoint() -> dict:
|
|
345
|
+
"""Return the stealth browser's raw CDP endpoint so ANY other client (Playwright / Puppeteer /
|
|
346
|
+
browser-use / Crawl4AI / Stagehand) can attach to the SAME browser with `connect_over_cdp`,
|
|
347
|
+
keeping the stealth persona. This is the whole point of the drop-in model."""
|
|
348
|
+
b = await _b()
|
|
349
|
+
return {"status": "ok", "cdp_url": b.cdp_url,
|
|
350
|
+
"connect": {
|
|
351
|
+
"playwright_python": f"p.chromium.connect_over_cdp({b.cdp_url!r})",
|
|
352
|
+
"playwright_node": f'chromium.connectOverCDP("{b.cdp_url}")',
|
|
353
|
+
"puppeteer": f'puppeteer.connect({{ browserURL: "{b.cdp_url}" }})',
|
|
354
|
+
"browser_use": f'cdp_url="{b.cdp_url}"'}}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def main() -> None:
|
|
358
|
+
"""stdio MCP entry point (used by `clearcote-mcp` and `python -m clearcote_mcp`)."""
|
|
359
|
+
try:
|
|
360
|
+
mcp.run()
|
|
361
|
+
except KeyboardInterrupt:
|
|
362
|
+
sys.exit(0)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
if __name__ == "__main__":
|
|
366
|
+
main()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clearcote-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Clearcote — drive the open-source stealth Chromium from Claude Desktop, Cursor, Cline, and any MCP client.
|
|
5
|
+
License: BSD-3-Clause
|
|
6
|
+
Project-URL: Homepage, https://github.com/clearcotelabs/clearcote-browser
|
|
7
|
+
Project-URL: Source, https://github.com/clearcotelabs/clearcote-browser/tree/main/mcp
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: clearcote>=0.13
|
|
11
|
+
Requires-Dist: mcp[cli]>=1.2
|
|
12
|
+
Requires-Dist: markdownify>=0.11
|
|
13
|
+
|
|
14
|
+
# Clearcote MCP server
|
|
15
|
+
|
|
16
|
+
Drive the open-source **Clearcote stealth Chromium** from any MCP client — Claude Desktop, Cursor,
|
|
17
|
+
Cline, Continue, or your own agent. One shared, coherent stealth browser; ~20 tools to navigate,
|
|
18
|
+
read, extract, click, fill, screenshot, and persist sessions — plus `get_cdp_endpoint`, which hands
|
|
19
|
+
the **same** stealth browser to any Playwright / Puppeteer / browser-use / Crawl4AI client.
|
|
20
|
+
|
|
21
|
+
The fingerprint is corrected in Chromium's **C++**, so you do **not** add `puppeteer-stealth` /
|
|
22
|
+
`undetected-chromedriver` / any JS patching — those self-reveal and undo it. Driving over CDP adds
|
|
23
|
+
no automation flags, so `navigator.webdriver` stays `false` and the persona is intact end to end.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Run it
|
|
28
|
+
|
|
29
|
+
**Node (no install):**
|
|
30
|
+
```bash
|
|
31
|
+
npx clearcote-mcp
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Python:**
|
|
35
|
+
```bash
|
|
36
|
+
pip install clearcote-mcp
|
|
37
|
+
clearcote-mcp # stdio server
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Both auto-download + SHA-256-verify the right Clearcote binary per OS on first use (native Windows
|
|
41
|
+
x64 + Linux x64).
|
|
42
|
+
|
|
43
|
+
## Add it to a client
|
|
44
|
+
|
|
45
|
+
Claude Desktop / Cursor / Cline `mcpServers` config:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"clearcote": {
|
|
51
|
+
"command": "npx",
|
|
52
|
+
"args": ["-y", "clearcote-mcp"],
|
|
53
|
+
"env": {
|
|
54
|
+
"CLEARCOTE_FINGERPRINT": "acct-1",
|
|
55
|
+
"CLEARCOTE_PLATFORM": "windows",
|
|
56
|
+
"CLEARCOTE_PROXY": "http://user:pass@host:port",
|
|
57
|
+
"CLEARCOTE_GEOIP": "1"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The **persona lives in the environment**, so the tool surface stays clean:
|
|
65
|
+
|
|
66
|
+
| env var | meaning |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `CLEARCOTE_FINGERPRINT` | seed → one stable, coherent identity (same seed = same machine across runs) |
|
|
69
|
+
| `CLEARCOTE_PLATFORM` | `windows` \| `linux` \| `macos` \| `android` |
|
|
70
|
+
| `CLEARCOTE_BRAND` | `Chrome` \| `Edge` \| `Opera` \| `Vivaldi` |
|
|
71
|
+
| `CLEARCOTE_PROXY` | `http://user:pass@host:port` (routes all traffic) |
|
|
72
|
+
| `CLEARCOTE_GEOIP` | `1` → derive timezone/locale/WebRTC IP from the proxy exit IP |
|
|
73
|
+
| `CLEARCOTE_TIMEZONE` / `CLEARCOTE_ACCEPT_LANGUAGE` | explicit overrides |
|
|
74
|
+
| `CLEARCOTE_HEADLESS` | `0` for a visible window (default headless) |
|
|
75
|
+
| `CLEARCOTE_BINARY` | path to a specific Clearcote binary (optional) |
|
|
76
|
+
|
|
77
|
+
Hardening knobs: `CLEARCOTE_MCP_TOOL_TIMEOUT` (s), `CLEARCOTE_MCP_WRITE_DIR` (sandbox for file
|
|
78
|
+
writes), `CLEARCOTE_MCP_ALLOW_ANY_PATH=1`, `CLEARCOTE_ALLOW_PRIVATE_EGRESS=1` (allow localhost /
|
|
79
|
+
private targets), `CLEARCOTE_MCP_PREWARM=0`, `CLEARCOTE_SERVE_PORT`.
|
|
80
|
+
|
|
81
|
+
## Tools
|
|
82
|
+
|
|
83
|
+
**Read** · `read_page` (text + Markdown) · `get_page_html` · `page_elements` (interactive elements +
|
|
84
|
+
selectors) · `evaluate_js` · `wait_for` · `current_page` · `get_cookies` · `list_tabs`
|
|
85
|
+
**Act** · `navigate` · `click` (selector or visible text) · `fill_field` (selector/label/placeholder/name)
|
|
86
|
+
· `press_key` · `new_tab` · `close_tab`
|
|
87
|
+
**Capture** · `screenshot_page` · `save_page_pdf`
|
|
88
|
+
**Session** · `save_profile` / `load_profile` (cookies + storage)
|
|
89
|
+
**Stealth / infra** · `get_egress_info` (public IP + active persona) · **`get_cdp_endpoint`** (attach any
|
|
90
|
+
other CDP client to the same stealth browser)
|
|
91
|
+
|
|
92
|
+
## Guardrails (built in)
|
|
93
|
+
|
|
94
|
+
- Every tool has a wall-clock timeout and returns a **structured error** instead of crashing the server.
|
|
95
|
+
- URL args are **SSRF-checked** — localhost / private / cloud-metadata are refused unless you opt in.
|
|
96
|
+
- File writes are **confined** to a sandbox dir (no path traversal).
|
|
97
|
+
- Oversized text is **capped** so a response never floods the agent's context.
|
|
98
|
+
- The shared browser is **rebuilt** automatically if it dies.
|
|
99
|
+
|
|
100
|
+
## Just want the raw endpoint?
|
|
101
|
+
|
|
102
|
+
If you don't need the tools, run the browser as a standing CDP endpoint and attach your existing code:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
clearcote-serve --port 9222 --fingerprint acct-1 --platform windows
|
|
106
|
+
# → prints http://127.0.0.1:9222 ; then: connect_over_cdp / puppeteer.connect({browserURL})
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
See [USAGE.md](USAGE.md) for per-client examples. Part of
|
|
110
|
+
[clearcote-browser](https://github.com/clearcotelabs/clearcote-browser) (BSD-3).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
clearcote_mcp/__init__.py
|
|
4
|
+
clearcote_mcp/__main__.py
|
|
5
|
+
clearcote_mcp/_facade.py
|
|
6
|
+
clearcote_mcp/server.py
|
|
7
|
+
clearcote_mcp.egg-info/PKG-INFO
|
|
8
|
+
clearcote_mcp.egg-info/SOURCES.txt
|
|
9
|
+
clearcote_mcp.egg-info/dependency_links.txt
|
|
10
|
+
clearcote_mcp.egg-info/entry_points.txt
|
|
11
|
+
clearcote_mcp.egg-info/requires.txt
|
|
12
|
+
clearcote_mcp.egg-info/top_level.txt
|
|
13
|
+
tests/test_server.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
clearcote_mcp
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "clearcote-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for Clearcote — drive the open-source stealth Chromium from Claude Desktop, Cursor, Cline, and any MCP client."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "BSD-3-Clause" }
|
|
12
|
+
dependencies = [
|
|
13
|
+
"clearcote>=0.13",
|
|
14
|
+
"mcp[cli]>=1.2",
|
|
15
|
+
"markdownify>=0.11",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
clearcote-mcp = "clearcote_mcp.server:main"
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/clearcotelabs/clearcote-browser"
|
|
23
|
+
Source = "https://github.com/clearcotelabs/clearcote-browser/tree/main/mcp"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
packages = ["clearcote_mcp"]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pytest
|
|
3
|
+
from clearcote_mcp import server as S
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.mark.asyncio
|
|
7
|
+
async def test_all_tools_registered():
|
|
8
|
+
tools = await S.mcp.list_tools()
|
|
9
|
+
names = {t.name for t in tools}
|
|
10
|
+
assert {"navigate", "read_page", "get_page_html", "page_elements", "click",
|
|
11
|
+
"fill_field", "press_key", "evaluate_js", "wait_for", "current_page",
|
|
12
|
+
"screenshot_page", "save_page_pdf", "get_cookies", "list_tabs", "new_tab",
|
|
13
|
+
"close_tab", "save_profile", "load_profile", "get_egress_info",
|
|
14
|
+
"get_cdp_endpoint"} <= names
|
|
15
|
+
assert len(tools) >= 20
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_ip_blocked():
|
|
19
|
+
assert S._ip_blocked("127.0.0.1")
|
|
20
|
+
assert S._ip_blocked("169.254.169.254") # cloud metadata
|
|
21
|
+
assert S._ip_blocked("10.0.0.5")
|
|
22
|
+
assert S._ip_blocked("::1")
|
|
23
|
+
assert not S._ip_blocked("8.8.8.8")
|
|
24
|
+
assert not S._ip_blocked("not-an-ip")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.asyncio
|
|
28
|
+
async def test_check_url_ssrf_guard(monkeypatch):
|
|
29
|
+
monkeypatch.delenv("CLEARCOTE_ALLOW_PRIVATE_EGRESS", raising=False)
|
|
30
|
+
with pytest.raises(ValueError):
|
|
31
|
+
await S._check_url("http://localhost/x")
|
|
32
|
+
with pytest.raises(ValueError):
|
|
33
|
+
await S._check_url("http://169.254.169.254/latest/meta-data/")
|
|
34
|
+
await S._check_url("https://example.com") # public → ok
|
|
35
|
+
await S._check_url(None) # no url → ok
|
|
36
|
+
monkeypatch.setenv("CLEARCOTE_ALLOW_PRIVATE_EGRESS", "1")
|
|
37
|
+
await S._check_url("http://localhost/x") # opted in → ok
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_confine_path_blocks_traversal(tmp_path, monkeypatch):
|
|
41
|
+
monkeypatch.setenv("CLEARCOTE_MCP_WRITE_DIR", str(tmp_path))
|
|
42
|
+
monkeypatch.delenv("CLEARCOTE_MCP_ALLOW_ANY_PATH", raising=False)
|
|
43
|
+
p = S._confine_path("../../etc/passwd", ".png")
|
|
44
|
+
assert os.path.dirname(p) == str(tmp_path) # confined to sandbox root
|
|
45
|
+
assert os.path.basename(p) == "passwd.png" # traversal + missing ext handled
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_cap_trims_and_flags():
|
|
49
|
+
out = S._cap({"text": "x" * 100, "small": "ok"}, {"text": 10})
|
|
50
|
+
assert out["text"] == "x" * 10 and out["text_truncated"] is True
|
|
51
|
+
assert out["small"] == "ok" and "small_truncated" not in out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_persona_from_env(monkeypatch):
|
|
55
|
+
for k in ("FINGERPRINT", "PLATFORM", "GEOIP", "HEADLESS", "PROXY", "BINARY"):
|
|
56
|
+
monkeypatch.delenv("CLEARCOTE_" + k, raising=False)
|
|
57
|
+
monkeypatch.setenv("CLEARCOTE_FINGERPRINT", "seed-9")
|
|
58
|
+
monkeypatch.setenv("CLEARCOTE_PLATFORM", "windows")
|
|
59
|
+
monkeypatch.setenv("CLEARCOTE_HEADLESS", "0")
|
|
60
|
+
monkeypatch.setenv("CLEARCOTE_PROXY", "http://u:p@h:8080")
|
|
61
|
+
p = S._persona_from_env()
|
|
62
|
+
assert p["fingerprint"] == "seed-9" and p["platform"] == "windows"
|
|
63
|
+
assert p["headless"] is False
|
|
64
|
+
assert p["proxy"] == {"server": "http://h:8080", "username": "u", "password": "p"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.asyncio
|
|
68
|
+
async def test_safe_returns_structured_error():
|
|
69
|
+
@S._safe
|
|
70
|
+
async def boom():
|
|
71
|
+
raise RuntimeError("nope")
|
|
72
|
+
r = await boom()
|
|
73
|
+
assert r["status"] == "error" and "RuntimeError" in r["error"]
|