"""Stateless MCP server exposing a conservative browser-cli tool surface. The MCP process stores no browser client, tab ID, or navigation state. Every call constructs a fresh :class:`browser_cli.BrowserCLI`; the real browser is the sole owner of browser state. """ from __future__ import annotations import argparse import base64 import os from collections.abc import Callable from typing import Any, Literal from urllib.parse import urlsplit from browser_cli import BrowserCLI from browser_cli.mcp.naming import resolve_tool_prefix, tool_name from browser_cli.mcp.serialization import structured ClientFactory = Callable[..., BrowserCLI] _SERVER_INSTRUCTIONS = """Control a real, user-visible browser through browser-cli. The server is stateless: pass browser, remote, and key on each tool call when a specific target is required. Tool calls affect the user's actual browser. Read current tabs/page state instead of assuming IDs or content from an earlier call. """ def _client(factory: ClientFactory, browser: str | None, remote: str | None, key: str | None) -> BrowserCLI: """Build a fresh client, making MCP process environment defaults explicit. Explicit values are important for SDK multi-browser routing: leaving ``browser=None`` would fan out list/count calls before lower transport code gets a chance to consult ``BROWSER_CLI_PROFILE``. """ return factory( browser=browser or os.environ.get("BROWSER_CLI_PROFILE"), remote=remote or os.environ.get("BROWSER_CLI_REMOTE"), key=key or os.environ.get("BROWSER_CLI_KEY"), ) def _screenshot_bytes(data_url: str) -> tuple[bytes, str]: """Decode a browser screenshot data URL into bytes and an MCP image format.""" header, separator, payload = data_url.partition(",") if not separator or ";base64" not in header: raise ValueError("Browser returned an invalid screenshot data URL") media_type = header[5:].split(";", 1)[0].lower() image_format = "jpeg" if media_type in {"image/jpeg", "image/jpg"} else "png" return base64.b64decode(payload, validate=True), image_format def create_server(*, client_factory: ClientFactory = BrowserCLI, tool_prefix: str | None = None): """Create the MCP server. Supplying *client_factory* keeps tests browser-free.""" try: from mcp.server import MCPServer from mcp.server.mcpserver import Image except ImportError as exc: # pragma: no cover - exercised without the optional extra raise RuntimeError( "MCP support is not installed. Install real-browser-cli with the 'mcp' extra: " "uv tool install 'real-browser-cli[mcp]'" ) from exc prefix = resolve_tool_prefix() if tool_prefix is None else tool_prefix mcp = MCPServer( "browser-cli", description="Control a real running browser through the browser-cli SDK.", instructions=_SERVER_INSTRUCTIONS, ) @mcp.tool(name=tool_name("tabs_list", prefix)) def tabs_list( browser: str | None = None, remote: str | None = None, key: str | None = None, ) -> list[dict[str, Any]]: """List current tabs. Optionally target a browser alias or authenticated remote.""" return structured(_client(client_factory, browser, remote, key).tabs.list()) @mcp.tool(name=tool_name("tabs_open", prefix)) def tabs_open( url: str, wait: bool = False, timeout: float = 30.0, background: bool = False, focus: bool = False, browser: str | None = None, remote: str | None = None, key: str | None = None, ) -> dict[str, Any]: """Open a URL in a new real-browser tab and return its current metadata.""" tab = _client(client_factory, browser, remote, key).tabs.open( url, wait=wait, timeout=timeout, background=background, focus=focus ) return structured(tab) @mcp.tool(name=tool_name("tabs_close", prefix)) def tabs_close( tab_id: int, browser: str | None = None, remote: str | None = None, key: str | None = None, ) -> dict[str, int]: """Close one tab by its current ID. This changes the user's real browser.""" closed = _client(client_factory, browser, remote, key).tabs.close(tab_id) return {"closed": closed} @mcp.tool(name=tool_name("navigate", prefix)) def navigate( tab_id: int, url: str, browser: str | None = None, remote: str | None = None, key: str | None = None, ) -> dict[str, Any]: """Navigate an existing tab to a URL and return a fresh tab snapshot.""" client = _client(client_factory, browser, remote, key) client.nav.to(tab_id, url) return structured(client.tabs.status(tab_id)) @mcp.tool(name=tool_name("page_info", prefix)) def page_info( browser: str | None = None, remote: str | None = None, key: str | None = None, ) -> dict[str, Any]: """Return title, URL, readiness, language, and metadata for the active page.""" return structured(_client(client_factory, browser, remote, key).page.info()) @mcp.tool(name=tool_name("extract_text", prefix)) def extract_text( browser: str | None = None, remote: str | None = None, key: str | None = None, ) -> str: """Extract plain text from the active page.""" return _client(client_factory, browser, remote, key).extract.text() @mcp.tool(name=tool_name("extract_markdown", prefix)) def extract_markdown( selector: str | None = None, browser: str | None = None, remote: str | None = None, key: str | None = None, ) -> str: """Extract clean Markdown from the active page or an optional CSS selector.""" return _client(client_factory, browser, remote, key).extract.markdown(selector) @mcp.tool(name=tool_name("dom_query", prefix)) def dom_query( selector: str, browser: str | None = None, remote: str | None = None, key: str | None = None, ) -> list[dict[str, Any]]: """Return elements matching a CSS selector on the active page.""" return structured(_client(client_factory, browser, remote, key).dom.query(selector)) @mcp.tool(name=tool_name("dom_click", prefix)) def dom_click( selector: str, browser: str | None = None, remote: str | None = None, key: str | None = None, ) -> dict[str, Any]: """Click the first matching element, then return current active-page info.""" client = _client(client_factory, browser, remote, key) client.dom.click(selector) return structured(client.page.info()) @mcp.tool(name=tool_name("dom_type", prefix)) def dom_type( selector: str, text: str, browser: str | None = None, remote: str | None = None, key: str | None = None, ) -> dict[str, bool]: """Type text into the first element matching a CSS selector.""" _client(client_factory, browser, remote, key).dom.type(selector, text) return {"typed": True} @mcp.tool(name=tool_name("screenshot", prefix), structured_output=False) def screenshot( tab_id: int | None = None, format: Literal["png", "jpeg"] = "png", quality: int | None = None, browser: str | None = None, remote: str | None = None, key: str | None = None, ) -> Any: """Capture the visible area of the active or specified tab as an image.""" data_url = _client(client_factory, browser, remote, key).tabs.screenshot( tab_id, format=format, quality=quality ) data, actual_format = _screenshot_bytes(data_url) return Image(data=data, format=actual_format) return mcp def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Run the stateless browser-cli MCP server.") parser.add_argument("--transport", choices=("stdio", "streamable-http"), default="stdio") parser.add_argument("--host", default="127.0.0.1", help="HTTP bind host (streamable-http only).") parser.add_argument("--port", type=int, default=8000, help="HTTP bind port (streamable-http only).") parser.add_argument("--path", default="/mcp", help="MCP endpoint path (streamable-http only).") return parser def main(argv: list[str] | None = None) -> None: """Run over stdio, or stateless Streamable HTTP when explicitly selected.""" args = _parser().parse_args(argv) mcp = create_server() if args.transport == "stdio": mcp.run() return if args.host not in {"127.0.0.1", "localhost", "::1"}: raise SystemExit( "Refusing to expose the unauthenticated MCP server beyond localhost. " "Use browser-cli's authenticated remote transport from a local MCP server instead." ) mcp.run( transport="streamable-http", host=args.host, port=args.port, streamable_http_path=args.path, stateless_http=True, json_response=True, ) if __name__ == "__main__": main()