Testing / test (3.10) (push) Successful in 48s
Testing / test (3.12) (push) Successful in 47s
Testing / test (3.14) (push) Successful in 1m7s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 6m59s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 6m47s
Testing / test (3.15) (push) Successful in 6m20s
- Drop backslash from f-string expression in markdown/html.py - That syntax needs 3.12+ (PEP 701) and broke import on 3.10/3.11 - Add Python matrix 3.10/3.12/3.14/3.15 to the Gitea testing workflow - Pass project_version() to MCPServer; serverInfo.version was empty - Cover the reported version with an in-memory MCP client test - Swap deprecated click.get_text_stream for sys.stdin.isatty() - Add explicit encoding to native host shim and manifest writes - Add explicit encoding to the registry lock file open - Add explicit encoding to every read_text/write_text call in tests - Bump version to 0.16.10 in pyproject.toml and extension/manifest.json - Move .python-version to 3.15 and refresh uv.lock Extension code is unchanged; only the manifest version string moved. Verified with 778 tests on 3.10 through 3.15, plus a strict run using -X warn_default_encoding with EncodingWarning and DeprecationWarning as errors.
317 lines
12 KiB
Python
317 lines
12 KiB
Python
"""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 json
|
|
import os
|
|
from collections.abc import Callable
|
|
from typing import Any, Literal
|
|
|
|
from browser_cli import BrowserCLI
|
|
from browser_cli.mcp.naming import resolve_tool_prefix, tool_name
|
|
from browser_cli.mcp.serialization import structured
|
|
from browser_cli.mcp.targets import resolve_tab_id
|
|
from browser_cli.url_match import find_reusable_tab, needs_navigation, tab_id as read_tab_id, tab_url
|
|
from browser_cli.version_manager import project_version
|
|
|
|
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.
|
|
Reuse before opening: tabs_open takes over an already-open tab whose URL is
|
|
nearly the same (same site and page, or any tab of that site when you ask for
|
|
its root) and reports reused=true with that tab's ID. Pass reuse="none" only
|
|
when a separate tab is really wanted, and reuse="origin" to take over any tab
|
|
of the site. Use tabs_list(search=...) first when you want to pick the tab.
|
|
"""
|
|
|
|
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,
|
|
version=project_version(),
|
|
)
|
|
|
|
@mcp.tool(name=tool_name("tabs_list", prefix))
|
|
def tabs_list(
|
|
search: str | None = None,
|
|
browser: str | None = None,
|
|
remote: str | None = None,
|
|
key: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""List current tabs, optionally filtering by URL or title substring.
|
|
|
|
Check this before opening anything so an already-open page gets reused.
|
|
"""
|
|
tabs = structured(_client(client_factory, browser, remote, key).tabs.list())
|
|
if not search:
|
|
return tabs
|
|
needle = search.lower()
|
|
return [
|
|
tab for tab in tabs
|
|
if needle in str(tab.get("url") or "").lower()
|
|
or needle in str(tab.get("title") or "").lower()
|
|
]
|
|
|
|
@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,
|
|
reuse: Literal["similar", "exact", "origin", "none"] = "similar",
|
|
browser: str | None = None,
|
|
remote: str | None = None,
|
|
key: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Open a URL in the real browser, reusing an already-open tab with a nearly identical URL.
|
|
|
|
Returns the tab metadata plus ``reused``. ``reuse`` selects the match:
|
|
``similar`` (default: same site and page, or any tab of that site when the
|
|
requested URL is its root), ``exact``, ``origin`` (any tab of the site), or
|
|
``none`` to always create a new tab.
|
|
"""
|
|
client = _client(client_factory, browser, remote, key)
|
|
existing = find_reusable_tab(structured(client.tabs.list()), url, reuse)
|
|
if existing is not None:
|
|
target = read_tab_id(existing)
|
|
if needs_navigation(tab_url(existing), url):
|
|
client.nav.to(target, url)
|
|
if focus:
|
|
client.tabs.activate(target)
|
|
return {**structured(client.tabs.status(target)), "reused": True}
|
|
tab = client.tabs.open(url, wait=wait, timeout=timeout, background=background, focus=focus)
|
|
return {**structured(tab), "reused": False}
|
|
|
|
@mcp.tool(name=tool_name("tabs_close", prefix))
|
|
def tabs_close(
|
|
tab_id: int | None = None,
|
|
browser: str | None = None,
|
|
remote: str | None = None,
|
|
key: str | None = None,
|
|
) -> dict[str, int]:
|
|
"""Close a tab, defaulting to the active tab. This changes the real browser."""
|
|
client = _client(client_factory, browser, remote, key)
|
|
target = resolve_tab_id(client, tab_id)
|
|
return {"closed": client.tabs.close(target), "tab_id": target}
|
|
|
|
@mcp.tool(name=tool_name("navigate", prefix))
|
|
def navigate(
|
|
url: str,
|
|
tab_id: int | None = None,
|
|
browser: str | None = None,
|
|
remote: str | None = None,
|
|
key: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Navigate a tab to a URL, defaulting to the active tab, and return it."""
|
|
client = _client(client_factory, browser, remote, key)
|
|
target = resolve_tab_id(client, tab_id)
|
|
client.nav.to(target, url)
|
|
return structured(client.tabs.status(target))
|
|
|
|
@mcp.tool(name=tool_name("page_info", prefix))
|
|
def page_info(
|
|
tab_id: int | None = None,
|
|
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 or tab_id."""
|
|
client = _client(client_factory, browser, remote, key)
|
|
if tab_id is not None:
|
|
return structured(client.tabs.status(tab_id))
|
|
return structured(client.page.info())
|
|
|
|
@mcp.tool(name=tool_name("extract_text", prefix))
|
|
def extract_text(
|
|
tab_id: int | None = None,
|
|
browser: str | None = None,
|
|
remote: str | None = None,
|
|
key: str | None = None,
|
|
) -> str:
|
|
"""Extract plain text from the active page or tab_id."""
|
|
return _client(client_factory, browser, remote, key).extract.text(tab_id)
|
|
|
|
@mcp.tool(name=tool_name("extract_markdown", prefix))
|
|
def extract_markdown(
|
|
selector: str | None = None,
|
|
tab_id: int | None = None,
|
|
browser: str | None = None,
|
|
remote: str | None = None,
|
|
key: str | None = None,
|
|
) -> str:
|
|
"""Extract clean Markdown from the active page or tab_id, optionally scoped by CSS selector."""
|
|
return _client(client_factory, browser, remote, key).extract.markdown(selector, tab_id)
|
|
|
|
@mcp.tool(name=tool_name("dom_query", prefix))
|
|
def dom_query(
|
|
selector: str,
|
|
tab_id: int | None = None,
|
|
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 or tab_id."""
|
|
client = _client(client_factory, browser, remote, key)
|
|
if tab_id is None:
|
|
return structured(client.dom.query(selector))
|
|
code = f"""
|
|
(() => {{
|
|
const selector = {json.dumps(selector)};
|
|
return [...document.querySelectorAll(selector)].map(el => ({{
|
|
tag: el.tagName,
|
|
text: (el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim(),
|
|
attrs: Object.fromEntries([...el.attributes].map(attr => [attr.name, attr.value])),
|
|
}}));
|
|
}})()
|
|
"""
|
|
return structured(client.dom.eval(code, tab_id))
|
|
|
|
@mcp.tool(name=tool_name("dom_click", prefix))
|
|
def dom_click(
|
|
selector: str,
|
|
tab_id: int | None = None,
|
|
browser: str | None = None,
|
|
remote: str | None = None,
|
|
key: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Click the first matching element, then return current page info."""
|
|
client = _client(client_factory, browser, remote, key)
|
|
if tab_id is None:
|
|
client.dom.click(selector)
|
|
return structured(client.page.info())
|
|
code = f"""
|
|
(() => {{
|
|
const selector = {json.dumps(selector)};
|
|
const el = document.querySelector(selector);
|
|
if (!el) throw new Error(`No element found for selector: ${{selector}}`);
|
|
el.click();
|
|
return true;
|
|
}})()
|
|
"""
|
|
client.dom.eval(code, tab_id)
|
|
return structured(client.page.info(tab_id))
|
|
|
|
@mcp.tool(name=tool_name("dom_type", prefix))
|
|
def dom_type(
|
|
selector: str,
|
|
text: str,
|
|
tab_id: int | None = None,
|
|
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(client_factory, browser, remote, key)
|
|
if tab_id is None:
|
|
client.dom.type(selector, text)
|
|
return {"typed": True}
|
|
code = f"""
|
|
(() => {{
|
|
const selector = {json.dumps(selector)};
|
|
const text = {json.dumps(text)};
|
|
const el = document.querySelector(selector);
|
|
if (!el) throw new Error(`No element found for selector: ${{selector}}`);
|
|
el.focus();
|
|
el.value = text;
|
|
el.dispatchEvent(new InputEvent('input', {{ bubbles: true, inputType: 'insertText', data: text }}));
|
|
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
|
return true;
|
|
}})()
|
|
"""
|
|
client.dom.eval(code, tab_id)
|
|
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()
|