Add optional stateless MCP server for the real browser

Expose a conservative browser-cli tool surface to MCP hosts without
duplicating browser-control logic: every tool is a thin adapter over the
existing Python SDK, and each call builds a fresh BrowserCLI so the real
browser stays the only owner of tab and navigation state.

The MCP process resolves BROWSER_CLI_PROFILE, BROWSER_CLI_REMOTE, and
BROWSER_CLI_KEY explicitly instead of passing None down, so a pinned server
targets one browser rather than fanning listing calls out across every
connected browser. Explicit tool arguments still win.

Tool names keep a browser_ prefix for hosts that expose raw MCP names, while
BROWSER_CLI_MCP_TOOL_PREFIX lets hosts that already namespace by server drop
it and avoid names like browser_cli_testing_browser_tabs_list.

JavaScript evaluation, raw commands, storage writes, and session import stay
unexposed, and Streamable HTTP refuses non-loopback binds because the endpoint
has no authentication of its own; remote browsers go through browser-cli's
authenticated remote transport instead.

MCP stays an optional extra, so normal installs are unaffected.
This commit is contained in:
2026-08-09 20:37:47 +02:00
parent 7b4d96845d
commit bd2a18baba
8 changed files with 1292 additions and 2 deletions
+224
View File
@@ -0,0 +1,224 @@
"""Tests for the optional stateless MCP adapter."""
from __future__ import annotations
import base64
from types import SimpleNamespace
import pytest
pytest.importorskip("mcp")
from mcp import Client
from mcp.types import ImageContent
from browser_cli.mcp.serialization import structured
from browser_cli.mcp.naming import resolve_tool_prefix
from browser_cli.mcp.server import _screenshot_bytes, create_server, main
from browser_cli.models import Tab
class FakeClient:
instances: list["FakeClient"] = []
def __init__(self, browser=None, remote=None, key=None):
self.target = (browser, remote, key)
self.calls: list[tuple] = []
self.tabs = SimpleNamespace(
list=self.tabs_list,
open=self.tabs_open,
close=self.tabs_close,
status=self.tabs_status,
screenshot=self.tabs_screenshot,
)
self.nav = SimpleNamespace(to=self.navigate_to)
self.page = SimpleNamespace(info=self.page_info)
self.extract = SimpleNamespace(text=self.extract_text, markdown=self.extract_markdown)
self.dom = SimpleNamespace(query=self.dom_query, click=self.dom_click, type=self.dom_type)
self.instances.append(self)
def tabs_list(self):
return [{"id": 7, "title": "Example", "url": "https://example.com"}]
def tabs_open(self, url, **kwargs):
self.calls.append(("open", url, kwargs))
return {"id": 8, "title": "Opened", "url": url}
def tabs_close(self, tab_id):
self.calls.append(("close", tab_id))
return 1
def tabs_status(self, tab_id):
self.calls.append(("status", tab_id))
return {"id": tab_id, "title": "Navigated", "url": "https://example.com/next"}
def tabs_screenshot(self, tab_id, **kwargs):
self.calls.append(("screenshot", tab_id, kwargs))
return "data:image/png;base64," + base64.b64encode(b"png-data").decode()
def navigate_to(self, tab_id, url):
self.calls.append(("navigate", tab_id, url))
def page_info(self):
return {"title": "Example", "url": "https://example.com"}
def extract_text(self):
return "Page text"
def extract_markdown(self, selector=None):
return f"# Page {selector or ''}".rstrip()
def dom_query(self, selector):
return [{"tag": "button", "selector": selector}]
def dom_click(self, selector):
self.calls.append(("click", selector))
def dom_type(self, selector, text):
self.calls.append(("type", selector, text))
@pytest.fixture(autouse=True)
def clear_instances():
FakeClient.instances.clear()
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture
async def client():
server = create_server(client_factory=FakeClient)
async with Client(server, raise_exceptions=True) as connected:
yield connected
@pytest.mark.anyio
async def test_each_tool_call_constructs_a_fresh_targeted_client(client, monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
first = await client.call_tool("browser_tabs_list", {
"browser": "work",
"remote": "browser-host.example:443",
"key": "agent",
})
second = await client.call_tool("browser_page_info", {})
assert first.structured_content == {
"result": [{"id": 7, "title": "Example", "url": "https://example.com"}]
}
assert second.structured_content == {
"title": "Example",
"url": "https://example.com",
}
assert len(FakeClient.instances) == 2
assert FakeClient.instances[0].target == ("work", "browser-host.example:443", "agent")
assert FakeClient.instances[1].target == (None, None, None)
@pytest.mark.anyio
async def test_environment_pins_all_calls_to_one_browser(client, monkeypatch):
monkeypatch.setenv("BROWSER_CLI_PROFILE", "testing")
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
await client.call_tool("browser_tabs_list", {})
assert FakeClient.instances[-1].target == ("testing", None, None)
def test_tool_prefix_defaults_to_browser_and_is_configurable():
assert resolve_tool_prefix({}) == "browser_"
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": ""}) == ""
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "web"}) == "web_"
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "web_"}) == "web_"
with pytest.raises(ValueError):
resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "9-bad prefix"})
@pytest.mark.anyio
async def test_hosts_that_namespace_tools_can_drop_the_builtin_prefix():
server = create_server(client_factory=FakeClient, tool_prefix="")
async with Client(server, raise_exceptions=True) as connected:
names = {tool.name for tool in (await connected.list_tools()).tools}
assert "tabs_list" in names
assert not any(name.startswith("browser_") for name in names)
@pytest.mark.anyio
async def test_explicit_target_overrides_environment_pin(client, monkeypatch):
monkeypatch.setenv("BROWSER_CLI_PROFILE", "testing")
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
await client.call_tool("browser_tabs_list", {"browser": "main"})
assert FakeClient.instances[-1].target == ("main", None, None)
@pytest.mark.anyio
async def test_mutating_tools_use_sdk_and_return_fresh_state(client):
opened = await client.call_tool("browser_tabs_open", {
"url": "https://example.com",
"wait": True,
"focus": True,
})
navigated = await client.call_tool("browser_navigate", {
"tab_id": 8,
"url": "https://example.com/next",
})
clicked = await client.call_tool("browser_dom_click", {"selector": "#submit"})
typed = await client.call_tool("browser_dom_type", {"selector": "#name", "text": "Daniel"})
assert opened.structured_content == {
"id": 8, "title": "Opened", "url": "https://example.com"
}
assert navigated.structured_content["id"] == 8
assert clicked.structured_content["url"] == "https://example.com"
assert typed.structured_content == {"typed": True}
assert FakeClient.instances[0].calls == [
("open", "https://example.com", {
"wait": True, "timeout": 30.0, "background": False, "focus": True
})
]
assert FakeClient.instances[1].calls == [
("navigate", 8, "https://example.com/next"), ("status", 8)
]
@pytest.mark.anyio
async def test_screenshot_returns_image_content(client):
result = await client.call_tool("browser_screenshot", {"tab_id": 7, "format": "png"})
assert result.structured_content is None
assert len(result.content) == 1
assert isinstance(result.content[0], ImageContent)
assert result.content[0].data == base64.b64encode(b"png-data").decode()
assert result.content[0].mime_type == "image/png"
def test_screenshot_decoder_rejects_non_data_url():
with pytest.raises(ValueError, match="invalid screenshot"):
_screenshot_bytes("not-an-image")
def test_sdk_dataclass_serialization_does_not_traverse_bound_client():
tab = Tab(id=7, window_id=1, active=True, title="Example")
tab._browser = SimpleNamespace(secret="must not be serialized")
result = structured(tab)
assert result["id"] == 7
assert result["window_id"] == 1
assert "_browser" not in result
def test_http_server_refuses_non_local_bind(monkeypatch):
monkeypatch.setattr("browser_cli.mcp.server.create_server", lambda: SimpleNamespace(run=lambda **kwargs: None))
with pytest.raises(SystemExit, match="Refusing to expose"):
main(["--transport", "streamable-http", "--host", "0.0.0.0"])
def test_http_server_enables_stateless_json_transport(monkeypatch):
calls = []
monkeypatch.setattr("browser_cli.mcp.server.create_server", lambda: SimpleNamespace(run=lambda **kwargs: calls.append(kwargs)))
main(["--transport", "streamable-http", "--port", "9000", "--path", "/browser"])
assert calls == [{
"transport": "streamable-http",
"host": "127.0.0.1",
"port": 9000,
"streamable_http_path": "/browser",
"stateless_http": True,
"json_response": True,
}]