"""Stdio MCP server exposing headless Chromium to codex. Protocol: JSON-RPC 2.0 over stdio, one JSON object per line. Logs go to stderr only -- anything on stdout would corrupt the JSON-RPC stream. This file is deliberately thin: all browser logic lives in ``playwright_browser.py``. It can run two ways: - inside the repo: ``python3 -m cloud.playwright_mcp`` (imports ``cloud.playwright_browser``); - copied flat into a tenant codex-home next to ``playwright_browser.py`` (imports plain ``playwright_browser`` from its own directory). The ``ensure_codex_home_playwright`` helper does the copying; proxy vars and PLAYWRIGHT_BROWSERS_PATH arrive via the MCP env block in config.toml, never via tool arguments. One Browser instance lives for the whole MCP process: navigate once, then snapshot/click/fill/screenshot against the same page. """ import json import os import sys try: from cloud.playwright_browser import Browser, BrowserError except ImportError: # flat copy in a tenant codex-home sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from playwright_browser import Browser, BrowserError # noqa: E402 SERVER_NAME = "playwright" SERVER_VERSION = "0.1.0" _TOOLS = [ { "name": "browser_navigate", "description": ( "Open a URL in headless Chromium and return its final " "URL and title. Use for pages that need JavaScript to " "render, which web_fetch cannot see. Network goes " "through the egress proxy, so domains outside the " "Tinyproxy whitelist fail with a proxy 403 -- report " "that plainly, do not bypass it." ), "inputSchema": { "type": "object", "properties": { "url": {"type": "string", "description": "http(s) URL, or file:// URL " "for a page already in the worktree"}, }, "required": ["url"], }, }, { "name": "browser_snapshot", "description": ( "Return the current page's title, URL and body text " "(truncated at 8000 characters with a flag). Call " "after navigate, and after click/fill, to see what " "actually rendered." ), "inputSchema": {"type": "object", "properties": {}}, }, { "name": "browser_click", "description": ( "Click an element by CSS selector or Playwright " "selector (e.g. 'button.submit', 'text=Next'). " "Then snapshot to see the result." ), "inputSchema": { "type": "object", "properties": { "selector": {"type": "string"}, }, "required": ["selector"], }, }, { "name": "browser_fill", "description": ( "Fill an input/textarea by CSS selector with text. " "Then snapshot or click to submit." ), "inputSchema": { "type": "object", "properties": { "selector": {"type": "string"}, "text": {"type": "string"}, }, "required": ["selector", "text"], }, }, { "name": "browser_evaluate", "description": ( "Run a JavaScript expression in the page and return " "its string value (truncated at 4000 characters). " "Same sandbox privilege as the shell tool; prefer " "snapshot/click/fill for ordinary reads and actions." ), "inputSchema": { "type": "object", "properties": { "expression": {"type": "string"}, }, "required": ["expression"], }, }, { "name": "browser_screenshot", "description": ( "Save a PNG screenshot of the current page into the " "working directory and return its absolute path and " "byte size. Verify the returned path with shell/PIL " "before reporting success." ), "inputSchema": { "type": "object", "properties": { "output_filename": {"type": "string", "description": "Plain basename " "to save as, e.g. 'shot.png'. " "Default: shot-.png"}, }, }, }, { "name": "browser_close", "description": "Close the browser page. Safe to call twice.", "inputSchema": {"type": "object", "properties": {}}, }, ] def _err(code, message): return {"code": code, "message": message} _BROWSER = None def _get_browser(browser=None): global _BROWSER if browser is not None: return browser if _BROWSER is None: _BROWSER = Browser() return _BROWSER def handle_request(request, browser=None): """Handle one JSON-RPC request dict. Returns a response dict or None for notifications (no id). Tests pass a fake browser.""" if not isinstance(request, dict): return {"jsonrpc": "2.0", "id": None, "error": _err(-32700, "request is not an object")} method = request.get("method") req_id = request.get("id") is_notification = "id" not in request if method == "initialize": if is_notification: return None return {"jsonrpc": "2.0", "id": req_id, "result": { "protocolVersion": request.get("params", {}).get( "protocolVersion", "2024-11-05"), "capabilities": {"tools": {}}, "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION}}} if method in ("notifications/initialized", "notifications/cancelled"): return None if method == "ping": if is_notification: return None return {"jsonrpc": "2.0", "id": req_id, "result": {}} if method == "tools/list": if is_notification: return None return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": _TOOLS}} if method == "tools/call": if is_notification: return None params = request.get("params") or {} name = params.get("name") args = params.get("arguments") or {} target = _get_browser(browser) try: if name == "browser_navigate": result = target.navigate(args.get("url")) elif name == "browser_snapshot": result = target.snapshot() elif name == "browser_click": result = target.click(args.get("selector")) elif name == "browser_fill": result = target.fill(args.get("selector"), args.get("text")) elif name == "browser_evaluate": result = target.evaluate(args.get("expression")) elif name == "browser_screenshot": result = target.screenshot( args.get("output_filename")) elif name == "browser_close": result = target.close() else: return {"jsonrpc": "2.0", "id": req_id, "error": _err(-32602, "unknown tool: %r" % name)} except Exception as exc: # noqa: BLE001 - tool errors are data return {"jsonrpc": "2.0", "id": req_id, "result": {"content": [{"type": "text", "text": "error: %s" % exc}], "isError": True}} return {"jsonrpc": "2.0", "id": req_id, "result": {"content": [{"type": "text", "text": json.dumps( result, ensure_ascii=False)}], "isError": False}} if is_notification: return None return {"jsonrpc": "2.0", "id": req_id, "error": _err(-32601, "method not found: %r" % method)} def main(): for line in sys.stdin: line = line.strip() if not line: continue try: request = json.loads(line) except json.JSONDecodeError as exc: sys.stdout.write(json.dumps( {"jsonrpc": "2.0", "id": None, "error": _err(-32700, "parse error: %s" % exc)}) + "\n") sys.stdout.flush() continue try: response = handle_request(request) except Exception as exc: # noqa: BLE001 - server must not die print("playwright_mcp: handler error: %s" % exc, file=sys.stderr) if "id" in request: response = {"jsonrpc": "2.0", "id": request["id"], "error": _err(-32603, "internal error")} else: response = None if response is not None: sys.stdout.write(json.dumps(response) + "\n") sys.stdout.flush() if __name__ == "__main__": main()