diff --git a/guest-tools/andcode-browser-mcp/andcode_browser_mcp.py b/app/src/main/assets/scripts/andcode-browser-mcp.py similarity index 55% rename from guest-tools/andcode-browser-mcp/andcode_browser_mcp.py rename to app/src/main/assets/scripts/andcode-browser-mcp.py index aac80e84..5adc3c28 100644 --- a/guest-tools/andcode-browser-mcp/andcode_browser_mcp.py +++ b/app/src/main/assets/scripts/andcode-browser-mcp.py @@ -1,19 +1,16 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = ["fastmcp>=2.0.0"] -# /// -"""AndCode guest-browser MCP server. +#!/usr/bin/env python3 +"""AndCode guest-browser MCP server (stdlib only). Bridges the agent to the in-app Guest Browser of the AndCode Android app: - ``browser_show`` asks the app (via a command file in the active workspace) to open the guest browser so the user can watch and operate the page. - The remaining tools drive the same WebView over CDP. The app enables WebView - debugging, which exposes ``webview_devtools_remote_`` as an Linux abstract - socket; the guest shares the kernel (and the app UID), so it can attach directly. + debugging, which exposes ``webview_devtools_remote_`` as a Linux abstract + socket; the guest shares the kernel (and the app UID), so it can attach. -The CDP client (HTTP /json + WebSocket) is implemented on AF_UNIX sockets and -needs no extra dependencies. +Speaks MCP over stdio (newline-delimited JSON-RPC) with no third-party deps so +it runs on the bare runtime rootfs python3. """ import base64 @@ -21,15 +18,11 @@ import os import socket import struct +import sys from pathlib import Path from urllib.parse import urlparse -from fastmcp import FastMCP - -mcp = FastMCP("and-code-browser") - COMMAND_FILE = Path(".and-code") / "browser-command.json" -CDP_BRIDGE_PORT_NOTE = "abstract socket only; no TCP bridge needed" def _find_devtools_socket() -> str | None: @@ -42,9 +35,8 @@ def _find_devtools_socket() -> str | None: parts = line.split() if len(parts) < 8: continue - hexname = parts[-1] try: - name = bytes.fromhex(hexname).decode("ascii") + name = bytes.fromhex(parts[-1]).decode("ascii") except (ValueError, UnicodeDecodeError): continue if name.startswith("webview_devtools_remote_"): @@ -123,7 +115,6 @@ def recv_text(self) -> str: raise CdpError("server closed ws") if opcode == 0x9: self._send_pong(payload) - # ignore pong/continuation fragments (CDP messages are single-frame) def _send_pong(self, payload: bytes) -> None: mask = os.urandom(4) @@ -141,7 +132,6 @@ def close(self) -> None: class CdpSession: def __init__(self, socket_name: str): self.socket_name = socket_name - self._next_id = 0 def _connect(self) -> socket.socket: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) @@ -154,7 +144,7 @@ def list_targets(self) -> list[dict]: try: sock.sendall(b"GET /json HTTP/1.1\r\nHost: localhost\r\n\r\n") data = b"" - while b"\r\n0\r\n\r\n" not in data and not data.endswith(b"]"): + while not data.endswith(b"]"): chunk = sock.recv(65536) if not chunk: break @@ -162,7 +152,6 @@ def list_targets(self) -> list[dict]: finally: sock.close() body = data.split(b"\r\n\r\n", 1)[1] if b"\r\n\r\n" in data else data - # chunked bodies: strip chunk-size lines if present if b"\r\n0\r\n\r\n" in body: chunks = [] rest = body @@ -184,8 +173,7 @@ def open_page_session(self) -> "_PageSession": raise CdpError("no WebView page target found; is the guest browser open?") ws_url = targets[0]["webSocketDebuggerUrl"] parsed = urlparse(ws_url) - sock = self._connect() - ws = _UnixWs(sock, parsed.netloc or "localhost", parsed.path or "/") + ws = _UnixWs(self._connect(), parsed.netloc or "localhost", parsed.path or "/") return _PageSession(ws) @@ -210,77 +198,62 @@ def _session() -> CdpSession: name = _find_devtools_socket() if name is None: raise CdpError( - "WebView devtools socket not found. Open the Guest Browser in the app first " - "(browser_show) and make sure the app build enables WebView debugging." + "WebView devtools socket not found. Open the Guest Browser first (browser_show) " + "and make sure the app build enables WebView debugging." ) return CdpSession(name) -@mcp.tool() -def browser_show(url: str) -> str: - """ユーザーの画面でゲストブラウザを開かせます(アプリがコマンドファイルを検知して起動)。 - - Args: - url: 表示するURL (例: http://127.0.0.1:8080/) - """ +def tool_show(args: dict) -> str: + url = args["url"] COMMAND_FILE.parent.mkdir(parents=True, exist_ok=True) COMMAND_FILE.write_text(json.dumps({"action": "open", "url": url}), encoding="utf-8") - return f"アプリに {url} を開くよう要求しました(約1秒で表示されます)" + return f"requested the app to open {url}" -@mcp.tool() -def browser_status() -> str: - """ゲストブラウザ(WebView)へのCDP接続状況と現在のページ情報を返します。""" +def tool_status(args: dict) -> str: name = _find_devtools_socket() if name is None: - return "未接続: WebView のデバッグソケットが見つかりません。browser_show でブラウザを開かせてください。" - session = CdpSession(name) - targets = session.list_targets() - pages = [t.get("url", "") for t in targets if t.get("type") in ("document", "page")] - return f"接続可: {name} / ページ: {pages}" + return "not connected: no WebView devtools socket; call browser_show first" + pages = [t.get("url", "") for t in CdpSession(name).list_targets() if t.get("type") in ("document", "page")] + return f"connected: {name} / pages: {pages}" -@mcp.tool() -def browser_navigate(url: str) -> str: - """開いているゲストブラウザで URL へ遷移します。""" +def tool_navigate(args: dict) -> str: page = _session().open_page_session() try: - page.call("Page.navigate", url=url) - return f"{url} へ遷移しました" + page.call("Page.navigate", url=args["url"]) + return f"navigated to {args['url']}" finally: page.close() -@mcp.tool() -def browser_click(x: float, y: float) -> str: - """ページ内のビューポート座標 (x, y) をタップ/クリックします。""" +def tool_click(args: dict) -> str: page = _session().open_page_session() try: expr = ( "(() => { const el = document.elementFromPoint(%f, %f); " - "if (!el) return 'no element'; el.click(); return el.tagName; })()" % (x, y) + "if (!el) return 'no element'; el.click(); return el.tagName; })()" + % (float(args["x"]), float(args["y"])) ) result = page.call("Runtime.evaluate", expression=expr, returnByValue=True) - return f"クリックしました: {result.get('result', {}).get('value')}" + return f"clicked: {result.get('result', {}).get('value')}" finally: page.close() -@mcp.tool() -def browser_type(text: str) -> str: - """フォーカス中の入力要素に文字列を入力します。""" +def tool_type(args: dict) -> str: page = _session().open_page_session() try: page.call("Runtime.evaluate", expression="document.activeElement && document.activeElement.focus()") - page.call("Input.insertText", text=text) - return "入力しました" + page.call("Input.insertText", text=args["text"]) + return "typed" finally: page.close() -@mcp.tool() -def browser_screenshot(save_path: str = "/tmp/opencode/browser.png") -> str: - """現在のページを PNG で保存し、パスを返します(Read で確認できます)。""" +def tool_screenshot(args: dict) -> str: + save_path = args.get("save_path") or "/tmp/andcode-browser.png" page = _session().open_page_session() try: result = page.call("Page.captureScreenshot", format="png") @@ -292,9 +265,7 @@ def browser_screenshot(save_path: str = "/tmp/opencode/browser.png") -> str: page.close() -@mcp.tool() -def browser_info() -> str: - """現在のページの URL とタイトルを返します。""" +def tool_info(args: dict) -> str: page = _session().open_page_session() try: result = page.call( @@ -307,5 +278,151 @@ def browser_info() -> str: page.close() +TOOLS = [ + { + "name": "browser_show", + "description": ( + "Open the AndCode in-app Guest Browser at the given URL so the user can watch and " + "operate the page. Call this before other browser_* tools." + ), + "inputSchema": { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"], + }, + }, + { + "name": "browser_status", + "description": "Report CDP connectivity to the Guest Browser WebView and the current pages.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "browser_navigate", + "description": "Navigate the open Guest Browser to a URL.", + "inputSchema": { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"], + }, + }, + { + "name": "browser_click", + "description": "Click/tap viewport coordinates (x, y) in the Guest Browser page.", + "inputSchema": { + "type": "object", + "properties": {"x": {"type": "number"}, "y": {"type": "number"}}, + "required": ["x", "y"], + }, + }, + { + "name": "browser_type", + "description": "Type text into the focused input of the Guest Browser page.", + "inputSchema": { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + }, + { + "name": "browser_screenshot", + "description": "Capture the Guest Browser page as PNG and return the saved path.", + "inputSchema": { + "type": "object", + "properties": {"save_path": {"type": "string"}}, + }, + }, + { + "name": "browser_info", + "description": "Return the current URL and title of the Guest Browser page.", + "inputSchema": {"type": "object", "properties": {}}, + }, +] + +HANDLERS = { + "browser_show": tool_show, + "browser_status": tool_status, + "browser_navigate": tool_navigate, + "browser_click": tool_click, + "browser_type": tool_type, + "browser_screenshot": tool_screenshot, + "browser_info": tool_info, +} + + +def respond(obj: dict) -> None: + sys.stdout.write(json.dumps(obj) + "\n") + sys.stdout.flush() + + +def main() -> None: + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + method = msg.get("method") + mid = msg.get("id") + if method == "initialize": + respond( + { + "jsonrpc": "2.0", + "id": mid, + "result": { + "protocolVersion": msg.get("params", {}).get("protocolVersion", "2024-11-05"), + "capabilities": {"tools": {}}, + "serverInfo": {"name": "and-code-browser", "version": "1.0.0"}, + }, + } + ) + elif method in ("notifications/initialized", "initialized"): + continue + elif method == "tools/list": + respond({"jsonrpc": "2.0", "id": mid, "result": {"tools": TOOLS}}) + elif method == "tools/call": + params = msg.get("params", {}) + name = params.get("name") + args = params.get("arguments", {}) or {} + handler = HANDLERS.get(name) + if handler is None: + respond( + { + "jsonrpc": "2.0", + "id": mid, + "result": { + "content": [{"type": "text", "text": f"unknown tool: {name}"}], + "isError": True, + }, + } + ) + else: + try: + text = handler(args) + respond( + {"jsonrpc": "2.0", "id": mid, "result": {"content": [{"type": "text", "text": text}]}} + ) + except Exception as exc: # noqa: BLE001 - report any failure to the agent + respond( + { + "jsonrpc": "2.0", + "id": mid, + "result": { + "content": [{"type": "text", "text": f"{type(exc).__name__}: {exc}"}], + "isError": True, + }, + } + ) + elif mid is not None: + respond( + { + "jsonrpc": "2.0", + "id": mid, + "error": {"code": -32601, "message": f"method not found: {method}"}, + } + ) + + if __name__ == "__main__": - mcp.run() + main() diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeInstaller.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeInstaller.kt index 1e418127..3ec965c7 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeInstaller.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeInstaller.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject import java.io.File class LocalRuntimeInstaller( @@ -139,6 +141,7 @@ class LocalRuntimeInstaller( // to agy's Debian tool runner as well. The scripts still fail closed when adb // or Pillow is not installed; they are never silently replaced by a fork. installAndroidHelperScripts(antigravityRootfs) + provisionBrowserMcp(antigravityRootfs) } // Credentials and agent config live under /root inside the rootfs. Activation swaps // the whole environment directory, so without this the user is signed out of every @@ -433,9 +436,80 @@ class LocalRuntimeInstaller( Os.symlink("libapk.so.3.0.0", libApk.absolutePath) } installAndroidHelperScripts(rootfs) + provisionBrowserMcp(rootfs) require(suite.proot.isFile) { "PRoot launcher is unavailable" } } + /** + * Re-seeds the guest-browser MCP server and its agent registrations on runtimes that were + * installed before the provisioning existed. Idempotent and safe to call on every start. + */ + fun provisionBrowserMcpForExistingInstall() { + val active = File(runtimeDirectory, "environment") + listOf(File(active, "rootfs"), File(active, "antigravity-rootfs")) + .filter(File::isDirectory) + .forEach { rootfs -> + installAndroidHelperScripts(rootfs) + provisionBrowserMcp(rootfs) + } + } + + /** + * Registers the guest-browser MCP server with every agent (OpenCode, Claude Code, + * Antigravity) so they all expose the same browser_* tools. User-added servers are kept. + */ + private fun provisionBrowserMcp(rootfs: File) { + mergeJsonConfig(File(rootfs, "root/.config/opencode/opencode.json")) { root -> + val mcp = root.optJSONObject("mcp") ?: JSONObject() + mcp.put(BROWSER_MCP_NAME, browserMcpEntry("opencode")) + root.put("mcp", mcp) + } + mergeJsonConfig(File(rootfs, "root/.claude.json")) { root -> + val servers = root.optJSONObject("mcpServers") ?: JSONObject() + servers.put(BROWSER_MCP_NAME, browserMcpEntry("claude")) + root.put("mcpServers", servers) + } + mergeJsonConfig(File(rootfs, "root/.gemini/config/mcp_config.json")) { root -> + val servers = root.optJSONObject("mcpServers") ?: JSONObject() + servers.put(BROWSER_MCP_NAME, browserMcpEntry("antigravity")) + root.put("mcpServers", servers) + } + } + + private fun browserMcpEntry(agent: String): JSONObject = + when (agent) { + "claude" -> + JSONObject() + .put("type", "stdio") + .put("command", BROWSER_MCP_BIN) + .put("args", JSONArray()) + "antigravity" -> JSONObject().put("command", BROWSER_MCP_BIN) + else -> + JSONObject() + .put("type", "local") + .put("command", JSONArray(listOf(BROWSER_MCP_BIN))) + .put("enabled", true) + .put("timeout", BROWSER_MCP_TIMEOUT_MILLIS) + } + + private fun mergeJsonConfig( + file: File, + mutate: (JSONObject) -> Unit, + ) { + file.parentFile?.mkdirs() + val root = + if (file.isFile) { + runCatching { JSONObject(file.readText()) }.getOrNull() ?: JSONObject() + } else { + JSONObject() + } + val before = root.toString() + mutate(root) + if (root.toString() != before) { + file.writeText(root.toString(2) + "\n") + } + } + private fun installAndroidHelperScripts(rootfs: File) { val binDir = File(rootfs, "usr/local/bin").apply { mkdirs() } File(binDir, "android-ui").delete() @@ -444,6 +518,7 @@ class LocalRuntimeInstaller( "android-screenshot.sh" to "android-screenshot", "android-instrument.sh" to "android-instrument", "android-app.sh" to "android-app", + "andcode-browser-mcp.py" to "andcode-browser-mcp.py", ).forEach { (assetName, scriptName) -> val scriptFile = File(binDir, scriptName) @@ -467,5 +542,8 @@ class LocalRuntimeInstaller( companion object { private const val METADATA_FILE = "metadata.json" + private const val BROWSER_MCP_NAME = "and-code-browser" + private const val BROWSER_MCP_BIN = "/usr/local/bin/andcode-browser-mcp.py" + private const val BROWSER_MCP_TIMEOUT_MILLIS = 30000 } } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManager.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManager.kt index 3e9aced5..87b640d2 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManager.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeManager.kt @@ -491,6 +491,9 @@ class LocalRuntimeManager( installed.metadata.version, installed.metadata.port, ) + // Runtimes installed before the guest-browser MCP provisioning existed pick it up + // here; the call is idempotent and a failure must never block the runtime start. + runCatching { installer?.provisionBrowserMcpForExistingInstall() } if (!portProbe(installed.metadata.port)) launcher.start(installed) val ready = LocalRuntimeStatus.Ready(