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.
@@ -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,5 @@
1
+ """Clearcote MCP server — drive the open-source stealth Chromium from any MCP client."""
2
+ from .server import main, mcp
3
+
4
+ __all__ = ["main", "mcp"]
5
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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,2 @@
1
+ [console_scripts]
2
+ clearcote-mcp = clearcote_mcp.server:main
@@ -0,0 +1,3 @@
1
+ clearcote>=0.13
2
+ mcp[cli]>=1.2
3
+ markdownify>=0.11
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]