Skip to content

Commit 6f04243

Browse files
authored
feat: add MCP executable path support (#360)
2 parents 2e0c7b0 + 94ed7c8 commit 6f04243

6 files changed

Lines changed: 183 additions & 6 deletions

File tree

docs/ai/mcp-server.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,35 @@ Here's the main article from Anthropic on [how to add MCP servers to Claude code
154154

155155
Then, after you've added the server, you need to completely quit and restart the app you used above. In Claude Desktop, you should see an MCP server indicator (🔧) in the bottom-right corner of the chat input or see `ScraplingServer` in the `Search and tools` dropdown in the chat input box.
156156

157+
### Custom Browser Executable
158+
159+
Browser-based tools (`fetch`, `bulk_fetch`, `stealthy_fetch`, `bulk_stealthy_fetch`, and `open_session`) can use a custom Chromium-compatible browser executable instead of the bundled Chromium. This is useful for custom browser builds or lightweight browser engines.
160+
161+
To configure it once for the whole MCP server, pass the executable path when starting the server:
162+
163+
```bash
164+
scrapling mcp --executable-path "/path/to/chromium"
165+
```
166+
167+
In a Claude Desktop configuration, add the option to the server arguments:
168+
169+
```json
170+
{
171+
"mcpServers": {
172+
"ScraplingServer": {
173+
"command": "/Users/<MyUsername>/.venv/bin/scrapling",
174+
"args": [
175+
"mcp",
176+
"--executable-path",
177+
"/path/to/chromium"
178+
]
179+
}
180+
}
181+
}
182+
```
183+
184+
You can also set the `SCRAPLING_EXECUTABLE_PATH` environment variable before starting the server. Tool calls can still pass `executable_path` directly when a single request or session needs a different browser executable.
185+
157186
### Streamable HTTP
158187
As per version 0.3.6, we have added the ability to make the MCP server use the 'Streamable HTTP' transport mode instead of the traditional 'stdio' transport.
159188

@@ -356,4 +385,4 @@ This protection runs automatically on all MCP tool responses. Keep `main_content
356385
357386
---
358387
359-
*Built with ❤️ by the Scrapling team. Happy scraping!*
388+
*Built with ❤️ by the Scrapling team. Happy scraping!*

docs/api-reference/mcp-server.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ server = ScraplingMCPServer()
2222
server.serve(http=False, host="0.0.0.0", port=8000)
2323
```
2424

25+
To set a custom Chromium-compatible browser executable for browser-based MCP tools, pass `executable_path`:
26+
27+
```python
28+
server = ScraplingMCPServer(executable_path="/path/to/chromium")
29+
```
30+
2531
## Response Model
2632

2733
The standardized response structure that's returned by all MCP server tools:
@@ -52,4 +58,4 @@ The main MCP server class that provides all web scraping tools:
5258

5359
## ::: scrapling.core.ai.ScraplingMCPServer
5460
handler: python
55-
:docstring:
61+
:docstring:

scrapling/cli.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,16 @@ def install(force): # pragma: no cover
157157
@option(
158158
"--port", type=int, default=8000, help="The port to use if streamable-http transport is enabled (Default: 8000)"
159159
)
160-
def mcp(http, host, port):
160+
@option(
161+
"--executable-path",
162+
type=str,
163+
default=None,
164+
help="Path to a custom Chromium-compatible browser executable for browser-based MCP tools",
165+
)
166+
def mcp(http, host, port, executable_path):
161167
from scrapling.core.ai import ScraplingMCPServer
162168

163-
server = ScraplingMCPServer()
169+
server = ScraplingMCPServer(executable_path=executable_path)
164170
server.serve(http, host, port)
165171

166172

scrapling/core/ai.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from uuid import uuid4
2+
from os import environ
23
from asyncio import gather
34
from datetime import datetime, timezone
45
from dataclasses import dataclass, field
@@ -33,6 +34,7 @@
3334

3435
SessionType = Literal["dynamic", "stealthy"]
3536
ScreenshotType = Literal["png", "jpeg"]
37+
MCP_EXECUTABLE_PATH_ENV = "SCRAPLING_EXECUTABLE_PATH"
3638

3739

3840
class ResponseModel(BaseModel):
@@ -105,8 +107,18 @@ def _normalize_credentials(credentials: Optional[Dict[str, str]]) -> Optional[Tu
105107

106108

107109
class ScraplingMCPServer:
108-
def __init__(self):
110+
def __init__(self, executable_path: Optional[str] = None):
111+
"""Create a Scrapling MCP server.
112+
113+
:param executable_path: Optional global Chromium-compatible browser executable path for browser tools.
114+
If omitted, the SCRAPLING_EXECUTABLE_PATH environment variable is used when set.
115+
"""
109116
self._sessions: Dict[str, _SessionEntry] = {}
117+
self._executable_path = executable_path or environ.get(MCP_EXECUTABLE_PATH_ENV) or None
118+
119+
def _resolve_executable_path(self, executable_path: Optional[str]) -> Optional[str]:
120+
"""Return a per-call executable path or the server-wide default."""
121+
return executable_path or self._executable_path
110122

111123
def _get_session(self, session_id: str, expected_type: Optional[SessionType]) -> _SessionEntry:
112124
"""Look up a session by ID, optionally validating its type. Pass `None` to skip the type check."""
@@ -136,6 +148,7 @@ async def open_session(
136148
extra_headers: Optional[Dict[str, str]] = None,
137149
useragent: Optional[str] = None,
138150
cdp_url: Optional[str] = None,
151+
executable_path: Optional[str] = None,
139152
timeout: int | float = 30000,
140153
disable_resources: bool = False,
141154
wait_selector: Optional[str] = None,
@@ -166,6 +179,7 @@ async def open_session(
166179
:param extra_headers: A dictionary of extra headers to add to the request.
167180
:param useragent: Pass a useragent string to be used. Otherwise the fetcher will generate a real Useragent of the same browser and use it.
168181
:param cdp_url: Instead of launching a new browser instance, connect to this CDP URL to control real browsers through CDP.
182+
:param executable_path: Absolute path to a custom Chromium-compatible browser executable. Overrides the server-wide default for this session.
169183
:param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000.
170184
:param disable_resources: Drop requests for unnecessary resources for a speed boost.
171185
:param wait_selector: Wait for a specific CSS selector to be in a specific state.
@@ -202,6 +216,7 @@ async def open_session(
202216
wait_selector=wait_selector,
203217
google_search=google_search,
204218
extra_headers=extra_headers,
219+
executable_path=self._resolve_executable_path(executable_path),
205220
disable_resources=disable_resources,
206221
wait_selector_state=wait_selector_state,
207222
)
@@ -494,6 +509,7 @@ async def fetch(
494509
extra_headers: Optional[Dict[str, str]] = None,
495510
useragent: Optional[str] = None,
496511
cdp_url: Optional[str] = None,
512+
executable_path: Optional[str] = None,
497513
timeout: int | float = 30000,
498514
disable_resources: bool = False,
499515
wait_selector: Optional[str] = None,
@@ -530,6 +546,7 @@ async def fetch(
530546
:param wait_selector_state: The state to wait for the selector given with `wait_selector`. The default state is `attached`.
531547
:param real_chrome: If you have a Chrome browser installed on your device, enable this, and the Fetcher will launch an instance of your browser and use it.
532548
:param cdp_url: Instead of launching a new browser instance, connect to this CDP URL to control real browsers through CDP.
549+
:param executable_path: Absolute path to a custom Chromium-compatible browser executable. Overrides the server-wide default for this request.
533550
:param google_search: Enabled by default, Scrapling will set a Google referer header.
534551
:param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._
535552
:param proxy: The proxy to be used with requests, it can be a string or a dictionary with the keys 'server', 'username', and 'password' only.
@@ -550,6 +567,7 @@ async def fetch(
550567
extra_headers=extra_headers,
551568
useragent=useragent,
552569
cdp_url=cdp_url,
570+
executable_path=executable_path,
553571
timeout=timeout,
554572
disable_resources=disable_resources,
555573
wait_selector=wait_selector,
@@ -576,6 +594,7 @@ async def bulk_fetch(
576594
extra_headers: Optional[Dict[str, str]] = None,
577595
useragent: Optional[str] = None,
578596
cdp_url: Optional[str] = None,
597+
executable_path: Optional[str] = None,
579598
timeout: int | float = 30000,
580599
disable_resources: bool = False,
581600
wait_selector: Optional[str] = None,
@@ -612,6 +631,7 @@ async def bulk_fetch(
612631
:param wait_selector_state: The state to wait for the selector given with `wait_selector`. The default state is `attached`.
613632
:param real_chrome: If you have a Chrome browser installed on your device, enable this, and the Fetcher will launch an instance of your browser and use it.
614633
:param cdp_url: Instead of launching a new browser instance, connect to this CDP URL to control real browsers through CDP.
634+
:param executable_path: Absolute path to a custom Chromium-compatible browser executable. Overrides the server-wide default for this request.
615635
:param google_search: Enabled by default, Scrapling will set a Google referer header.
616636
:param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._
617637
:param proxy: The proxy to be used with requests, it can be a string or a dictionary with the keys 'server', 'username', and 'password' only.
@@ -653,6 +673,7 @@ async def bulk_fetch(
653673
wait_selector=wait_selector,
654674
google_search=google_search,
655675
extra_headers=extra_headers,
676+
executable_path=self._resolve_executable_path(executable_path),
656677
disable_resources=disable_resources,
657678
wait_selector_state=wait_selector_state,
658679
) as session:
@@ -678,6 +699,7 @@ async def stealthy_fetch(
678699
useragent: Optional[str] = None,
679700
hide_canvas: bool = False,
680701
cdp_url: Optional[str] = None,
702+
executable_path: Optional[str] = None,
681703
timeout: int | float = 30000,
682704
disable_resources: bool = False,
683705
wait_selector: Optional[str] = None,
@@ -722,6 +744,7 @@ async def stealthy_fetch(
722744
:param hide_canvas: Add random noise to canvas operations to prevent fingerprinting.
723745
:param block_webrtc: Forces WebRTC to respect proxy settings to prevent local IP address leak.
724746
:param cdp_url: Instead of launching a new browser instance, connect to this CDP URL to control real browsers through CDP.
747+
:param executable_path: Absolute path to a custom Chromium-compatible browser executable. Overrides the server-wide default for this request.
725748
:param google_search: Enabled by default, Scrapling will set a Google referer header.
726749
:param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._
727750
:param proxy: The proxy to be used with requests, it can be a string or a dictionary with the keys 'server', 'username', and 'password' only.
@@ -744,6 +767,7 @@ async def stealthy_fetch(
744767
useragent=useragent,
745768
hide_canvas=hide_canvas,
746769
cdp_url=cdp_url,
770+
executable_path=executable_path,
747771
timeout=timeout,
748772
disable_resources=disable_resources,
749773
wait_selector=wait_selector,
@@ -775,6 +799,7 @@ async def bulk_stealthy_fetch(
775799
useragent: Optional[str] = None,
776800
hide_canvas: bool = False,
777801
cdp_url: Optional[str] = None,
802+
executable_path: Optional[str] = None,
778803
timeout: int | float = 30000,
779804
disable_resources: bool = False,
780805
wait_selector: Optional[str] = None,
@@ -819,6 +844,7 @@ async def bulk_stealthy_fetch(
819844
:param hide_canvas: Add random noise to canvas operations to prevent fingerprinting.
820845
:param block_webrtc: Forces WebRTC to respect proxy settings to prevent local IP address leak.
821846
:param cdp_url: Instead of launching a new browser instance, connect to this CDP URL to control real browsers through CDP.
847+
:param executable_path: Absolute path to a custom Chromium-compatible browser executable. Overrides the server-wide default for this request.
822848
:param google_search: Enabled by default, Scrapling will set a Google referer header.
823849
:param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._
824850
:param proxy: The proxy to be used with requests, it can be a string or a dictionary with the keys 'server', 'username', and 'password' only.
@@ -864,6 +890,7 @@ async def bulk_stealthy_fetch(
864890
wait_selector=wait_selector,
865891
google_search=google_search,
866892
extra_headers=extra_headers,
893+
executable_path=self._resolve_executable_path(executable_path),
867894
additional_args=additional_args,
868895
solve_cloudflare=solve_cloudflare,
869896
disable_resources=disable_resources,

tests/ai/test_ai_mcp.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import base64
22
import struct
3+
from typing import Any
34
from contextlib import contextmanager
45
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
56
from threading import Thread
@@ -16,6 +17,48 @@
1617
SessionClosedModel,
1718
_normalize_credentials,
1819
)
20+
from scrapling.engines.toolbelt.custom import Response
21+
22+
23+
class _FakeAsyncBrowserSession:
24+
instances: list["_FakeAsyncBrowserSession"] = []
25+
26+
def __init__(self, **kwargs: Any) -> None:
27+
self.kwargs = kwargs
28+
self._is_alive = False
29+
type(self).instances.append(self)
30+
31+
async def __aenter__(self):
32+
self._is_alive = True
33+
return self
34+
35+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
36+
self._is_alive = False
37+
38+
async def start(self) -> None:
39+
self._is_alive = True
40+
41+
async def close(self) -> None:
42+
self._is_alive = False
43+
44+
async def fetch(self, url: str, **kwargs: Any) -> Response:
45+
return Response(
46+
url=url,
47+
content="<html><body>ok</body></html>",
48+
status=200,
49+
reason="OK",
50+
cookies={},
51+
headers={},
52+
request_headers={},
53+
)
54+
55+
56+
class _FakeDynamicSession(_FakeAsyncBrowserSession):
57+
instances = []
58+
59+
60+
class _FakeStealthySession(_FakeAsyncBrowserSession):
61+
instances = []
1962

2063

2164
@pytest_httpbin.use_class_based_httpbin
@@ -204,6 +247,60 @@ async def test_open_session_duplicate_id_raises(self, server):
204247
await server.close_session("dupe")
205248

206249

250+
class TestExecutablePath:
251+
"""Test custom browser executable path plumbing in the MCP browser tools"""
252+
253+
@pytest.fixture(autouse=True)
254+
def reset_fakes(self):
255+
_FakeDynamicSession.instances = []
256+
_FakeStealthySession.instances = []
257+
258+
@pytest.mark.asyncio
259+
async def test_open_session_passes_executable_path(self, monkeypatch):
260+
"""open_session forwards per-session executable_path to the dynamic session"""
261+
monkeypatch.setattr("scrapling.core.ai.AsyncDynamicSession", _FakeDynamicSession)
262+
server = ScraplingMCPServer()
263+
264+
created = await server.open_session(session_type="dynamic", executable_path="/tmp/chrome")
265+
266+
assert _FakeDynamicSession.instances[0].kwargs["executable_path"] == "/tmp/chrome"
267+
await server.close_session(created.session_id)
268+
269+
@pytest.mark.asyncio
270+
async def test_open_session_uses_environment_default(self, monkeypatch):
271+
"""open_session uses SCRAPLING_EXECUTABLE_PATH when no per-call value is provided"""
272+
monkeypatch.setenv("SCRAPLING_EXECUTABLE_PATH", "/opt/custom-chromium")
273+
monkeypatch.setattr("scrapling.core.ai.AsyncStealthySession", _FakeStealthySession)
274+
server = ScraplingMCPServer()
275+
276+
created = await server.open_session(session_type="stealthy")
277+
278+
assert _FakeStealthySession.instances[0].kwargs["executable_path"] == "/opt/custom-chromium"
279+
await server.close_session(created.session_id)
280+
281+
@pytest.mark.asyncio
282+
async def test_fetch_overrides_global_executable_path(self, monkeypatch):
283+
"""fetch forwards a per-call executable_path instead of the server default"""
284+
monkeypatch.setattr("scrapling.core.ai.AsyncDynamicSession", _FakeDynamicSession)
285+
server = ScraplingMCPServer(executable_path="/opt/default-chromium")
286+
287+
result = await server.fetch(url="https://example.com", executable_path="/opt/request-chromium")
288+
289+
assert isinstance(result, ResponseModel)
290+
assert _FakeDynamicSession.instances[0].kwargs["executable_path"] == "/opt/request-chromium"
291+
292+
@pytest.mark.asyncio
293+
async def test_stealthy_fetch_uses_global_executable_path(self, monkeypatch):
294+
"""stealthy_fetch forwards the server executable_path default"""
295+
monkeypatch.setattr("scrapling.core.ai.AsyncStealthySession", _FakeStealthySession)
296+
server = ScraplingMCPServer(executable_path="/opt/default-chromium")
297+
298+
result = await server.stealthy_fetch(url="https://example.com")
299+
300+
assert isinstance(result, ResponseModel)
301+
assert _FakeStealthySession.instances[0].kwargs["executable_path"] == "/opt/default-chromium"
302+
303+
207304
def _png_height(data: bytes) -> int:
208305
"""Read the height field from a PNG IHDR chunk."""
209306
return struct.unpack(">I", data[20:24])[0]

tests/cli/test_cli.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,19 @@ def test_mcp_command(self, runner):
5757

5858
result = runner.invoke(mcp)
5959
assert result.exit_code == 0
60-
mock_instance.serve.assert_called_once()
60+
mock_server.assert_called_once_with(executable_path=None)
61+
mock_instance.serve.assert_called_once_with(False, "0.0.0.0", 8000)
62+
63+
def test_mcp_command_with_executable_path(self, runner):
64+
"""Test MCP command with a custom browser executable"""
65+
with patch('scrapling.core.ai.ScraplingMCPServer') as mock_server:
66+
mock_instance = MagicMock()
67+
mock_server.return_value = mock_instance
68+
69+
result = runner.invoke(mcp, ['--executable-path', '/opt/custom-chromium'])
70+
assert result.exit_code == 0
71+
mock_server.assert_called_once_with(executable_path='/opt/custom-chromium')
72+
mock_instance.serve.assert_called_once_with(False, "0.0.0.0", 8000)
6173

6274
def test_extract_get_command(self, runner, tmp_path, html_url):
6375
"""Test extract `get` command"""

0 commit comments

Comments
 (0)