utils/flutter_web_drive/mcp_server.py
Krzysztof kuhy Rudnicki f93a24e283 flutter_web_drive: headless CDP driver + MCP server for Flutter web apps
Drives a Flutter web build in a persistent headless chromium (systemd-run
unit, own profile, CDP port) through the semantics tree: tree, tap by label,
text, key, scroll, wait, shot, console. Never touches the live display;
attaches only to HeadlessChrome. The MCP server wraps the same Driver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZ4sULGfyav7EEwQ6vhbfg
2026-10-24 22:37:49 +02:00

174 lines
5.1 KiB
Python

"""Stdio MCP server exposing the driver's verbs as tools (thin: every tool is one Driver call).
Register (user scope) with:
claude mcp add flutter-web-drive -s user -e PYTHONPATH=$HOME/src/utils \
-- python3 -m flutter_web_drive.mcp_server
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from flutter_web_drive.api import Driver
from flutter_web_drive.errors import DriveError
server = MCPServer(
"flutter-web-drive",
instructions=(
"Drive a Flutter web app in a private headless chromium (never the user's screen). "
"start once, then tree -> tap/text/key/scroll -> wait -> shot. Widgets are addressed "
"by their semantics label as shown by `tree`. `app` namespaces state per app."
),
)
def _run(app: str, call: Callable[[Driver], str]) -> str:
"""Run one verb; a DriveError becomes a tool error naming the failing step."""
try:
return call(Driver(app))
except DriveError as err:
raise ToolError(f"{err.step} failed: {err.message}") from err
@server.tool()
def start(
url: str,
profile: str,
cdp_port: int,
app: str = "default",
unit: str | None = None,
size: str = "1400x1000",
) -> str:
"""Launch (or reuse) a headless chromium unit on url with a persistent profile dir."""
return _run(
app, lambda d: d.start(url, Path(profile), cdp_port, unit=unit, size=size)
)
@server.tool()
def stop(app: str = "default") -> str:
"""Stop the app's headless chromium unit (profile kept)."""
return _run(app, lambda d: d.stop())
@server.tool()
def shot(out: str | None = None, min_colours: int = 0, app: str = "default") -> str:
"""Screenshot the viewport to a PNG; returns 'path WxH colours=N stddev=S'."""
return _run(
app, lambda d: d.shot(Path(out) if out else None, min_colours=min_colours)
)
@server.tool()
def tree(
filt: str | None = None,
show_all: bool = False,
row: str | None = None,
app: str = "default",
) -> str:
"""List semantics nodes: '#id role "label" = "value" [tap,current,...] @x,y wxh'. row: only the row of that label."""
return _run(app, lambda d: d.tree(filt=filt, show_all=show_all, row=row))
@server.tool()
def tap(label: str, contains: bool = False, app: str = "default") -> str:
"""Click the node with this label (exact label or one of its lines; contains=substring)."""
return _run(app, lambda d: d.tap(label, contains=contains))
@server.tool()
def tap_xy(x: float, y: float, app: str = "default") -> str:
"""Click at viewport CSS-pixel coordinates."""
return _run(app, lambda d: d.tap_xy(x, y))
@server.tool()
def text(
value: str | None = None, from_file: str | None = None, app: str = "default"
) -> str:
"""Insert text into the focused field. Use from_file for secrets so they never appear in the transcript."""
if from_file:
try:
value = Path(from_file).read_text().removesuffix("\n")
except OSError as exc:
raise ToolError(
f"text failed: cannot read {from_file}: {exc.strerror}"
) from exc
if value is None:
raise ToolError("text failed: pass value or from_file")
secret = value
return _run(app, lambda d: d.text(secret))
@server.tool()
def key(name: str, app: str = "default") -> str:
"""Press a key: Enter, Escape, Tab, Backspace, Delete, Space, Arrow*, Home, End, PageUp, PageDown."""
return _run(app, lambda d: d.key(name))
@server.tool()
def scroll(
dx: float,
dy: float,
at_x: float | None = None,
at_y: float | None = None,
app: str = "default",
) -> str:
"""Mouse-wheel scroll by (dx, dy) at (at_x, at_y), default viewport center."""
at = (at_x, at_y) if at_x is not None and at_y is not None else None
return _run(app, lambda d: d.scroll(dx, dy, at=at))
@server.tool()
def wait(
label: str,
timeout: float = 10.0,
contains: bool = False,
gone: bool = False,
flag: str | None = None,
row: str | None = None,
app: str = "default",
) -> str:
"""Poll until a label (carrying flag, e.g. current/checked) is present, or absent with gone; timeout <= 50 s, screenshot on failure."""
return _run(
app,
lambda d: d.wait(
label, timeout=timeout, contains=contains, gone=gone, flag=flag
),
)
@server.tool()
def eval_js(expr: str, app: str = "default") -> str:
"""Evaluate JavaScript in the page; returns the JSON value."""
return _run(app, lambda d: d.eval(expr))
@server.tool()
def console(
limit: int = 20,
warnings: bool = False,
fail_on_error: bool = False,
app: str = "default",
) -> str:
"""Console errors (and optionally warnings) since page load."""
return _run(
app,
lambda d: d.console(
limit=limit, warnings=warnings, fail_on_error=fail_on_error
),
)
def main() -> None:
"""Serve over stdio."""
server.run(transport="stdio")
if __name__ == "__main__": # pragma: no cover - exercised by the stdio smoke test
main()